diff --git a/package.json b/package.json index ff1da83ceb4b..a6f3527b81e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.8.5", + "version": "10.9.0", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { diff --git a/public/version.json b/public/version.json index 734751ab5a2f..b4196392f644 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.8.5" -} \ No newline at end of file + "version": "10.9.0" +} diff --git a/src/api/ApiCall.jsx b/src/api/ApiCall.jsx index 579161a8225a..1a74d97210fe 100644 --- a/src/api/ApiCall.jsx +++ b/src/api/ApiCall.jsx @@ -4,6 +4,7 @@ import { useDispatch } from "react-redux"; import { showToast } from "../store/toasts"; import { getCippError } from "../utils/get-cipp-error"; import { buildVersionedHeaders } from "../utils/cippVersion"; +import { impersonationCacheParams } from "../utils/impersonation"; const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const wildcardToRegExp = (pattern) => @@ -71,7 +72,7 @@ export function ApiGetCall(props) { const element = data[i]; const response = await axios.get(url, { signal: signal, - params: element, + params: { ...element, ...impersonationCacheParams() }, headers: await buildVersionedHeaders(), }); results.push(response.data); @@ -109,7 +110,7 @@ export function ApiGetCall(props) { } else { const response = await axios.get(url, { signal: url === "/api/tenantFilter" ? null : signal, - params: data, + params: { ...data, ...impersonationCacheParams() }, headers: await buildVersionedHeaders(), responseType: responseType, }); @@ -292,7 +293,7 @@ export function ApiGetCallWithPagination({ queryFn: async ({ pageParam = null, signal }) => { const response = await axios.get(url, { signal: signal, - params: { ...data, ...pageParam }, + params: { ...data, ...pageParam, ...impersonationCacheParams() }, headers: await buildVersionedHeaders(), }); return response.data; diff --git a/src/components/BECRemediationReportButton.js b/src/components/BECRemediationReportButton.js index dd67d546cb18..5c33c927c1d9 100644 --- a/src/components/BECRemediationReportButton.js +++ b/src/components/BECRemediationReportButton.js @@ -12,7 +12,8 @@ import { CircularProgress, } from '@mui/material' import { PictureAsPdf, Download, Close } from '@mui/icons-material' -import { PDFViewer, PDFDownloadLink } from '@react-pdf/renderer' +import { PDFDownloadLink } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdf/CippPdfPreview' import { useReportVariables } from './CippPdf/useReportVariables' import { useBrandingSettings } from './CippPdf/useBrandingSettings' import { @@ -1268,7 +1269,12 @@ export const BECRemediationReportButton = ({ userData, becData, tenantName }) => {hasData && ( - + tenantName={tenantName} variables={variables} /> - + )} diff --git a/src/components/CippAllTenants/AllTenantsPrimitives.jsx b/src/components/CippAllTenants/AllTenantsPrimitives.jsx index 6377b0ea8444..a36ed93b648d 100644 --- a/src/components/CippAllTenants/AllTenantsPrimitives.jsx +++ b/src/components/CippAllTenants/AllTenantsPrimitives.jsx @@ -552,7 +552,7 @@ export const AllTenantsTrendChart = ({ /** Band heading that separates the dashboard into Portfolio / Security / Operations. */ export const AllTenantsBandHeading = ({ title, description }) => ( - { + if (!dateAdded) return false + return differenceInDays(new Date(), new Date(dateAdded)) <= 30 +} + +// Extract the base compliance framework from a benchmark tag (same buckets as the +// classic standards picker) so 'CIS M365 3.0 (1.1.1)' style tags filter as one group. +const extractTagFramework = (tag) => { + if (tag.startsWith('CIS M365')) { + const versionMatch = tag.match(/CIS M365 (\d+\.\d+)/) + return versionMatch ? `CIS M365 ${versionMatch[1]}` : 'CIS M365' + } + if (tag.startsWith('CISA ')) return 'CISA' + if (tag.startsWith('EIDSCA.')) return 'EIDSCA' + if (tag.startsWith('Essential 8')) return 'Essential 8' + if (tag.startsWith('NIST CSF')) { + const versionMatch = tag.match(/NIST CSF (\d+\.\d+)/) + return versionMatch ? `NIST CSF ${versionMatch[1]}` : 'NIST CSF' + } + if (tag.startsWith('exo_')) return 'Secure Score - Exchange' + if (tag.startsWith('mdo_')) return 'Secure Score - Defender' + if (tag.startsWith('spo_')) return 'Secure Score - SharePoint' + if (tag.startsWith('mip_')) return 'Secure Score - Purview' + return null +} + +// One sort control instead of separate field + direction dropdowns: each option +// carries its natural reading order. +const sortOptions = [ + { label: 'Name (A-Z)', value: 'label-asc' }, + { label: 'Newest first', value: 'addedDate-desc' }, + { label: 'Category', value: 'category-asc' }, + { label: 'Impact (High-Low)', value: 'impact-desc' }, +] + +// Benchmark tags on a card: the first few visible, the rest behind a toggle chip. +const StandardTagChips = ({ tags, max = 4 }) => { + const [showAll, setShowAll] = useState(false) + if (tags.length === 0) return null + const visible = showAll ? tags : tags.slice(0, max) + return ( + + {visible.map((tag) => ( + + ))} + {tags.length > max && ( + setShowAll((prev) => !prev)} + sx={{ fontSize: '0.7rem', height: 20 }} + /> + )} + + ) +} + +// Browse-and-add picker for Baseline standard definitions. The filters stay +// visible (no collapsed panel to discover): search + four multi-select filters on +// one row, then a slim toolbar with the result count, added-state filter, sort, +// and the card/list view toggle. export const CippBaselineStandardDialog = ({ open, onClose, @@ -35,172 +122,577 @@ export const CippBaselineStandardDialog = ({ onToggle, }) => { const [search, setSearch] = useState('') - const [category, setCategory] = useState('All') + const [viewMode, setViewMode] = useState('card') + const [sortOption, setSortOption] = useState(sortOptions[0]) + const [selectedCategories, setSelectedCategories] = useState([]) + const [selectedImpacts, setSelectedImpacts] = useState([]) + const [selectedRecommendedBy, setSelectedRecommendedBy] = useState([]) + const [selectedTagFrameworks, setSelectedTagFrameworks] = useState([]) + const [statusFilter, setStatusFilter] = useState('all') - const categories = useMemo( - () => ['All', ...new Set(catalog.map((standard) => standard.cat))], - [catalog] - ) + const { allCategories, allImpacts, allRecommendedBy, allTagFrameworks } = + useMemo(() => { + const categorySet = new Set() + const impactSet = new Set() + const recommendedBySet = new Set() + const tagFrameworkSet = new Set() + for (const standard of catalog) { + if (standard.cat) categorySet.add(standard.cat) + if (standard.impact) impactSet.add(standard.impact) + for (const source of standard.recommendedBy ?? []) { + recommendedBySet.add(source) + } + for (const tag of standard.tag ?? []) { + const framework = extractTagFramework(tag) + if (framework) tagFrameworkSet.add(framework) + } + } + const impactOrder = ['Low Impact', 'Medium Impact', 'High Impact'] + return { + allCategories: [...categorySet].sort(), + allImpacts: [...impactSet].sort( + (a, b) => impactOrder.indexOf(a) - impactOrder.indexOf(b) + ), + allRecommendedBy: [...recommendedBySet].sort(), + allTagFrameworks: [...tagFrameworkSet].sort(), + } + }, [catalog]) + + const toOptions = (values, labelOf = (value) => value) => + values.map((value) => ({ label: labelOf(value), value })) + + // Multi-instance standards count 'Name#n' keys; each click adds another instance. + const instanceCountOf = (standard) => + selectedStandards.filter((key) => key.split('#')[0] === standard.name) + .length const filtered = catalog.filter((standard) => { - if (category !== 'All' && standard.cat !== category) return false - if (!search) return true const query = search.toLowerCase() - return ( + const matchesSearch = + !query || standard.label.toLowerCase().includes(query) || (standard.helpText ?? '').toLowerCase().includes(query) || - (standard.tag ?? []).some((tag) => tag.toLowerCase().includes(query)) + (standard.tag ?? []).some((tag) => tag.toLowerCase().includes(query)) || + (standard.appliesToTest ?? []).some((testId) => + testId.toLowerCase().includes(query) + ) + const matchesCategory = + selectedCategories.length === 0 || + selectedCategories.includes(standard.cat) + const matchesImpact = + selectedImpacts.length === 0 || selectedImpacts.includes(standard.impact) + const matchesRecommendedBy = + selectedRecommendedBy.length === 0 || + (standard.recommendedBy ?? []).some((source) => + selectedRecommendedBy.includes(source) + ) + const matchesTagFramework = + selectedTagFrameworks.length === 0 || + (standard.tag ?? []).some((tag) => { + const framework = extractTagFramework(tag) + return framework && selectedTagFrameworks.includes(framework) + }) + const isSelected = instanceCountOf(standard) > 0 + const matchesStatusFilter = + statusFilter === 'all' || + (statusFilter === 'added' && isSelected) || + (statusFilter === 'notAdded' && !isSelected) + return ( + matchesSearch && + matchesCategory && + matchesImpact && + matchesRecommendedBy && + matchesTagFramework && + matchesStatusFilter ) }) + const [sortBy, sortOrder] = sortOption.value.split('-') + const sorted = [...filtered].sort((a, b) => { + let aValue + let bValue + switch (sortBy) { + case 'addedDate': + aValue = new Date(a.addedDate || '1900-01-01') + bValue = new Date(b.addedDate || '1900-01-01') + break + case 'category': + aValue = a.cat?.toLowerCase() ?? '' + bValue = b.cat?.toLowerCase() ?? '' + break + case 'impact': { + const impactOrder = { + 'High Impact': 3, + 'Medium Impact': 2, + 'Low Impact': 1, + } + aValue = impactOrder[a.impact] ?? 0 + bValue = impactOrder[b.impact] ?? 0 + break + } + default: + aValue = a.label.toLowerCase() + bValue = b.label.toLowerCase() + } + if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1 + if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1 + return 0 + }) + + const hasActiveFilters = + search !== '' || + selectedCategories.length > 0 || + selectedImpacts.length > 0 || + selectedRecommendedBy.length > 0 || + selectedTagFrameworks.length > 0 || + statusFilter !== 'all' + const selectedCount = selectedStandards.length - const handleClose = () => { + const clearAllFilters = () => { setSearch('') - setCategory('All') + setSelectedCategories([]) + setSelectedImpacts([]) + setSelectedRecommendedBy([]) + setSelectedTagFrameworks([]) + setStatusFilter('all') + } + + const handleClose = () => { + clearAllFilters() + setSortOption(sortOptions[0]) + setViewMode('card') onClose() } + const addButton = (standard, instanceCount) => { + const isSelected = instanceCount > 0 + return ( + + ) + } + return ( Add Standards to Stage - setSearch(event.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - - {categories.map((entry) => ( - setCategory(entry)} + + + setSearch(event.target.value)} + autoComplete="off" + placeholder="Search by name, description, or benchmark tag..." + InputProps={{ + startAdornment: ( + + ), + }} + /> + + + + setSelectedCategories( + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } /> - ))} + + + + setSelectedImpacts( + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } + /> + + + + setSelectedRecommendedBy( + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } + /> + + + + setSelectedTagFrameworks( + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } + /> + + + + + + + Showing {sorted.length} of {catalog.length} standards + + {hasActiveFilters && ( + + )} + + + { + if (newValue !== null) setStatusFilter(newValue) + }} + > + All + Added + Not added + + + { + if (newValue) setSortOption(newValue) + }} + /> + + { + if (newViewMode !== null) setViewMode(newViewMode) + }} + > + + + + + + + + + + + + - - {filtered.map((standard) => { - // Multi-instance standards count 'Name#n' keys; each click adds another instance. - const instanceCount = selectedStandards.filter( - (key) => key.split('#')[0] === standard.name - ).length - const isSelected = instanceCount > 0 - return ( - - + + No standards match your search and filter criteria + + + Try adjusting your search terms or clearing some filters + + + )} + + {viewMode === 'card' ? ( + + {sorted.map((standard) => { + const instanceCount = instanceCountOf(standard) + const isSelected = instanceCount > 0 + const benchmarkTags = (standard.tag ?? []).filter( + (tag) => !tag.toLowerCase().includes('impact') + ) + return ( + + + + + {standard.label} + + + {standard.cat} + + + + {standard.secureScoreImpact > 0 && ( + + + + )} + {(standard.recommendedBy ?? []).map((source) => ( + + ))} + {isNewStandard(standard.addedDate) && ( + + )} + + + {standard.helpText} + + + + + {addButton(standard, instanceCount)} + + + + ) + })} + + ) : ( + + {sorted.map((standard) => { + const instanceCount = instanceCountOf(standard) + const isSelected = instanceCount > 0 + return ( + - - - {standard.label} - - - {standard.cat} - - - - {standard.secureScoreImpact > 0 && ( - + + + {standard.label} + + {isNewStandard(standard.addedDate) && ( - - )} - {(standard.recommendedBy ?? []).map((source) => ( + )} - ))} - - - {standard.helpText} - - - - - - - - ) - })} - {filtered.length === 0 && ( - - - No standards match this search. - - - )} - + + + } + secondary={ + + + {standard.helpText} + + + {(standard.tag ?? []) + .filter( + (tag) => !tag.toLowerCase().includes('impact') + ) + .slice(0, 3) + .map((tag) => ( + + ))} + {(standard.recommendedBy ?? []).length > 0 && ( + + • Recommended by:{' '} + {standard.recommendedBy.join(', ')} + + )} + {standard.secureScoreImpact > 0 && ( + + • +{standard.secureScoreImpact} Secure Score pts + + )} + + + } + sx={{ pr: 22 }} + /> + + {addButton(standard, instanceCount)} + + + ) + })} + + )} diff --git a/src/components/CippBaselines/CippBaselineStandardItem.jsx b/src/components/CippBaselines/CippBaselineStandardItem.jsx index a2ac421247cd..179f03ea4fec 100644 --- a/src/components/CippBaselines/CippBaselineStandardItem.jsx +++ b/src/components/CippBaselines/CippBaselineStandardItem.jsx @@ -86,9 +86,12 @@ export const CippBaselineStandardItem = ({ const watched = useWatch({ control: formControl.control, name: fieldBase }) // Identity-carrying standards (CA/Intune templates via instanceIdentity, manual tasks // via taskName) title as ' ); } @@ -96,21 +95,18 @@ const CippWizardPage = (props) => { sx={{ backgroundColor: "background.default", flexGrow: 1, - pb: 4, + pb: { xs: 2, md: 4 }, }} > - - - - - {wizardNode} - - - - + {/* Three nested Stacks used to sit here, each wrapping exactly one child. Stack + spacing only emits a margin on :not(:first-of-type), so all three were inert + at every width. */} + + {wizardNode} + diff --git a/src/components/CippWizard/CippWizardProgressHeader.jsx b/src/components/CippWizard/CippWizardProgressHeader.jsx new file mode 100644 index 000000000000..bdb14abaf41a --- /dev/null +++ b/src/components/CippWizard/CippWizardProgressHeader.jsx @@ -0,0 +1,45 @@ +import PropTypes from "prop-types"; +import { LinearProgress, Stack, Typography } from "@mui/material"; + +/** + * The wizard's step indicator below md. + * + * A horizontal MUI Stepper gives every step a 36px icon beside two lines of text; with the + * 3-7 steps these wizards have, and ~326px of usable width on a phone, the labels collapse + * into each other. This says the same thing in the space available: where you are, what + * this step is, and how much is left. + * + * Takes the same two props as WizardSteps so the swap needs no new plumbing. + */ +export const CippWizardProgressHeader = (props) => { + const { activeStep = 0, steps = [] } = props; + + const total = steps.length; + // Clamped because handleNext currently counts against the unfiltered step list, so + // activeStep can point past the end of a wizard whose steps are conditionally hidden. + const index = total > 0 ? Math.min(Math.max(activeStep, 0), total - 1) : 0; + const current = steps[index]; + const value = total > 0 ? ((index + 1) / total) * 100 : 0; + + return ( + + + {total > 0 ? `Step ${index + 1} of ${total}` : "No steps"} + + {current?.description ?? current?.title ?? ""} + {/* Carries the same error/loading states the step icons show on desktop, so the + GDAP-style "this step failed" signal survives the swap. */} + + + ); +}; + +CippWizardProgressHeader.propTypes = { + activeStep: PropTypes.number, + steps: PropTypes.array, +}; diff --git a/src/components/CippWizard/CippWizardStepButtons.jsx b/src/components/CippWizard/CippWizardStepButtons.jsx index 7a070d124e08..55fb723d07b4 100644 --- a/src/components/CippWizard/CippWizardStepButtons.jsx +++ b/src/components/CippWizard/CippWizardStepButtons.jsx @@ -1,9 +1,10 @@ -import { Button, Stack } from "@mui/material"; +import { Button } from "@mui/material"; import { useFormState } from "react-hook-form"; import { createPortal } from "react-dom"; import { ApiPostCall } from "../../api/ApiCall"; import { CippApiResults } from "../CippComponents/CippApiResults"; import { useCippWizardDialog } from "./CippWizardDialogContext"; +import { CippWizardActionsRow } from "./CippWizardActionsRow"; export const CippWizardStepButtons = (props) => { const { @@ -47,20 +48,14 @@ export const CippWizardStepButtons = (props) => { }; const buttonStack = ( - + {dialogContext?.onClose && ( @@ -98,7 +93,7 @@ export const CippWizardStepButtons = (props) => { {dialogContext.completionButton.label} )} - + ); return ( diff --git a/src/components/CippWizard/CippWizardVacationActions.jsx b/src/components/CippWizard/CippWizardVacationActions.jsx index 7a8db5b558c7..cdf8130ea2bb 100644 --- a/src/components/CippWizard/CippWizardVacationActions.jsx +++ b/src/components/CippWizard/CippWizardVacationActions.jsx @@ -26,10 +26,15 @@ export const CippWizardVacationActions = (props) => { const tenantDomain = currentTenant?.value || currentTenant const enableCA = useWatch({ control: formControl.control, name: 'enableCAExclusion' }) + const enableLocationAlertExclusion = useWatch({ + control: formControl.control, + name: 'excludeLocationAuditAlerts', + }) const enableMailbox = useWatch({ control: formControl.control, name: 'enableMailboxPermissions' }) const enableForwarding = useWatch({ control: formControl.control, name: 'enableForwarding' }) const enableOOO = useWatch({ control: formControl.control, name: 'enableOOO' }) - const atLeastOneEnabled = enableCA || enableMailbox || enableForwarding || enableOOO + const atLeastOneEnabled = + enableCA || enableLocationAlertExclusion || enableMailbox || enableForwarding || enableOOO const users = useWatch({ control: formControl.control, name: 'Users' }) const firstUser = Array.isArray(users) && users.length > 0 ? users[0] : null @@ -194,14 +199,6 @@ export const CippWizardVacationActions = (props) => { disabled={!tenantDomain} /> - - - { + {/* Location Alert Exclusion Section */} + + + + + + + + + + The users are added to the audit log location alert exclusion list at the start + date and removed again at the end date, so alerts that fire on sign-ins from an + unusual location stay quiet while they travel. This works on its own and does not + require a Conditional Access policy. + + + + + + {/* Mailbox Permissions Section */} { const { formControl, onPreviousStep, currentStep, lastStep } = props @@ -22,6 +23,7 @@ export const CippWizardVacationConfirmation = (props) => { const values = useWatch({ control: formControl.control }) const caExclusion = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) + const auditExclusion = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) const mailboxVacation = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) const forwardingVacation = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) const oooVacation = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) @@ -29,11 +31,13 @@ export const CippWizardVacationConfirmation = (props) => { const tenantFilter = values.tenantFilter?.value || values.tenantFilter const isSubmitting = caExclusion.isPending || + auditExclusion.isPending || mailboxVacation.isPending || forwardingVacation.isPending || oooVacation.isPending const hasSubmitted = caExclusion.isSuccess || + auditExclusion.isSuccess || mailboxVacation.isSuccess || forwardingVacation.isSuccess || oooVacation.isSuccess @@ -54,7 +58,6 @@ export const CippWizardVacationConfirmation = (props) => { vacation: true, reference: values.reference || null, postExecution: values.postExecution || [], - excludeLocationAuditAlerts: values.excludeLocationAuditAlerts || false, // Only send the travel policy fields on the first request so the // temporary policy is scheduled once, not once per selected CA policy ...(index === 0 && createTravelPolicy @@ -68,6 +71,20 @@ export const CippWizardVacationConfirmation = (props) => { }) } + if (values.excludeLocationAuditAlerts) { + auditExclusion.mutate({ + url: '/api/ExecScheduleAuditExclusionVacation', + data: { + tenantFilter, + Users: values.Users, + startDate: values.startDate, + endDate: values.endDate, + reference: values.reference || null, + postExecution: values.postExecution || [], + }, + }) + } + if (values.enableMailboxPermissions) { mailboxVacation.mutate({ url: '/api/ExecScheduleMailboxVacation', @@ -225,6 +242,7 @@ export const CippWizardVacationConfirmation = (props) => { {(() => { const enabledCount = [ values.enableCAExclusion, + values.excludeLocationAuditAlerts, values.enableMailboxPermissions, values.enableForwarding, values.enableOOO, @@ -254,13 +272,6 @@ export const CippWizardVacationConfirmation = (props) => { : 'Not selected'} - {values.excludeLocationAuditAlerts && ( -
- - Location-based audit log alerts will be excluded - -
- )} {values.createTravelPolicy && (
@@ -284,6 +295,24 @@ export const CippWizardVacationConfirmation = (props) => { )} + {values.excludeLocationAuditAlerts && ( + + + } + /> + + + + The users are excluded from location-based audit log alerts between the start + and end date. + + + + + )} + {values.enableMailboxPermissions && ( @@ -434,18 +463,13 @@ export const CippWizardVacationConfirmation = (props) => { {/* API Results */} {values.enableCAExclusion && } + {values.excludeLocationAuditAlerts && } {values.enableMailboxPermissions && } {values.enableForwarding && } {values.enableOOO && } {/* Navigation + Custom Submit */} - + {currentStep > 0 && ( )} - + ) } diff --git a/src/components/CippWizard/wizard-steps.js b/src/components/CippWizard/wizard-steps.js index 67b79a654105..bfdc473a7e51 100644 --- a/src/components/CippWizard/wizard-steps.js +++ b/src/components/CippWizard/wizard-steps.js @@ -1,5 +1,7 @@ import PropTypes from "prop-types"; import CheckIcon from "@heroicons/react/24/outline/CheckIcon"; +import { useIsMobileLayout } from "../../hooks/use-breakpoint"; +import { CippWizardProgressHeader } from "./CippWizardProgressHeader"; import { Box, Step, @@ -137,6 +139,14 @@ const WizardStepIcon = (props) => { export const WizardSteps = (props) => { const { activeStep = 1, orientation = "vertical", steps = [] } = props; + const isMobile = useIsMobileLayout(); + + // Only the horizontal stepper is wizard navigation. The vertical one is a status list — + // GDAP onboarding feeds it server-side steps where each step's message and pass/fail + // state IS the content, so collapsing it to a progress bar would delete that. + if (isMobile && orientation === "horizontal") { + return ; + } return (
@@ -145,8 +155,10 @@ export const WizardSteps = (props) => { activeStep={activeStep} connector={} > - {steps.map((step) => ( - + {/* Onboarding's steps carry only a description, so keying on title alone made + every key undefined and reconciliation index-driven by accident. */} + {steps.map((step, index) => ( + device.isEncrypted === true).length, + // Cloud PCs never report BitLocker but are platform-encrypted by Azure. + value: deviceData.filter( + (device) => device.isEncrypted === true || isCloudPcDevice(device), + ).length, label: 'Encrypted', }, ]} @@ -1622,6 +1628,10 @@ export const ExecutiveReportButton = (props) => { setPreviewOpen(false) } + // Below md the 320px config rail would leave the preview about 70px wide, so it moves into + // a drawer and the preview takes the whole dialog. + const [sectionsOpen, setSectionsOpen] = useState(false) + // Section configuration options const sectionOptions = [ { @@ -1671,6 +1681,102 @@ export const ExecutiveReportButton = (props) => { }, ] + // One definition, two homes: the desktop rail and the mobile drawer. The drawer's own + // header already says "Report Sections", so it takes the panel without the heading. + const sectionPanel = ({ showHeading = true } = {}) => ( + + {showHeading && ( + + + Report Sections + + )} + + Configure which sections to include in your executive report. Changes are reflected in + real-time. + + + + option.value === brandingPresetId) ?? presetOptions[0] + } + onChange={(option) => setPresetOverride(option?.value ?? '')} + /> + + Presets are managed in Settings → Branding + + + + + {sectionOptions.map((option) => ( + handleSectionToggle(option.key)} + sx={{ + p: 1.5, + border: '1px solid', + borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', + bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', + cursor: 'pointer', + transition: 'all 0.2s ease-in-out', + display: 'flex', + alignItems: 'center', + '&:hover': { + borderColor: 'primary.main', + bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', + }, + }} + > + { + event.stopPropagation() + handleSectionToggle(option.key) + }} + onClick={(event) => event.stopPropagation()} + color="primary" + size="small" + disabled={ + sectionConfig[option.key] && + Object.values(sectionConfig).filter(Boolean).length === 1 + } + /> + + + {option.label} + + + {option.description} + + + + ))} + + + + + 💡 Pro Tip + + + Enable only the sections relevant to your audience to create focused, impactful reports. + At least one section must be enabled. + + + + ) + return ( <> {/* Main Executive Summary Button - Always available */} @@ -1742,8 +1848,9 @@ export const ExecutiveReportButton = (props) => { fullWidth sx={{ '& .MuiDialog-paper': { - height: '95vh', - maxHeight: '95vh', + // dvh, not vh: iOS counts the collapsing address bar in vh, so 95vh overflows. + height: { xs: '100dvh', md: '95vh' }, + maxHeight: { xs: '100dvh', md: '95vh' }, }, }} > @@ -1757,16 +1864,28 @@ export const ExecutiveReportButton = (props) => { borderColor: 'divider', }} > - + Executive Report - {tenantName} - - - + + {/* The config rail's stand-in below md, in the title bar because the dialog is + full-screen there and this is the only chrome that stays put. */} + setSectionsOpen(true)} + size="small" + aria-label="Report sections" + sx={{ display: { xs: 'inline-flex', md: 'none' } }} + > + + + + + + - {/* Left Panel - Section Configuration */} + {/* Left Panel - Section Configuration. Below md it lives in the drawer instead. */} { borderColor: 'divider', height: '100%', overflow: 'auto', + display: { xs: 'none', md: 'block' }, }} > - - - - Report Sections - - - Configure which sections to include in your executive report. Changes are reflected - in real-time. - - - - option.value === brandingPresetId) ?? - presetOptions[0] - } - onChange={(option) => setPresetOverride(option?.value ?? '')} - /> - - Presets are managed in Settings → Branding - - - - - {sectionOptions.map((option) => ( - handleSectionToggle(option.key)} - sx={{ - p: 1.5, - border: '1px solid', - borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', - bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', - cursor: 'pointer', - transition: 'all 0.2s ease-in-out', - display: 'flex', - alignItems: 'center', - '&:hover': { - borderColor: 'primary.main', - bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', - }, - }} - > - { - event.stopPropagation() - handleSectionToggle(option.key) - }} - onClick={(event) => event.stopPropagation()} - color="primary" - size="small" - disabled={ - sectionConfig[option.key] && - Object.values(sectionConfig).filter(Boolean).length === 1 - } - /> - - - {option.label} - - - {option.description} - - - - ))} - - - - - 💡 Pro Tip - - - Enable only the sections relevant to your audience to create focused, impactful - reports. At least one section must be enabled. - - - + {sectionPanel()} {/* Right Panel - PDF Preview */} - + {isDataLoading ? ( { justifyContent: 'center', height: '100%', gap: 2, + // Gutters and a measure: this pane is the full width of the screen below md, + // where the second line is long enough to run edge to edge and break badly. + px: 3, + textAlign: 'center', }} > Loading Report Data... - + Fetching additional data for comprehensive report generation ) : reportDocument ? ( - { showToolbar={true} > {reportDocument} - + ) : ( { - + :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, + }} + > Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '} @@ -2000,6 +2036,19 @@ export const ExecutiveReportButton = (props) => { Close + + {/* Mounted inside the Dialog so it inherits its theme scope; aboveModal lifts it over + the dialog it is opened from. */} + setSectionsOpen(false)} + title="Report Sections" + size="sm" + contentPadding={0} + aboveModal + > + {sectionPanel({ showHeading: false })} + ) diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js index 0ad27744fbea..08811b2286f6 100644 --- a/src/components/ReleaseNotesDialog.js +++ b/src/components/ReleaseNotesDialog.js @@ -10,6 +10,12 @@ } from 'react' import { Box, + ButtonBase, + IconButton, + List, + ListItemButton, + ListItemIcon, + ListItemText, Button, CircularProgress, Dialog, @@ -20,14 +26,18 @@ import { Stack, Typography, } from '@mui/material' +import { visuallyHidden } from '@mui/utils' import ReactMarkdown from 'react-markdown' +import { useHistoryDismiss } from '../hooks/use-history-dismiss' +import { CippBottomSheet } from './CippComponents/CippBottomSheet' +import { useIsMobileLayout } from '../hooks/use-breakpoint' import remarkGfm from 'remark-gfm' import remarkParse from 'remark-parse' import rehypeRaw from 'rehype-raw' import { unified } from 'unified' import packageInfo from '../../public/version.json' import { ApiGetCall } from '../api/ApiCall' -import { GitHub } from '@mui/icons-material' +import { Check, Close, GitHub, KeyboardArrowDown, MoreHoriz } from '@mui/icons-material' import { CippAutoComplete } from './CippComponents/CippAutocomplete' const RELEASE_COOKIE_KEY = 'cipp_release_notice' @@ -134,22 +144,41 @@ class MarkdownErrorBoundary extends Component { } } +// Which release the dialog *shows*. Hotfix and maintenance builds (v10.8.1, v10.8.2) carry +// only the delta since the feature release, so opening on one tells the user almost nothing +// about what changed. Default to the newest vX.Y.0 instead; the picker still lists every +// release, and dismissal keeps tracking the exact running tag (see buildReleaseMetadata) so +// this can't reintroduce the dialog-reopens-forever bug. +const isFeatureRelease = (tag) => /^v?\d+\.\d+\.0$/.test(String(tag ?? '')) + +const pickDisplayRelease = (catalog, releaseMeta) => + catalog.find((release) => isFeatureRelease(release.releaseTag)) || + catalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || + catalog.find((release) => release.releaseTag === releaseMeta.baseTag) || + catalog[0] + export const ReleaseNotesDialog = forwardRef((_props, ref) => { const releaseMeta = useMemo(() => buildReleaseMetadata(packageInfo.version), []) const [isEligible, setIsEligible] = useState(false) const [open, setOpen] = useState(false) const [isExpanded, setIsExpanded] = useState(false) const [manualOpenRequested, setManualOpenRequested] = useState(false) - const [selectedReleaseTag, setSelectedReleaseTag] = useState(releaseMeta.baseTag) + const [moreActionsOpen, setMoreActionsOpen] = useState(false) + const [releasePickerOpen, setReleasePickerOpen] = useState(false) + // Left unset until the catalog loads so pickDisplayRelease chooses; seeding it with + // the running tag meant a hotfix build always displayed its own thin release notes. + const [selectedReleaseTag, setSelectedReleaseTag] = useState(null) const hasOpenedRef = useRef(false) + const isMobile = useIsMobileLayout() useEffect(() => { hasOpenedRef.current = false }, [releaseMeta.releaseTag]) useEffect(() => { - setSelectedReleaseTag(releaseMeta.baseTag) - }, [releaseMeta.baseTag]) + // New build -> re-pick from the catalog rather than pinning to this build's tag + setSelectedReleaseTag(null) + }, [releaseMeta.releaseTag]) useEffect(() => { if (typeof window === 'undefined') { @@ -191,17 +220,14 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { } if (!selectedReleaseTag) { - setSelectedReleaseTag(releaseCatalog[0].releaseTag) + setSelectedReleaseTag(pickDisplayRelease(releaseCatalog, releaseMeta)?.releaseTag) return } const hasSelected = releaseCatalog.some((release) => release.releaseTag === selectedReleaseTag) if (!hasSelected) { - const fallbackRelease = - releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || - releaseCatalog.find((release) => release.releaseTag === releaseMeta.baseTag) || - releaseCatalog[0] + const fallbackRelease = pickDisplayRelease(releaseCatalog, releaseMeta) if (fallbackRelease) { setSelectedReleaseTag(fallbackRelease.releaseTag) } @@ -211,7 +237,13 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { const releaseOptions = useMemo(() => { const mapped = releaseCatalog.map((release) => { const tag = release.releaseTag ?? release.tagName - const label = release.name ? `${release.name} (${tag})` : tag + // GitHub release names usually start with the tag ("v10.8.0 - Ramos Melon Fizz"), + // so the parenthetical only earns its width when the name doesn't carry it. + const label = release.name + ? release.name.includes(tag) + ? release.name + : `${release.name} (${tag})` + : tag return { label, value: tag, @@ -307,6 +339,10 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { setManualOpenRequested(false) } + // Phone back gesture dismisses the dialog instead of navigating the page away — same + // remind-later semantics as the ✕, the backdrop and Esc. + useHistoryDismiss(open, handleRemindLater, isMobile) + const toggleExpanded = () => { setIsExpanded((prev) => !prev) } @@ -367,33 +403,72 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { }, }} > - - + {isMobile ? ( + setReleasePickerOpen(true)} + aria-haspopup="dialog" + sx={{ + minWidth: 0, + flex: 1, + display: 'flex', + alignItems: 'center', + gap: 0.5, + borderRadius: 1, + textAlign: 'left', + justifyContent: 'flex-start', + }} + > + + {selectedReleaseValue?.label ?? 'Release notes'} + + + switch release + + + + ) : ( + + + {`Release notes for ${releaseHeading}`} + + + + + )} + {/* Phones drop the "Remind me next time" button — closing IS remind-later + (onClose runs the same handler) — so the ✕ is the visible way to do it. */} + - - {`Release notes for ${releaseHeading}`} - - - - + + @@ -423,8 +498,25 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { { - - + - - + + - + setMoreActionsOpen(true)} + sx={{ + display: { xs: 'inline-flex', md: 'none' }, + minWidth: 44, + minHeight: 44, + border: 1, + borderColor: 'divider', + borderRadius: 1, + }} + > + + + + setReleasePickerOpen(false)} + title="Release" + > + + {releaseOptions.map((option) => { + const selected = option.value === selectedReleaseTag + return ( + { + setReleasePickerOpen(false) + if (!selected) handleReleaseChange(option) + }} + > + + {selected && } + + ) + })} + + + setMoreActionsOpen(false)} + title="Release notes" + > + + setMoreActionsOpen(false)} + sx={{ minHeight: 48 }} + > + + + + + + { + setMoreActionsOpen(false) + handleDismissPermanently() + }} + sx={{ minHeight: 48 }} + > + + + + + + + ) }) diff --git a/src/components/ReportBuilder/ReportBuilderPDF.js b/src/components/ReportBuilder/ReportBuilderPDF.js index 8ed9160b9867..d0f0a85235e2 100644 --- a/src/components/ReportBuilder/ReportBuilderPDF.js +++ b/src/components/ReportBuilder/ReportBuilderPDF.js @@ -1,5 +1,6 @@ import { useMemo } from 'react' -import { Text, View, StyleSheet, PDFViewer } from '@react-pdf/renderer' +import { Text, View, StyleSheet } from '@react-pdf/renderer' +import { CippPdfPreview } from '../CippPdf/CippPdfPreview' import { ContentPage, DEFAULT_PAGE_SETUP, @@ -650,9 +651,15 @@ export const ReportBuilderPDF = ({ if (mode === 'preview') { return ( - + {document} - + ) } return null diff --git a/src/components/ShadowAIReportButton.js b/src/components/ShadowAIReportButton.js index 54153c42a0bc..82b13955ca47 100644 --- a/src/components/ShadowAIReportButton.js +++ b/src/components/ShadowAIReportButton.js @@ -15,7 +15,8 @@ import { Typography, } from '@mui/material' import { Close, Download, PictureAsPdf, Settings } from '@mui/icons-material' -import { PDFViewer } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdf/CippPdfPreview' +import { CippOffCanvas } from './CippComponents/CippOffCanvas' import { useReportVariables } from './CippPdf/useReportVariables' import { useBrandingSettings } from './CippPdf/useBrandingSettings' import { @@ -576,6 +577,9 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { const brandingSettings = useBrandingSettings() const variables = useReportVariables() const [previewOpen, setPreviewOpen] = useState(false) + // Below md the 320px config rail would leave the preview about 70px wide, so it moves into + // a drawer and the preview takes the whole dialog. Same treatment as the executive report. + const [sectionsOpen, setSectionsOpen] = useState(false) const [sectionConfig, setSectionConfig] = useState({ coverPage: true, executiveSummary: true, @@ -603,6 +607,73 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { new Date().toISOString().split('T')[0] }.pdf` + // One definition, two homes: the desktop rail and the mobile drawer. The drawer's own + // header already says "Report Sections", so it takes the panel without the heading. + const sectionPanel = ({ showHeading = true } = {}) => ( + + {showHeading && ( + + + Report Sections + + )} + + Configure which sections to include in your Shadow AI report. Changes are reflected in + real-time. + + + + {sectionOptions.map((option) => ( + handleSectionToggle(option.key)} + sx={{ + p: 1.5, + border: '1px solid', + borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', + bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', + cursor: 'pointer', + transition: 'all 0.2s ease-in-out', + display: 'flex', + alignItems: 'center', + '&:hover': { + borderColor: 'primary.main', + bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', + }, + }} + > + { + event.stopPropagation() + handleSectionToggle(option.key) + }} + onClick={(event) => event.stopPropagation()} + color="primary" + size="small" + disabled={ + sectionConfig[option.key] && + Object.values(sectionConfig).filter(Boolean).length === 1 + } + /> + + + {option.label} + + + {option.description} + + + + ))} + + + ) + const reportDocument = useMemo(() => { if (!previewOpen) return null return ( @@ -642,7 +713,13 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { onClose={() => setPreviewOpen(false)} maxWidth="xl" fullWidth - sx={{ '& .MuiDialog-paper': { height: '95vh', maxHeight: '95vh' } }} + sx={{ + '& .MuiDialog-paper': { + // dvh, not vh: iOS counts the collapsing address bar in vh, so 95vh overflows. + height: { xs: '100dvh', md: '95vh' }, + maxHeight: { xs: '100dvh', md: '95vh' }, + }, + }} > { borderColor: 'divider', }} > - + Shadow AI Report - {tenantName} - setPreviewOpen(false)} size="small"> - - + + {/* The config rail's stand-in below md, in the title bar because the dialog is + full-screen there and this is the only chrome that stays put. */} + setSectionsOpen(true)} + size="small" + aria-label="Report sections" + sx={{ display: { xs: 'inline-flex', md: 'none' } }} + > + + + setPreviewOpen(false)} + size="small" + aria-label="Close preview" + > + + + - {/* Left Panel - Section Configuration */} + {/* Left Panel - Section Configuration. Below md it lives in the drawer instead. */} { borderColor: 'divider', height: '100%', overflow: 'auto', + display: { xs: 'none', md: 'block' }, }} > - - - - Report Sections - - - Configure which sections to include in your Shadow AI report. Changes are reflected - in real-time. - - - - {sectionOptions.map((option) => ( - handleSectionToggle(option.key)} - sx={{ - p: 1.5, - border: '1px solid', - borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', - bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', - cursor: 'pointer', - transition: 'all 0.2s ease-in-out', - display: 'flex', - alignItems: 'center', - '&:hover': { - borderColor: 'primary.main', - bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', - }, - }} - > - { - event.stopPropagation() - handleSectionToggle(option.key) - }} - onClick={(event) => event.stopPropagation()} - color="primary" - size="small" - disabled={ - sectionConfig[option.key] && - Object.values(sectionConfig).filter(Boolean).length === 1 - } - /> - - - {option.label} - - - {option.description} - - - - ))} - - + {sectionPanel()} {/* Right Panel - PDF Preview */} - + {reportDocument && ( - {reportDocument} - + )} - + :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, + }} + > Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '} @@ -803,6 +844,19 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { Close + + {/* Mounted inside the Dialog so it inherits its theme scope; aboveModal lifts it over + the dialog it is opened from. */} + setSectionsOpen(false)} + title="Report Sections" + size="sm" + contentPadding={0} + aboveModal + > + {sectionPanel({ showHeading: false })} + ) diff --git a/src/components/actions-menu.js b/src/components/actions-menu.js index 77a4c1c6a6cc..19f4ec9f2d66 100644 --- a/src/components/actions-menu.js +++ b/src/components/actions-menu.js @@ -2,25 +2,17 @@ import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; import PropTypes from "prop-types"; import { Button, ListItemText, Menu, MenuItem, SvgIcon } from "@mui/material"; import { usePopover } from "../hooks/use-popover"; -import { useState } from "react"; -import { useDialog } from "../hooks/use-dialog"; -import { CippApiDialog } from "./CippComponents/CippApiDialog"; +import { useActionsDispatch } from "../hooks/use-actions-dispatch"; export const ActionsMenu = (props) => { const { actions = [], label = "Actions", data, queryKeys, ...other } = props; const popover = usePopover(); - const [actionData, setActionData] = useState({ data: {}, action: {}, ready: false }); - const createDialog = useDialog(); - const handleActionDisabled = (row, action) => { - //add nullsaftey for row. It can sometimes be undefined(still loading) or null(no data) - if (!row) { - return true; - } - if (action?.condition) { - return !action?.condition(row); - } - return false; - }; + const { visibleActions, isDisabled, dispatch, dialog } = useActionsDispatch({ + actions, + data, + queryKeys, + }); + return ( <> { vertical: "top", }} > - {actions - ?.filter((action) => !action.link || action.showInActionsMenu) - .map((action, index) => ( - { - setActionData({ - data: data, - action: action, - ready: true, - }); - - if (action?.noConfirm && action.customFunction) { - action.customFunction(data, action, {}); - popover.handleClose(); - } else { - createDialog.handleOpen(); - popover.handleClose(); - } - }} - > - - {action.icon} - - {action.label} - - ))} + {visibleActions.map((action, index) => ( + { + dispatch(action); + popover.handleClose(); + }} + > + + {action.icon} + + {action.label} + + ))} - {actionData.ready && ( - - )} + {dialog} ); }; diff --git a/src/components/images-dialog.js b/src/components/images-dialog.js index 71668979d5db..d17c6d1cad8f 100644 --- a/src/components/images-dialog.js +++ b/src/components/images-dialog.js @@ -92,7 +92,7 @@ export const ImagesDialog = (props) => { onDrop={handleDrop} sx={{ mb: 3 }} /> - https://huntresslabs.github.io/rogueapps/. CIPP also has a list of community collected rogue apps.", + "description": "Huntress has provided a repository of known rogue apps that are commonly used in BEC, data exfiltration and other Microsoft 365 attacks. This alert will notify you if any of these apps are detected in the selected tenant(s). For more information, see https://huntresslabs.github.io/rogueapps/. CIPP also maintains its own curated list of rogue apps, so detections may include applications that are not on the Huntress site. See the CIPP documentation for the full list.", "requiresInput": true, "inputType": "switch", "inputLabel": "Ignore Disabled Apps?", diff --git a/src/data/cipp-roles.json b/src/data/cipp-roles.json index ac3c389f65a2..750449ac13a6 100644 --- a/src/data/cipp-roles.json +++ b/src/data/cipp-roles.json @@ -18,7 +18,8 @@ "CIPP.SuperAdmin.*", "CIPP.Admin.*", "CIPP.AppSettings.*", - "Tenant.Standards.ReadWrite" + "Tenant.Standards.ReadWrite", + "Tenant.Baselines.ReadWrite" ] }, "admin": { diff --git a/src/data/standards.json b/src/data/standards.json index ac06341b8b8c..d4a68dc0e98c 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -1212,15 +1212,55 @@ "cat": "Entra (AAD) Standards", "tag": [], "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02", "ZTNA21845", "ZTNA21846"], - "helpText": "Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon.", + "helpText": "Enable TAP with the specified configuration settings.", "docsDescription": "Enables Temporary Access Pass generation for the tenant.", - "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passs provide a secure way to restore access without compromising long-term security policies.", + "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passes provide a secure way to restore access without compromising long-term security policies.", "addedComponent": [ + { + "type": "number", + "name": "standards.TAP.MinimumLifetime", + "label": "Minimum Lifetime (minutes)", + "defaultValue": 60, + "validators": { + "min": { "value": 10, "message": "Minimum value is 10" }, + "max": { "value": 43200, "message": "Maximum value is 43200" } + } + }, + { + "type": "number", + "name": "standards.TAP.MaximumLifetime", + "label": "Maximum Lifetime (minutes)", + "defaultValue": 480, + "validators": { + "min": { "value": 10, "message": "Minimum value is 10" }, + "max": { "value": 43200, "message": "Maximum value is 43200" } + } + }, + { + "type": "number", + "name": "standards.TAP.DefaultLifetime", + "label": "Default Lifetime (minutes)", + "defaultValue": 60, + "validators": { + "min": { "value": 10, "message": "Minimum value is 10" }, + "max": { "value": 43200, "message": "Maximum value is 43200" } + } + }, + { + "type": "number", + "name": "standards.TAP.TAPLength", + "label": "Length (characters)", + "defaultValue": 8, + "validators": { + "min": { "value": 8, "message": "Minimum value is 8" }, + "max": { "value": 48, "message": "Maximum value is 48" } + } + }, { "type": "autoComplete", "multiple": false, "creatable": false, - "label": "Select TAP Lifetime", + "label": "Number of Times Usable", "name": "standards.TAP.config", "options": [ { "label": "Only Once", "value": "true" }, @@ -1353,14 +1393,22 @@ "ZTNA21809", "ZTNA21869" ], - "helpText": "Enables App consent admin requests for the tenant via the GA role. Does not overwrite existing reviewer settings", - "docsDescription": "Enables the ability for users to request admin consent for applications. Should be used in conjunction with the \"Require admin consent for applications\" standards", + "helpText": "Enables App consent admin requests for the tenant via the GA role. Optionally adds specific users (matched by display name) as reviewers. Does not overwrite existing reviewer settings", + "docsDescription": "Enables the ability for users to request admin consent for applications. Reviewers can be directory roles and/or specific users matched by display name, e.g. a central MSP support account that exists as a guest in each tenant, so each consent request generates a notification to a monitored mailbox. Should be used in conjunction with the \"Require admin consent for applications\" standards", "executiveText": "Establishes a formal approval process where employees can request access to business applications that require administrative review. This balances security with productivity by allowing controlled access to necessary tools while preventing unauthorized application installations.", "addedComponent": [ { "type": "AdminRolesMultiSelect", "label": "App Consent Reviewer Roles", "name": "standards.EnableAppConsentRequests.ReviewerRoles" + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "label": "Optional: reviewer users (display names of existing users or guests)", + "name": "standards.EnableAppConsentRequests.ReviewerUsers" } ], "label": "Enable App consent admin requests", @@ -1387,7 +1435,8 @@ "name": "standards.NudgeMFA.state", "options": [ { "label": "Enabled", "value": "enabled" }, - { "label": "Disabled", "value": "disabled" } + { "label": "Disabled", "value": "disabled" }, + { "label": "Microsoft managed", "value": "default" } ] }, { @@ -3429,21 +3478,38 @@ "name": "standards.QuarantineRequestAlert", "cat": "Defender Standards", "tag": [], - "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message.", - "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released.", + "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message. Set the alert state to Removed to delete the alert rule CIPP created from the tenant.", + "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released. Setting the alert state to Removed deletes the alert rule CIPP created from the tenant, for when the alert is no longer wanted.", "executiveText": "Notifies IT administrators when employees request to release emails that were quarantined for security reasons, enabling oversight of potentially dangerous messages. This helps ensure that legitimate emails are released while maintaining security controls over suspicious content.", "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, + "label": "Alert state (blank or Enabled creates the alert, Removed deletes it)", + "name": "standards.QuarantineRequestAlert.state", + "options": [ + { "label": "Enabled", "value": "enabled" }, + { "label": "Removed", "value": "removed" } + ] + }, { "type": "textField", "name": "standards.QuarantineRequestAlert.NotifyUser", - "label": "E-mail to receive the alert" + "label": "E-mail to receive the alert", + "condition": { + "field": "standards.QuarantineRequestAlert.state", + "compareType": "isNot", + "compareValue": { "label": "Removed", "value": "removed" } + } } ], "label": "Quarantine Release Request Alert", "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-07-15", - "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", + "powershellEquivalent": "New-ProtectionAlert, Set-ProtectionAlert and Remove-ProtectionAlert", "recommendedBy": [], "requiredCapabilities": [ "EXCHANGE_S_STANDARD", @@ -6021,6 +6087,16 @@ "type": "switch", "name": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerAccess", "label": "Allow communication with unmanaged Teams accounts" + }, + { + "type": "switch", + "name": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerInbound", + "label": "Allow unmanaged Teams users to initiate contact", + "condition": { + "field": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerAccess", + "compareType": "is", + "compareValue": true + } } ], "label": "External Access Settings for Microsoft Teams", @@ -6045,6 +6121,16 @@ "name": "standards.TeamsFederationConfiguration.AllowTeamsConsumer", "label": "Allow users to communicate with consumer Teams accounts" }, + { + "type": "switch", + "name": "standards.TeamsFederationConfiguration.AllowTeamsConsumerInbound", + "label": "Allow unmanaged Teams users to initiate contact", + "condition": { + "field": "standards.TeamsFederationConfiguration.AllowTeamsConsumer", + "compareType": "is", + "compareValue": true + } + }, { "type": "autoComplete", "required": true, @@ -6463,6 +6549,68 @@ "recommendedBy": [], "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, + { + "name": "standards.AppleEnrollmentTypeProfile", + "cat": "Intune Standards", + "tag": ["enrollment", "apple", "ios"], + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "helpText": "Creates and manages an Apple user-initiated enrollment type profile (such as iOS/iPadOS web based device enrollment) and keeps it assigned to the configured groups. The tenant needs an Apple MDM push certificate for the enrollment itself to function.", + "executiveText": "Ensures every tenant offers the same enrollment experience for Apple devices, such as web based enrollment for personal iPhones and iPads, without engineers configuring each tenant by hand. This keeps device onboarding consistent and makes it possible to report on which tenants are correctly configured.", + "docsDescription": "Deploys an Apple user-initiated enrollment type profile through deviceManagement/appleUserInitiatedEnrollmentProfiles. The profile is matched by display name; the enrollment type (web based device enrollment, account driven user enrollment, or device enrollment with Company Portal), description and group assignments are kept in sync, with a wrong assignment repaired in place. Priority is only applied when the profile is first created, because reordering is relative to the other profiles in each tenant.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.AppleEnrollmentTypeProfile.DisplayName", + "label": "Profile Display Name", + "required": true + }, + { + "type": "textField", + "name": "standards.AppleEnrollmentTypeProfile.Description", + "label": "Profile Description", + "required": false + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "name": "standards.AppleEnrollmentTypeProfile.EnrollmentType", + "label": "Enrollment Type", + "options": [ + { "label": "Web based device enrollment", "value": "webDeviceEnrollment" }, + { "label": "Account driven user enrollment", "value": "accountDrivenUserEnrollment" }, + { "label": "Device enrollment with Company Portal", "value": "device" } + ] + }, + { + "type": "number", + "name": "standards.AppleEnrollmentTypeProfile.Priority", + "label": "Priority (applied when the profile is created)", + "defaultValue": 1 + }, + { + "type": "radio", + "name": "standards.AppleEnrollmentTypeProfile.AssignTo", + "label": "Profile Assignment", + "options": [ + { "label": "Do not assign", "value": "none" }, + { "label": "Assign to Custom Group", "value": "customGroup" } + ] + }, + { + "type": "textField", + "name": "standards.AppleEnrollmentTypeProfile.customGroup", + "label": "Custom group name(s). Comma separated, wildcards allowed.", + "required": false + } + ], + "label": "Deploy Apple Enrollment Type Profile", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-08-18", + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] + }, { "name": "standards.IntuneTemplate", "cat": "Templates", @@ -6939,7 +7087,8 @@ "label": "Select Sensitivity Label Templates", "api": { "url": "/api/ListSensitivityLabelTemplates", - "labelField": "name", + "labelField": "DisplayName", + "altLabelField": "Name", "valueField": "GUID", "queryKey": "ListSensitivityLabelTemplates" } @@ -7655,6 +7804,28 @@ "EXCHANGE_LITE" ] }, + { + "name": "standards.MessageEncryption", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Enables Microsoft Purview Message Encryption by turning on Azure RMS licensing for Exchange Online. Skipped with a warning when the tenant still points at an on-premises AD RMS cluster, because AD RMS has to be migrated to Azure RMS first. This standard only turns the feature on: branding, one-time passcodes, and social ID sign-in for encrypted messages are configured in the [Configure Encrypted Message Branding (OME)](https://standards.cipp.app/standards/omebranding) standard. [Read more](https://learn.microsoft.com/en-us/purview/set-up-new-message-encryption-capabilities)", + "docsDescription": "Sets AzureRMSLicensingEnabled to true, the only prerequisite for Microsoft Purview Message Encryption. Reports the IRM licensing state per tenant, including the licensing location, so you can see at a glance which tenants have message encryption available. Remediation is deliberately skipped for tenants with an on-premises AD RMS licensing location, as Purview Message Encryption is not compatible with AD RMS and those tenants need to be migrated to Azure RMS first.", + "executiveText": "Turns on the built-in encryption that lets staff send protected email to anyone, including recipients outside the organization. Uses licensing the organization already owns, removing the need for a separate secure-email product.", + "addedComponent": [], + "label": "Enable Purview Message Encryption", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-08-04", + "powershellEquivalent": "Set-IRMConfiguration -AzureRMSLicensingEnabled $true", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, { "name": "standards.OMEBranding", "cat": "Exchange Standards", diff --git a/src/hooks/use-actions-dispatch.jsx b/src/hooks/use-actions-dispatch.jsx new file mode 100644 index 000000000000..bad8a7f87968 --- /dev/null +++ b/src/hooks/use-actions-dispatch.jsx @@ -0,0 +1,91 @@ +import { useCallback, useState } from "react"; +import { CippApiDialog } from "../components/CippComponents/CippApiDialog"; +import { useDialog } from "./use-dialog"; +import { useSettings } from "./use-settings"; + +const IDLE = { data: {}, action: {}, ready: false }; + +/** + * Shared dispatch for a page-level `actions` array. + * + * The desktop ActionsMenu and the mobile page-actions sheet present the same actions two + * ways; keeping the confirm-vs-run decision and the dialog wiring here is what stops the + * two presentations from drifting apart. + * + * Note the state is per-instance: two mounted consumers get two dispatchers, so this shares + * the decision, not an in-flight dialog. + */ +export const useActionsDispatch = ({ actions = [], data, queryKeys }) => { + const [actionData, setActionData] = useState(IDLE); + const [customAction, setCustomAction] = useState(null); + const createDialog = useDialog(); + const settings = useSettings(); + + // Nullsafety for data: it can be undefined (still loading) or null (no data) + const isDisabled = (action) => { + if (!data) return true; + if (action?.condition) return !action.condition(data); + return false; + }; + + const visibleActions = actions?.filter((action) => !action.link || action.showInActionsMenu) ?? []; + + const dispatch = (action) => { + // An AllTenants row carries its own tenant; posting under "AllTenants" would target the + // wrong one. Page-level data has no Tenant, so this is a no-op there. + if (settings?.currentTenant === "AllTenants" && data?.Tenant) { + settings.handleUpdate({ currentTenant: data.Tenant }); + } + + // Run-and-return paths must NOT set ready: doing so mounts CippApiDialog with + // api.noConfirm true, and its mount effect auto-submits into the very customFunction + // just called here — one tap, two invocations. + if (action?.noConfirm && action.customFunction) { + action.customFunction(data, action, {}); + return; + } + if (typeof action?.customComponent === "function") { + setCustomAction({ data, action }); + return; + } + + setActionData({ data, action, ready: true }); + createDialog.handleOpen(); + }; + + // Dropped once the close transition finishes rather than on close, so the dialog keeps its + // exit animation. Leaving it mounted would hold a live mutation, an API subscription and a + // form instance for the life of the page — and HeaderedTabbedLayout never unmounts. + const handleExited = useCallback(() => setActionData(IDLE), []); + + const dialog = ( + <> + {actionData.ready && ( + + )} + {customAction?.action?.customComponent(customAction.data, { + drawerVisible: Boolean(customAction), + setDrawerVisible: (visible) => !visible && setCustomAction(null), + fromRowAction: false, + })} + + ); + + return { visibleActions, isDisabled, dispatch, dialog }; +}; diff --git a/src/hooks/use-breakpoint.js b/src/hooks/use-breakpoint.js new file mode 100644 index 000000000000..dedf377e95a5 --- /dev/null +++ b/src/hooks/use-breakpoint.js @@ -0,0 +1,44 @@ +import { useMediaQuery } from "@mui/material"; +import { useSettings } from "./use-settings"; + +// Shared breakpoint hooks so the two mobile thresholds sit next to each other. + +// Chrome pivots where the side nav gives way to the drawer (layouts/index.js). Everything that +// has to agree with the nav reads this: content gutter, top-nav hamburger, page toolbars. +export const useIsMobileLayout = () => useMediaQuery((theme) => theme.breakpoints.down("lg")); + +// Tables pivot narrower: a table still reads fine at 1100, cards that wide are mostly whitespace. +export const useIsNarrowForTables = () => useMediaQuery((theme) => theme.breakpoints.down("md")); + +export const useIsTabletLayout = () => + useMediaQuery((theme) => theme.breakpoints.between("sm", "md")); + +// Settings values can be raw strings or {value,label} autocomplete objects depending on +// which control wrote them — accept both. +const unwrap = (setting) => (typeof setting === "object" && setting !== null ? setting.value : setting); + +const VALID_MODES = ["auto", "cards", "table"]; + +/** + * Resolves how a CippDataTable should present itself: 'cards' or 'table'. + * + * Precedence: per-call viewMode prop > settings.tableViewMode > 'auto'. + * 'auto' means cards below the md breakpoint, table at or above it. + * simple tables are always 'table' — they are 2-3 column embeds that already fit. + * + * The explicit modes exist for more than preference: jsdom has no width-based + * matchMedia, so unit tests drive card mode through this path rather than by + * stubbing media queries. + */ +export const useTableViewMode = ({ viewMode, simple = false } = {}) => { + const settings = useSettings(); + const isNarrow = useIsNarrowForTables(); + + if (simple) return "table"; + + let mode = unwrap(viewMode) ?? unwrap(settings?.tableViewMode) ?? "auto"; + if (!VALID_MODES.includes(mode)) mode = "auto"; + + if (mode === "auto") return isNarrow ? "cards" : "table"; + return mode; +}; diff --git a/src/hooks/use-history-dismiss.js b/src/hooks/use-history-dismiss.js new file mode 100644 index 000000000000..633841887496 --- /dev/null +++ b/src/hooks/use-history-dismiss.js @@ -0,0 +1,40 @@ +import { useEffect, useRef } from "react"; +import { useRouter } from "next/router"; +import { + installOverlayHistory, + pushOverlayEntry, + releaseOverlayEntry, +} from "../utils/overlay-history"; + +/** + * Gives an overlay a history entry of its own, so a phone's back gesture dismisses it + * instead of navigating the page away. + * + * useHistoryDismiss(visible, onClose, isMobile); + * + * The entry is pushed while the overlay is open and popped when it closes for any other + * reason, so the history stack is only ever as deep as what is actually on screen. + * + * Currently used by full-screen mobile surfaces (CippOffCanvas), not bottom sheets: the + * release pops synchronously, and the FAB sheet closes itself in the same tick as the + * drawer its child opens, which would let the queued back() take the drawer's entry with + * it. Registering sheets means deferring the pop so the incoming overlay can reuse the + * outgoing one's entry. + */ +export const useHistoryDismiss = (open, onClose, enabled = true) => { + const router = useRouter(); + const closeRef = useRef(onClose); + + useEffect(() => { + closeRef.current = onClose; + }, [onClose]); + + useEffect(() => { + // Nothing to hand the gesture to — an overlay with no onClose can't be dismissed, and + // claiming a history entry for it would only eat a back press. + if (!enabled || !open || typeof closeRef.current !== "function") return undefined; + installOverlayHistory(router); + const entry = pushOverlayEntry(() => closeRef.current?.()); + return () => releaseOverlayEntry(entry); + }, [enabled, open, router]); +}; diff --git a/src/hooks/use-sheet-handoff.js b/src/hooks/use-sheet-handoff.js new file mode 100644 index 000000000000..7846e22c1eae --- /dev/null +++ b/src/hooks/use-sheet-handoff.js @@ -0,0 +1,59 @@ +import { useCallback, useRef } from "react"; + +/** + * Hands a bottom sheet off to the overlay it launches. + * + * A sheet row that closes the sheet and opens a drawer/dialog in the same tick puts two + * MUI Modals in flight at once: the new one registers with the modal manager while the + * outgoing Drawer is still transitioning, and when that Drawer finally unmounts it + * restores scroll lock, focus and aria-hidden on top of the overlay that just opened — + * which reads as the overlay refusing to open, or opening dead. + * + * Instead, park the callback and run it from the sheet's exit transition: + * + * const sheet = useSheetHandoff(() => setOpen(false)); + * sheet.run(() => setDrawerOpen(true))} /> + * + * + * `run` still closes the sheet immediately, so the tap feels the same. + */ +// Drawer's exit is ~195ms; well past it the sheet is gone whether or not the transition +// reported in. Running late beats never running, and flush() is idempotent. +const EXIT_FALLBACK_MS = 400; + +export const useSheetHandoff = (close) => { + const pendingRef = useRef(null); + const fallbackRef = useRef(null); + + const flush = useCallback(() => { + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); + fallbackRef.current = null; + } + const pending = pendingRef.current; + pendingRef.current = null; + pending?.(); + }, []); + + const run = useCallback( + (fn) => { + pendingRef.current = typeof fn === "function" ? fn : null; + if (fallbackRef.current) clearTimeout(fallbackRef.current); + fallbackRef.current = setTimeout(flush, EXIT_FALLBACK_MS); + close?.(); + }, + [close, flush] + ); + + // Dismissed without picking anything — drop whatever was parked. + const cancel = useCallback(() => { + pendingRef.current = null; + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); + fallbackRef.current = null; + } + close?.(); + }, [close]); + + return { run, handleExited: flush, cancel }; +}; diff --git a/src/hooks/use-swipe-close-transition.js b/src/hooks/use-swipe-close-transition.js new file mode 100644 index 000000000000..2f83e9b25cb2 --- /dev/null +++ b/src/hooks/use-swipe-close-transition.js @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useRef } from "react"; + +// Slide probes the paper's untranslated position when the exit starts (Slide.js +// getTranslateValue), so a paper carrying a drag transform snaps wide open and animates the +// full width out. Re-seed the start position with where the finger let go. +export const useSwipeCloseTransition = (open, onClose) => { + const paperRef = useRef(null); + const dragFrom = useRef(null); + + // fires as the open transition starts, so a drag that begins mid-animation still has the node + const handleEnter = useCallback((node) => { + paperRef.current = node; + }, []); + + const handleClose = useCallback( + (...args) => { + const transform = paperRef.current?.style.transform; + dragFrom.current = transform && transform !== "none" ? transform : null; + onClose?.(...args); + }, + [onClose] + ); + + // Effects flush child-first, so this lands after Slide's own exit effect, which runs the same + // probe again. Repairing from the transition's onExit callback gets overwritten by it. + useEffect(() => { + const node = paperRef.current; + const from = dragFrom.current; + dragFrom.current = null; + if (open || !node || !from) { + return; + } + const target = node.style.transform; + const transition = node.style.transition; + node.style.transition = "none"; + node.style.transform = from; + node.getBoundingClientRect(); + node.style.transition = transition; + node.style.transform = target; + }, [open]); + + return { + onClose: handleClose, + transitionProps: { onEnter: handleEnter }, + }; +}; diff --git a/src/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx index d217c9c87d5e..9ab87cf40918 100644 --- a/src/layouts/HeaderedTabbedLayout.jsx +++ b/src/layouts/HeaderedTabbedLayout.jsx @@ -1,11 +1,10 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { usePathname } from "next/navigation"; import { useRouter } from "next/router"; import PropTypes from "prop-types"; import ArrowLeftIcon from "@heroicons/react/24/outline/ArrowLeftIcon"; import { Box, - Button, Container, Divider, Skeleton, @@ -16,8 +15,12 @@ import { Typography, } from "@mui/material"; import { ActionsMenu } from "../components/actions-menu"; -import { useMediaQuery } from "@mui/material"; import { getIconByName } from "../utils/icon-registry"; +import { useIsMobileLayout } from "../hooks/use-breakpoint"; +import { useActionsDispatch } from "../hooks/use-actions-dispatch"; +import { TabNavigationContext, useTabNavigationValue } from "./tab-navigation-context"; +import { CippPageActionsFab } from "../components/CippComponents/CippPageActionsFab"; +import { CippTabPicker } from "../components/CippComponents/CippTabPicker"; export const HeaderedTabbedLayout = (props) => { const { @@ -27,16 +30,23 @@ export const HeaderedTabbedLayout = (props) => { subtitle, actions, actionsData, + // Without this the dispatch falls back to CippApiDialog's hardcoded title, so a header + // action mutates successfully and never invalidates the page query. + queryKeys, isFetching = false, backUrl, + // Optional replacement for the title Typography — same slot, same truncation duties. + titleControl, } = props; - const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + // The shared hook rather than an inline useMediaQuery: same threshold, but only this one is + // mockable, and jsdom has no width-based matchMedia to drive the mobile branch with. + const isMobile = useIsMobileLayout(); const router = useRouter(); const pathname = usePathname(); const queryParams = router.query; - const handleTabsChange = useCallback( - (event, value) => { + const navigateToTab = useCallback( + (value) => { //if we have query params, we need to append them to the new path router.push( { @@ -47,106 +57,221 @@ export const HeaderedTabbedLayout = (props) => { { shallow: true } ); }, - [router] + [router, queryParams] ); + const handleTabsChange = useCallback((event, value) => navigateToTab(value), [navigateToTab]); + const currentTab = tabOptions.find((option) => option.path === pathname); - return ( - - - - + // Below md the tab row scrolls horizontally and still hides tabs off the right edge, so + // navigation collapses to a picker in the title row — the one part of that row that is + // empty at this width, since the Actions menu gets clipped here and moves to the FAB. + const actionsDispatch = useActionsDispatch({ actions, data: actionsData, queryKeys }); + // No isFetching term: the desktop menu's equivalent `disabled` prop is swallowed by + // ActionsMenu's unspread ...other, so including it here greyed out every action on mobile + // during a background refetch while desktop left them clickable. Actions operate on + // stale-but-present data quite happily; aligning down keeps the two surfaces identical + // without changing desktop. + const { visibleActions, isDisabled, dispatch } = actionsDispatch; + const sheetActions = useMemo( + () => + isMobile + ? visibleActions.map((action) => ({ + label: action.label, + icon: action.icon ? {action.icon} : null, + disabled: isDisabled(action), + onClick: () => dispatch(action), + })) + : [], + [isMobile, visibleActions, isDisabled, dispatch] + ); + + const tabNavValue = useTabNavigationValue({ + tabs: tabOptions, + currentPath: pathname, + onNavigate: navigateToTab, + actions: sheetActions, + enabled: isMobile, + providesGutters: true, + }); + + const subtitleBlock = isFetching ? ( + + ) : ( + subtitle && ( + // useFlexGap: Stack's default spacing is a margin-left between children, which every + // wrapped row inherits — that margin is why the icon/chip pairs sat indented from the + // title above them. Gap applies to both axes, so the row gap is set separately or the + // stacked pairs end up as far apart vertically as they are horizontally. + + {/* minWidth: 0 down the whole chain, and flexShrink: 0 on the icon. A copy-chip + already carries MUI's ellipsis and maxWidth: 100%, but flex items default to + min-width: auto, so every ancestor grew to fit instead of letting it truncate — + which is how a guest UPN (user_domain.onmicrosoft.com#EXT#@tenant...) ran off the + right edge of the screen. */} + {subtitle.map((item, index) => + item.component ? ( + + {item.component} + + ) : ( + + {item.icon} + + + {item.text} + + + ) + )} + + ) + ); + + return ( + + + {/* One gutter for the whole page, matching the layout's breadcrumb rail + (mx: {xs: 2, md: 3}): the breadcrumbs, this header's text and the left edge of + every card below it then share a single left edge. */} + + + - {title} - - {isFetching ? ( - - ) : ( - subtitle && ( - - {subtitle.map((item, index) => - item.component ? ( - {item.component} - ) : ( - - {item.icon} - - {item.text} - - + {/* minWidth: 0 so a long tenant/entity name truncates in the space the + picker leaves rather than pushing it off the right edge of the row. + Scoped to the picker's own breakpoint — above md this is unchanged. */} + + + {/* A name-shaped skeleton, not the word "Loading...": the header is + the entity's identity, and a text placeholder reads as a title. + titleControl lets a page swap the text for an interactive control + in the same clothes (the View User pages mount a user switcher). */} + {isFetching ? ( + + + + ) : ( + titleControl ?? ( + + {title} + ) )} - ) - )} + {!isMobile && subtitleBlock} + + {/* The right half of this row is free below md, which is where the tab + picker goes. Above md it belongs to the Actions menu, as it always did. */} + {isMobile ? ( + + ) : ( + actions && + actions.length > 0 && ( + + ) + )} + + {/* Below md the subtitle gets the full width instead of sharing the title's + row: a UPN copy-chip squeezed beside a half-width picker has nowhere to go + and runs off the right edge of the screen. */} + {isMobile && subtitleBlock} - {actions && actions.length > 0 && ( - + {!isMobile && ( +
+ + {tabOptions.map((option) => { + const icon = getIconByName(option.icon, { fontSize: "small" }); + const iconPosition = option.iconPosition ?? "start"; + const compactIcon = icon && ["end", "start"].includes(iconPosition); + + return ( + + ); + })} + + +
)}
-
- - {tabOptions.map((option) => { - const icon = getIconByName(option.icon, { fontSize: "small" }); - const iconPosition = option.iconPosition ?? "start"; - const compactIcon = icon && ["end", "start"].includes(iconPosition); - - return ( - - ); - })} - - -
-
- - {children} - -
-
-
+ > + {children} + +
+ + + {/* Not gated on isMobile: crossing the breakpoint with a dialog open — a rotate, or a + tablet at 900px — would unmount it mid-request, taking CippApiResults with it. + The hook already renders nothing until an action is dispatched. */} + {actionsDispatch.dialog} + {/* Actions only, and only when no page FAB claimed the corner — otherwise they ride in + that sheet. Tabs are in the title row and never come down here. */} + {isMobile && sheetActions.length > 0 && !tabNavValue.isActionCornerClaimed && ( + + )} + ); }; diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index af7403327d53..cc89fd11a794 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -1,10 +1,13 @@ -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { usePathname, useRouter } from 'next/navigation' import { Box, Divider, Stack, Tab, Tabs } from '@mui/material' import { useSearchParams } from 'next/navigation' import { ApiGetCall } from '../api/ApiCall' import { getIconByName } from '../utils/icon-registry' import { useSettings } from '../hooks/use-settings' +import { useIsMobileLayout } from '../hooks/use-breakpoint' +import { TabNavigationContext, useTabNavigationValue } from './tab-navigation-context' +import { CippTabPicker } from '../components/CippComponents/CippTabPicker' export const TabbedLayout = (props) => { const { tabOptions, children } = props @@ -37,57 +40,85 @@ export const TabbedLayout = (props) => { return tabs.filter((option) => !disabledPages.includes(option.path)) }, [tabOptions, featureFlags.isSuccess, featureFlags.data, showAdvanced]) - const handleTabsChange = (event, value) => { - // Preserve existing query parameters when changing tabs - const currentParams = new URLSearchParams(searchParams.toString()) - const queryString = currentParams.toString() - const newPath = queryString ? `${value}?${queryString}` : value - router.push(newPath) - } + const navigateToTab = useCallback( + (value) => { + // Preserve existing query parameters when changing tabs + const currentParams = new URLSearchParams(searchParams.toString()) + const queryString = currentParams.toString() + const newPath = queryString ? `${value}?${queryString}` : value + router.push(newPath) + }, + [router, searchParams] + ) + + const handleTabsChange = (event, value) => navigateToTab(value) const currentTab = visibleTabs.find((option) => option.path === pathname) + // Below md the tab row scrolls horizontally and still hides tabs off the right edge, so + // navigation collapses to a full-width picker in the slot the tab bar occupied. Always the + // layout's own row: a picker that sometimes annexes a heading somewhere on the page and + // sometimes doesn't is a control you have to go looking for. + const isMobile = useIsMobileLayout() + const tabNavValue = useTabNavigationValue({ + tabs: visibleTabs, + currentPath: pathname, + onNavigate: navigateToTab, + enabled: isMobile, + }) + return ( - - - - - {visibleTabs.map((option) => { - const icon = getIconByName(option.icon, { fontSize: 'small' }) - const iconPosition = option.iconPosition ?? 'start' - const compactIcon = icon && ['end', 'start'].includes(iconPosition) + + + + {isMobile && ( + // pt: 2 nets to the same 16px the sides and the Stack gap below pay: the + // breadcrumb divider's mb (8) is cancelled by this layout's mt: -1. + + + + )} + {!isMobile && ( + + + {visibleTabs.map((option) => { + const icon = getIconByName(option.icon, { fontSize: 'small' }) + const iconPosition = option.iconPosition ?? 'start' + const compactIcon = icon && ['end', 'start'].includes(iconPosition) - return ( - - ) - })} - - - - {children} - - + return ( + + ) + })} + + + + )} + {children} + + + ) } diff --git a/src/layouts/account-popover.js b/src/layouts/account-popover.js index 444ee7dcd4ac..e27a3aba6e83 100644 --- a/src/layouts/account-popover.js +++ b/src/layouts/account-popover.js @@ -2,8 +2,10 @@ import { useCallback } from "react"; import PropTypes from "prop-types"; import { useRouter } from "next/navigation"; import toast from "react-hot-toast"; +import ArrowPathIcon from "@heroicons/react/24/outline/ArrowPathIcon"; import ArrowRightOnRectangleIcon from "@heroicons/react/24/outline/ArrowRightOnRectangleIcon"; import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; +import MagnifyingGlassIcon from "@heroicons/react/24/outline/MagnifyingGlassIcon"; import MoonIcon from "@heroicons/react/24/outline/MoonIcon"; import SunIcon from "@heroicons/react/24/outline/SunIcon"; import { @@ -22,22 +24,32 @@ import { useMediaQuery, } from "@mui/material"; import { usePopover } from "../hooks/use-popover"; +import { useIsMobileLayout } from "../hooks/use-breakpoint"; +import { useDialog } from "../hooks/use-dialog"; import { paths } from "../paths"; import { ApiGetCall } from "../api/ApiCall"; -import { CogIcon, DocumentTextIcon } from "@heroicons/react/24/outline"; +import { CippApiDialog } from "../components/CippComponents/CippApiDialog"; +import { CogIcon, DocumentTextIcon, LifebuoyIcon, TrashIcon } from "@heroicons/react/24/outline"; +import ArrowTopRightOnSquareIcon from "@heroicons/react/24/outline/ArrowTopRightOnSquareIcon"; import { useReleaseNotes } from "../contexts/release-notes-context"; import { useQueryClient } from "@tanstack/react-query"; +import { usePathname } from "next/navigation"; +import { Divider } from "@mui/material"; +import { getHelpLinks, clearCippCache } from "../utils/help-links"; export const AccountPopover = (props) => { const { direction = "ltr", language = "en", onThemeSwitch, + onOpenSearch, paletteMode = "light", ...other } = props; const router = useRouter(); + const pathname = usePathname(); const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + const navCollapsed = useIsMobileLayout(); const popover = usePopover(); const queryClient = useQueryClient(); const { openReleaseNotes } = useReleaseNotes(); @@ -59,6 +71,11 @@ export const AccountPopover = (props) => { convertToDataUrl: true, }); + // Re-checks Entra group membership server-side, then refetches /api/me so a role granted + // through a just-activated PIM group applies without waiting out the role cache. Runs + // through the standard confirm dialog, which also renders the API result. + const refreshAccessDialog = useDialog(); + const handleLogout = useCallback(async () => { try { popover.handleClose(); @@ -125,6 +142,20 @@ export const AccountPopover = (props) => { )} + {orgData.data?.clientPrincipal?.userDetails && ( + + )} {orgData.data?.clientPrincipal?.userDetails && ( { PaperProps={{ sx: { width: 260 } }} > + {/* Pairs with the trigger above: the identity is either beside the avatar or here. */} {mdDown && ( + + + + )} + {/* Home for the two bar icons top-nav drops at navCollapsed (useIsMobileLayout), + so they stay reachable wherever the bar isn't showing them. */} + {navCollapsed && ( <> - - - + {onOpenSearch && ( + { + popover.handleClose(); + onOpenSearch(); + }} + > + + + + + + + + )} { popover.handleClose(); onThemeSwitch(); }}> @@ -177,6 +228,62 @@ export const AccountPopover = (props) => { + {/* Mobile home for the help SpeedDial's destinations — its FAB corner belongs + to page actions there (the SpeedDial hides itself below md). */} + {mdDown && ( + <> + + {getHelpLinks(pathname ?? "").map((link) => ( + { + popover.handleClose(); + window.open(link.href, "_blank"); + }} + > + + + + + + + + + + + ))} + { + popover.handleClose(); + clearCippCache(queryClient); + }} + > + + + + + + + + + + )} + { + popover.handleClose(); + refreshAccessDialog.handleOpen(); + }} + > + + + + + + + diff --git a/src/layouts/config.js b/src/layouts/config.js index 3be081ae159a..8dab949ae483 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -44,6 +44,11 @@ export const nativeMenuItems = [ path: '/identity/administration/users', permissions: ['Identity.User.*'], }, + { + title: 'Guest Users', + path: '/identity/administration/guest-users', + permissions: ['Identity.User.*'], + }, { title: 'Risky Users', path: '/identity/administration/risky-users', @@ -215,6 +220,7 @@ export const nativeMenuItems = [ title: 'Standards & Drift', permissions: [ 'Tenant.Standards.*', + 'Tenant.Baselines.*', 'Tenant.BestPracticeAnalyser.*', 'Tenant.DomainAnalyser.*', ], @@ -225,12 +231,12 @@ export const nativeMenuItems = [ permissions: ['Tenant.Standards.*'], scope: 'global', }, - // Baselines mockup - hidden from the nav for now; reach it directly + // Baselines - hidden from the nav for now; reach it directly // at /tenant/baselines // { // title: 'Baselines (Preview)', // path: '/tenant/baselines', - // permissions: ['Tenant.Standards.*'], + // permissions: ['Tenant.Baselines.*'], // scope: 'global', // }, { @@ -1086,6 +1092,11 @@ export const nativeMenuItems = [ path: '/email/tools/mailbox-restores', permissions: ['Exchange.Mailbox.*'], }, + { + title: 'Message Encryption', + path: '/email/tools/message-encryption', + permissions: ['Exchange.Mailbox.*'], + }, ], }, { diff --git a/src/layouts/constants.js b/src/layouts/constants.js index ee450b86e3dd..37c513ad06cc 100644 --- a/src/layouts/constants.js +++ b/src/layouts/constants.js @@ -7,8 +7,7 @@ export const TOP_NAV_HEIGHT = 64 export const SIDE_NAV_WIDTH = 290 -export const SIDE_NAV_PINNED_WIDTH = 50 -export const SIDE_NAV_COLLAPSED_WIDTH = 73 // icon size + padding + border right +export const SIDE_NAV_COLLAPSED_WIDTH = 73 // icon size + padding + border right; also the unpinned content offset // Height of the hosted maintenance banner, published by CippMaintenanceBanner via a CSS custom // property on :root so the fixed chrome can offset itself without prop threading. Resolves to 0px diff --git a/src/layouts/index.js b/src/layouts/index.js index 510a2c62a4aa..44c33201f056 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useState, useRef } from 'react' import { usePathname } from 'next/navigation' -import { Box, Container, Divider, Stack, useMediaQuery } from '@mui/material' +import { Box, Container, Divider, Stack } from '@mui/material' import { styled } from '@mui/material/styles' +import { useIsMobileLayout } from '../hooks/use-breakpoint' import { useSettings } from '../hooks/use-settings' import { Footer } from './footer' import { MobileNav } from './mobile-nav' @@ -19,10 +20,11 @@ import { ForcedSsoMigrationDialog } from '../components/CippComponents/ForcedSso import { SubscriptionEndedDialog } from '../components/CippComponents/SubscriptionEndedDialog' import { FailedPaymentDialog } from '../components/CippComponents/FailedPaymentDialog' import { CippMaintenanceBanner } from '../components/CippComponents/CippMaintenanceBanner' +import { CippImpersonationBanner } from '../components/CippComponents/CippImpersonationBanner' import { BANNER_HEIGHT_VAR, - SIDE_NAV_PINNED_WIDTH, + SIDE_NAV_COLLAPSED_WIDTH, SIDE_NAV_WIDTH, TOP_NAV_HEIGHT, } from './constants' @@ -56,6 +58,9 @@ const useMobileNav = () => { } } +// No breakpoint paddingLeft here: the side-nav offset is applied once, via the inline +// `sx` on the rendered LayoutRoot (it depends on pinNav). A second static rule at lg+ +// used to fight that dynamic one over the same property. const LayoutRoot = styled('div')(({ theme }) => ({ backgroundColor: theme.palette.background.default, display: 'flex', @@ -64,9 +69,6 @@ const LayoutRoot = styled('div')(({ theme }) => ({ height: '100vh', overflow: 'hidden', paddingTop: `calc(${TOP_NAV_HEIGHT}px + ${BANNER_HEIGHT_VAR})`, - [theme.breakpoints.up('lg')]: { - paddingLeft: SIDE_NAV_WIDTH, - }, })) const LayoutContainer = styled('div')({ @@ -82,7 +84,8 @@ export const Layout = (props) => { // showBreadcrumb: the error routes opt out — there is no trail to a page that // doesn't exist or just crashed, and the bookmark button lives in there too. const { children, allTenantsSupport = true, showBreadcrumb = true } = props - const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) + // one gate for the swap: drawer, the hamburger that opens it (top-nav), the gutter below + const navCollapsed = useIsMobileLayout() const settings = useSettings() const mobileNav = useMobileNav() const [fetchingVisible, setFetchingVisible] = useState([]) @@ -203,7 +206,9 @@ export const Layout = (props) => { }) }, [settings]) - const offset = settings.pinNav ? SIDE_NAV_WIDTH : SIDE_NAV_PINNED_WIDTH + // Unpinned content offset must match the collapsed drawer's real width — the old 50px + // constant left 23px of content underneath the 73px rail. + const offset = settings.pinNav ? SIDE_NAV_WIDTH : SIDE_NAV_COLLAPSED_WIDTH const userSettingsAPI = ApiGetCall({ url: '/api/ListUserSettings', @@ -303,19 +308,26 @@ export const Layout = (props) => { <> {/* Rendered outside the hideSidebar check - maintenance applies to chrome-less pages too. */} + {hideSidebar === false && ( <> - {mdDown && ( - + {navCollapsed && ( + )} - {!mdDown && } + {!navCollapsed && } )} @@ -331,7 +343,7 @@ export const Layout = (props) => { - + { ) : ( - {showBreadcrumb && ( - <> - - - - - - )} + {/* The nav carries its own rail chrome (gutter + divider) so that when it + renders nothing — a single crumb on a phone — no hairline is left behind. */} + {showBreadcrumb && } {children} )} diff --git a/src/layouts/mobile-nav-item.js b/src/layouts/mobile-nav-item.js index 961d01ee33be..4367198a7ad7 100644 --- a/src/layouts/mobile-nav-item.js +++ b/src/layouts/mobile-nav-item.js @@ -24,6 +24,9 @@ export const MobileNavItem = (props) => { const isGlobal = scope === "global"; const [open, setOpen] = useState(openImmediately); + // same step as side-nav-item, nesting reads the same in both navs + const indent = depth > 0 ? depth * 1.5 : 1; + const handleToggle = useCallback(() => { setOpen((prevOpen) => !prevOpen); }, []); @@ -43,7 +46,7 @@ export const MobileNavItem = (props) => { fontSize: 14, fontWeight: 500, justifyContent: 'flex-start', - px: '6px', + px: `${indent * 6}px`, py: '12px', textAlign: 'left', whiteSpace: 'nowrap', @@ -119,7 +122,7 @@ export const MobileNavItem = (props) => { fontSize: 14, fontWeight: 500, justifyContent: 'flex-start', - px: '6px', + px: `${indent * 6}px`, py: '12px', textAlign: 'left', whiteSpace: 'nowrap', diff --git a/src/layouts/mobile-nav.js b/src/layouts/mobile-nav.js index 334914c822db..264b7cd97afe 100644 --- a/src/layouts/mobile-nav.js +++ b/src/layouts/mobile-nav.js @@ -1,18 +1,23 @@ +import { useMemo, useState } from "react"; import NextLink from "next/link"; import { usePathname } from "next/navigation"; import PropTypes from "prop-types"; -import { Box, Divider, Drawer, Stack } from "@mui/material"; +import { Box, Divider, InputAdornment, OutlinedInput, Stack, SwipeableDrawer, Typography } from "@mui/material"; +import { Search } from "@mui/icons-material"; import { Logo } from "../components/logo"; +import { CippSponsor } from "../components/CippComponents/CippSponsor"; 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"; +import { useSwipeCloseTransition } from "../hooks/use-swipe-close-transition"; -const MOBILE_NAV_WIDTH = "80%"; +// 80% of the viewport truncated third-level labels at 320px (256px) and was absurd at +// 899px (719px). Cap it like a real nav drawer. +const MOBILE_NAV_WIDTH = "min(360px, 88vw)"; -const renderItems = ({ depth = 0, items, pathname }) => +const renderItems = ({ depth = 0, items, pathname, forceOpen = false }) => items.reduce( (acc, item) => reduceChildRoutes({ @@ -20,11 +25,12 @@ const renderItems = ({ depth = 0, items, pathname }) => depth, item, pathname, + forceOpen, }), [] ); -const reduceChildRoutes = ({ acc, depth, item, pathname }) => { +const reduceChildRoutes = ({ acc, depth, item, pathname, forceOpen }) => { const checkPath = !!(item.path && pathname); // Special handling for root path "/" to avoid matching all paths const partialMatch = checkPath && item.path !== "/" ? pathname.includes(item.path) : false; @@ -37,8 +43,9 @@ const reduceChildRoutes = ({ acc, depth, item, pathname }) => { depth={depth} external={item.external} icon={item.icon} - key={item.title} - openImmediately={partialMatch} + // Search results re-render with a different key so collapse state resets open + key={`${item.title}-${forceOpen ? "open" : "closed"}`} + openImmediately={forceOpen || partialMatch} path={item.path} scope={item.scope} title={item.title} @@ -56,6 +63,7 @@ const reduceChildRoutes = ({ acc, depth, item, pathname }) => { depth: depth + 1, items: item.items, pathname, + forceOpen, })} @@ -78,60 +86,120 @@ const reduceChildRoutes = ({ acc, depth, item, pathname }) => { return acc; }; +// Prune the nav tree to items whose title matches the query, keeping ancestors of matches. +// A matching branch keeps its whole subtree so its children stay reachable. +const filterNavItems = (items, query) => + items.reduce((acc, item) => { + const selfMatch = item.title?.toLowerCase().includes(query); + if (item.items) { + if (selfMatch) { + acc.push(item); + return acc; + } + const filteredChildren = filterNavItems(item.items, query); + if (filteredChildren.length > 0) { + acc.push({ ...item, items: filteredChildren }); + } + return acc; + } + if (selfMatch) { + acc.push(item); + } + return acc; + }, []); + export const MobileNav = (props) => { - const { open, onClose, items } = props; + const { open, onClose, onOpen, items } = props; const pathname = usePathname(); const settings = useSettings(); + const swipeClose = useSwipeCloseTransition(open, onClose); + const [search, setSearch] = useState(""); const showSidebarBookmarks = settings.bookmarkSidebar !== false; + const query = search.trim().toLowerCase(); + const visibleItems = useMemo( + () => (query ? filterNavItems(items ?? [], query) : (items ?? [])), + [items, query] + ); + return ( - {})} open={open} + slotProps={{ transition: swipeClose.transitionProps }} PaperProps={{ sx: { + // desktop side-nav renders on background.default, keep the drawer on the same surface + backgroundColor: "background.default", width: MOBILE_NAV_WIDTH, + // Column layout so the sponsor footer pins to the bottom and the menu scrolls + // between it and the sticky header, rather than the footer riding the list. + display: "flex", + flexDirection: "column", }, }} variant="temporary" > + {/* Sticky header: logo (relocated from the mobile top bar) + nav search */} + + + + + setSearch(event.target.value)} + inputProps={{ enterKeyHint: "search", "aria-label": "Search navigation" }} + startAdornment={ + + + + } + sx={{ minHeight: 44 }} + /> + - - - - - - - - { }} > {/* Bookmarks section above Dashboard */} - {showSidebarBookmarks && ( + {showSidebarBookmarks && !query && ( <> @@ -153,17 +221,36 @@ export const MobileNav = (props) => { {/* Render all menu items */} {renderItems({ depth: 0, - items, + items: visibleItems, pathname, + forceOpen: Boolean(query), })} + {query && visibleItems.length === 0 && ( + + No pages match “{search}”. + + )} - + {/* Pinned below the scrolling menu rather than at the end of it, so it stays visible + without the long nav list pushing it off-screen. Compact: the drawer's vertical + space belongs to navigation. */} + + + + ); }; MobileNav.propTypes = { onClose: PropTypes.func, + onOpen: PropTypes.func, open: PropTypes.bool, }; diff --git a/src/layouts/notifications-popover.js b/src/layouts/notifications-popover.js index dddbf442555d..b585b6871d5e 100644 --- a/src/layouts/notifications-popover.js +++ b/src/layouts/notifications-popover.js @@ -88,7 +88,22 @@ export const NotificationsPopover = () => { return ( <> - + diff --git a/src/layouts/side-nav-bookmarks.js b/src/layouts/side-nav-bookmarks.js index 0ae0ec7abdec..08bba90d2b84 100644 --- a/src/layouts/side-nav-bookmarks.js +++ b/src/layouts/side-nav-bookmarks.js @@ -16,10 +16,15 @@ import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; import { useSettings } from "../hooks/use-settings"; import { useUserBookmarks } from "../hooks/use-user-bookmarks"; -export const SideNavBookmarks = ({ collapse = false }) => { +// alignWithRail: the pinned side nav sits beside the content area's breadcrumb rail, and the +// two header rows share a divider line across the seam — the desktop nav passes this so the +// Bookmarks row matches the rail's 28px row instead of the 48px nav-item rhythm. The mobile +// drawer has no rail beside it and keeps the roomier row. +export const SideNavBookmarks = ({ collapse = false, alignWithRail = false }) => { const settings = useSettings(); const compactNav = settings.compactNav ?? false; const navItemPy = compactNav ? "6px" : "12px"; + const headerPy = alignWithRail ? "2px" : navItemPy; const emptyStatePy = compactNav ? "4px" : "8px"; const { bookmarks, setBookmarks } = useUserBookmarks(); const [open, setOpen] = useState(settings.bookmarksOpen ?? false); @@ -190,7 +195,7 @@ export const SideNavBookmarks = ({ collapse = false }) => { fontWeight: 500, justifyContent: "flex-start", px: "6px", - py: navItemPy, + py: headerPy, textAlign: "left", whiteSpace: "nowrap", width: "100%", diff --git a/src/layouts/side-nav.js b/src/layouts/side-nav.js index cd8cd5ce834d..5181643c64e8 100644 --- a/src/layouts/side-nav.js +++ b/src/layouts/side-nav.js @@ -227,6 +227,9 @@ export const SideNav = (props) => { flexDirection: 'column', height: '100%', p: 2, + // The breadcrumb rail across the seam starts 10px under the top nav; starting + // the Bookmarks header at the same offset lets the two rows share a line. + pt: '10px', }} > { {/* Bookmarks section above Dashboard */} {showSidebarBookmarks && ( <> - - + + {/* mt matches the rail row's mb: 1, so the dividers meet across the seam */} + )} {/* Render all menu items */} diff --git a/src/layouts/tab-navigation-context.js b/src/layouts/tab-navigation-context.js new file mode 100644 index 000000000000..69b175921e4c --- /dev/null +++ b/src/layouts/tab-navigation-context.js @@ -0,0 +1,116 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useState, +} from 'react' +import { useIsMobileLayout } from '../hooks/use-breakpoint' + +/** + * Lets a tabbed layout publish its tab list — and, on the headered variant, its page actions. + * + * Below md the scrollable tab row costs a band of vertical space and still hides tabs off the + * right edge, so navigation collapses to a picker in the content flow (CippTabPicker). That + * picker is always drawn by the layout, so there is nothing to negotiate over it. + * + * The FAB corner is different: it fits exactly one FAB, and about a quarter of tabbed pages + * already grow one from a table's `cardButton`. A headered layout therefore hands its actions + * to that FAB rather than adding a second one — hence the claim registry below. + */ +export const TabNavigationContext = createContext(null) + +export const useTabNavigation = () => useContext(TabNavigationContext) + +/** + * Claims the bottom-right corner while `active`. A claimant takes responsibility for making + * the layout's actions reachable — or for deliberately withholding them, as the card list does + * while its select-mode bulk bar owns the bottom of the screen. + */ +export const useActionCornerClaim = (active) => { + const context = useContext(TabNavigationContext) + const claimId = useId() + const claim = context?.claim + const release = context?.release + + useEffect(() => { + if (!active || !claim || !release) return undefined + claim(claimId) + return () => release(claimId) + }, [active, claim, release, claimId]) +} + +/** + * True when the mobile tab picker already names this page. The picker trigger wears the + * current tab's label in heading clothes directly above the page header, so a page whose own + * title is the same string would print it twice in a row. The page keeps its title on + * desktop, where the tab bar looks like navigation rather than a heading. + */ +export const useTitleClaimedByTabPicker = (title) => { + const context = useContext(TabNavigationContext) + const isMobile = useIsMobileLayout() + // Mirrors CippTabPicker's own render conditions: below two destinations it draws nothing, + // so there is no trigger to claim the title. + if (!isMobile || !context?.enabled || (context.tabs?.length ?? 0) < 2 || !title) return false + const current = context.tabs.find((tab) => tab.path === context.currentPath) + return current?.label?.trim().toLowerCase() === String(title).trim().toLowerCase() +} + +/** + * Builds the context value for a tabbed layout. `tabs` are the already-filtered options + * ({label, path, icon}); `onNavigate` receives a path. + */ +export const useTabNavigationValue = ({ + tabs, + currentPath, + onNavigate, + actions = [], + enabled, + // HeaderedTabbedLayout wraps its children in a Container; TabbedLayout does not. Content + // that renders its own Container (CippFormPage) reads this so the two don't double up. + providesGutters = false, +}) => { + const [claims, setClaims] = useState([]) + + // An aliased route (pages/index.js re-exports the dashboard, so it renders at "/") matches + // no tab path — which left the picker labelled "Views" with nothing checked. The page an + // alias re-exports is one of these tabs, and in practice the first: treat it as current. + const resolvedPath = tabs?.some((tab) => tab.path === currentPath) + ? currentPath + : (tabs?.[0]?.path ?? currentPath) + + const claim = useCallback((id) => { + setClaims((prev) => (prev.includes(id) ? prev : [...prev, id])) + }, []) + + const release = useCallback((id) => { + setClaims((prev) => prev.filter((claimId) => claimId !== id)) + }, []) + + return useMemo( + () => ({ + enabled, + tabs, + currentPath: resolvedPath, + onNavigate, + actions, + providesGutters, + claim, + release, + isActionCornerClaimed: claims.length > 0, + }), + [ + enabled, + tabs, + resolvedPath, + onNavigate, + actions, + providesGutters, + claim, + release, + claims.length, + ] + ) +} diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index f3de8a1920e7..f796bc222996 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -26,7 +26,6 @@ import { Stack, SvgIcon, Tooltip, - useMediaQuery, Popover, List, ListItem, @@ -35,11 +34,13 @@ import { } from '@mui/material' import { useTheme } from '@mui/material/styles' import { Logo } from '../components/logo' +import { useIsMobileLayout } from '../hooks/use-breakpoint' import { useSettings } from '../hooks/use-settings' import { useUserBookmarks } from '../hooks/use-user-bookmarks' import { paths } from '../paths' import { AccountPopover } from './account-popover' import { CippTenantSelector } from '../components/CippComponents/CippTenantSelector' +import { CippMobileTenantPicker } from '../components/CippComponents/CippMobileTenantPicker' import { NotificationsPopover } from './notifications-popover' import { useDialog } from '../hooks/use-dialog' import { CippUniversalSearchV2 } from '../components/CippCards/CippUniversalSearchV2' @@ -53,7 +54,8 @@ export const TopNav = (props) => { const { onNavOpen } = props const settings = useSettings() const { bookmarks, setBookmarks } = useUserBookmarks() - const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) + // same gate as the side nav in layouts/index.js, the hamburger below is the drawer's only opener + const navCollapsed = useIsMobileLayout() const showPopoverBookmarks = settings.bookmarkPopover === true const reorderMode = settings.bookmarkReorderMode || 'arrows' const locked = settings.bookmarkLocked ?? true @@ -263,35 +265,44 @@ export const TopNav = (props) => { alignItems="center" sx={{ minHeight: TOP_NAV_HEIGHT, - px: 3, + // Mobile: the 24px desktop inset pushed the hamburger far off the left edge — + // an 8px inset puts the ☰ glyph on the content gutter line. + px: { xs: 1, md: 3 }, }} > + navCollapsed ? undefined : ( + + ) } > - - - - {!mdDown && ( + {/* On phones the logo gives way to the tenant chip — the app's primary scoping + control earns the space a 24px decorative link was using. */} + {!navCollapsed && ( + + + + )} + {!navCollapsed && ( { /> )} - {mdDown && ( - + {navCollapsed && ( + )} + {navCollapsed && ( + + + + )} - - {!mdDown && ( + {/* 0.5 left the notification dot and the account avatar sharing the same few pixels */} + + {!navCollapsed && ( { )} - {!mdDown && ( + {!navCollapsed && ( {effectivePaletteMode === 'dark' ? : } )} - {!mdDown && ( + {!navCollapsed && ( { )} + {/* Mobile: no search icon in the bar — the tenant chip is the more important + control and gets the width. Universal search lives in the account menu. */} {showPopoverBookmarks && ( <> @@ -627,17 +651,18 @@ export const TopNav = (props) => { open={universalSearchDialog.open} onClose={closeUniversalSearch} fullWidth + fullScreen={navCollapsed} maxWidth="md" sx={{ '& .MuiDialog-container': { alignItems: 'flex-start', }, '& .MuiDialog-paper': { - mt: 8, + mt: navCollapsed ? 0 : 8, }, }} > - + { useFlexGap spacing={1} > - Universal Search - - Pages: Ctrl/Cmd+K · Users: Ctrl/Cmd+Shift+F · Tenant: Ctrl/Cmd+Alt+K - + + {/* Fullscreen on mobile leaves no backdrop to tap — provide a close button */} + {navCollapsed && ( + + + + )} + Universal Search + + {!navCollapsed && ( + + Pages: Ctrl/Cmd+K · Users: Ctrl/Cmd+Shift+F · Tenant: Ctrl/Cmd+Alt+K + + )} @@ -680,6 +719,7 @@ export const TopNav = (props) => { openUniversalSearch('Pages')} paletteMode={effectivePaletteMode === 'light' ? 'dark' : 'light'} /> diff --git a/src/pages/_app.js b/src/pages/_app.js index c0d6d5e0daeb..a3f69ebe2727 100644 --- a/src/pages/_app.js +++ b/src/pages/_app.js @@ -53,9 +53,12 @@ import { AutoStories, Gavel, ClearAll as ClearAllIcon, + SupportAgent, + FiberManualRecord, } from '@mui/icons-material' import { School as TutorialIcon } from '@mui/icons-material' -import { SvgIcon } from '@mui/material' +import { getHelpLinks, clearCippCache } from '../utils/help-links' +import { Chip, SvgIcon } from '@mui/material' import React, { useEffect, useState, useRef } from 'react' import { usePathname } from 'next/navigation' import { useRouter } from 'next/router' @@ -63,6 +66,7 @@ import { persistQueryClient } from '@tanstack/react-query-persist-client' import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' import { TutorialProvider } from '../contexts/tutorial-context' import CippTutorialDialog from '../components/CippComponents/CippTutorialDialog' +import CippSupportBundleDialog from '../components/CippComponents/CippSupportBundleDialog' const ReactQueryDevtoolsProduction = React.lazy(() => import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({ @@ -91,6 +95,8 @@ const App = (props) => { const route = useRouter() const [dateLocale, setDateLocale] = useState(enUS) const [tutorialDialogOpen, setTutorialDialogOpen] = useState(false) + const [supportBundleOpen, setSupportBundleOpen] = useState(false) + const [supportRecording, setSupportRecording] = useState(false) useEffect(() => { if (typeof window === 'undefined') return @@ -195,29 +201,21 @@ const App = (props) => { } }, []) + // Link/cache destinations are shared with AccountPopover's mobile help section — see + // utils/help-links.js. Only the icons and SpeedDial-specific actions live here. + const helpLinkIcons = { + 'bug-report': , + 'feature-request': , + discord: Discord, + documentation: , + } + const speedDialActions = [ { - // add clear cache action that removes the persisted query cache from local storage and reloads the page id: 'clearCache', icon: , name: 'Clear Cache and Reload', - onClick: () => { - // Clear the TanStack Query cache - queryClient.clear() - - // Remove persisted cache from localStorage - if (typeof window !== 'undefined') { - // Remove the persisted query cache keys - Object.keys(localStorage).forEach((key) => { - if (key.startsWith('REACT_QUERY_OFFLINE_CACHE')) { - localStorage.removeItem(key) - } - }) - } - - // Force refresh the page to bypass browser cache and reload JavaScript - window.location.reload(true) - }, + onClick: () => clearCippCache(queryClient), }, { id: 'license', @@ -227,38 +225,16 @@ const App = (props) => { onClick: () => route.push('/license'), }, { - id: 'bug-report', - icon: , - name: 'Report Bug', - href: 'https://github.com/CyberDrain/CIPP/issues/new?template=bug.yml', - onClick: () => - window.open('https://github.com/CyberDrain/CIPP/issues/new?template=bug.yml', '_blank'), - }, - { - id: 'feature-request', - icon: , - name: 'Request Feature', - href: 'https://github.com/CyberDrain/CIPP/issues/new?template=feature.yml', - onClick: () => - window.open( - 'https://github.com/CyberDrain/CIPP/issues/new?template=feature.yml', - '_blank' - ), - }, - { - id: 'discord', - icon: Discord, - name: 'Join the Discord!', - href: 'https://discord.gg/cyberdrain', - onClick: () => window.open('https://discord.gg/cyberdrain', '_blank'), - }, - { - id: 'documentation', - icon: , - name: 'Check the Documentation', - href: `https://docs.cipp.app/user-documentation${pathname}`, - onClick: () => window.open(`https://docs.cipp.app/user-documentation${pathname}`, '_blank'), + id: 'supportBundle', + icon: , + name: 'Generate Support File', + onClick: () => setSupportBundleOpen(true), }, + ...getHelpLinks(pathname).map((link) => ({ + ...link, + icon: helpLinkIcons[link.id], + onClick: () => window.open(link.href, '_blank'), + })), { id: 'tutorials', icon: , @@ -305,10 +281,34 @@ const App = (props) => { open={tutorialDialogOpen} onClose={() => setTutorialDialogOpen(false)} /> + setSupportBundleOpen(false)} + onRecordingChange={setSupportRecording} + /> + {supportRecording && !supportBundleOpen && ( + } + label="Recording — click to stop" + color="error" + onClick={() => setSupportBundleOpen(true)} + sx={{ + position: 'fixed', + bottom: 20, + // Pinned left of the speed dial FAB (46px wide + 12px gap), + // which itself shifts left when devtools is enabled. + right: + (settings.isInitialized && settings?.showDevtools === true + ? 60 + : 12) + 58, + zIndex: (muiTheme) => muiTheme.zIndex.speedDial, + }} + /> + )} } diff --git a/src/pages/cipp/advanced/authentication/cipp-roles/index.js b/src/pages/cipp/advanced/authentication/cipp-roles/index.js index b759ddfa29b6..fc2c7e20aab4 100644 --- a/src/pages/cipp/advanced/authentication/cipp-roles/index.js +++ b/src/pages/cipp/advanced/authentication/cipp-roles/index.js @@ -3,19 +3,20 @@ import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; import tabOptions from "../tabOptions"; import CippPageCard from "../../../../../components/CippCards/CippPageCard"; import CippRoles from "../../../../../components/CippSettings/CippRoles"; -import { CardContent, Stack, Alert } from "@mui/material"; +import { CippExpandableAlert } from "../../../../../components/CippComponents/CippExpandableAlert"; +import { CardContent, Stack } from "@mui/material"; const Page = () => { return ( - + Custom roles can be used to restrict permissions for users with the 'editor' or 'readonly' roles in CIPP. They can be limited to a subset of tenants and API permissions. Built-in and custom roles can be assigned to Entra security groups for granular access control. - + diff --git a/src/pages/cipp/advanced/authentication/cipp-users.js b/src/pages/cipp/advanced/authentication/cipp-users.js index ad3b6097c147..c2d37ea3f344 100644 --- a/src/pages/cipp/advanced/authentication/cipp-users.js +++ b/src/pages/cipp/advanced/authentication/cipp-users.js @@ -3,14 +3,17 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import tabOptions from "./tabOptions"; import CippPageCard from "../../../../components/CippCards/CippPageCard"; import { CippUserManagement } from "../../../../components/CippSettings/CippUserManagement"; -import { CardContent, Stack, Alert } from "@mui/material"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; +import { CardContent, Stack } from "@mui/material"; const Page = () => { return ( - + // Titled to match the tab label, so the mobile picker claims the heading and the page + // does not say "CIPP Users" and "CIPP User Management" back to back. + - + Manage users who can access CIPP. Users are automatically synced from your partner tenant every 15 minutes based on Entra group memberships configured on the CIPP Roles page. You can also manually add users or assign additional roles — manual assignments @@ -23,7 +26,7 @@ const Page = () => { to access CIPP, you can add them as guest users in your partner tenant and assign them the appropriate roles in CIPP or enable the multi tenant mode in the CIPP SSO tab and add the users to the list below without needing to add them as guest users in your tenant. - + diff --git a/src/pages/cipp/advanced/authentication/sso.js b/src/pages/cipp/advanced/authentication/sso.js index fc5b112f3f1c..ec8cb012df9a 100644 --- a/src/pages/cipp/advanced/authentication/sso.js +++ b/src/pages/cipp/advanced/authentication/sso.js @@ -7,7 +7,7 @@ import { CippSSOSettings } from "../../../../components/CippSettings/CippSSOSett const Page = () => { return ( - + diff --git a/src/pages/cipp/advanced/container-management/logs.js b/src/pages/cipp/advanced/container-management/logs.js index 9b5682001207..44f7745e5388 100644 --- a/src/pages/cipp/advanced/container-management/logs.js +++ b/src/pages/cipp/advanced/container-management/logs.js @@ -23,6 +23,7 @@ import { CippTablePage } from "../../../../components/CippComponents/CippTablePa import { ApiGetCall } from "../../../../api/ApiCall"; import defaultPresets from "../../../../data/ContainerLogPresets.json"; import tabOptions from "./tabOptions"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; const levelOptions = [ { label: "All Levels", value: "" }, @@ -249,7 +250,7 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => { {tabValue === 0 && ( - + Query Syntax Use a KQL-inspired pipe syntax to filter container logs. Separate clauses with{" "} @@ -279,7 +280,7 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => {
search all files — include rotated logs
-
+ @@ -310,7 +311,7 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => { }} /> - + diff --git a/src/pages/cipp/advanced/container-management/worker-health.js b/src/pages/cipp/advanced/container-management/worker-health.js index b1eb44776b4d..27a5b422cc75 100644 --- a/src/pages/cipp/advanced/container-management/worker-health.js +++ b/src/pages/cipp/advanced/container-management/worker-health.js @@ -64,6 +64,7 @@ import { CippInfoBar } from "../../../../components/CippCards/CippInfoBar"; import { CippDataTable } from "../../../../components/CippTable/CippDataTable"; import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall"; import tabOptions from "./tabOptions"; +import { useTitleClaimedByTabPicker } from "../../../../layouts/tab-navigation-context"; const formatDuration = (ms) => { if (ms === 0 || ms == null) return "—"; @@ -352,6 +353,8 @@ const CompactStatsRow = ({ snapshot }) => { { k: "Queued", v: jobs.Queued ?? 0, w: jobs.Queued > 10 }, { k: "Done", v: jobs.Completed?.toLocaleString() ?? 0 }, { k: "Failed", v: jobs.Failed ?? 0, w: jobs.Failed > 0 }, + // Stale queue entries whose task was gone by dispatch time — benign, so never flagged. + { k: "Skipped", v: jobs.Skipped ?? 0 }, ], }, { @@ -459,6 +462,7 @@ const HistoryChart = ({ data, rangeMinutes, title, icon, children }) => { const Page = () => { const theme = useTheme(); + const titleClaimed = useTitleClaimedByTabPicker("Worker Health"); const queryClient = useQueryClient(); const fileInputRef = useRef(null); const [historyRange, setHistoryRange] = useState(60); @@ -720,7 +724,9 @@ const Page = () => { {/* ── Header toolbar ── */} - Worker Health + {/* Empty Box keeps the toolbar on the right when the mobile tab picker has + already said "Worker Health" directly above this row. */} + {titleClaimed ? : Worker Health} {isImported && ( { }} simpleColumns={jobSimpleColumns} actions={jobActions} + offCanvas={{ + extendedInfoFields: [ + "Id", + "Name", + "RunName", + "Status", + "Priority", + "QueuedUtc", + "StartedUtc", + "CompletedUtc", + "WaitSeconds", + "DurationSeconds", + "LastError", + ], + }} defaultSorting={[{ id: "QueuedUtc", desc: true }]} cardButton={ @@ -846,7 +867,7 @@ const Page = () => { onChange={(_, val) => val !== null && setJobStatus(val)} size="small" > - {["", "Queued", "Running", "Completed", "Failed", "Cancelled"].map((s) => ( + {["", "Queued", "Running", "Completed", "Failed", "Cancelled", "Skipped"].map((s) => ( {s || "All"} @@ -1145,7 +1166,7 @@ const Page = () => { /> {/* Stats row */} - + {cacheStats.map((s) => { const cell = ( diff --git a/src/pages/cipp/advanced/super-admin/jit-admin-settings.js b/src/pages/cipp/advanced/super-admin/jit-admin-settings.js index fa6401e9b7c3..514b5b097c07 100644 --- a/src/pages/cipp/advanced/super-admin/jit-admin-settings.js +++ b/src/pages/cipp/advanced/super-admin/jit-admin-settings.js @@ -7,6 +7,7 @@ import { Typography, Alert } from "@mui/material"; import { Grid } from "@mui/system"; import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; import { ApiGetCall } from "../../../../api/ApiCall"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; import { useEffect } from "react"; const Page = () => { @@ -103,7 +104,7 @@ const Page = () => { - + Important Notes: @@ -121,7 +122,7 @@ const Page = () => {
  • This setting applies globally to all tenants and all JIT admin creations
  • - + diff --git a/src/pages/cipp/advanced/table-maintenance.js b/src/pages/cipp/advanced/table-maintenance.js index fea95d6e3774..a6f83e4dfe96 100644 --- a/src/pages/cipp/advanced/table-maintenance.js +++ b/src/pages/cipp/advanced/table-maintenance.js @@ -66,8 +66,15 @@ const CustomAddEditRowDialog = ({ formControl, open, onClose, onSubmit, defaultV {Array.isArray(fields) && fields?.length > 0 && ( <> {fields.map((field, index) => ( - - + + - + - + { that should only be used when directed by CyberDrain support. - + { } /> - + {selectedTable && ( diff --git a/src/pages/cipp/custom-data/schema-extensions/index.js b/src/pages/cipp/custom-data/schema-extensions/index.js index fab26e70f51c..17faba908d40 100644 --- a/src/pages/cipp/custom-data/schema-extensions/index.js +++ b/src/pages/cipp/custom-data/schema-extensions/index.js @@ -6,6 +6,7 @@ import { Add, Block, CheckCircleOutline } from "@mui/icons-material"; import tabOptions from "../tabOptions"; import { TrashIcon } from "@heroicons/react/24/outline"; import NextLink from "next/link"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; const Page = () => { const pageTitle = "Schema Extensions"; @@ -107,7 +108,7 @@ const Page = () => { + {
  • There is a limit of 5 total schema extensions.
  • - +
    } cardButton={ + } + > + + + + Microsoft Purview Message Encryption lets users send protected email + to any recipient, including Gmail and Outlook.com. The only + prerequisite is that Azure Rights Management is active for the + tenant. + + + {irmRequest.isError && ( + + + Failed to load the IRM configuration for this tenant. + + + )} + {irm?.AdRmsDetected && ( + + + This tenant has an on-premises AD RMS licensing location ({' '} + {irm.LicensingLocation.join(', ')} ). Purview Message Encryption + is not compatible with AD RMS, so the tenant has to be{' '} + + migrated to Azure RMS + {' '} + before enabling it. + + + )} + {(irm || irmRequest.isFetching) && ( + + + + )} + + + + + Test the configuration + + Runs Test-IRMConfiguration, which verifies that RMS templates can be + acquired and that encryption and decryption both work. Use any + mailbox in the tenant for both addresses. + + + + `${option.displayName} (${option.UPN})`, + valueField: 'UPN', + }} + /> + + + `${option.displayName} (${option.UPN})`, + valueField: 'UPN', + }} + /> + + + + + + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/endpoint/MEM/assignment-filters/index.js b/src/pages/endpoint/MEM/assignment-filters/index.js index bedf0ef1ada4..e41259dad6a6 100644 --- a/src/pages/endpoint/MEM/assignment-filters/index.js +++ b/src/pages/endpoint/MEM/assignment-filters/index.js @@ -88,9 +88,9 @@ const Page = () => { - {reportDB.controls}
    } + dataSourceControls={reportDB.controls} apiUrl={reportDB.resolvedApiUrl} queryKey={reportDB.resolvedQueryKey} actions={actions} diff --git a/src/pages/endpoint/MEM/devices/device/index.jsx b/src/pages/endpoint/MEM/devices/device/index.jsx index 079bc4307d46..25fdbcff7b18 100644 --- a/src/pages/endpoint/MEM/devices/device/index.jsx +++ b/src/pages/endpoint/MEM/devices/device/index.jsx @@ -18,6 +18,7 @@ import { Group, } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import { CippEntitySwitcher } from '../../../../../components/CippComponents/CippEntitySwitcher' import tabOptions from './tabOptions' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { getIntuneDeviceActions } from '../../../../../components/CippComponents/CippIntuneDeviceActions.jsx' @@ -477,6 +478,28 @@ const Page = () => { device.deviceName} + getSecondary={(device) => device.userPrincipalName} + sortByPrimary + /> + } actions={deviceActions} actionsData={data} subtitle={subtitle} @@ -492,7 +515,7 @@ const Page = () => { > - + { - + Compliance Policies { requiredPermissions={cardButtonPermissions} PermissionButton={PermissionButton} /> - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/endpoint/MEM/list-compliance-policies/index.js b/src/pages/endpoint/MEM/list-compliance-policies/index.js index 32574567a0ff..f3f8299baa1f 100644 --- a/src/pages/endpoint/MEM/list-compliance-policies/index.js +++ b/src/pages/endpoint/MEM/list-compliance-policies/index.js @@ -65,9 +65,9 @@ const Page = () => { requiredPermissions={cardButtonPermissions} PermissionButton={PermissionButton} /> - {reportDB.controls}
    } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/endpoint/MEM/list-policies/index.js b/src/pages/endpoint/MEM/list-policies/index.js index 5895224b9e14..b20f3733ad22 100644 --- a/src/pages/endpoint/MEM/list-policies/index.js +++ b/src/pages/endpoint/MEM/list-policies/index.js @@ -69,9 +69,9 @@ const Page = () => { requiredPermissions={cardButtonPermissions} PermissionButton={PermissionButton} /> - {reportDB.controls}
    } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/endpoint/MEM/list-scripts/index.jsx b/src/pages/endpoint/MEM/list-scripts/index.jsx index 2661e040c2a4..0a1d6ead381d 100644 --- a/src/pages/endpoint/MEM/list-scripts/index.jsx +++ b/src/pages/endpoint/MEM/list-scripts/index.jsx @@ -502,7 +502,7 @@ const Page = () => { actions={actions} offCanvas={offCanvas} simpleColumns={simpleColumns} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> diff --git a/src/pages/endpoint/MEM/reusable-settings/index.js b/src/pages/endpoint/MEM/reusable-settings/index.js index 75219f0d4136..b42086aa1afa 100644 --- a/src/pages/endpoint/MEM/reusable-settings/index.js +++ b/src/pages/endpoint/MEM/reusable-settings/index.js @@ -76,9 +76,9 @@ const Page = () => { cardButton={ - {reportDB.controls} } + dataSourceControls={reportDB.controls} apiUrl={reportDB.resolvedApiUrl} queryKey={reportDB.resolvedQueryKey} actions={actions} diff --git a/src/pages/endpoint/applications/list/index.js b/src/pages/endpoint/applications/list/index.js index 232e55a21dae..cdbbc0d97baa 100644 --- a/src/pages/endpoint/applications/list/index.js +++ b/src/pages/endpoint/applications/list/index.js @@ -386,9 +386,9 @@ const Page = () => { - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> { group.mail} + /> + } actions={groupActions} actionsData={data} subtitle={subtitle} @@ -699,7 +721,7 @@ const Page = () => { > - + @@ -806,7 +828,7 @@ const Page = () => { - + Members { const pageTitle = 'Groups' - const [showMembers, setShowMembers] = useState(false) - const [showOwners, setShowOwners] = useState(false) const { currentTenant } = useSettings() + const tenantQuery = + currentTenant === 'AllTenants' ? '[Tenant]' : currentTenant + const nestedTenantQuery = + currentTenant === 'AllTenants' ? '[parent.Tenant]' : currentTenant const reportDB = useCippReportDB({ apiUrl: '/api/ListGroups', @@ -35,25 +39,10 @@ const Page = () => { cacheColumns: ['CacheTimestamp'], }) - const handleMembersToggle = () => { - setShowMembers((prev) => { - const next = !prev - if (next) setShowOwners(false) - return next - }) - } - - const handleOwnersToggle = () => { - setShowOwners((prev) => { - const next = !prev - if (next) setShowMembers(false) - return next - }) - } const actions = [ { label: 'View Group', - link: `/identity/administration/groups/group?groupId=[id]&tenantFilter=${currentTenant}`, + link: `/identity/administration/groups/group?groupId=[id]&tenantFilter=${tenantQuery}`, color: 'info', icon: , multiPost: false, @@ -66,6 +55,81 @@ const Page = () => { icon: , color: 'success', }, + { + label: 'Add Member', + type: 'POST', + url: '/api/EditGroup', + icon: , + customDataformatter: (row, action, formData) => { + // Members picked in the dialog already carry {label, value: id, addedFields} + const addMember = [...(formData.AddMember ?? [])] + // CSV rows only carry a userPrincipalName; without a value the backend + // resolves the directory object id itself + ;(formData.bulkMember ?? []).forEach((csvRow) => { + const upnKey = Object.keys(csvRow).find( + (key) => key.trim().toLowerCase() === 'userprincipalname' + ) + const userPrincipalName = upnKey ? csvRow[upnKey]?.trim() : undefined + if (userPrincipalName) { + addMember.push({ + label: userPrincipalName, + addedFields: { userPrincipalName: userPrincipalName }, + }) + } + }) + + // Handle multiple groups - return an array of requests (one per group) + const selectedGroups = Array.isArray(row) ? row : [row] + return selectedGroups.map((group) => ({ + AddMember: addMember, + tenantFilter: getRowTenant(group, currentTenant), + groupId: group.id, + groupName: group.displayName, + groupType: group.groupType, + })) + }, + fields: [ + { + type: 'autoComplete', + name: 'AddMember', + label: 'Select users to add as members', + multiple: true, + creatable: false, + api: { + url: '/api/ListGraphRequest', + data: { + Endpoint: 'users', + $select: 'id,displayName,userPrincipalName', + $top: 999, + $count: true, + }, + dataKey: 'Results', + labelField: (user) => `${user.displayName} (${user.userPrincipalName})`, + valueField: 'id', + addedField: { + userPrincipalName: 'userPrincipalName', + displayName: 'displayName', + }, + queryKey: 'ListUsersAutoComplete', + showRefresh: true, + }, + validators: { + validate: (value, formValues) => + (Array.isArray(value) && value.length > 0) || + (Array.isArray(formValues.bulkMember) && formValues.bulkMember.length > 0) || + 'Select at least one user or upload a CSV', + }, + }, + { + type: 'CSVReader', + name: 'bulkMember', + }, + ], + confirmText: + 'Select the users to add as members to [displayName], or drop a CSV file with a userPrincipalName column to bulk add members.', + multiPost: false, + allowResubmit: true, + }, { label: 'Set Global Address List Visibility', type: 'POST', @@ -360,16 +424,6 @@ const Page = () => { title={pageTitle} cardButton={ - {!reportDB.useReportDB && ( - <> - - - - )} @@ -380,27 +434,13 @@ const Page = () => { > Deploy Group Template - {reportDB.controls} } + dataSourceControls={reportDB.controls} apiUrl={reportDB.resolvedApiUrl} - apiData={ - reportDB.useReportDB - ? undefined - : showMembers - ? { expandMembers: true } - : showOwners - ? { expandOwners: true } - : {} - } + apiData={reportDB.useReportDB ? undefined : {}} queryKey={ - reportDB.useReportDB - ? reportDB.resolvedQueryKey - : showMembers - ? `groups-with-members-${currentTenant}` - : showOwners - ? `groups-with-owners-${currentTenant}` - : `groups-${currentTenant}` + reportDB.useReportDB ? reportDB.resolvedQueryKey : `groups-${currentTenant}` } actions={actions} offCanvas={offCanvas} @@ -419,6 +459,148 @@ const Page = () => { 'onPremisesSamAccountName', 'membershipRule', 'onPremisesSyncEnabled', + 'members', + 'owners', + ]} + subTables={[ + { + id: 'members', + header: 'Members', + label: 'View members', + cachedColumn: 'membersCsv', + table: { + title: 'Members of [displayName]', + queryKey: 'group-members-[id]', + api: { + url: '/api/ListGroups', + data: { groupID: '[id]', members: true, groupType: '[groupType]' }, + dataKey: 'members', + }, + simpleColumns: ['displayName', 'userPrincipalName', 'mail', '@odata.type'], + actions: [ + { + label: 'View User', + link: `/identity/administration/users/user?userId=[id]&tenantFilter=${nestedTenantQuery}`, + color: 'info', + icon: , + condition: (row) => + !row?.['@odata.type'] || row['@odata.type'] === '#microsoft.graph.user', + }, + { + label: 'View Group', + link: `/identity/administration/groups/group?groupId=[id]&tenantFilter=${nestedTenantQuery}`, + color: 'info', + icon: , + condition: (row) => row?.['@odata.type'] === '#microsoft.graph.group', + }, + { + label: 'Remove Member', + type: 'POST', + url: '/api/ExecGroupMembers', + icon: , + data: { action: '!removeMember', groupId: 'parent.id', users: 'id' }, + confirmText: 'Remove [displayName] from [parent.displayName]?', + condition: (row) => + !row?.parent?.dynamicGroupBool && !row?.parent?.membershipRule, + }, + ], + cardButton: { + label: 'Add Members', + icon: , + url: '/api/ExecGroupMembers', + allowResubmit: true, + relatedQueryKeys: 'group-members-[id]', + confirmText: 'Add members to [displayName]?', + condition: (row) => !row?.dynamicGroupBool && !row?.membershipRule, + data: { action: '!addMember', groupId: 'id' }, + fields: [ + { + type: 'autoComplete', + name: 'users', + label: 'Add Members', + multiple: true, + creatable: false, + csvColumn: 'userPrincipalName', + api: { + url: '/api/ListUsersAndGroups', + dataKey: 'Results', + valueField: 'id', + labelField: 'displayName', + descriptionField: 'userPrincipalName', + }, + }, + ], + }, + }, + }, + { + id: 'owners', + header: 'Owners', + label: 'View owners', + cachedColumn: 'ownersCsv', + table: { + title: 'Owners of [displayName]', + queryKey: 'group-owners-[id]', + api: { + url: '/api/ListGroups', + data: { groupID: '[id]', owners: true, groupType: '[groupType]' }, + dataKey: 'owners', + }, + simpleColumns: ['displayName', 'userPrincipalName', 'mail'], + actions: [ + { + label: 'View User', + link: `/identity/administration/users/user?userId=[id]&tenantFilter=${nestedTenantQuery}`, + color: 'info', + icon: , + condition: (row) => + !row?.['@odata.type'] || row['@odata.type'] === '#microsoft.graph.user', + }, + { + label: 'Remove Owner', + type: 'POST', + url: '/api/ExecGroupMembers', + icon: , + data: { action: '!removeOwner', groupId: 'parent.id', users: 'id' }, + confirmText: 'Remove [displayName] as owner of [parent.displayName]?', + }, + ], + cardButton: { + label: 'Add Owners', + icon: , + url: '/api/ExecGroupMembers', + allowResubmit: true, + relatedQueryKeys: 'group-owners-[id]', + confirmText: 'Add owners to [displayName]?', + data: { action: '!addOwner', groupId: 'id' }, + fields: [ + { + type: 'autoComplete', + name: 'users', + label: 'Add Owners', + multiple: true, + creatable: false, + csvColumn: 'userPrincipalName', + api: { + url: '/api/ListGraphRequest', + dataKey: 'Results', + valueField: 'id', + labelField: 'displayName', + descriptionField: 'userPrincipalName', + data: { + Endpoint: 'users', + manualPagination: true, + $select: 'id,userPrincipalName,displayName', + $count: true, + $orderby: 'displayName', + $top: 999, + }, + }, + }, + ], + }, + }, + }, ]} /> {reportDB.syncDialog} diff --git a/src/pages/identity/administration/guest-users/index.js b/src/pages/identity/administration/guest-users/index.js new file mode 100644 index 000000000000..0056c50626c7 --- /dev/null +++ b/src/pages/identity/administration/guest-users/index.js @@ -0,0 +1,241 @@ +import { useMemo, useState } from 'react' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { ApiGetCallWithPagination } from '../../../../api/ApiCall' +import { useSettings } from '../../../../hooks/use-settings' +import { useCippReportDB } from '../../../../components/CippComponents/CippReportDBControls' +import { + Card, + CardActionArea, + CardContent, + Skeleton, + Stack, + Typography, +} from '@mui/material' +import { Box, Grid } from '@mui/system' +import { EyeIcon } from '@heroicons/react/24/outline' +import { + Block, + CheckCircle, + GroupOutlined, + HourglassEmpty, + PersonOff, + Send, + WarningAmber, +} from '@mui/icons-material' + +const GUEST_STATUSES = [ + { status: 'Active', color: 'success', icon: CheckCircle }, + { status: 'Stale', color: 'error', icon: WarningAmber }, + { status: 'Pending Acceptance', color: 'warning', icon: HourglassEmpty }, + { status: 'Never Signed In', color: 'info', icon: PersonOff }, + { status: 'Disabled', color: 'secondary', icon: Block }, +] + +const SummaryCard = ({ + title, + count, + icon: Icon, + color, + selected, + isFetching, + onClick, +}) => ( + + + + + + + + {isFetching ? : count} + + + {title} + + + + + + +) + +const Page = () => { + const pageTitle = 'Guest Users' + const currentTenant = useSettings().currentTenant + const [statusFilter, setStatusFilter] = useState(null) + + const reportDB = useCippReportDB({ + apiUrl: '/api/ListGuestUsers', + queryKey: 'ListGuestUsers', + cacheName: 'Guests', + syncTitle: 'Sync Guest Users', + allowToggle: true, + defaultCached: true, + allowAllTenantSync: true, + cacheColumns: ['CacheTimestamp'], + }) + + // Same url/data/queryKey as the table below, so react-query shares one request + // between the summary cards and the table. + const guestData = ApiGetCallWithPagination({ + url: reportDB.resolvedApiUrl, + data: { tenantFilter: currentTenant }, + queryKey: reportDB.resolvedQueryKey, + waiting: true, + }) + + const guests = useMemo( + () => + guestData.data?.pages?.flatMap((page) => + Array.isArray(page) ? page : [] + ) ?? [], + [guestData.data] + ) + + const statusCounts = useMemo(() => { + const counts = {} + for (const guest of guests) { + counts[guest.status] = (counts[guest.status] ?? 0) + 1 + } + return counts + }, [guests]) + + // The trailing column-format entry drives the table's status filter from the + // summary cards; an empty value clears it again. The named presets surface the + // same one-click filters in the table's filter menu. + const filterList = useMemo( + () => [ + ...GUEST_STATUSES.map(({ status }) => ({ + filterName: `${status} guests`, + value: [{ id: 'status', value: status }], + type: 'column', + })), + { id: 'status', value: statusFilter ?? '' }, + ], + [statusFilter] + ) + + const toggleStatusFilter = (status) => + setStatusFilter((current) => (current === status ? null : status)) + + const tableFilter = ( + + + setStatusFilter(null)} + /> + + {GUEST_STATUSES.map(({ status, color, icon }) => ( + + toggleStatusFilter(status)} + /> + + ))} + + ) + + const actions = [ + { + label: 'View User', + link: '/identity/administration/users/user?userId=[id]', + multiPost: false, + icon: , + color: 'success', + }, + { + label: 'Re-invite Guest', + type: 'POST', + icon: , + url: '/api/AddGuest', + data: { displayName: 'displayName', mail: 'mail', sendInvite: '!true' }, + confirmText: 'Are you sure you want to re-send the invitation to [mail]?', + multiPost: false, + condition: (row) => + !!row.mail && + (row.status === 'Pending Acceptance' || row.status === 'Stale'), + }, + ] + + const offCanvas = { + extendedInfoFields: [ + 'displayName', + 'userPrincipalName', + 'mail', + 'id', + 'status', + 'externalUserState', + 'externalUserStateChangeDateTime', + 'createdDateTime', + 'lastSignInDateTime', + 'lastInteractiveSignInDateTime', + 'lastNonInteractiveSignInDateTime', + 'lastSuccessfulSignInDateTime', + 'daysSinceSignIn', + 'accountEnabled', + 'sourceDomain', + 'sponsors', + ], + actions: actions, + } + + const simpleColumns = [ + ...reportDB.cacheColumns, + 'displayName', + 'mail', + 'sourceDomain', + 'status', + 'accountEnabled', + 'createdDateTime', + 'lastSignInDateTime', + 'daysSinceSignIn', + ] + + return ( + <> + + {reportDB.syncDialog} + + ) +} + +Page.getLayout = (page) => ( + {page} +) + +export default Page diff --git a/src/pages/identity/administration/users/user/bec.jsx b/src/pages/identity/administration/users/user/bec.jsx index faac9f2e1dc5..af143ece582b 100644 --- a/src/pages/identity/administration/users/user/bec.jsx +++ b/src/pages/identity/administration/users/user/bec.jsx @@ -7,6 +7,7 @@ import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon' import { Download, Mail, Fingerprint, Launch } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' import tabOptions from './tabOptions' +import { CippUserSwitcher } from '../../../../../components/CippComponents/CippUserSwitcher' import ReactTimeAgo from 'react-time-ago' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { Box, Stack } from '@mui/system' @@ -455,6 +456,13 @@ const Page = () => { + } subtitle={subtitle} isFetching={userRequest.isFetching} > @@ -469,7 +477,7 @@ const Page = () => { > {/* Remediation Card */} - + { /> {/* Check 1 Card with Loading */} - + { > {/* Remediation Card */} - + { /> {/* All Steps */} - + diff --git a/src/pages/identity/administration/users/user/conditional-access.jsx b/src/pages/identity/administration/users/user/conditional-access.jsx index 8449148b8562..0ef3dfc43811 100644 --- a/src/pages/identity/administration/users/user/conditional-access.jsx +++ b/src/pages/identity/administration/users/user/conditional-access.jsx @@ -7,6 +7,7 @@ import CalendarIcon from "@heroicons/react/24/outline/CalendarIcon"; import { Mail, Fingerprint, Launch } from "@mui/icons-material"; import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; import tabOptions from "./tabOptions"; +import { CippUserSwitcher } from "../../../../../components/CippComponents/CippUserSwitcher"; import ReactTimeAgo from "react-time-ago"; import { CippCopyToClipBoard } from "../../../../../components/CippComponents/CippCopyToClipboard"; import { Box, Stack, Typography, Button } from "@mui/material"; @@ -95,6 +96,13 @@ const Page = () => { + } subtitle={subtitle} isFetching={userRequest.isLoading} > diff --git a/src/pages/identity/administration/users/user/edit.jsx b/src/pages/identity/administration/users/user/edit.jsx index fac7c794366a..f4d7fbf0ad2a 100644 --- a/src/pages/identity/administration/users/user/edit.jsx +++ b/src/pages/identity/administration/users/user/edit.jsx @@ -12,6 +12,7 @@ import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon' import { Mail, Fingerprint, Launch } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' import tabOptions from './tabOptions' +import { CippUserSwitcher } from '../../../../../components/CippComponents/CippUserSwitcher' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { CippTimeAgo } from '../../../../../components/CippComponents/CippTimeAgo' import { Button, Alert } from '@mui/material' @@ -155,6 +156,13 @@ const Page = () => { + } subtitle={subtitle} isFetching={userRequest.isLoading} > diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index f2ef68719bcd..ca1b39c65aa6 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -20,6 +20,7 @@ import { } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' import tabOptions from './tabOptions' +import { CippUserSwitcher } from '../../../../../components/CippComponents/CippUserSwitcher' import { CippTimeAgo } from '../../../../../components/CippComponents/CippTimeAgo' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { Box, Stack } from '@mui/system' @@ -1407,6 +1408,13 @@ const Page = () => { + } subtitle={subtitle} actions={CippExchangeActions()} actionsData={userRequest.data?.[0]?.MailboxActionsData} @@ -1451,7 +1459,9 @@ const Page = () => { 'Microsoft.Exchange.Configuration.Tasks.ManagementObjectNotFoundException' ) && ( <> - + {/* Stacked below lg — a 4/8 split at phone widths leaves both columns too + narrow to hold a label, breaking the text one word per line. */} + { handleRefresh={() => userRequest.refetch()} /> - + { <> Location - + { ]} /> - + { + } actions={userActions} actionsData={data} subtitle={subtitle} isFetching={userRequest.isLoading} > - {userRequest.isLoading && } + {/* The loading state is the loaded page's own scaffold with each card in its + skeleton form — generic form-row bars looked nothing like what replaces them + and left the rest of the viewport empty. */} + {userRequest.isLoading && ( + + + + + + + + {['Latest Logon', 'Applied Conditional Access Policies', 'Multi-Factor Authentication Devices', 'Memberships'].map( + (section) => ( + + {section} + + + ) + )} + + + + + )} {userRequest.isSuccess && ( - + {/* Stacked below lg — at phone widths a 4/8 split leaves both columns too + narrow to hold a label, breaking the text one word per line. */} + - + Latest Logon { value: [{ id: "Name", value: "CA Exclusion" }], type: "column", }, + { + filterName: "Location Alerts", + value: [{ id: "Name", value: "Location Alert Exclusion" }], + type: "column", + }, { filterName: "Mailbox Permissions", value: [{ id: "Name", value: "Mailbox Vacation" }], diff --git a/src/pages/identity/reports/inactive-users-report/index.js b/src/pages/identity/reports/inactive-users-report/index.js index 3ed0ef6c286f..e04dc5d1dd7c 100644 --- a/src/pages/identity/reports/inactive-users-report/index.js +++ b/src/pages/identity/reports/inactive-users-report/index.js @@ -61,6 +61,7 @@ const Page = () => { "createdDateTime", "lastSignInDateTime", "lastNonInteractiveSignInDateTime", + "lastSuccessfulSignInDateTime", "numberOfAssignedLicenses", "daysSinceLastSignIn", "lastRefreshedDateTime", @@ -75,6 +76,7 @@ const Page = () => { "displayName", "lastSignInDateTime", "lastNonInteractiveSignInDateTime", + "lastSuccessfulSignInDateTime", "numberOfAssignedLicenses", "daysSinceLastSignIn", ...reportDB.cacheColumns.filter((c) => c !== "Tenant"), @@ -89,7 +91,7 @@ const Page = () => { actions={actions} offCanvas={offCanvas} simpleColumns={simpleColumns} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/identity/reports/mfa-report/index.js b/src/pages/identity/reports/mfa-report/index.js index 668030f9f923..efe9e34a5c04 100644 --- a/src/pages/identity/reports/mfa-report/index.js +++ b/src/pages/identity/reports/mfa-report/index.js @@ -117,7 +117,7 @@ const Page = () => { simpleColumns={simpleColumns} filters={filters} actions={actions} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} initialFilters={urlFilters} /> {reportDB.syncDialog} diff --git a/src/pages/identity/reports/signin-report/index.js b/src/pages/identity/reports/signin-report/index.js index c835fa6ccf4b..c21dda6804f0 100644 --- a/src/pages/identity/reports/signin-report/index.js +++ b/src/pages/identity/reports/signin-report/index.js @@ -242,7 +242,7 @@ const Page = () => { /> - + diff --git a/src/pages/security/reports/mde-onboarding/index.js b/src/pages/security/reports/mde-onboarding/index.js index 955ec17fa66a..aa88b8c7516a 100644 --- a/src/pages/security/reports/mde-onboarding/index.js +++ b/src/pages/security/reports/mde-onboarding/index.js @@ -355,7 +355,7 @@ const Page = () => { "partnerUnresponsivenessThresholdInDays", "CacheTimestamp", ]} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/teams-share/onedrive/index.js b/src/pages/teams-share/onedrive/index.js index 9b4f9029c08d..bb4fa1c56d1c 100644 --- a/src/pages/teams-share/onedrive/index.js +++ b/src/pages/teams-share/onedrive/index.js @@ -119,7 +119,7 @@ const Page = () => { queryKey={reportDB.resolvedQueryKey} actions={actions} simpleColumns={simpleColumns} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/teams-share/permissions-report/index.js b/src/pages/teams-share/permissions-report/index.js index 61d24b785e0e..12dcbd1c913b 100644 --- a/src/pages/teams-share/permissions-report/index.js +++ b/src/pages/teams-share/permissions-report/index.js @@ -13,6 +13,7 @@ import { ApiGetCall } from '../../../api/ApiCall' import { useSettings } from '../../../hooks/use-settings' import { Alert, Button, Container, Stack, SvgIcon, Typography } from '@mui/material' import { Grid } from '@mui/system' +import { CippExpandableAlert } from '../../../components/CippComponents/CippExpandableAlert' import { BuildingOfficeIcon, CloudArrowDownIcon, @@ -253,7 +254,7 @@ const Page = () => { )} - + Applies To shows how far each permission reaches.{' '} Whole site is a permission on the site itself, which every library that still inherits also gets. This library only means that library was detached @@ -261,7 +262,7 @@ const Page = () => { that still inherit are not listed — their permissions are the site's, so everything here is either the site's own permissions or a deliberate exception to them. - + { > Bulk Add Sites - {reportDB.controls} ) @@ -766,6 +765,7 @@ const Page = () => { offCanvas={offCanvas} simpleColumns={simpleColumns} cardButton={pageActions} + dataSourceControls={reportDB.controls} tableFilter={ <> diff --git a/src/pages/teams-share/sharepoint2/index.js b/src/pages/teams-share/sharepoint2/index.js new file mode 100644 index 000000000000..6020001b4e71 --- /dev/null +++ b/src/pages/teams-share/sharepoint2/index.js @@ -0,0 +1,322 @@ +import { useEffect, useMemo, useState } from 'react' +import { useRouter } from 'next/router' +import { Container, IconButton, Stack, Tooltip, Typography } from '@mui/material' +import { Grid } from '@mui/system' +import { Delete, FolderOpen, Launch, Refresh, Storage as StorageIcon } from '@mui/icons-material' +import { Layout as DashboardLayout } from '../../../layouts/index.js' +import { CippHead } from '../../../components/CippComponents/CippHead' +import { CippSharePointBrowserBanner } from '../../../components/CippComponents/CippSharePointBrowserBanner' +import { CippSharePointBrowserProperties } from '../../../components/CippComponents/CippSharePointBrowserProperties' +import { CippSharePointBrowserPermissions } from '../../../components/CippComponents/CippSharePointBrowserPermissions' +import { CippSharePointBrowserStorage } from '../../../components/CippComponents/CippSharePointBrowserStorage' +import { CippSharePointFolderView } from '../../../components/CippComponents/CippSharePointFolderView' +import { ApiGetCall } from '../../../api/ApiCall' +import { useSettings } from '../../../hooks/use-settings' + +const openUrls = (rows) => { + const list = Array.isArray(rows) ? rows : [rows] + list.forEach((row) => { + if (row?.webUrl) { + window.open(row.webUrl, '_blank', 'noopener,noreferrer') + } + }) +} + +const queryString = (value) => (typeof value === 'string' && value.length > 0 ? value : null) + +const isSiteRow = (row) => row?.type === 'site' + +const Page = () => { + const router = useRouter() + const tenantFilter = useSettings().currentTenant + const [checkedIds, setCheckedIds] = useState([]) + const [permissionsOpen, setPermissionsOpen] = useState(false) + const [storageOpen, setStorageOpen] = useState(false) + + // Location is owned by the URL (?siteId=…) — name/url come from navigation or API Site + const siteId = queryString(router.query.siteId) + const [siteMeta, setSiteMeta] = useState(null) + + const openedSite = + router.isReady && siteId + ? { + id: siteId, + webUrl: siteMeta?.id === siteId ? siteMeta.webUrl : undefined, + displayName: siteMeta?.id === siteId ? siteMeta.displayName || siteMeta.webUrl : '…', + type: 'site', + canOpen: true, + storageUsedInBytes: + siteMeta?.id === siteId ? siteMeta.storageUsedInBytes : undefined, + } + : null + const path = openedSite ? [openedSite] : [] + const atRoot = !openedSite + + // Browser back/forward changes location without going through handlers + useEffect(() => { + setCheckedIds([]) + }, [siteId]) + + const setBrowserLocation = (site) => { + if (!router.isReady) return + const query = { ...router.query } + if (site?.id) { + query.siteId = site.id + setSiteMeta(site) + } else { + delete query.siteId + setSiteMeta(null) + } + delete query.siteUrl + delete query.siteName + delete query.siteType + router.replace({ pathname: router.pathname, query }, undefined, { shallow: true }) + } + + const browserApi = ApiGetCall({ + url: '/api/ListSiteBrowser', + data: { + tenantFilter, + ...(siteId ? { SiteId: siteId } : {}), + }, + queryKey: siteId + ? `ListSiteBrowser-${tenantFilter}-${siteId}` + : `ListSiteBrowser-${tenantFilter}-root`, + waiting: router.isReady && !!tenantFilter && tenantFilter !== 'AllTenants', + }) + + // Enrich from API after cold load / refresh + useEffect(() => { + const site = browserApi.data?.Site + if (site?.id && site.id === siteId) { + setSiteMeta((prev) => ({ + ...prev, + ...site, + // Keep storage from the site we opened if the Site payload doesn't include it + storageUsedInBytes: site.storageUsedInBytes ?? prev?.storageUsedInBytes, + })) + } + }, [browserApi.data?.Site, siteId]) + + const rawResults = browserApi.data?.Results + const items = useMemo(() => { + if (!Array.isArray(rawResults)) return [] + return rawResults.map((row) => ({ + ...row, + canOpen: row.type === 'site', + })) + }, [rawResults]) + + const checkedItems = useMemo(() => { + if (!checkedIds.length) return [] + const idSet = new Set(checkedIds) + return items.filter((item) => idSet.has(item.id)) + }, [items, checkedIds]) + + // Single checked row drives properties / permissions; multi-check is for Actions only. + const selected = checkedItems.length === 1 ? checkedItems[0] : null + + const actionRows = useMemo(() => { + if (checkedItems.length) return checkedItems + if (openedSite?.webUrl) return [openedSite] + return [] + }, [checkedItems, openedSite]) + + const errorMessage = + typeof rawResults === 'string' + ? rawResults + : browserApi.isError + ? (browserApi.error?.message ?? 'Failed to load items.') + : null + + // Banner always reflects the opened site; library only when one is selected + const bannerSite = openedSite ?? (selected?.type === 'site' ? selected : null) + const bannerLibrary = selected?.type === 'library' ? selected : null + const propertiesItem = selected + + // Storage is site-scoped: selected site at root, or the opened site when drilled in + const storageSite = isSiteRow(selected) ? selected : openedSite + const showStorage = Boolean(storageSite?.webUrl) + + const handleCheckedChange = (ids) => { + setCheckedIds(ids) + } + + const handleOpen = (item) => { + if (!item?.canOpen) return + setCheckedIds([]) + setBrowserLocation(item) + } + + const handleNavigate = (nextPath) => { + setCheckedIds([]) + setBrowserLocation(nextPath?.[0] ?? null) + } + + const bulkActions = useMemo( + () => [ + { + label: 'Open in SharePoint', + icon: , + showInActionsMenu: true, + noConfirm: true, + customFunction: (rows) => openUrls(rows), + condition: (rows) => + (Array.isArray(rows) ? rows : [rows]).some((row) => Boolean(row?.webUrl)), + }, + { + label: 'Storage', + icon: , + showInActionsMenu: true, + noConfirm: true, + condition: (rows) => { + const list = Array.isArray(rows) ? rows : [rows] + return list.length === 1 && isSiteRow(list[0]) && Boolean(list[0]?.webUrl) + }, + customFunction: (rows) => { + const list = Array.isArray(rows) ? rows : [rows] + if (list[0]?.id) setCheckedIds([list[0].id]) + setStorageOpen(true) + }, + }, + { + label: 'Delete', + icon: , + showInActionsMenu: true, + noConfirm: true, + customFunction: () => {}, + }, + ], + [] + ) + + const rowActions = useMemo( + () => [ + { + label: 'Open in SharePoint', + icon: , + condition: (item) => Boolean(item?.webUrl), + href: (item) => item.webUrl, + }, + { + label: 'Browse', + icon: , + condition: (item) => Boolean(item?.canOpen), + onClick: handleOpen, + }, + { + label: 'Storage', + icon: , + condition: (item) => isSiteRow(item) && Boolean(item?.webUrl), + onClick: (item) => { + if (item?.id) setCheckedIds([item.id]) + setStorageOpen(true) + }, + }, + { + label: 'Delete', + icon: , + onClick: () => {}, + }, + ], + [] + ) + + return ( + <> + + + + + SharePoint Site Browser + + + browserApi.refetch()} + disabled={!tenantFilter || tenantFilter === 'AllTenants' || browserApi.isFetching} + > + + + + + + {!tenantFilter || tenantFilter === 'AllTenants' ? ( + + Select a tenant to browse SharePoint sites. + + ) : ( + <> + setStorageOpen(true)} + showPermissions={selected?.type === 'site' || selected?.type === 'library'} + onPermissionsClick={() => setPermissionsOpen(true)} + showEditSite={Boolean(openedSite) || isSiteRow(selected)} + queryKeys={ + siteId + ? `ListSiteBrowser-${tenantFilter}-${siteId}` + : `ListSiteBrowser-${tenantFilter}-root` + } + /> + setPermissionsOpen(false)} + item={selected} + tenantFilter={tenantFilter} + siteUrl={selected?.type === 'library' ? openedSite?.webUrl : selected?.webUrl} + siteId={selected?.type === 'library' ? openedSite?.id : selected?.id} + /> + setStorageOpen(false)} + item={storageSite} + tenantFilter={tenantFilter} + /> + + + + + + + + + + )} + + + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/teams-share/sharing-report/index.js b/src/pages/teams-share/sharing-report/index.js index 38958272b110..5f28b1969dd8 100644 --- a/src/pages/teams-share/sharing-report/index.js +++ b/src/pages/teams-share/sharing-report/index.js @@ -87,7 +87,7 @@ const SharingLinkDetail = ({ row }) => { {properties .filter((prop) => prop.value !== undefined && prop.value !== null && prop.value !== '') .map((prop) => ( - + {prop.label} diff --git a/src/pages/teams-share/teams/business-voice/index.js b/src/pages/teams-share/teams/business-voice/index.js index 2a600064ca8e..72f80d1dda12 100644 --- a/src/pages/teams-share/teams/business-voice/index.js +++ b/src/pages/teams-share/teams/business-voice/index.js @@ -139,7 +139,7 @@ const Page = () => { "Complex: AssignmentStatus eq Unassigned; AcquiredCapabilities like UserAssignment", }, ]} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/teams-share/teams/list-team/index.js b/src/pages/teams-share/teams/list-team/index.js index 99b51994bafe..82f175d82544 100644 --- a/src/pages/teams-share/teams/list-team/index.js +++ b/src/pages/teams-share/teams/list-team/index.js @@ -62,9 +62,9 @@ const Page = () => { - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/teams-share/teams/teams-activity/index.js b/src/pages/teams-share/teams/teams-activity/index.js index 69d3fa3ebc72..bc3f825da9cd 100644 --- a/src/pages/teams-share/teams/teams-activity/index.js +++ b/src/pages/teams-share/teams/teams-activity/index.js @@ -39,7 +39,7 @@ const Page = () => { "CallCount", "TeamsChat", ]} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/src/pages/tenant/administration/add-subscription/index.jsx b/src/pages/tenant/administration/add-subscription/index.jsx index 457f350b3d3b..9792db06d0e9 100644 --- a/src/pages/tenant/administration/add-subscription/index.jsx +++ b/src/pages/tenant/administration/add-subscription/index.jsx @@ -54,7 +54,7 @@ const Page = () => { {/* Conditional Access Policy Selector */} - + { sortOptions={true} /> - + { sx={{ mb: 2 }} key={event.id} > - + { }} /> - + { ]} /> - + {/* Show textField for String properties when NOT using in/notIn operators */} { - + { const pageTitle = 'Alerts' @@ -29,6 +37,37 @@ const Page = () => { color: 'success', target: '_self', }, + { + label: 'Enable Alert', + type: 'POST', + url: '/api/ExecToggleAlert', + data: { + ID: 'RowKey', + EventType: 'EventType', + Disabled: '!false', + }, + icon: , + relatedQueryKeys: 'ListAlertsQueue', + condition: (row) => row.Enabled !== true, + confirmText: 'Are you sure you want to enable this alert?', + multiPost: false, + }, + { + label: 'Disable Alert', + type: 'POST', + url: '/api/ExecToggleAlert', + data: { + ID: 'RowKey', + EventType: 'EventType', + Disabled: '!true', + }, + icon: , + relatedQueryKeys: 'ListAlertsQueue', + condition: (row) => row.Enabled === true, + confirmText: + 'Are you sure you want to disable this alert? It will not run until you enable it again.', + multiPost: false, + }, { label: 'Delete Alert', type: 'POST', @@ -62,6 +101,7 @@ const Page = () => { simpleColumns={[ 'Tenants', 'EventType', + 'Enabled', 'Conditions', 'RepeatsEvery', 'Actions', diff --git a/src/pages/tenant/administration/applications/app-registration/index.jsx b/src/pages/tenant/administration/applications/app-registration/index.jsx index 7bcfe12fad20..8c5e25707661 100644 --- a/src/pages/tenant/administration/applications/app-registration/index.jsx +++ b/src/pages/tenant/administration/applications/app-registration/index.jsx @@ -14,6 +14,7 @@ import { Badge, } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import { CippAppRegistrationSwitcher } from '../../../../../components/CippComponents/CippAppRegistrationSwitcher' import tabOptions from './tabOptions' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { Box, Stack } from '@mui/system' @@ -368,6 +369,13 @@ const Page = () => { + } subtitle={subtitle} actions={appData ? appActions : []} actionsData={actionsData} @@ -390,7 +398,7 @@ const Page = () => { > - + @@ -478,7 +486,7 @@ const Page = () => { - + Credentials { + } subtitle={subtitle} actions={appData ? appActions : []} actionsData={actionsData} diff --git a/src/pages/tenant/administration/applications/enterprise-app/index.jsx b/src/pages/tenant/administration/applications/enterprise-app/index.jsx index fb17f8e88994..48d5116cf579 100644 --- a/src/pages/tenant/administration/applications/enterprise-app/index.jsx +++ b/src/pages/tenant/administration/applications/enterprise-app/index.jsx @@ -6,6 +6,7 @@ import CippFormSkeleton from '../../../../../components/CippFormPages/CippFormSk import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon' import { Fingerprint, Launch, Apps, Group, CheckCircle, Warning, Badge } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import { CippEnterpriseAppSwitcher } from '../../../../../components/CippComponents/CippEnterpriseAppSwitcher' import tabOptions from './tabOptions' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { Box, Stack } from '@mui/system' @@ -288,6 +289,13 @@ const Page = () => { + } subtitle={subtitle} actions={spData ? appActions : []} actionsData={actionsData} @@ -305,7 +313,7 @@ const Page = () => { - + @@ -393,7 +401,7 @@ const Page = () => { - + Credentials { + } subtitle={subtitle} actions={spData ? appActions : []} actionsData={actionsData} diff --git a/src/pages/tenant/administration/tenants/edit.js b/src/pages/tenant/administration/tenants/edit.js index cc6d59397910..f8214e55b6e7 100644 --- a/src/pages/tenant/administration/tenants/edit.js +++ b/src/pages/tenant/administration/tenants/edit.js @@ -71,6 +71,7 @@ const Page = () => { ClearImmutableId: false, DisableOneDriveSharing: false, removeCalendarPermissions: false, + OOO: "", postExecution: { psa: false, email: false, @@ -122,6 +123,7 @@ const Page = () => { ClearImmutableId: false, DisableOneDriveSharing: false, removeCalendarPermissions: false, + OOO: "", postExecution: { psa: false, email: false, diff --git a/src/pages/tenant/baselines/alignment/index.js b/src/pages/tenant/baselines/alignment/index.js index a595d8ec2333..7f643e3bef01 100644 --- a/src/pages/tenant/baselines/alignment/index.js +++ b/src/pages/tenant/baselines/alignment/index.js @@ -65,6 +65,7 @@ import { CippDataTable } from '../../../../components/CippTable/CippDataTable' import { CippQueueTracker } from '../../../../components/CippTable/CippQueueTracker' import { CippHead } from '../../../../components/CippComponents/CippHead' import { CippInfoBar } from '../../../../components/CippCards/CippInfoBar' +import { CippChartCard } from '../../../../components/CippCards/CippChartCard' import CippButtonCard from '../../../../components/CippCards/CippButtonCard' import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog' import { CippApiLogsDrawer } from '../../../../components/CippComponents/CippApiLogsDrawer' @@ -556,6 +557,13 @@ const Page = () => { }) const catalog = definitionsApi.data ?? [] + // A per-path deny queues an OBJECT deletion, so it only exists where the + // definition ships a delete executor (the detect-drift standards, where each + // path IS a policy). Ordinary standards get accept-only per-property actions - + // enforcing the baseline is the row-level Deny. + const supportsPathDeletion = (standardName) => + !!catalog.find((entry) => entry.name === `${standardName}`.split('#')[0]) + ?.delete const baselines = baselinesApi.data ?? [] const standardAggregates = aggregateApi.data?.standards ?? [] const tenant = { @@ -1309,23 +1317,24 @@ const Page = () => { Accept this property only )} - {!acceptedPath && ( - - )} + {!acceptedPath && + supportsPathDeletion(row.standardName) && ( + + )} ) @@ -1469,20 +1478,22 @@ const Page = () => { Accept this property only )} - {drifted && !acceptedPath && ( - - )} + {drifted && + !acceptedPath && + supportsPathDeletion(row.standardName) && ( + + )} ) @@ -1672,6 +1683,32 @@ const Page = () => { : 'None', }, ])} + {/* A single point is just today's live score (already listed above) - the + chart earns its space once there is an actual line to draw. */} + {Array.isArray(row.trend) && row.trend.length > 1 && ( + + ({ + x: point.date, + y: point.aligned, + })), + }, + { + name: 'Compliant with baseline', + data: row.trend.map((point) => ({ + x: point.date, + y: point.verified, + })), + }, + ]} + /> + + )} { + // Force the value onto every standard: dirty + touched so the form registers + // the change even on fields the operator never interacted with. stage.standards.forEach((instanceKey) => { - formControl.setValue(`${instanceKey}.${field}`, value) + formControl.setValue(`${instanceKey}.${field}`, value, { + shouldDirty: true, + shouldTouch: true, + }) }) } @@ -227,16 +238,24 @@ const StagePanel = ({ }), standards: stage.standards.map((instanceKey) => { const config = values[instanceKey] ?? {} + // A standard the operator never expanded never mounts its settings fields, + // so its variables never enter the form - serialize the SAVED variables for + // those, or saving a large baseline would silently wipe their configuration. + // Unwrapped either way: legacy saves stored option objects ({label, value}) + // for some variables, and passing them through verbatim keeps that debt alive. + const savedVariables = + stage.standardConfigs?.[instanceKey]?.variables ?? {} return { standard: instanceKey.split('#')[0], instance: instanceKey, variables: Object.fromEntries( - Object.entries(config.variables ?? {}).map(([key, value]) => [ - key, - unwrapValue(value), - ]) + Object.entries(config.variables ?? savedVariables).map( + ([key, value]) => [key, unwrapValue(value)] + ) ), - remediateEnabled: config.remediateEnabled ?? true, + // Report-only unless the operator explicitly enabled remediation - a + // missing value must never fail open into auto-fixing tenants. + remediateEnabled: config.remediateEnabled ?? false, alertEnabled: config.alertEnabled ?? true, alertOnRemediate: config.alertOnRemediate ?? false, } @@ -326,8 +345,8 @@ const StagePanel = ({ )} {conditionIds.map((conditionId) => { const conditionType = get( - watchForm, - `conditions.${conditionId}.type` + watchConditions, + `${conditionId}.type` )?.value return ( @@ -522,6 +541,10 @@ const Page = () => { const router = useRouter() const [activeStage, setActiveStage] = useState(0) const [loadedTemplateId, setLoadedTemplateId] = useState(null) + // The GUID the next save updates. Null means the save CREATES a baseline (new + // editor, or a clone before its first save); the save response's id is adopted + // so saving twice never creates twice. + const [saveTargetId, setSaveTargetId] = useState(null) const [stages, setStages] = useState(() => buildEditorStages(undefined)) const [dialogOpen, setDialogOpen] = useState(false) const [dialogStageIndex, setDialogStageIndex] = useState(0) @@ -541,6 +564,23 @@ const Page = () => { // and table), all alignment views for every tenant, and the standards catalog. const saveBaseline = ApiPostCall({ relatedQueryKeys: ['ListBaseline*'], + onResult: (result) => { + const savedId = result?.Metadata?.id + if (!savedId) return + // Adopt the saved baseline: the next save updates it instead of creating a + // duplicate, and the URL reflects it so a refresh keeps editing the same one. + // Matching loadedTemplateId also stops the render-phase loader from + // re-resetting the form when the refetched list arrives. + setSaveTargetId(savedId) + setLoadedTemplateId(savedId) + if (router.query.id !== savedId || router.query.clone) { + router.replace( + { pathname: router.pathname, query: { id: savedId } }, + undefined, + { shallow: true } + ) + } + }, }) // After a save, the natural next step is seeing where the tenants stand - offer a // no-changes check right away instead of ending the setup flow in silence. @@ -579,6 +619,7 @@ const Page = () => { // Render-phase reset (not an effect) so the switch happens before anything paints. if (template && template.GUID !== loadedTemplateId) { setLoadedTemplateId(template.GUID) + setSaveTargetId(router.query.clone ? null : template.GUID) setStages(buildEditorStages(template)) setActiveStage(0) setHasUnsavedChanges(false) @@ -791,7 +832,7 @@ const Page = () => { saveBaseline.mutate({ url: '/api/AddBaseline', data: { - GUID: router.query.clone ? undefined : (loadedTemplateId ?? undefined), + GUID: saveTargetId ?? undefined, templateName: values.templateName, description: values.description, // Send the selector's option objects as-is (label/value/type) so they can be diff --git a/src/pages/tenant/gdap-management/invites/add.js b/src/pages/tenant/gdap-management/invites/add.js index fd46d263d0b8..660644782135 100644 --- a/src/pages/tenant/gdap-management/invites/add.js +++ b/src/pages/tenant/gdap-management/invites/add.js @@ -6,6 +6,7 @@ import CippPageCard from "../../../../components/CippCards/CippPageCard"; import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall"; import { CippDataTable } from "../../../../components/CippTable/CippDataTable"; import { CippApiResults } from "../../../../components/CippComponents/CippApiResults"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; import { Accordion, AccordionDetails, @@ -99,7 +100,7 @@ const Page = () => { - + Use this form to generate invites for the selected GDAP Role Template. After generating the invite, you will receive two URLs: @@ -122,7 +123,7 @@ const Page = () => { {" "} in Application Settings. - + {createDefaults && ( <> diff --git a/src/pages/tenant/gdap-management/offboarding.js b/src/pages/tenant/gdap-management/offboarding.js index aa6cddc87d0d..5cfc5be94fbf 100644 --- a/src/pages/tenant/gdap-management/offboarding.js +++ b/src/pages/tenant/gdap-management/offboarding.js @@ -226,6 +226,12 @@ const Page = () => { label="Remove all Domain Analyser results for this tenant." type="switch" /> + diff --git a/src/pages/tenant/gdap-management/relationships/relationship/index.js b/src/pages/tenant/gdap-management/relationships/relationship/index.js index 7a93d35548c2..df4c3f3004ca 100644 --- a/src/pages/tenant/gdap-management/relationships/relationship/index.js +++ b/src/pages/tenant/gdap-management/relationships/relationship/index.js @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import { ApiGetCall } from "../../../../../api/ApiCall"; import CippFormSkeleton from "../../../../../components/CippFormPages/CippFormSkeleton"; import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; +import { CippGdapRelationshipSwitcher } from "../../../../../components/CippComponents/CippGdapRelationshipSwitcher"; import tabOptions from "./tabOptions.json"; import { Box, Grid, Stack } from "@mui/system"; import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo"; @@ -135,6 +136,7 @@ const Page = () => { } subtitle={subtitle} isFetching={relationshipRequest.isLoading} actions={CippGdapActions()} diff --git a/src/pages/tenant/gdap-management/relationships/relationship/mappings.js b/src/pages/tenant/gdap-management/relationships/relationship/mappings.js index b9669e3f725f..383dfc279451 100644 --- a/src/pages/tenant/gdap-management/relationships/relationship/mappings.js +++ b/src/pages/tenant/gdap-management/relationships/relationship/mappings.js @@ -2,6 +2,7 @@ import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; import { useRouter } from "next/router"; import { ApiGetCall } from "../../../../../api/ApiCall"; import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; +import { CippGdapRelationshipSwitcher } from "../../../../../components/CippComponents/CippGdapRelationshipSwitcher"; import tabOptions from "./tabOptions.json"; import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo"; import { CippDataTable } from "../../../../../components/CippTable/CippDataTable"; @@ -45,6 +46,7 @@ const Page = () => { } subtitle={subtitle} isFetching={relationshipRequest.isLoading} backUrl="/tenant/gdap-management/relationships" diff --git a/src/pages/tenant/gdap-management/roles/add.js b/src/pages/tenant/gdap-management/roles/add.js index 6c2e2a808f22..3cd90ac53a9e 100644 --- a/src/pages/tenant/gdap-management/roles/add.js +++ b/src/pages/tenant/gdap-management/roles/add.js @@ -13,6 +13,7 @@ import cippDefaults from "../../../../data/CIPPDefaultGDAPRoles"; import { ApiGetCall } from "../../../../api/ApiCall"; import { Settings, SyncAlt } from "@mui/icons-material"; import { CippDataTable } from "../../../../components/CippTable/CippDataTable"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; import { TrashIcon } from "@heroicons/react/24/outline"; const Page = () => { @@ -217,7 +218,7 @@ const Page = () => { compareType="is" compareValue={true} > - + In Advanced Mode, you can manually map existing groups to GDAP roles. This functionality is designed to help map existing groups to GDAP roles that do not @@ -244,7 +245,7 @@ const Page = () => { on GDAP Role Guidance. - + { mt: 2, }} > - + { sx={{ height: "100%", display: "flex", flexDirection: "column" }} > Backup History - + {settings.currentTenant === "AllTenants" && ( - + { display: "flex", justifyContent: "space-between", alignItems: "flex-start", + flexWrap: "wrap", + rowGap: 1, + columnGap: 1, }} > - + {(() => { const match = backup.name.match( @@ -485,7 +499,12 @@ const Page = () => { /> )} - + - + {pageTitle} @@ -144,7 +144,7 @@ const Page = () => { {currentTenant === "AllTenants" && layoutMode !== "Table" ? ( - + { <> {blockCards.map((block, index) => ( { disable the schedule. After conversion, please check the new templates to ensure they are correct and re-enable the schedule. - + - + diff --git a/src/pages/tenant/tools/geoiplookup/index.js b/src/pages/tenant/tools/geoiplookup/index.js index 58b739e439f1..ba3fb20f6d6a 100644 --- a/src/pages/tenant/tools/geoiplookup/index.js +++ b/src/pages/tenant/tools/geoiplookup/index.js @@ -88,13 +88,13 @@ const Page = () => { > - + - + { required /> - + + + Just activated a role through PIM? Re-check your access. + + + + ), } return ( @@ -115,6 +171,9 @@ const Page = ({ reason = 'session' }) => { {isSessionEnded ? 'Sign in - CIPP' : '401 - Access Denied'} + {/* If an impersonated role can't load /me, this page is what renders — the exit + affordance must exist here or the user is stuck until they clear localStorage. */} + {(orgData.isSuccess || swaStatus.isSuccess) && Array.isArray(userRoles) && ( { disableRipple: true, }, }, + MuiAccordionDetails: { + styleOverrides: { + // An accordion is almost always nested inside a card that already pays for gutters, + // and its own content usually adds a third layer. Halve the horizontal padding on a + // phone so the innermost text is not reading through 70px of chrome. + root: { + "@media (max-width: 899.95px)": { + paddingLeft: 8, + paddingRight: 8, + }, + }, + }, + }, + MuiAccordionSummary: { + styleOverrides: { + root: { + "@media (max-width: 899.95px)": { + paddingLeft: 8, + paddingRight: 8, + }, + }, + }, + }, MuiCardActions: { styleOverrides: { root: { @@ -78,6 +101,10 @@ export const createComponents = () => { paddingLeft: 24, paddingRight: 24, paddingTop: 16, + "@media (max-width: 899.95px)": { + paddingLeft: 16, + paddingRight: 16, + }, }, }, }, @@ -88,6 +115,13 @@ export const createComponents = () => { paddingLeft: 24, paddingRight: 24, paddingTop: 20, + // 48px of the 390 a phone has is 12% of the screen spent on one card's gutters, + // and cards nest — a card inside an accordion inside a page card pays it three + // times over. Vertical padding is left alone; it isn't what runs out. + "@media (max-width: 899.95px)": { + paddingLeft: 16, + paddingRight: 16, + }, }, }, }, @@ -98,6 +132,11 @@ export const createComponents = () => { paddingLeft: 24, paddingRight: 24, paddingTop: 16, + // Matches MuiCardContent, or the header would sit inset from its own card body. + "@media (max-width: 899.95px)": { + paddingLeft: 16, + paddingRight: 16, + }, }, subheader: { fontSize: 14, @@ -176,6 +215,24 @@ export const createComponents = () => { }, }, }, + MuiDialog: { + styleOverrides: { + paper: { + // Below md a centred dialog wastes both screen edges and clips long forms, and + // there are ~70 dialogs in the app that never opted into fullScreen. Give them + // the full width and the full available height here rather than per call site. + // Height stays content-driven, so a two-line confirmation doesn't become an + // empty full screen. Dialogs that already pass fullScreen are unaffected. + "@media (max-width: 899.95px)": { + margin: 0, + width: "100%", + maxWidth: "100%", + maxHeight: "100%", + borderRadius: 0, + }, + }, + }, + }, MuiDialogActions: { styleOverrides: { root: { @@ -186,6 +243,21 @@ export const createComponents = () => { "&>:not(:first-of-type)": { marginLeft: 16, }, + "@media (max-width: 899.95px)": { + // 32px of side padding is a lot of a 390px screen. + paddingBottom: 16, + paddingLeft: 16, + paddingRight: 16, + paddingTop: 16, + // Spacing as gap, not margin-left. `:first-of-type` counts per element type, so an + // actions row of [caption div, button, button] gave the FIRST button no margin and + // the second 16px — invisible in a row, but once the row stacks on a phone the two + // buttons sit at different left edges and different widths. gap works either way. + gap: 8, + "&>:not(:first-of-type)": { + marginLeft: 0, + }, + }, }, }, }, @@ -232,9 +304,17 @@ export const createComponents = () => { root: { borderRadius: 6, padding: 8, + // Touch devices get 44px hit targets without changing desktop density — + // pointer:coarse only matches touch-primary input. + "@media (pointer: coarse)": { + padding: 10, + }, }, sizeSmall: { padding: 4, + "@media (pointer: coarse)": { + padding: 8, + }, }, }, }, @@ -254,6 +334,11 @@ export const createComponents = () => { styleOverrides: { input: { fontSize: 14, + // iOS Safari zooms the viewport when a focused input's text is under 16px, and + // never zooms back out. Touch devices get 16px; pointer devices keep 14. + "@media (pointer: coarse)": { + fontSize: 16, + }, height: "40px", // Apply height only to single-line inputs "&.MuiInputBase-inputMultiline": { height: "unset", // Allow textareas to be flexible @@ -291,6 +376,11 @@ export const createComponents = () => { input: { padding: "0 12px", // Adds padding to the left and right of the text fontSize: 14, + // iOS Safari zooms the viewport when a focused input's text is under 16px, and + // never zooms back out. Touch devices get 16px; pointer devices keep 14. + "@media (pointer: coarse)": { + fontSize: 16, + }, height: "40px", // Height for single-line input fields only "&.MuiInputBase-inputMultiline": { height: "unset", // Exclude multiline inputs (textareas) from fixed height @@ -427,6 +517,17 @@ export const createComponents = () => { }, }, }, + MuiTooltip: { + defaultProps: { + // MUI's Tooltip attaches no touchmove and no scroll listener, so a press held + // through a scroll opens the tooltip after 700ms and nothing is scheduled to close + // it until the finger lifts — it rides the page as you drag. A tooltip is a hover + // affordance and touch has no hover, so the long-press variant is not worth the + // defect. Sites that genuinely want one opt back in with disableTouchListener={false} + // (CippJSONView's field descriptions are the only one). + disableTouchListener: true, + }, + }, MuiTextField: { defaultProps: { variant: "filled", diff --git a/src/utils/cippVersion.js b/src/utils/cippVersion.js index ed64050c0759..4f20325c8d14 100644 --- a/src/utils/cippVersion.js +++ b/src/utils/cippVersion.js @@ -26,12 +26,17 @@ export async function getCippVersion() { return fetchPromise; } +import { getImpersonatedRole } from "./impersonation"; + // Build headers including X-CIPP-Version. Accept extra headers to merge. export async function buildVersionedHeaders(extra = {}) { const version = await getCippVersion(); + // Backend honors this only for real superadmins; harmless for everyone else. + const impersonatedRole = getImpersonatedRole(); return { "Content-Type": "application/json", "X-CIPP-Version": version, + ...(impersonatedRole ? { "x-cipp-impersonate-role": impersonatedRole } : {}), ...extra, }; } diff --git a/src/utils/csv-field-values.js b/src/utils/csv-field-values.js new file mode 100644 index 000000000000..7a61882af298 --- /dev/null +++ b/src/utils/csv-field-values.js @@ -0,0 +1,50 @@ +/** + * Pull values from CSV rows for a named column (case-insensitive, trimmed header match). + */ +export const extractCsvColumnValues = (csvRows, csvColumn) => { + if (!csvColumn || !Array.isArray(csvRows) || csvRows.length === 0) { + return [] + } + const colLower = String(csvColumn).trim().toLowerCase() + return csvRows + .map((row) => { + if (!row || typeof row !== 'object') return null + const key = Object.keys(row).find((k) => k.trim().toLowerCase() === colLower) + return key ? String(row[key]).trim() : null + }) + .filter((v) => v != null && v !== '') +} + +/** + * Flatten autocomplete form values to plain string ids/UPNs. + */ +export const normalizeAutoCompleteValues = (value) => { + const items = Array.isArray(value) ? value : value != null && value !== '' ? [value] : [] + return items + .filter(Boolean) + .map((item) => + typeof item === 'object' && item?.value != null + ? String(item.value) + : item != null + ? String(item) + : null + ) + .filter(Boolean) +} + +/** + * Merge autocomplete + optional CSV companion field (`${name}__csv`) into a flat string array. + */ +export const mergeCsvFormFields = (formData, fields) => { + if (!fields?.length) return formData + const merged = { ...formData } + fields.forEach((field) => { + if (!field.csvColumn || !field.name) return + const csvFieldName = `${field.name}__csv` + const acValues = normalizeAutoCompleteValues(merged[field.name]) + const csvValues = extractCsvColumnValues(merged[csvFieldName], field.csvColumn) + merged[field.name] = [...acValues, ...csvValues] + delete merged[csvFieldName] + }) + return merged +} diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index cbc0673f0257..30b4b2809596 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -43,6 +43,22 @@ const getCountryNameFromCode = (countryCode) => { return country ? country.Name : countryCode } +// Shared so the card list and the extended-info drawer can label a portal link with the +// same glyph the table cell uses. +export const portalIcons = { + portal_m365: CogIcon, + portal_exchange: MailOutline, + portal_entra: UserIcon, + portal_teams: UsersIcon, + portal_azure: ServerIcon, + portal_intune: LaptopWindows, + portal_security: Shield, + portal_compliance: CompassCalibration, + portal_sharepoint: Description, + portal_platform: PrecisionManufacturing, + portal_bi: BarChart, +} + export const getCippFormatting = ( data, cellName, @@ -63,20 +79,6 @@ export const getCippFormatting = ( ) } - const portalIcons = { - portal_m365: CogIcon, - portal_exchange: MailOutline, - portal_entra: UserIcon, - portal_teams: UsersIcon, - portal_azure: ServerIcon, - portal_intune: LaptopWindows, - portal_security: Shield, - portal_compliance: CompassCalibration, - portal_sharepoint: Description, - portal_platform: PrecisionManufacturing, - portal_bi: BarChart, - } - // Create a helper function to render chips with CollapsibleChipList const renderChipList = (items, maxItems = 4) => { if (!Array.isArray(items) || items.length === 0) { @@ -266,6 +268,9 @@ export const getCippFormatting = ( 'NextAttemptUtc', 'LastErrorUtc', 'LastPolledUtc', + 'QueuedUtc', // Worker health job queue + 'StartedUtc', // Worker health job queue + 'CompletedUtc', // Worker health job queue ] if (absoluteDateArray.includes(cellName)) { if (data === null || data === undefined || data === '') { @@ -274,9 +279,11 @@ export const getCippFormatting = ( const dt = parseCippDate(data) if (isNaN(dt.getTime())) return isText ? '' : '' if (dt.getTime() === 0) return isText ? '' : 'Never' - // text mode: Date object so MRT sorts chronologically (toLocaleString for CSV export); + // text mode: Date object so MRT sorts chronologically — except when the caller can + // receive a rendered node ('both': off-canvas, card views) or explicitly wants a + // string (false: CSV export); a raw Date is not a valid React child. // cell mode: long absolute string in the browser's locale + timezone. - if (isText) return canReceive === false ? dt.toLocaleString() : dt + if (isText) return canReceive === 'both' || canReceive === false ? dt.toLocaleString() : dt return dt.toLocaleString() } @@ -317,6 +324,7 @@ export const getCippFormatting = ( 'requestDate', // App Consent Requests 'reviewedDate', // App Consent Requests 'GeneratedAt', // Report Builder + 'RecordedAt', // Container update history 'directTenantAuthDate', // Direct tenant service account 'ServiceAccountLastAuth', // Direct tenant service account ] diff --git a/src/utils/get-filtered-portals.js b/src/utils/get-filtered-portals.js new file mode 100644 index 000000000000..d4f00dff1ecd --- /dev/null +++ b/src/utils/get-filtered-portals.js @@ -0,0 +1,37 @@ +import Portals from "../data/portals"; + +// Which M365 portal links the user wants shown, resolved from user-specific settings +// (preferred), tenant-level settings, or the all-on defaults. Pure so both the dashboard +// menu and the mobile FAB sheet share one filter (and it stays unit-testable). +export const getFilteredPortals = (settings) => { + const defaultLinks = { + M365_Portal: true, + Exchange_Portal: true, + Entra_Portal: true, + Teams_Portal: true, + Azure_Portal: true, + Intune_Portal: true, + SharePoint_Admin: true, + Security_Portal: true, + Compliance_Portal: true, + Power_Platform_Portal: true, + Power_BI_Portal: true, + }; + + let portalLinks; + if (settings?.UserSpecificSettings?.portalLinks) { + portalLinks = { + ...defaultLinks, + ...settings.UserSpecificSettings.portalLinks, + }; + } else if (settings?.portalLinks) { + portalLinks = { ...defaultLinks, ...settings.portalLinks }; + } else { + portalLinks = defaultLinks; + } + + return Portals.filter((portal) => { + const settingKey = portal.name; + return settingKey ? portalLinks[settingKey] === true : true; + }); +}; diff --git a/src/utils/help-links.js b/src/utils/help-links.js new file mode 100644 index 000000000000..8de5cb2ed923 --- /dev/null +++ b/src/utils/help-links.js @@ -0,0 +1,41 @@ +// Help/support destinations shared by CippSpeedDial (desktop FAB) and AccountPopover +// (mobile, where the FAB corner belongs to page actions). One definition so the two +// surfaces can't drift. + +export const getHelpLinks = (pathname = "") => [ + { + id: "bug-report", + name: "Report Bug", + href: "https://github.com/CyberDrain/CIPP/issues/new?template=bug.yml", + }, + { + id: "feature-request", + name: "Request Feature", + href: "https://github.com/CyberDrain/CIPP/issues/new?template=feature.yml", + }, + { + id: "discord", + name: "Join the Discord!", + href: "https://discord.gg/cyberdrain", + }, + { + id: "documentation", + name: "Check the Documentation", + href: `https://docs.cipp.app/user-documentation${pathname}`, + }, +]; + +// Clears the TanStack Query cache (memory + the persisted localStorage copy) and hard-reloads. +export const clearCippCache = (queryClient) => { + queryClient.clear(); + + if (typeof window !== "undefined") { + Object.keys(localStorage).forEach((key) => { + if (key.startsWith("REACT_QUERY_OFFLINE_CACHE")) { + localStorage.removeItem(key); + } + }); + // Force refresh the page to bypass browser cache and reload JavaScript + window.location.reload(true); + } +}; diff --git a/src/utils/impersonation.js b/src/utils/impersonation.js new file mode 100644 index 000000000000..a9e7d9a75b4e --- /dev/null +++ b/src/utils/impersonation.js @@ -0,0 +1,75 @@ +/** + * Role impersonation state (superadmin-only feature). + * + * Lives in its own localStorage key - NOT app.settings, which round-trips to the server + * via ExecUserSettings and races on init - so it is readable synchronously from + * non-React code (buildVersionedHeaders) and via useSyncExternalStore in components. + * The backend only honors the header for real superadmins, so this state can never + * grant privileges; it only narrows them. + */ + +const KEY = 'cipp_impersonate_role' +const listeners = new Set() +const notify = () => listeners.forEach((listener) => listener()) + +// localStorage throws in locked-down browsers - never let that break the app. +export const getImpersonatedRole = () => { + if (typeof window === 'undefined') return null + try { + return window.localStorage.getItem(KEY) || null + } catch { + return null + } +} + +export const subscribeImpersonation = (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) +} + +// Everything except authmecipp is persisted to localStorage (REACT_QUERY_OFFLINE_CACHE*), +// so both transitions must clear the persisted cache and hard-reload or role-scoped data +// from the other identity survives. Mirrors the "Clear Cache and Reload" speed-dial in +// _app.js. Never use queryClient.cancelQueries() here (permanent-abort race). +const clearCachesAndReload = (queryClient) => { + try { + queryClient?.clear() + } catch { + /* reload still gives a clean slate */ + } + try { + Object.keys(window.localStorage) + .filter((key) => key.startsWith('REACT_QUERY_OFFLINE_CACHE')) + .forEach((key) => window.localStorage.removeItem(key)) + } catch { + /* worst case: stale cache entries expire on their own */ + } + window.location.reload() +} + +export const enterImpersonation = (role, queryClient) => { + try { + window.localStorage.setItem(KEY, String(role).toLowerCase()) + } catch { + return + } + notify() + clearCachesAndReload(queryClient) +} + +export const exitImpersonation = (queryClient) => { + try { + window.localStorage.removeItem(KEY) + } catch { + /* fall through - reload clears in-memory state regardless */ + } + notify() + clearCachesAndReload(queryClient) +} + +// The Craft response cache keys on URL + params, not headers - impersonated GETs carry +// this param so the two identities can never share a cached response. +export const impersonationCacheParams = () => { + const role = getImpersonatedRole() + return role ? { _imp: role } : {} +} diff --git a/src/utils/is-cloud-pc-device.js b/src/utils/is-cloud-pc-device.js new file mode 100644 index 000000000000..cda4c5259da4 --- /dev/null +++ b/src/utils/is-cloud-pc-device.js @@ -0,0 +1,13 @@ +// Windows 365 Cloud PCs never report BitLocker (isEncrypted stays false) although their disks +// are platform-encrypted by Azure, so encryption reporting must not flag them as unencrypted. +// Mirrors the backend Test-CIPPCloudPCDevice check: the cached CIPP marker, the documented +// deviceType signal (chassisType kept in case the service starts emitting it), then the +// model/manufacturer pair Windows 365 provisions. +export const isCloudPcDevice = (device) => + device?.isCloudPC === true || + device?.deviceType === 'cloudPC' || + device?.chassisType === 'cloudPC' || + (typeof device?.model === 'string' && + device.model.toLowerCase().startsWith('cloud pc') && + typeof device?.manufacturer === 'string' && + device.manufacturer.toLowerCase() === 'microsoft corporation') diff --git a/src/utils/overlay-history.js b/src/utils/overlay-history.js new file mode 100644 index 000000000000..c5d997fb9779 --- /dev/null +++ b/src/utils/overlay-history.js @@ -0,0 +1,123 @@ +/** + * Makes the phone's back gesture dismiss the topmost overlay instead of leaving the page. + * + * A back swipe IS history navigation, so the only way to intercept it is to own a history + * entry. An overlay that opts in pushes one entry at the SAME url — nothing visible + * changes — carrying a depth marker; swiping back pops that entry and we close the overlay + * rather than letting the router move. + * + * Two details keep this safe next to Next's pages router: + * + * 1. The pushed state CLONES the router's current state (__N/url/as/key) and only adds + * the marker. If the user navigates away with an overlay open, our entry is still a + * valid route entry, so returning to it renders that page instead of dead-ending on a + * state Next refuses to recognise. + * 2. router.beforePopState() suppresses Next's same-url re-render for pops that are ours. + * Without it, closing a drawer would emit route events and reset the scroll position — + * a long list would jump back to the top every time you dismissed a row. + * + * Module-level rather than per-component because the stack has to be shared: a back press + * must close the deepest open overlay, whoever rendered it. + */ + +const MARKER = "__cippOverlay"; + +// Entries mirror the history entries we pushed, deepest last. +let stack = []; +// The marker depth of the entry the browser is currently sitting on. Tracked here because +// beforePopState runs after window.history has already moved, so the previous depth is +// otherwise unknowable. +let depth = 0; +// history.back() calls WE made. The resulting popstate must not be mistaken for the user's. +let selfPops = 0; +let installed = false; + +const hasWindow = () => typeof window !== "undefined" && typeof window.history !== "undefined"; + +const readDepth = () => { + if (!hasWindow()) return 0; + const value = window.history.state?.[MARKER]; + return typeof value === "number" ? value : 0; +}; + +const handlePopState = () => { + const next = readDepth(); + const wasSelfPop = selfPops > 0; + if (wasSelfPop) selfPops -= 1; + depth = next; + + // Pop deepest-first: closing bottom-up would briefly leave an overlay covering one that + // is still open. Entries released by their own component are already gone from the stack, + // so a self-pop normally finds nothing here. + const dismissed = []; + while (stack.length > 0 && stack[stack.length - 1].depth > next) { + dismissed.push(stack.pop()); + } + dismissed.forEach((entry) => { + if (entry.released) return; + entry.released = true; + entry.close?.(); + }); +}; + +/** + * Next calls this from its own popstate listener, before ours. Returning false means "the + * app handled this" and Next skips the route change entirely. + */ +const shouldNextHandle = (state) => { + if (selfPops > 0) return false; + if (stack.length === 0) return true; + // Not a pop out of an overlay entry — a forward move or an unrelated traversal. + if (readDepth() >= depth) return true; + // A real navigation that happens to jump past our entries still belongs to Next. + const top = stack[stack.length - 1]; + if (state?.as && top.as && state.as !== top.as) return true; + return false; +}; + +export const installOverlayHistory = (router) => { + if (installed || !hasWindow()) return; + installed = true; + depth = readDepth(); + window.addEventListener("popstate", handlePopState); + router?.beforePopState?.(shouldNextHandle); +}; + +export const pushOverlayEntry = (close) => { + if (!hasWindow()) return null; + const entry = { + depth: readDepth() + 1, + as: window.history.state?.as, + close, + released: false, + }; + stack.push(entry); + depth = entry.depth; + window.history.pushState({ ...window.history.state, [MARKER]: entry.depth }, ""); + return entry; +}; + +export const releaseOverlayEntry = (entry) => { + if (!entry || entry.released) return; + entry.released = true; + const index = stack.indexOf(entry); + if (index === -1) return; + const isTop = index === stack.length - 1; + stack.splice(index, 1); + // Only the entry the browser is actually sitting on can be popped. If something was + // pushed over ours — another overlay, or a route change while we were open — ours is + // buried: drop it and leave history alone rather than yanking the user backwards. + if (!isTop || !hasWindow() || readDepth() !== entry.depth) return; + selfPops += 1; + depth = entry.depth - 1; + window.history.back(); +}; + +// Module state outlives a render tree, so tests need a way back to zero. +export const resetOverlayHistory = () => { + if (hasWindow()) window.removeEventListener("popstate", handlePopState); + stack = []; + depth = 0; + selfPops = 0; + installed = false; +}; diff --git a/src/utils/permission-rules.js b/src/utils/permission-rules.js new file mode 100644 index 000000000000..56f8a6048a24 --- /dev/null +++ b/src/utils/permission-rules.js @@ -0,0 +1,180 @@ +/** + * Permission rule helpers for custom roles. + * + * Rules use the same include/exclude glob format as base roles (cipp-roles.json): + * patterns match against "Category.Object.Read|ReadWrite" strings, exclude wins. + * Matching mirrors PowerShell -like: * is the only wildcard, case-insensitive. + */ + +const escapeRegex = (str) => str.replace(/[.+?^${}()|[\]\\]/g, '\\$&') + +export const matchPattern = (pattern, value) => { + if (typeof pattern !== 'string' || typeof value !== 'string') return false + const regex = new RegExp( + `^${escapeRegex(pattern).replace(/\*/g, '.*')}$`, + 'i' + ) + return regex.test(value) +} + +// Flatten the ExecAPIPermissionList tree ({Cat: {Obj: {Read|ReadWrite: {...}}}}) +// into the sorted list of concrete permission strings. +export const flattenPermissionTree = (apiPermissions) => { + const universe = [] + if (!apiPermissions || typeof apiPermissions !== 'object') return universe + Object.keys(apiPermissions).forEach((cat) => { + Object.keys(apiPermissions[cat] || {}).forEach((obj) => { + Object.keys(apiPermissions[cat][obj] || {}).forEach((type) => { + universe.push(`${cat}.${obj}.${type}`) + }) + }) + }) + return universe.sort() +} + +const normalizeRuleList = (list) => + (Array.isArray(list) ? list : []) + .map((entry) => (typeof entry === 'string' ? entry : entry?.value)) + .filter((entry) => typeof entry === 'string' && entry.length > 0) + +/** + * Expand include/exclude rules over a permission universe. + * Returns the matched permissions plus per-pattern stats for the live preview: + * - includeCounts: pattern -> total universe matches + * - excludeCounts: pattern -> included permissions this pattern removed + * - excludedBy: permission -> first exclude pattern that removed it + */ +export const expandRules = (rules, universe) => { + const include = normalizeRuleList(rules?.Include) + const exclude = normalizeRuleList(rules?.Exclude) + const includeCounts = {} + const excludeCounts = {} + const excludedBy = {} + include.forEach((pattern) => (includeCounts[pattern] = 0)) + exclude.forEach((pattern) => (excludeCounts[pattern] = 0)) + + const matched = [] + ;(universe || []).forEach((permission) => { + let included = false + include.forEach((pattern) => { + if (matchPattern(pattern, permission)) { + includeCounts[pattern] += 1 + included = true + } + }) + if (!included) return + const excludedByPattern = exclude.find((pattern) => + matchPattern(pattern, permission) + ) + if (excludedByPattern !== undefined) { + excludeCounts[excludedByPattern] += 1 + excludedBy[permission] = excludedByPattern + return + } + matched.push(permission) + }) + + return { matched, includeCounts, excludeCounts, excludedBy } +} + +/** + * Convert rules into the flat editor/storage map: { "CatObj": "Cat.Obj.None|Read|ReadWrite" }. + * ReadWrite beats Read; CIPP.Core is floored at Read (login breaks without it). + */ +export const rulesToFlatMap = (rules, apiPermissions) => { + const flat = {} + if (!apiPermissions || typeof apiPermissions !== 'object') return flat + const include = normalizeRuleList(rules?.Include) + const exclude = normalizeRuleList(rules?.Exclude) + + const granted = (permission) => + include.some((pattern) => matchPattern(pattern, permission)) && + !exclude.some((pattern) => matchPattern(pattern, permission)) + + Object.keys(apiPermissions).forEach((cat) => { + Object.keys(apiPermissions[cat] || {}).forEach((obj) => { + let level = 'None' + if (granted(`${cat}.${obj}.ReadWrite`)) { + level = 'ReadWrite' + } else if (granted(`${cat}.${obj}.Read`)) { + level = 'Read' + } + if (cat === 'CIPP' && obj === 'Core' && level === 'None') { + level = 'Read' + } + flat[`${cat}${obj}`] = `${cat}.${obj}.${level}` + }) + }) + return flat +} + +// Convert the flat map back into concrete-string rules (the canonical storage +// format for advanced-mode roles): Include = explicit non-None values. +export const flatMapToRules = (flatMap) => { + const include = [ + ...new Set( + Object.values(flatMap || {}).filter( + (value) => + typeof value === 'string' && + value.length > 0 && + !value.endsWith('.None') + ) + ), + ].sort() + return { Include: include, Exclude: [] } +} + +// 1-3 dot-separated segments of letters/digits/wildcards, e.g. "*", "*.Read", +// "Identity.User.*", "Identity.User.ReadWrite". Same grammar the backend enforces. +export const validateRulePattern = (str) => + typeof str === 'string' && /^[A-Za-z0-9*]+(\.[A-Za-z0-9*]+){0,2}$/.test(str) + +// Suggestion options for the rule autocompletes, grouped for CippAutocompleteGrouping. +export const buildRuleSuggestions = (apiPermissions) => { + const suggestions = [ + { label: '* (everything)', value: '*', category: 'Global' }, + { label: '*.Read (all read-only)', value: '*.Read', category: 'Global' }, + { + label: '*.ReadWrite (all read/write)', + value: '*.ReadWrite', + category: 'Global', + }, + ] + if (!apiPermissions || typeof apiPermissions !== 'object') return suggestions + Object.keys(apiPermissions) + .sort() + .forEach((cat) => { + suggestions.push({ + label: `${cat}.* (entire category)`, + value: `${cat}.*`, + category: cat, + }) + suggestions.push({ + label: `${cat}.*.Read`, + value: `${cat}.*.Read`, + category: cat, + }) + suggestions.push({ + label: `${cat}.*.ReadWrite`, + value: `${cat}.*.ReadWrite`, + category: cat, + }) + Object.keys(apiPermissions[cat] || {}) + .sort() + .forEach((obj) => { + suggestions.push({ + label: `${cat}.${obj}.*`, + value: `${cat}.${obj}.*`, + category: cat, + }) + Object.keys(apiPermissions[cat][obj] || {}).forEach((type) => { + suggestions.push({ + label: `${cat}.${obj}.${type}`, + value: `${cat}.${obj}.${type}`, + category: cat, + }) + }) + }) + }) + return suggestions +} diff --git a/src/utils/render-url-value.jsx b/src/utils/render-url-value.jsx new file mode 100644 index 000000000000..6757b9aa6f5d --- /dev/null +++ b/src/utils/render-url-value.jsx @@ -0,0 +1,58 @@ +import { Link, SvgIcon } from "@mui/material"; +import OpenInNew from "@mui/icons-material/OpenInNew"; +import { portalIcons } from "./get-cipp-formatting"; + +const ABSOLUTE_URL = /^https?:\/\//i; +// A bare host like "contoso-admin.sharepoint.com" — a portal link often arrives without +// its scheme, which would otherwise be resolved against the CIPP origin. +const HOST_LIKE = /^[\w-]+(\.[\w-]+)+(\/|$)/; + +/** + * A tappable, self-describing link for a URL-valued field. + * + * Table cells render portals as a bare icon, which reads fine under a narrow column header + * and not at all once the same value appears in a card or a labelled property list. + * Returns null when the value isn't linkable, so callers fall back to normal formatting. + */ +export const renderUrlValue = (value, field = "") => { + if (typeof value !== "string" || !value.trim()) return null; + + const isPortal = field.startsWith("portal_"); + const trimmed = value.trim(); + if (!isPortal && !ABSOLUTE_URL.test(trimmed)) return null; + + const href = ABSOLUTE_URL.test(trimmed) + ? trimmed + : HOST_LIKE.test(trimmed) + ? `https://${trimmed}` + : trimmed; + + const PortalIcon = portalIcons[field]; + + return ( + event.stopPropagation()} + sx={{ + display: "inline-flex", + alignItems: "center", + gap: 0.5, + minWidth: 0, + overflowWrap: "anywhere", + }} + > + {PortalIcon && ( + + + + )} + {isPortal ? "Open portal" : trimmed} + + + + + ); +}; diff --git a/src/utils/resolve-row-templates.js b/src/utils/resolve-row-templates.js new file mode 100644 index 000000000000..7a3656c7dbdc --- /dev/null +++ b/src/utils/resolve-row-templates.js @@ -0,0 +1,93 @@ +const TEMPLATE = /\[([^\]]+)\]/g + +/** + * Resolve a dotted path against an object. Missing segments yield undefined. + */ +export const getNestedValue = (source, path) => { + if (source === undefined || source === null) { + return undefined + } + if (!path) { + return source + } + + return path.split('.').reduce((acc, key) => { + if (acc === undefined || acc === null) { + return undefined + } + if (typeof acc !== 'object') { + return undefined + } + return acc[key] + }, source) +} + +/** + * Nested-table action context: `parent` is the opening row. If the child already + * had `parent` (API data), chain it at `parent.parent` unless the opening row is + * itself nested and already owns that slot. + */ +export const attachParentRow = (row, parentRow) => { + if (!parentRow || row == null) { + return row + } + if (Array.isArray(row)) { + return row.map((item) => attachParentRow(item, parentRow)) + } + if (row.parent === parentRow) { + return row + } + + let nextParent = parentRow + if (row.parent !== undefined && parentRow.parent === undefined) { + nextParent = { ...parentRow, parent: row.parent } + } + return { ...row, parent: nextParent } +} + +/** + * AllTenants convention used across CIPP: prefer the row (or nested parent) tenant. + */ +export const getRowTenant = (row, currentTenant) => { + if (currentTenant !== 'AllTenants') { + return currentTenant + } + const source = Array.isArray(row) ? row[0] : row + return ( + source?.Tenant || + source?.parent?.Tenant || + source?.tenantFilter || + source?.parent?.tenantFilter || + currentTenant + ) +} + +const replaceTemplatesInString = (value, row) => + value.replace(TEMPLATE, (_, key) => { + const resolved = getNestedValue(row, key) + if (resolved === undefined || resolved === null) { + return `[${key}]` + } + return String(resolved) + }) + +/** + * Walk strings (and objects/arrays of them) and replace `[field]` / `[nested.path]` + * from `row`. Booleans, numbers, and null stay as-is. + */ +export const resolveRowTemplates = (value, row) => { + if (typeof value === 'string') { + return replaceTemplatesInString(value, row) + } + if (Array.isArray(value)) { + return value.map((item) => resolveRowTemplates(item, row)) + } + if (value && typeof value === 'object') { + const next = {} + for (const key of Object.keys(value)) { + next[key] = resolveRowTemplates(value[key], row) + } + return next + } + return value +} diff --git a/src/utils/support-bundle.js b/src/utils/support-bundle.js new file mode 100644 index 000000000000..421d7b4d0c04 --- /dev/null +++ b/src/utils/support-bundle.js @@ -0,0 +1,246 @@ +import axios from 'axios' + +// Captures the API traffic behind the current page for the speed dial's support-file +// generator. The recorder is armed only while the support dialog is collecting: the dialog +// forces every active (mounted) query to refetch, so everything the page reads flows +// through axios inside the capture window and is recorded here — successes included, since +// support usually needs to see what the page DID get alongside what failed. + +// One oversized Graph list page must not balloon the bundle into something the user +// cannot email, so recorded bodies are capped and flagged instead of stored whole. +const MAX_BODY_CHARS = 262144 + +let armed = false +let seq = 0 +let calls = [] + +const serializeValue = (data, responseType) => { + if (data === null || data === undefined) return { value: null } + if ( + responseType === 'blob' || + (typeof Blob !== 'undefined' && data instanceof Blob) + ) { + return { + value: ``, + } + } + if (typeof FormData !== 'undefined' && data instanceof FormData) { + return { value: '' } + } + let text + try { + text = typeof data === 'string' ? data : JSON.stringify(data) + } catch { + text = String(data) + } + if (typeof text === 'string' && text.length > MAX_BODY_CHARS) { + return { value: text.slice(0, MAX_BODY_CHARS), truncated: true } + } + // Small bodies keep their shape so the bundle stays readable as plain JSON. + return { value: typeof data === 'string' ? data : data } +} + +// By response time axios has already transformed the request payload into its wire form, +// which for CIPP means a JSON string. Parse it back so the recorded requestBody is a +// readable object rather than an escaped string inside the bundle. +const parseMaybeJson = (data) => { + if (typeof data !== 'string') return data + const trimmed = data.trim() + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return data + try { + return JSON.parse(trimmed) + } catch { + return data + } +} + +const record = (config, response, error) => { + if (!config?.cippSupportMeta || config.cippSupportRecorded) return + // HMR in dev can register the interceptors more than once; the per-request flag + // keeps a call from being recorded twice. + config.cippSupportRecorded = true + const { start, seq: n } = config.cippSupportMeta + const entry = { + seq: n, + startedAt: new Date(start).toISOString(), + durationMs: Date.now() - start, + method: (config.method || 'get').toUpperCase(), + url: config.url, + params: config.params ?? null, + status: response?.status ?? null, + success: !error, + } + if (error) entry.errorMessage = String(error.message ?? error) + // The payload the client SENT matters as much as what came back - a failing write + // usually fails because of what was in it. + if (config.data !== undefined) { + const requestBody = serializeValue(parseMaybeJson(config.data)) + entry.requestBody = requestBody.value + if (requestBody.truncated) entry.requestBodyTruncated = true + } + const responseBody = serializeValue(response?.data, config.responseType) + entry.responseBody = responseBody.value + if (responseBody.truncated) entry.responseBodyTruncated = true + calls.push(entry) +} + +axios.interceptors.request.use((config) => { + if (armed) { + config.cippSupportMeta = { start: Date.now(), seq: ++seq } + } + return config +}) + +axios.interceptors.response.use( + (response) => { + record(response.config, response, null) + return response + }, + (error) => { + record(error?.config, error?.response, error ?? new Error('Request failed')) + return Promise.reject(error) + } +) + +export const armSupportRecorder = () => { + calls = [] + seq = 0 + armed = true +} + +export const disarmSupportRecorder = () => { + armed = false +} + +export const getSupportRecording = () => + [...calls].sort((a, b) => a.seq - b.seq) + +export const getSupportRecordingCount = () => calls.length + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +// A bearer token is a live credential, not an identifier — there is no support value in +// shipping one, so tokens are stripped from EVERY bundle regardless of the redaction +// option. Known token fields (/.auth/me's access_token, id_token, refresh_token and +// friends) are emptied by name, and anything shaped like a JWT is removed wherever it +// appears, error payloads included. base64url can never contain a quote or backslash, +// so the substitution cannot break the serialized JSON. +const TOKEN_FIELD_PATTERN = + /"([A-Za-z0-9_]*(?:access|id|refresh|session)_token[A-Za-z0-9_]*)":"[^"]*"/g +const JWT_PATTERN = /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g + +export const stripTokens = (bundle) => { + let text = JSON.stringify(bundle) + let removed = 0 + text = text.replace(TOKEN_FIELD_PATTERN, (match, key) => { + removed++ + return `"${key}":""` + }) + text = text.replace(JWT_PATTERN, () => { + removed++ + return '' + }) + return { bundle: JSON.parse(text), removed } +} + +const EMAIL_PATTERN = /[A-Za-z0-9._%+'-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g +const GUID_PATTERN = + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi +const ONMICROSOFT_PATTERN = + /[A-Za-z0-9-]+\.(?:mail\.)?onmicrosoft\.(?:com|us|de)/g + +// Replaces every email address, GUID (tenant and object ids alike) and known tenant +// domain with a consistent token: the same original value always maps to the same token, +// so support can still correlate "user3 appears in the failing call and the roles list" +// without seeing who user3 is. Domains are redacted from a harvested set (email domains, +// *.onmicrosoft.* matches and the selected tenant) rather than a blind hostname regex, +// so Graph schema strings and infrastructure URLs are never mangled. +// Works on the serialized bundle: none of the matched values or tokens can contain a +// quote or backslash, so the JSON structure survives the substitution. +export const redactBundle = (bundle, { keepHostnames = [] } = {}) => { + let text = JSON.stringify(bundle) + const emailMap = new Map() + const guidMap = new Map() + const domainMap = new Map() + const domainSet = new Set() + // Kept hostnames must survive even when a harvested domain is their suffix — the + // instance hostname often shares the MSP's own mail domain. Swap them for inert + // placeholders first (nothing the email/domain/GUID patterns can match), and swap + // them back after every substitution has run. + const keepTokens = new Map() + for (const host of keepHostnames.filter(Boolean)) { + const token = `__CIPP_KEEP_${keepTokens.size}__` + keepTokens.set(token, host) + text = text.replace(new RegExp(escapeRegExp(host), 'gi'), token) + } + + // Harvest tenant domains before emails are replaced, so bare occurrences of an + // email's domain are caught too. + for (const match of text.matchAll(EMAIL_PATTERN)) { + domainSet.add(match[0].split('@')[1].toLowerCase()) + } + for (const match of text.matchAll(ONMICROSOFT_PATTERN)) { + domainSet.add(match[0].toLowerCase()) + } + const tenant = bundle?.client?.tenant + if (tenant && tenant !== 'AllTenants' && tenant.includes('.')) { + domainSet.add(tenant.toLowerCase()) + } + + text = text.replace(EMAIL_PATTERN, (value) => { + const key = value.toLowerCase() + if (!emailMap.has(key)) + emailMap.set(key, `user${emailMap.size + 1}@redacted.invalid`) + return emailMap.get(key) + }) + + for (const domain of domainSet) { + if (!domainMap.has(domain)) + domainMap.set(domain, `domain${domainMap.size + 1}.invalid`) + text = text.replace( + new RegExp(escapeRegExp(domain), 'gi'), + domainMap.get(domain) + ) + } + + text = text.replace(GUID_PATTERN, (value) => { + const key = value.toLowerCase() + if (!guidMap.has(key)) { + guidMap.set( + key, + `00000000-0000-0000-0000-${String(guidMap.size + 1).padStart(12, '0')}` + ) + } + return guidMap.get(key) + }) + + for (const [token, host] of keepTokens) { + text = text.replaceAll(token, host) + } + + return { + bundle: JSON.parse(text), + summary: { + emails: emailMap.size, + domains: domainMap.size, + guids: guidMap.size, + }, + } +} + +export const downloadSupportBundle = (bundle) => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + const filename = `cipp-support-bundle_${window.location.hostname}_${timestamp}.json` + const blob = new Blob([JSON.stringify(bundle, null, 2)], { + type: 'application/json', + }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) + return filename +} diff --git a/tests/components/CippCards/CippPageCard.test.jsx b/tests/components/CippCards/CippPageCard.test.jsx index b19ebde03541..0a4933779dcb 100644 --- a/tests/components/CippCards/CippPageCard.test.jsx +++ b/tests/components/CippCards/CippPageCard.test.jsx @@ -1,35 +1,90 @@ -import React from 'react' -import { screen } from '@testing-library/react' -import { renderWithProviders } from '../../test-utils' -import CippPageCard from '../../../src/components/CippCards/CippPageCard' - -describe('CippPageCard', () => { - it('renders title and children', () => { - renderWithProviders( - -
    Child content
    -
    - ) - expect(screen.getByText('Test Page Title')).toBeInTheDocument() - expect(screen.getByText('Child content')).toBeInTheDocument() - }) - - it('does not render heading when hideTitleText is true', () => { - renderWithProviders( - -
    Child content
    -
    - ) - expect(screen.queryByRole('heading', { name: 'Hidden Title' })).not.toBeInTheDocument() - expect(screen.queryByText('Hidden Title')).not.toBeInTheDocument() - }) - - it('renders infoBar when provided', () => { - renderWithProviders( - Info bar content
    }> -
    Child content
    +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, +})); + +const routerState = vi.hoisted(() => ({ push: vi.fn(), pathname: "/cipp/roles" })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: routerState.push }), + usePathname: () => routerState.pathname, + useSearchParams: () => new URLSearchParams(""), +})); +vi.mock("next/router", () => ({ + useRouter: () => ({ push: routerState.push, back: vi.fn() }), +})); + +// Stable identities: a fresh object per call re-renders forever (tests/mocks/api-call.js) +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { TabbedLayout } from "../../../src/layouts/TabbedLayout"; +import CippPageCard from "../../../src/components/CippCards/CippPageCard"; + +const tabOptions = [ + { label: "CIPP Roles", path: "/cipp/roles" }, + { label: "CIPP Users", path: "/cipp/users" }, +]; + +const renderPage = (title) => + renderWithProviders( + + +
    page content
    - ) - expect(screen.getByText('Info bar content')).toBeInTheDocument() - }) -}) +
    + ); + +describe("CippPageCard title vs the mobile tab picker", () => { + beforeEach(() => { + layoutState.isMobile = false; + routerState.pathname = "/cipp/roles"; + }); + + // The picker trigger wears the current tab's label in heading clothes right above the + // page header — a page titled the same printed "CIPP Roles" twice in a row on a phone. + it("stands its title down when the picker already says it", () => { + layoutState.isMobile = true; + renderPage("CIPP Roles"); + + // once: the picker trigger (whose label is itself an h6 — query the page h4 by level) + expect(screen.getAllByText("CIPP Roles")).toHaveLength(1); + expect(screen.getByRole("button", { name: /CIPP Roles switch view/i })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { level: 4, name: "CIPP Roles" })).not.toBeInTheDocument(); + }); + + it("keeps a title the picker does not carry", () => { + layoutState.isMobile = true; + renderPage("Edit Role: limited"); + + expect( + screen.getByRole("heading", { level: 4, name: "Edit Role: limited" }) + ).toBeInTheDocument(); + }); + + it("keeps its title on desktop, where tabs look like navigation", () => { + renderPage("CIPP Roles"); + + expect(screen.getByRole("heading", { level: 4, name: "CIPP Roles" })).toBeInTheDocument(); + }); +}); diff --git a/tests/components/CippCards/CippUniversalSearchV2.stories.jsx b/tests/components/CippCards/CippUniversalSearchV2.stories.jsx new file mode 100644 index 000000000000..3111dfff96be --- /dev/null +++ b/tests/components/CippCards/CippUniversalSearchV2.stories.jsx @@ -0,0 +1,62 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { CippUniversalSearchV2 } from '../../../src/components/CippCards/CippUniversalSearchV2' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport' + +export default { + title: 'Components/CippCards/CippUniversalSearchV2', + component: CippUniversalSearchV2, + tags: ['autodocs'], +} + +// Desktop: the scope button, field and search button are one joined bordered control. The +// theme defaults TextField to the filled variant, whose own rounded border ignores every +// join rule (they target .MuiOutlinedInput-root) — which once rendered the scope button and +// field as two separate boxes. +export const JoinedControlOnDesktop = { + render: () => ( + + ), + play: async ({ canvasElement, step }) => { + const onDesktop = await growToDesktopViewport() + if (!onDesktop) return + const canvas = within(canvasElement) + + await step('the scope button and the field share one border, no gap', async () => { + const scope = canvas.getByRole('button', { name: /pages/i }) + const field = canvasElement.querySelector('.MuiOutlinedInput-root') + await waitFor(() => { + expect(field).not.toBeNull() + const gap = field.getBoundingClientRect().left - scope.getBoundingClientRect().right + expect(Math.abs(gap)).toBeLessThanOrEqual(1) + expect(getComputedStyle(field).borderTopLeftRadius).toBe('0px') + expect(getComputedStyle(scope).borderTopRightRadius).toBe('0px') + }) + }) + }, +} + +// Phones: no scope button in the group — the field spans the row and each scope is a chip, +// one tap away, so entity search has a direct entry point. +export const ScopeChipsAtPhoneWidth = { + render: () => ( + + ), + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + + await step('the field takes the full row and every scope is a visible chip', async () => { + const field = canvasElement.querySelector('.MuiOutlinedInput-root') + await waitFor(() => { + expect(field).not.toBeNull() + for (const label of ['Users', 'Groups', 'Applications', 'Licenses', 'BitLocker', 'Pages']) { + expect(canvas.getByText(label)).toBeInTheDocument() + } + }) + const host = canvasElement + expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth) + }) + }, +} diff --git a/tests/components/CippCards/CippUniversalSearchV2.test.jsx b/tests/components/CippCards/CippUniversalSearchV2.test.jsx new file mode 100644 index 000000000000..2f6cdc92f3b6 --- /dev/null +++ b/tests/components/CippCards/CippUniversalSearchV2.test.jsx @@ -0,0 +1,125 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../../test-utils' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})) + +const bookmarkState = vi.hoisted(() => ({ bookmarks: [] })) +vi.mock('../../../src/hooks/use-user-bookmarks', () => ({ + useUserBookmarks: () => ({ bookmarks: bookmarkState.bookmarks, setBookmarks: () => {} }), +})) + +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isLoading: false, + isError: false, + data: undefined, + refetch: () => {}, +})) +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})) + +const routerState = vi.hoisted(() => ({ push: vi.fn() })) +vi.mock('next/router', () => ({ + useRouter: () => ({ + pathname: '/', + query: {}, + isReady: true, + push: routerState.push, + events: { on: () => {}, off: () => {} }, + }), +})) + +vi.mock('../../../src/hooks/use-permissions', () => ({ + // the page index filters by permission; 'Identity.User.Read' satisfies the config's + // 'Identity.User.*' requirement so the Users pages exist to be found + usePermissions: () => ({ userPermissions: ['Identity.User.Read'], userRoles: ['superadmin'] }), +})) + +import { CippUniversalSearchV2 } from '../../../src/components/CippCards/CippUniversalSearchV2' + +describe('CippUniversalSearchV2 mobile layout', () => { + beforeEach(() => { + layoutState.isMobile = false + bookmarkState.bookmarks = [] + routerState.push = vi.fn() + }) + + it('keeps the scope dropdown on desktop, no chips', () => { + renderWithProviders() + expect(screen.getByRole('button', { name: /pages/i })).toBeInTheDocument() + expect(screen.queryByText('Users', { selector: '.MuiChip-label' })).not.toBeInTheDocument() + }) + + // The desktop scope dropdown cost two taps, and entity search had no direct mobile entry + // point at all — one chip per scope closes that. + it('renders one chip per scope on mobile and switches with a tap', async () => { + layoutState.isMobile = true + const user = userEvent.setup() + renderWithProviders() + + for (const label of ['Users', 'Groups', 'Applications', 'Licenses', 'BitLocker', 'Pages']) { + expect(screen.getByText(label, { selector: '.MuiChip-label' })).toBeInTheDocument() + } + + await user.click(screen.getByText('Users', { selector: '.MuiChip-label' })) + expect(screen.getByPlaceholderText(/search users/i)).toBeInTheDocument() + + // BitLocker reveals its lookup sub-choice as a second chip row + await user.click(screen.getByText('BitLocker', { selector: '.MuiChip-label' })) + expect(screen.getByText('Key ID', { selector: '.MuiChip-label' })).toBeInTheDocument() + expect(screen.getByText('Device ID', { selector: '.MuiChip-label' })).toBeInTheDocument() + }) + + it('fills the empty state with bookmarks that navigate and close', async () => { + layoutState.isMobile = true + bookmarkState.bookmarks = [ + { label: 'GDAP Relationships', path: '/tenant/gdap-management/relationships', category: 'Tenant' }, + ] + const onConfirm = vi.fn() + const user = userEvent.setup() + renderWithProviders() + + expect(screen.getByText('Bookmarks')).toBeInTheDocument() + await user.click(screen.getByText('GDAP Relationships')) + expect(routerState.push).toHaveBeenCalledWith('/tenant/gdap-management/relationships') + expect(onConfirm).toHaveBeenCalled() + }) + + // userEvent.click fires mousedown -> click; the outside-click closer ran on mousedown, + // unmounted the row, and the click landed on nothing — results vanished, no navigation. + it('navigates when a page result is tapped, instead of just closing', async () => { + layoutState.isMobile = true + const onConfirm = vi.fn() + const user = userEvent.setup() + renderWithProviders() + + await user.type(screen.getByPlaceholderText(/search pages/i), 'users') + const result = await screen.findAllByRole('menuitem') + await user.click(result[0]) + + expect(routerState.push).toHaveBeenCalled() + expect(onConfirm).toHaveBeenCalled() + }) + + it('renders page results in flow on mobile, not in a portal panel', async () => { + layoutState.isMobile = true + const user = userEvent.setup() + renderWithProviders() + + await user.type(screen.getByPlaceholderText(/search pages/i), 'users') + // the floating panel marks itself; in-flow results must not + expect(document.querySelector('[data-dropdown-portal]')).toBeNull() + }) +}) diff --git a/tests/components/CippCards/mobile-overflow.stories.jsx b/tests/components/CippCards/mobile-overflow.stories.jsx new file mode 100644 index 000000000000..4809afc86ebb --- /dev/null +++ b/tests/components/CippCards/mobile-overflow.stories.jsx @@ -0,0 +1,167 @@ +import React, { useRef, useState, useEffect } from 'react' +import { Box, Card } from '@mui/material' +import { within, waitFor, expect } from 'storybook/test' +import { CippChartCard } from '../../../src/components/CippCards/CippChartCard' +import { CippImageCard } from '../../../src/components/CippCards/CippImageCard' +import { CippVariableAutocomplete } from '../../../src/components/CippComponents/CippVariableAutocomplete' +import { PermissionTable } from '../../../src/components/CippSettings/CippSSOSettings' +import { shrinkToPhoneViewport } from '../../viewport' + +/** + * Phone-width overflow checks for the shared components the mobile audit found spilling out + * of the viewport. Each story renders the component with the hostile content class that + * broke it — API free text, fixed-width caps — and asserts the page body gained no sideways + * scroll at 390px. + */ +export default { + title: 'Components/MobileOverflow', + tags: ['autodocs'], +} + +const noBodyOverflow = () => { + const doc = document.documentElement + expect(doc.scrollWidth).toBeLessThanOrEqual(doc.clientWidth) +} + +// Legend labels are API free text — recipient addresses, SharePoint library URLs. Without +// minWidth: 0 flexbox refuses to shrink them and the rows push out of the card. +export const ChartLegendWithUrlLabels = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const label = await canvas.findByText(/finance/, { exact: false }) + await waitFor(() => { + // the count is the row's right-hand cell: an unshrinkable label pushed it past the + // card's clipped edge, where MUI's overflow: hidden ate it without a trace + const card = label.closest('.MuiCard-root') + const count = canvas.getByText('12') + expect(count.getBoundingClientRect().right).toBeLessThanOrEqual( + card.getBoundingClientRect().right + 1 + ) + noBodyOverflow() + }) + }, +} + +// The headline/illustration pair had no breakpoint and no minWidth: 0 — at 390px the text +// column collapsed against the image's intrinsic width. This is the AllTenants interstitial. +export const ImageCardAtPhoneWidth = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const title = await canvas.findByText(/does not support/, { exact: false }) + await waitFor(() => { + // stacked, not squeezed: the old row layout let flexbox settle the fight by + // collapsing the illustration to zero width — "no overflow" while showing nothing + const img = canvasElement.querySelector('img') + const imgBox = img.getBoundingClientRect() + expect(imgBox.top).toBeGreaterThanOrEqual(title.getBoundingClientRect().bottom) + expect(imgBox.width).toBeGreaterThanOrEqual(200) + noBodyOverflow() + }) + }, +} + +const LONG_DESCRIPTION = + 'The primary tenant domain name used for routing and identification across all portals, ' + + 'reports and scheduled tasks — substituted at execution time from the tenant record.' + +const PopperHost = () => { + const anchorRef = useRef(null) + const [anchorEl, setAnchorEl] = useState(null) + useEffect(() => setAnchorEl(anchorRef.current), []) + return ( + +
    + {anchorEl && ( + {}} + onSelect={() => {}} + customVariables={[ + { variable: 'tenantfilter', description: LONG_DESCRIPTION }, + { variable: 'defaultdomainname', description: LONG_DESCRIPTION }, + ]} + /> + )} + + ) +} + +// Sentinel, not a repro: in this browser the absolutely-positioned Paper shrink-to-fits +// inside the viewport even pre-fix, so this story also passed before the clamp. It stands +// guard against a future fixed `width` here. The popper is portaled, so the assertion +// measures against the viewport, not the canvas. +export const VariablePopperStaysOnScreen = { + render: () => , + play: async () => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + await waitFor(() => { + const paper = document.querySelector('[data-cipp-autocomplete="true"]') + expect(paper).not.toBeNull() + const { right, left } = paper.getBoundingClientRect() + expect(left).toBeGreaterThanOrEqual(0) + expect(right).toBeLessThanOrEqual(document.documentElement.clientWidth) + }) + noBodyOverflow() + }, +} + +// Sentinel: in this browser the longest name happens to fit a full-width card even without +// the fix (the audited clip came from the settings page's narrower column and other font +// metrics). Guards the invariant that matters — the permission being consented to is +// readable inside the card, whatever this table is later wrapped in. +export const SsoPermissionTableReadable = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const name = await canvas.findByText(/ApplicationConfiguration/, { exact: false }) + await waitFor(() => { + // reachable: the name's box ends inside the card, not under its clipped edge + const card = name.closest('.MuiCard-root') + expect(name.getBoundingClientRect().right).toBeLessThanOrEqual( + card.getBoundingClientRect().right + 1 + ) + noBodyOverflow() + }) + }, +} diff --git a/tests/components/CippComponents/CippAddUserDrawer.test.jsx b/tests/components/CippComponents/CippAddUserDrawer.test.jsx new file mode 100644 index 000000000000..f77592a792cd --- /dev/null +++ b/tests/components/CippComponents/CippAddUserDrawer.test.jsx @@ -0,0 +1,197 @@ +import React, { useReducer } from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders, settingsWith } from '../../test-utils' +import { CippAddUserDrawer } from '../../../src/components/CippComponents/CippAddUserDrawer' +import { ApiGetCall, ApiPostCall, ApiGetCallWithPagination } from '../../../src/api/ApiCall' + +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: vi.fn(), + ApiPostCall: vi.fn(), + ApiGetCallWithPagination: vi.fn(), +})) + +// The user pickers and the license selector pull in the data-table stack and the 2.2 MB license +// dataset; none of them take part in the create-another-user flow, so they are stubbed. The +// domain selector stays real - its auto-preselect is central to the bug under test. +vi.mock('../../../src/components/CippComponents/CippFormUserSelector', () => ({ + CippFormUserSelector: () =>
    , + default: () =>
    , +})) +vi.mock('../../../src/components/CippComponents/CippFormLicenseSelector', () => ({ + CippFormLicenseSelector: () =>
    , + default: () =>
    , +})) +vi.mock('../../../src/components/CippComponents/CippApiResults', () => ({ + CippApiResults: () => null, +})) +// CippFormComponent statically imports the data-table stack for its cippDataTable case; +// nothing in this drawer uses it, but importing it is enough to exhaust the test worker. +vi.mock('../../../src/components/CippTable/CippDataTable', () => ({ + CippDataTable: () =>
    , + default: () =>
    , +})) +// CippAutoComplete statically imports CippJsonView for its option-preview offcanvas, which +// drags in the formatting/code-block/Intune-definition graph - another worker-killer this +// flow never renders. +vi.mock('../../../src/components/CippFormPages/CippJSONView', () => ({ + default: () => null, +})) +// The real drawer shell drags in the property-card/formatting graph, which this test does +// not exercise. The stub keeps the essential contract: content + footer render only while +// the drawer is open. +vi.mock('../../../src/components/CippComponents/CippOffCanvas', () => ({ + CippOffCanvas: ({ visible, children, footer }) => + visible ? ( +
    + {children} + {footer} +
    + ) : null, +})) + +const idleGet = { isSuccess: false, isFetching: false, isError: false, data: undefined, refetch: vi.fn() } +const okGet = (data) => ({ isSuccess: true, isFetching: false, isError: false, data, refetch: vi.fn() }) + +// Mutable state backing the ApiPostCall mock: flipping it and re-rendering imitates the +// react-query mutation lifecycle (idle -> pending -> success) the drawer sees in production. +let postState +let mutateSpy + +function mockApis() { + ApiGetCall.mockImplementation(({ url }) => { + if (url.startsWith('/api/ListNewUserDefaults')) return okGet([]) + if (url.startsWith('/api/ListExtensionsConfig')) return okGet({}) + if (url.startsWith('/api/ListGroups')) return okGet([]) + if (url.startsWith('/api/ListCustomDataMappings')) return okGet({ Results: [] }) + if (url.startsWith('/api/ListUserGroups')) return okGet([]) + return idleGet + }) + ApiGetCallWithPagination.mockImplementation(({ url }) => { + if (url === '/api/ListGraphRequest') { + return { + isSuccess: true, + isFetching: false, + isError: false, + data: { + pages: [ + { + Results: [ + { id: 'testdomain.com', isDefault: true, isInitial: false, isVerified: true }, + { id: 'other.com', isDefault: false, isInitial: false, isVerified: true }, + ], + }, + ], + }, + fetchNextPage: vi.fn(), + refetch: vi.fn(), + } + } + return { ...idleGet, fetchNextPage: vi.fn() } + }) + ApiPostCall.mockImplementation(() => ({ ...postState, mutate: mutateSpy })) +} + +// Buttons that force a re-render after mutating postState stand in for react-query pushing new +// mutation state into the drawer. +function Harness() { + const [, force] = useReducer((x) => x + 1, 0) + return ( + <> + + + + + ) +} + +const getDomainInput = () => + screen.getByLabelText(/Primary Domain name/i, { selector: 'input' }) + +const fillRequiredFields = async (user, { displayName, username }) => { + const displayNameInput = screen.getByLabelText(/Display Name/i, { selector: 'input' }) + await user.clear(displayNameInput) + await user.type(displayNameInput, displayName) + const usernameInput = screen.getByLabelText(/^Username/i, { selector: 'input' }) + await user.clear(usernameInput) + await user.type(usernameInput, username) +} + +describe('CippAddUserDrawer - create another user without a page refresh (issue #309)', () => { + beforeEach(() => { + vi.clearAllMocks() + postState = { isPending: false, isSuccess: false, isError: false } + mutateSpy = vi.fn() + mockApis() + }) + + it('re-enables the Create button for a second user after the first succeeds', async () => { + const user = userEvent.setup() + renderWithProviders(, { + settings: settingsWith({ usageLocation: { value: 'US', label: 'United States' } }), + }) + + await user.click(screen.getByRole('button', { name: 'Add User' })) + + // First user: the domain selector auto-picks the tenant default domain + await waitFor(() => { + expect(getDomainInput()).toHaveValue('testdomain.com') + }) + await fillRequiredFields(user, { displayName: 'First User', username: 'first.user' }) + + const createButton = screen.getByRole('button', { name: 'Create User' }) + await waitFor(() => { + expect(createButton).toBeEnabled() + }) + await user.click(createButton) + expect(mutateSpy).toHaveBeenCalledTimes(1) + expect(mutateSpy.mock.calls[0][0].data).toMatchObject({ + displayName: 'First User', + username: 'first.user', + primDomain: { value: 'testdomain.com' }, + }) + + // Simulate the mutation lifecycle so isSuccess transitions like it does in production + await user.click(screen.getByRole('button', { name: 'flip-pending' })) + await user.click(screen.getByRole('button', { name: 'flip-success' })) + + // The drawer resets the form for the next user + const anotherButton = await screen.findByRole('button', { name: 'Create Another User' }) + + // The remounted domain selector must auto-pick the default domain again; without it the + // required primDomain stays silently empty and the button never re-enables (issue #309) + await waitFor(() => { + expect(getDomainInput()).toHaveValue('testdomain.com') + }) + + // Second user: complete all required fields again, exactly as the issue describes + await fillRequiredFields(user, { displayName: 'Second User', username: 'second.user' }) + + await waitFor(() => { + expect(anotherButton).toBeEnabled() + }) + await user.click(anotherButton) + expect(mutateSpy).toHaveBeenCalledTimes(2) + expect(mutateSpy.mock.calls[1][0].data).toMatchObject({ + displayName: 'Second User', + username: 'second.user', + primDomain: { value: 'testdomain.com' }, + }) + }) +}) diff --git a/tests/components/CippComponents/CippApiDialog.test.jsx b/tests/components/CippComponents/CippApiDialog.test.jsx index a1c94bfc7358..032d9d8f5a23 100644 --- a/tests/components/CippComponents/CippApiDialog.test.jsx +++ b/tests/components/CippComponents/CippApiDialog.test.jsx @@ -116,4 +116,38 @@ describe('CippApiDialog', () => { await user.click(screen.getByRole('button', { name: 'Close' })) expect(createDialog.handleClose).toHaveBeenCalledTimes(1) }) + + it('resolves dotted parent maps on confirm', async () => { + const user = userEvent.setup() + renderDialog({ + row: { + id: 'member-1', + displayName: 'Jane', + parent: { id: 'group-1', displayName: 'Finance' }, + }, + api: { + type: 'POST', + url: '/api/ExecWhatever', + data: { childId: 'id', parentId: 'parent.id' }, + confirmText: 'Remove [displayName] from [parent.displayName]?', + }, + }) + + expect(screen.getByText('Remove Jane from Finance?')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Confirm' })) + + await waitFor(() => { + expect(apiState.mutate).toHaveBeenCalledTimes(1) + }) + expect(apiState.mutate).toHaveBeenCalledWith({ + url: '/api/ExecWhatever', + bulkRequest: false, + data: { + tenantFilter: 'testdomain.com', + childId: 'member-1', + parentId: 'group-1', + }, + }) + }) }) diff --git a/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx b/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx new file mode 100644 index 000000000000..1fff3b9167d2 --- /dev/null +++ b/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx @@ -0,0 +1,93 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { within, expect, waitFor } from 'storybook/test' +import { Box } from '@mui/material' +import { useForm } from 'react-hook-form' +import { shrinkToPhoneViewport } from '../../viewport' +import CippAppPermissionBuilder from '../../../src/components/CippComponents/CippAppPermissionBuilder' + +// The summary row carries a 36-character app id, so this is where the overflow shows up. +const graph = { + id: 'sp-graph', + appId: '00000003-0000-0000-c000-000000000000', + displayName: 'Microsoft Graph', + appRoles: [], + publishedPermissionScopes: [], +} + +const servicePrincipals = { Metadata: { Success: true }, Results: [graph] } + +// The same route serves the list and, with ?Id=, one principal's detail — where Results is +// an object rather than an array. +const handlers = [ + http.get('*/api/ExecServicePrincipals', ({ request }) => { + const id = new URL(request.url).searchParams.get('Id') + return HttpResponse.json( + id ? { Metadata: { Success: true }, Results: graph } : servicePrincipals + ) + }), +] + +const Harness = (props) => { + const formControl = useForm({ mode: 'onChange', defaultValues: { servicePrincipal: null } }) + return ( + {}} + updatePermissions={{ isPending: false, isSuccess: false, isError: false }} + currentPermissions={{ + Permissions: { + '00000003-0000-0000-c000-000000000000': { + applicationPermissions: [{ id: '1', value: 'Application.ReadWrite.All' }], + delegatedPermissions: [{ id: '2', value: 'User.Read' }], + }, + }, + }} + {...props} + /> + ) +} + +export default { + title: 'Components/CippComponents/CippAppPermissionBuilder', + component: CippAppPermissionBuilder, + parameters: { msw: { handlers } }, +} + +// jsdom has no layout engine, so overflow is invisible to the unit tests — this is the one +// place a real browser can measure it. 390px is an iPhone 14/15 in portrait. +// +// The VIEWPORT has to shrink, not a wrapper: MUI's breakpoints are media queries, so a +// 390px-wide Box inside a desktop-width iframe still renders every `md` branch. +export const PhoneWidth = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + await canvas.findByText('Microsoft Graph', {}, { timeout: 10000 }) + // Opened in the Storybook app rather than the test runner: the layout is on show, but + // measuring it against a desktop-width iframe would only assert the wrong thing. + if (!onAPhone) return + + // The app-id chip used to force the row wider than the phone, pushing the service + // principal's name off the left edge — the row scrolled, the name was unreachable. + await waitFor(() => { + const rows = canvasElement.querySelectorAll('.MuiAccordionSummary-root') + expect(rows.length).toBeGreaterThan(0) + rows.forEach((row) => { + expect(row.scrollWidth).toBeLessThanOrEqual(row.clientWidth) + }) + }) + + // and the name is inside the viewport, not off to the left of it + const name = canvas.getByText('Microsoft Graph') + const phone = canvasElement.querySelector('[data-testid="phone"]') + expect(name.getBoundingClientRect().left).toBeGreaterThanOrEqual( + phone.getBoundingClientRect().left + ) + }, +} diff --git a/tests/components/CippComponents/CippAutocomplete.test.jsx b/tests/components/CippComponents/CippAutocomplete.test.jsx index e5a47bc5845a..90dbd2aa6a36 100644 --- a/tests/components/CippComponents/CippAutocomplete.test.jsx +++ b/tests/components/CippComponents/CippAutocomplete.test.jsx @@ -345,4 +345,51 @@ describe('CippAutoComplete', () => { expect(document.querySelector('.MuiFormLabel-asterisk')).toBeTruthy() }) }) + + // TextField forwards what it doesn't consume to the FormControl root, so a leak lands as a DOM attr + describe('prop routing', () => { + it('keeps autocomplete-only props off the DOM', () => { + const { container } = renderWithProviders( + {}} + noOptionsText="nothing here" + /> + ) + expect(container.querySelector('[nooptionstext]')).toBeNull() + }) + + it('routes variant to the text field, not to the autocomplete root', () => { + const { container } = renderWithProviders( + {}} + variant="outlined" + /> + ) + // outlined draws the notched fieldset/legend, the themed filled default does not + expect(container.querySelector('fieldset legend')).toBeTruthy() + expect(container.querySelector('[variant]')).toBeNull() + }) + + it('forwards filterSelectedOptions to the autocomplete, selected option stays listed', async () => { + const user = userEvent.setup() + renderWithProviders( + {}} + filterSelectedOptions={false} + /> + ) + await user.click(screen.getByRole('combobox')) + expect(await screen.findByRole('option', { name: 'Alpha' })).toBeInTheDocument() + }) + }) }) diff --git a/tests/components/CippComponents/CippAutopilotProfileDrawer.test.jsx b/tests/components/CippComponents/CippAutopilotProfileDrawer.test.jsx new file mode 100644 index 000000000000..6dbb03a2797b --- /dev/null +++ b/tests/components/CippComponents/CippAutopilotProfileDrawer.test.jsx @@ -0,0 +1,163 @@ +import React from 'react' +import { act, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../../test-utils' +import { api, apiCallMock, getResult } from '../../mocks/api-call' +import { CippAutopilotProfileDrawer } from '../../../src/components/CippComponents/CippAutopilotProfileDrawer' + +// The tenant selector talks to Graph; the autopilot drawer only needs it to drive +// `selectedTenants` on the form so the single-tenant gate around the group picker works. +const tenants = vi.hoisted(() => ({ value: [] })) +const tenantForm = vi.hoisted(() => ({ current: null })) +vi.mock( + '../../../src/components/CippComponents/CippFormTenantSelector', + async () => { + const React = await import('react') + return { + CippFormTenantSelector: ({ formControl, name = 'selectedTenants' }) => { + tenantForm.current = formControl + React.useEffect(() => { + formControl.setValue(name, tenants.value, { + shouldValidate: true, + shouldDirty: true, + }) + }, [formControl, name]) + return null + }, + } + } +) + +vi.mock('../../../src/api/ApiCall', () => apiCallMock()) + +const singleTenant = [{ value: 'contoso.com', label: 'Contoso' }] +const multiTenant = [ + { value: 'contoso.com', label: 'Contoso' }, + { value: 'fabrikam.com', label: 'Fabrikam' }, +] +const groupsResult = getResult({ data: [] }) +const authWithGroupRead = getResult({ + data: { + clientPrincipal: { userRoles: ['custom'] }, + permissions: ['Endpoint.Autopilot.ReadWrite', 'Identity.Group.Read'], + }, +}) +const authWithoutGroupRead = getResult({ + data: { + clientPrincipal: { userRoles: ['custom'] }, + permissions: ['Endpoint.Autopilot.ReadWrite'], + }, +}) + +async function openDrawer() { + const user = userEvent.setup() + renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Add Profile' })) + return user +} + +describe('CippAutopilotProfileDrawer', () => { + beforeEach(() => { + tenants.value = singleTenant + tenantForm.current = null + api.get = (options) => + options.url === '/api/me' ? authWithGroupRead : groupsResult + api.post = { ...api.post, mutate: vi.fn() } + }) + + it('shows no group UI while "Assign to all devices" is on (default)', async () => { + await openDrawer() + expect( + screen.queryByText('Assign to Selected Groups') + ).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('warns instead of group-picking when more than one tenant is selected', async () => { + tenants.value = multiTenant + const user = await openDrawer() + await user.click(screen.getByLabelText('Assign to all devices')) + expect( + screen.getByText(/profiling by group requires selecting a single tenant/i) + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Assign to Selected Groups') + ).not.toBeInTheDocument() + }) + + it('shows the group picker when groups are off and exactly one tenant is selected', async () => { + const user = await openDrawer() + await user.click(screen.getByLabelText('Assign to all devices')) + expect( + screen.queryByText(/profiling by group requires/i) + ).not.toBeInTheDocument() + expect( + screen.getByRole('combobox', { name: 'Assign to Selected Groups' }) + ).toBeInTheDocument() + }) + + it('does not load the group picker without Identity Group Read permission', async () => { + api.get = (options) => + options.url === '/api/me' ? authWithoutGroupRead : groupsResult + + const user = await openDrawer() + await user.click(screen.getByLabelText('Assign to all devices')) + + expect( + screen.getByText(/requires the Identity Group Read permission/i) + ).toBeInTheDocument() + expect( + screen.queryByLabelText('Assign to Selected Groups') + ).not.toBeInTheDocument() + }) + + it('drops selected groups when the tenant changes before submission', async () => { + const user = await openDrawer() + await user.click(screen.getByLabelText('Assign to all devices')) + + act(() => { + tenantForm.current.setValue('GroupIds', [ + { value: 'group-1', label: 'Group 1' }, + ]) + tenantForm.current.setValue('selectedTenants', [ + { value: 'fabrikam.com', label: 'Fabrikam' }, + ]) + }) + + await user.type( + screen.getByRole('textbox', { name: 'Display Name' }), + 'Test AP' + ) + const submit = screen.getByRole('button', { name: 'Create Profile' }) + await waitFor(() => expect(submit).toBeEnabled()) + await user.click(submit) + + await waitFor(() => expect(api.post.mutate).toHaveBeenCalledTimes(1)) + expect(api.post.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ GroupIds: [] }), + }) + ) + }) + + it('submits to AddAutopilotConfig with group ids as an array', async () => { + const user = await openDrawer() + await user.type( + screen.getByRole('textbox', { name: 'Display Name' }), + 'Test AP' + ) + const submit = screen.getByRole('button', { name: 'Create Profile' }) + await waitFor(() => expect(submit).toBeEnabled()) + await user.click(submit) + + await waitFor(() => { + expect(api.post.mutate).toHaveBeenCalledTimes(1) + }) + expect(api.post.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/api/AddAutopilotConfig', + data: expect.objectContaining({ DisplayName: 'Test AP', GroupIds: [] }), + }) + ) + }) +}) diff --git a/tests/components/CippComponents/CippBottomSheet.stories.jsx b/tests/components/CippComponents/CippBottomSheet.stories.jsx new file mode 100644 index 000000000000..71e9268c2b0c --- /dev/null +++ b/tests/components/CippComponents/CippBottomSheet.stories.jsx @@ -0,0 +1,207 @@ +import React from 'react' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { + Button, + Dialog, + DialogContent, + List, + ListItemButton, + ListItemText, + Typography, +} from '@mui/material' +import { CippBottomSheet } from '../../../src/components/CippComponents/CippBottomSheet' +import { shrinkToPhoneViewport } from '../../viewport' + +// The mobile stand-in for a desktop Menu: every place the app opens a Menu on a pointer +// device opens one of these below md instead. +const SheetHarness = ({ children, triggerLabel = 'Open sheet', ...sheetProps }) => { + const [open, setOpen] = React.useState(false) + return ( + <> + + setOpen(false)} {...sheetProps}> + {children} + + + ) +} + +const actionRows = ['Edit user', 'Reset password', 'Block sign-in'].map((label) => ( + + + +)) + +export default { + title: 'Components/CippComponents/CippBottomSheet', + component: CippBottomSheet, + tags: ['autodocs'], +} + +export const WithTitle = { + render: () => ( + + {actionRows} + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + await step('opens on tap and shows its rows', async () => { + await userEvent.click(canvas.getByRole('button', { name: 'Open sheet' })) + await waitFor(() => expect(body.getByText('Row actions')).toBeInTheDocument()) + expect(body.getByText('Reset password')).toBeInTheDocument() + }) + + await step('closes on backdrop tap', async () => { + await userEvent.click(document.querySelector('.MuiBackdrop-root')) + await waitFor(() => expect(body.queryByText('Row actions')).not.toBeInTheDocument()) + }) + }, +} + +export const WithFooter = { + render: () => ( + + Apply to 12 selected + + } + > + {actionRows} + + ), +} + +export const LongContentScrolls = { + render: () => ( + + + {Array.from({ length: 30 }, (_, i) => ( + + + + ))} + + + ), +} + +// Regression guard for the live bug: popout table dialogs sit at zIndex.modal (1300), so a +// plain Drawer (1200) opened from inside one is invisible. The sheet claims modal + 1. +export const OverADialog = { + render: () => { + const [dialogOpen, setDialogOpen] = React.useState(true) + return ( + <> + + setDialogOpen(false)} fullWidth> + + + A popout table lives here. Its filter sheet must layer above this dialog. + + + {actionRows} + + + + + ) + }, + play: async ({ step }) => { + const body = within(document.body) + + await step('sheet renders above the dialog', async () => { + await userEvent.click(body.getByRole('button', { name: 'Open filters' })) + const sheetRoot = await waitFor(() => { + const title = body.getByText('Filters') + return title.closest('.MuiDrawer-root') + }) + const dialogRoot = document.querySelector('.MuiDialog-root') + const sheetZ = Number(window.getComputedStyle(sheetRoot).zIndex) + const dialogZ = Number(window.getComputedStyle(dialogRoot).zIndex) + expect(sheetZ).toBeGreaterThan(dialogZ) + }) + }, +} + +// The grab handle used to be decoration — a 36x4 bar that promised a gesture nothing +// implemented. Only a real browser can settle whether the drag works: jsdom has no layout, +// so the paper's height is 0 and the swipe distance the gesture is measured against is +// meaningless there. +export const DragHandleDismisses = { + render: () => ( + + {actionRows} + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + const body = within(document.body) + + await userEvent.click(canvas.getByRole('button', { name: 'Open sheet' })) + await body.findByText('Reset password') + if (!onAPhone) return + + const paper = document.querySelector('.MuiDrawer-paper') + const handle = paper.firstElementChild + const start = handle.getBoundingClientRect() + + // A real touch drag down the screen, starting on the handle. + const at = (clientY) => + new Touch({ + identifier: 1, + target: handle, + clientX: start.x + start.width / 2, + clientY, + }) + // Dispatched ON the handle and left to bubble: MUI reads event.target to decide the + // gesture started inside the paper, so firing at the document would bail immediately. + const fire = (type, clientY) => + handle.dispatchEvent( + new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type === 'touchend' ? [] : [at(clientY)], + changedTouches: [at(clientY)], + }) + ) + + // MUI flags "maybe swiping" in React state on touchstart and ignores moves until that + // has been applied, so the gesture has to be spread across ticks like a real one. + const tick = () => new Promise((resolve) => setTimeout(resolve, 30)) + const from = start.y + start.height / 2 + fire('touchstart', from) + await tick() + for (const dy of [20, 60, 120, 200, 260]) { + fire('touchmove', from + dy) + await tick() + } + const draggedTo = new DOMMatrixReadOnly(getComputedStyle(paper).transform).m42 + expect(draggedTo).toBeGreaterThan(100) + fire('touchend', from + 260) + + // The exit has to continue from where the finger let go. Slide probes the paper's + // untranslated position when the exit starts (Slide.js getTranslateValue), and the browser + // takes that probe as the transition's start, which puts the sheet back at full height for + // the length of the close. + const firstExitFrame = await new Promise((resolve) => { + requestAnimationFrame(() => + requestAnimationFrame(() => + resolve(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m42) + ) + ) + }) + expect(firstExitFrame).toBeGreaterThan(draggedTo * 0.6) + + await waitFor(() => expect(body.queryByText('Reset password')).not.toBeInTheDocument()) + }, +} diff --git a/tests/components/CippComponents/CippBreadcrumbNav.test.jsx b/tests/components/CippComponents/CippBreadcrumbNav.test.jsx index 06c181b55374..79794a7045a0 100644 --- a/tests/components/CippComponents/CippBreadcrumbNav.test.jsx +++ b/tests/components/CippComponents/CippBreadcrumbNav.test.jsx @@ -5,10 +5,16 @@ import { CippBreadcrumbNav } from '../../../src/components/CippComponents/CippBr // second require.context consumer, this one globs every pages/**/tabOptions.json. covers the // subdirectory + regex arms of the polyfill that the tutorial glob (flat, no subdirs) doesn't. // 'Groups' only reaches the trail through src/pages/tenant/administration/tenants/tabOptions.json +const routerState = vi.hoisted(() => ({ pathname: '/tenant/administration/tenants/groups' })) +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})) vi.mock('next/router', () => ({ useRouter: () => ({ - pathname: '/tenant/administration/tenants/groups', - asPath: '/tenant/administration/tenants/groups', + pathname: routerState.pathname, + asPath: routerState.pathname, query: {}, isReady: true, push: () => Promise.resolve(), @@ -18,6 +24,49 @@ vi.mock('next/router', () => ({ })) describe('CippBreadcrumbNav', () => { + beforeEach(() => { + routerState.pathname = '/tenant/administration/tenants/groups' + layoutState.isMobile = false + }) + + // The dashboard's rail is one crumb saying "Overview" directly above a picker saying + // "Overview" — a single crumb is no hierarchy, so on phones the rail stands down. + it('hides the rail on mobile when there is no hierarchy to show', () => { + routerState.pathname = '/' + layoutState.isMobile = true + renderWithProviders() + + expect(screen.queryByLabelText('page hierarchy')).not.toBeInTheDocument() + }) + + // "Overview > Identity" is the dashboard's own tab set — the exact list the view picker + // beneath it presents, so on phones it says nothing the page doesn't. + it('hides the rail on mobile across all dashboard views, not just the root', () => { + routerState.pathname = '/dashboardv2/identity' + layoutState.isMobile = true + renderWithProviders() + + expect(screen.queryByLabelText('page hierarchy')).not.toBeInTheDocument() + }) + + it('keeps the dashboard rail on desktop', () => { + routerState.pathname = '/dashboardv2/identity' + renderWithProviders() + expect(screen.getByLabelText('page hierarchy')).toBeInTheDocument() + }) + + it('keeps a single-crumb rail on desktop, and deep rails on mobile', () => { + routerState.pathname = '/' + renderWithProviders() + expect(screen.getByLabelText('page hierarchy')).toBeInTheDocument() + }) + + it('keeps a multi-crumb rail on mobile', () => { + layoutState.isMobile = true + renderWithProviders() + expect(screen.getByText('Groups')).toBeInTheDocument() + }) + it('labels the tab crumb from the tabOptions require.context', () => { renderWithProviders() diff --git a/tests/components/CippComponents/CippExpandableAlert.stories.jsx b/tests/components/CippComponents/CippExpandableAlert.stories.jsx new file mode 100644 index 000000000000..19ebf1403927 --- /dev/null +++ b/tests/components/CippComponents/CippExpandableAlert.stories.jsx @@ -0,0 +1,80 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { userEvent } from 'storybook/test' +import { CippExpandableAlert } from '../../../src/components/CippComponents/CippExpandableAlert' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport' + +export default { + title: 'Components/CippComponents/CippExpandableAlert', + component: CippExpandableAlert, + tags: ['autodocs'], +} + +const LONG_TEXT = + "Custom roles can be used to restrict permissions for users with the 'editor' or " + + "'readonly' roles in CIPP. They can be limited to a subset of tenants and API permissions. " + + 'Built-in and custom roles can be assigned to Entra security groups for granular access ' + + 'control. This sentence pads the message past any phone clamp so the toggle must appear.' + +const SHORT_TEXT = 'Nothing here needs a second look.' + +// A page-intro alert used to fill most of the first phone screen; the clamp keeps it to a +// few lines and hands the rest to a toggle. +export const ClampsLongMessagesOnAPhone = { + render: () => {LONG_TEXT}, + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + + await step('the message is clipped and offers Show more', async () => { + const toggle = await canvas.findByRole('button', { name: /show more/i }) + const message = canvas.getByText(/Custom roles/, { exact: false }) + expect(message.scrollHeight).toBeGreaterThan(message.clientHeight) + expect(toggle).toBeInTheDocument() + }) + + await step('expanding shows everything and offers Show less', async () => { + await userEvent.click(canvas.getByRole('button', { name: /show more/i })) + const message = canvas.getByText(/Custom roles/, { exact: false }) + await waitFor(() => { + expect(message.scrollHeight).toBeLessThanOrEqual(message.clientHeight + 1) + expect(canvas.getByRole('button', { name: /show less/i })).toBeInTheDocument() + }) + }) + }, +} + +// Measured, not assumed: a message that fits its clamp renders as a plain alert. +export const LeavesShortMessagesAlone = { + render: () => {SHORT_TEXT}, + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + + await step('no toggle for a message that already fits', async () => { + await canvas.findByText(SHORT_TEXT) + await waitFor(() => { + expect(canvas.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument() + }) + }) + }, +} + +export const NeverClampsOnDesktop = { + render: () => {LONG_TEXT}, + play: async ({ canvasElement, step }) => { + const onDesktop = await growToDesktopViewport() + if (!onDesktop) return + const canvas = within(canvasElement) + + await step('full message, no toggle', async () => { + const message = await canvas.findByText(/Custom roles/, { exact: false }) + await waitFor(() => { + expect(message.scrollHeight).toBeLessThanOrEqual(message.clientHeight + 1) + expect(canvas.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument() + }) + }) + }, +} diff --git a/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx b/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx new file mode 100644 index 000000000000..4d68cac88e65 --- /dev/null +++ b/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx @@ -0,0 +1,117 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Box, Paper, Stack } from '@mui/material' +import { CippMobileTenantPicker } from '../../../src/components/CippComponents/CippMobileTenantPicker' + +const tenants = [ + { customerId: 'all', displayName: 'All Tenants', defaultDomainName: 'AllTenants' }, + { customerId: 't-1', displayName: 'Contoso Ltd', defaultDomainName: 'contoso.com' }, + { customerId: 't-2', displayName: 'Fabrikam Inc', defaultDomainName: 'fabrikam.com' }, + { customerId: 't-3', displayName: 'Northwind Traders', defaultDomainName: 'northwind.com' }, + { customerId: 't-4', displayName: 'Adventure Works', defaultDomainName: 'adventure-works.com' }, +] + +export default { + title: 'Components/CippComponents/CippMobileTenantPicker', + component: CippMobileTenantPicker, + tags: ['autodocs'], + parameters: { + msw: { + handlers: [http.get('*/api/listTenants', () => HttpResponse.json(tenants))], + }, + }, + decorators: [ + (Story) => ( + // Stands in for the mobile top bar, where the chip takes the width a search icon + // used to occupy (universal search moved into the account menu). + + + + + + + + ), + ], +} + +export const Chip = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('chip shows the current tenant name', async () => { + await waitFor(() => expect(canvasElement.textContent).toContain('testdomain.com')) + }) + }, +} + +export const PickerOpen = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + await step('the chip opens a fullscreen picker listing every tenant', async () => { + await userEvent.click(canvas.getByRole('button')) + await waitFor(() => expect(body.getByText('Contoso Ltd')).toBeInTheDocument()) + expect(body.getByText('Fabrikam Inc')).toBeInTheDocument() + expect(body.getByText('All Tenants')).toBeInTheDocument() + }) + + // Avatar's default colour is background.default, so setting only bgcolor leaves the + // globe a dark grey sitting on the accent. Real browser: read what actually painted. + await step('the All Tenants glyph contrasts with the accent behind it', async () => { + const avatar = body + .getByText('All Tenants') + .closest('[role="button"]') + .querySelector('.MuiAvatar-root') + const style = getComputedStyle(avatar) + expect(style.backgroundColor).not.toBe(style.color) + + const luminance = (rgb) => { + const [r, g, b] = rgb.match(/\d+/g).map(Number) + const channel = (c) => { + const v = c / 255 + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) + } + const a = luminance(style.color) + const b = luminance(style.backgroundColor) + const contrast = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) + expect(contrast).toBeGreaterThan(3) + }) + }, +} + +export const SearchFiltersTheList = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + await userEvent.click(canvas.getByRole('button')) + await waitFor(() => expect(body.getByText('Contoso Ltd')).toBeInTheDocument()) + + await step('search narrows by display name', async () => { + await userEvent.type(body.getByPlaceholderText(/search/i), 'north') + await waitFor(() => expect(body.queryByText('Contoso Ltd')).toBeNull()) + expect(body.getByText('Northwind Traders')).toBeInTheDocument() + }) + }, +} + +export const FavoritingATenant = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + await userEvent.click(canvas.getByRole('button')) + await waitFor(() => expect(body.getByText('Fabrikam Inc')).toBeInTheDocument()) + + await step('favoriting promotes the tenant into a Favorites section', async () => { + const favoriteButtons = body.getAllByRole('button', { name: /favorite/i }) + await userEvent.click(favoriteButtons[1]) + await waitFor(() => expect(body.getByText('Favorites')).toBeInTheDocument()) + }) + }, +} diff --git a/tests/components/CippComponents/CippOffCanvas.test.jsx b/tests/components/CippComponents/CippOffCanvas.test.jsx index 9bb9a8d39769..dbe2f48737c4 100644 --- a/tests/components/CippComponents/CippOffCanvas.test.jsx +++ b/tests/components/CippComponents/CippOffCanvas.test.jsx @@ -1,9 +1,46 @@ import React, { useState } from 'react' -import { screen, within } from '@testing-library/react' +import { act, cleanup, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Button } from '@mui/material' import { renderWithTheme } from '../../test-utils' import { CippOffCanvas } from '../../../src/components/CippComponents/CippOffCanvas' +import { resetOverlayHistory } from '../../../src/utils/overlay-history' + +// jsdom has no width-based matchMedia, so the mobile branch has to be stubbed in. Every +// query the drawer asks about below md is a max-width one. +const useMobileViewport = () => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + cache.set(query, { + matches: query.includes('max-width'), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } +} + +const swipeBack = async () => { + await act(async () => { + const settled = new Promise((resolve) => + window.addEventListener('popstate', resolve, { once: true }) + ) + window.history.back() + await settled + }) +} + +afterEach(() => { + resetOverlayHistory() + delete window.matchMedia +}) const mockDeviceData = { displayName: 'DESKTOP-ENTRA-01', @@ -15,7 +52,12 @@ const mockDeviceData = { }, } -const InteractiveWrapper = ({ onClose, onNavigateUp, onNavigateDown, ...props }) => { +const InteractiveWrapper = ({ + onClose, + onNavigateUp, + onNavigateDown, + ...props +}) => { const [open, setOpen] = useState(false) return ( <> @@ -88,6 +130,55 @@ describe('CippOffCanvas', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('closes on the phone back gesture instead of navigating the list page away', async () => { + useMobileViewport() + const user = userEvent.setup() + const onClose = vi.fn() + + renderWithTheme( + + ) + + await user.click(screen.getByRole('button', { name: /open offcanvas/i })) + expect(within(document.body).getByText('Device Details')).toBeVisible() + + await swipeBack() + + expect(onClose).toHaveBeenCalledTimes(1) + await waitFor(() => + expect( + within(document.body).queryByText('Device Details') + ).not.toBeInTheDocument() + ) + }) + + it('leaves the back button to the router on desktop', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + renderWithTheme( + + ) + + await user.click(screen.getByRole('button', { name: /open offcanvas/i })) + // Somewhere to go back to, so the press is a real navigation attempt. + window.history.pushState({}, '') + await swipeBack() + + expect(onClose).not.toHaveBeenCalled() + expect(within(document.body).getByText('Device Details')).toBeVisible() + }) + it('maps extendedInfoFields to values, dotted paths resolve and missing fields fall back to N/A', () => { renderWithTheme( { // field absent from extendedData renders the N/A fallback expect(root.getByText('N/A')).toBeInTheDocument() }) + + it('renders the info card above children by default and below with actionsPosition bottom', () => { + const renderCanvas = (actionsPosition) => { + renderWithTheme( + ( +
    child content
    + )} + /> + ) + } + const childrenBox = () => + within(document.body).getByTestId('custom-children') + const infoValue = () => within(document.body).getByText('DESKTOP-ENTRA-01') + + renderCanvas('top') + expect( + childrenBox().compareDocumentPosition(infoValue()) & + Node.DOCUMENT_POSITION_PRECEDING + ).toBeTruthy() + + cleanup() + renderCanvas('bottom') + expect( + childrenBox().compareDocumentPosition(infoValue()) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) }) diff --git a/tests/components/CippComponents/CippPageActionsFab.stories.jsx b/tests/components/CippComponents/CippPageActionsFab.stories.jsx new file mode 100644 index 000000000000..5609ef91e917 --- /dev/null +++ b/tests/components/CippComponents/CippPageActionsFab.stories.jsx @@ -0,0 +1,215 @@ +import React from 'react' +import { within, expect, userEvent, waitFor, fn } from 'storybook/test' +import { + Box, + Button, + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemText, + ListSubheader, + MenuItem, + Typography, +} from '@mui/material' +import { Add, Assessment, Public, Summarize } from '@mui/icons-material' +import { CippPageActionsFab } from '../../../src/components/CippComponents/CippPageActionsFab' +import { TabNavigationContext } from '../../../src/layouts/tab-navigation-context' + +const TABS = [ + { label: 'Edit Tenant', path: '/tenant/manage/edit', icon: 'Settings' }, + { label: 'Manage Drift', path: '/tenant/manage/drift', icon: 'Sync' }, + { label: 'Configuration Backup', path: '/tenant/manage/backup', icon: 'Backup' }, +] + +const LAYOUT_ACTIONS = [{ label: 'Reset Password', onClick: () => {} }] + +// Stands in for a headered tabbed layout: below md its header Actions menu is clipped, so +// those actions ride in whichever FAB owns the corner. Its tabs do not — those live in the +// title row (CippTabPicker), which is why this sheet never shows a "Views" section. +const withLayoutActions = (Story) => ( + {}, + actions: LAYOUT_ACTIONS, + claim: () => {}, + release: () => {}, + isActionCornerClaimed: false, + }} + > + + +) + +export default { + title: 'Components/CippComponents/CippPageActionsFab', + component: CippPageActionsFab, + tags: ['autodocs'], + decorators: [ + (Story) => ( + + + Page content. The FAB is fixed to the viewport's bottom-right corner — below md + that corner belongs to page actions (CippSpeedDial hides itself there). + + + + ), + ], +} + +// How table pages use it: cardButton is an arbitrary Box of drawer triggers laid out for a +// desktop CardHeader, restacked vertically by the primitive's descendant CSS. +export const RestackedCardButton = { + render: () => ( + + + + + + + + ), + play: async ({ step }) => { + const body = within(document.body) + + await step('opens the sheet from the FAB', async () => { + await userEvent.click(body.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(body.getByText('Actions')).toBeInTheDocument()) + }) + + await step('children are restacked to full width', async () => { + const addButton = body.getByRole('button', { name: 'Add User' }) + expect(window.getComputedStyle(addButton).justifyContent).toBe('flex-start') + }) + + await step('tapping an action closes the sheet', async () => { + await userEvent.click(body.getByRole('button', { name: 'Bulk Add' })) + // keepMounted: a cardButton child owns its own drawer, so the sheet hides rather + // than unmounting — otherwise that drawer would vanish the moment it opened. + await waitFor(() => expect(body.getByText('Actions')).not.toBeVisible()) + }) + }, +} + +// How the dashboard uses it: purpose-built list rows, so restacking is off. +export const DashboardSections = { + render: (args) => ( + + + Portals + + } + > + {['M365', 'Exchange', 'Entra'].map((label) => ( + + + + + + + ))} + + + + Reports + + } + > + {/* ExecutiveReportButton renders exactly this: a MenuItem, not a Button */} + + + + + + + + + + + + + + + ), + args: { + onExecutiveSummary: fn(), + }, + play: async ({ args, step }) => { + const body = within(document.body) + + await step('sections render under their subheaders', async () => { + await userEvent.click(body.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(body.getByText('Dashboard actions')).toBeInTheDocument()) + expect(body.getByText('Portals')).toBeInTheDocument() + expect(body.getByText('Reports')).toBeInTheDocument() + }) + + await step('a MenuItem child fires its handler and closes the sheet', async () => { + await userEvent.click(body.getByRole('menuitem', { name: 'Executive Summary' })) + expect(args.onExecutiveSummary).toHaveBeenCalled() + // keepMounted leaves the sheet in the DOM (so ExecutiveReportButton's own preview + // Dialog survives) — closed means hidden here, not unmounted. + await waitFor(() => expect(body.getByText('Dashboard actions')).not.toBeVisible()) + }) + }, +} + +// Under a headered tabbed layout the sheet carries the page's own action and the layout's +// header actions, labelled as two sections. Every page-actions FAB uses the same neutral +// glyph — a "+" only ever told the truth on pages whose sheet creates things. +export const PageAndLayoutActions = { + decorators: [withLayoutActions], + render: () => ( + + + + ), + play: async ({ step }) => { + const body = within(document.body) + + await step('the FAB carries the one shared glyph', async () => { + const fab = body.getByRole('button', { name: 'Page actions' }) + expect(within(fab).queryByTestId('AddIcon')).toBeNull() + expect(within(fab).getByTestId('MoreHorizIcon')).toBeInTheDocument() + }) + + await step('one sheet holds both kinds of action', async () => { + await userEvent.click(body.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(body.getByText('Actions')).toBeInTheDocument()) + expect(body.getByRole('button', { name: 'Add Variable' })).toBeInTheDocument() + expect(body.getByText('Reset Password')).toBeInTheDocument() + }) + + // Destinations moved to the title row; a FAB is for a screen's primary action. + await step('and no destinations', async () => { + expect(body.queryByText('Views')).toBeNull() + expect(body.queryByText('Manage Drift')).toBeNull() + expect(body.queryByText('Configuration Backup')).toBeNull() + }) + }, +} diff --git a/tests/components/CippComponents/CippPageActionsFab.test.jsx b/tests/components/CippComponents/CippPageActionsFab.test.jsx new file mode 100644 index 000000000000..9019a21edfbd --- /dev/null +++ b/tests/components/CippComponents/CippPageActionsFab.test.jsx @@ -0,0 +1,197 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Button, Drawer, ListItemButton, MenuItem, Stack, Typography } from "@mui/material"; +import { CippPageActionsFab } from "../../../src/components/CippComponents/CippPageActionsFab"; +import { renderWithProviders } from "../../test-utils"; + +const openSheet = async (user, label = "Page actions") => { + await user.click(screen.getByRole("button", { name: label })); + await screen.findByText("Sheet content"); +}; + +describe("CippPageActionsFab", () => { + it("renders the FAB and opens the sheet with its children", async () => { + const user = userEvent.setup(); + renderWithProviders( + + Sheet content + + + ); + + // keepMounted: the children stay mounted so a child-owned overlay survives the + // sheet closing, so "closed" means hidden rather than absent. + expect(screen.getByText("Sheet content")).not.toBeVisible(); + await openSheet(user); + + expect(screen.getByText("Sheet content")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Do a thing" })).toBeInTheDocument(); + expect(screen.getByText("Actions")).toBeInTheDocument(); + }); + + // A cardButton laid out for a desktop CardHeader is as often a Stack as a Box. Matching + // only Box left the row intact while every button was stretched to 100%, so three import + // buttons ran off the side of the sheet. + it("restacks a row of buttons that arrived as a Stack", async () => { + const user = userEvent.setup(); + renderWithProviders( + + + + + + + + ); + await openSheet(user); + + const row = screen.getByText("Manual Import").closest(".MuiStack-root"); + const styles = window.getComputedStyle(row); + expect(styles.flexDirection).toBe("column"); + // Stack's spacing is a margin-left that would survive the flip and indent each row + const button = screen.getByText("Manual Import").closest("button"); + expect(window.getComputedStyle(button).marginLeft).toBe("0px"); + }); + + // The sheet's paper is grey; a text button's default primary accent reads as + // orange-on-grey and doesn't match the list rows underneath it. + it("neutralises text buttons without flattening the branded ones", async () => { + const user = userEvent.setup(); + renderWithProviders( + <> + + + + + + + + + ); + await openSheet(user); + + // Compared against the same button outside the sheet, so the assertion fails if the + // override goes away rather than merely describing MUI's defaults. + const inSheet = screen.getByText("Sheet content").closest("button"); + const outside = screen.getByText("Untouched").closest("button"); + expect(window.getComputedStyle(inSheet).color).not.toBe( + window.getComputedStyle(outside).color + ); + // a deliberate call to action keeps its branding + expect(screen.getByText("Add User").closest("button").className).toMatch(/containedPrimary/); + }); + + it("uses custom title and aria-label", async () => { + const user = userEvent.setup(); + renderWithProviders( + + Sheet content + + ); + + await openSheet(user, "Dashboard shortcuts"); + expect(screen.getByText("Dashboard actions")).toBeInTheDocument(); + }); + + it("closes the sheet when a child button is tapped", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + renderWithProviders( + + Sheet content + + + ); + + await openSheet(user); + await user.click(screen.getByRole("button", { name: "Do a thing" })); + + expect(onClick).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByText("Sheet content")).not.toBeVisible()); + }); + + it("closes the sheet when a child link is tapped", async () => { + const user = userEvent.setup(); + renderWithProviders( + + Sheet content + + External portal + + + ); + + await openSheet(user); + await user.click(screen.getByRole("link", { name: "External portal" })); + + await waitFor(() => expect(screen.getByText("Sheet content")).not.toBeVisible()); + }); + + it("closes the sheet when a MenuItem child is tapped", async () => { + // ExecutiveReportButton renders variant="menuItem" — a
  • , not a button + const user = userEvent.setup(); + const onClick = vi.fn(); + renderWithProviders( + + Sheet content + Executive Summary + + ); + + await openSheet(user); + await user.click(screen.getByRole("menuitem", { name: "Executive Summary" })); + + expect(onClick).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByText("Sheet content")).not.toBeVisible()); + }); + + it("keeps the sheet open when non-interactive content is tapped", async () => { + const user = userEvent.setup(); + renderWithProviders( + + Sheet content + + ); + + await openSheet(user); + await user.click(screen.getByText("Sheet content")); + + expect(screen.getByText("Sheet content")).toBeInTheDocument(); + }); +}); + +// A cardButton child renders both its trigger and its own overlay (CippAddUserDrawer is a +// button plus a CippOffCanvas). If the sheet unmounts its children on close, that overlay +// disappears the instant it opens. +describe("CippPageActionsFab with a child that owns an overlay", () => { + const DrawerAction = () => { + const [open, setOpen] = React.useState(false); + return ( + <> + + setOpen(false)}> +
    Add user form
    +
    + + ); + }; + + it("keeps the child's overlay open after the sheet closes", async () => { + const user = userEvent.setup(); + renderWithProviders( + + + + ); + + await user.click(screen.getByRole("button", { name: "Page actions" })); + await user.click(await screen.findByRole("button", { name: "Add User" })); + + // the tap closes the sheet and opens the child's drawer — the drawer must survive it + expect(await screen.findByText("Add user form")).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(screen.getByText("Add user form")).toBeInTheDocument(); + }); +}); diff --git a/tests/components/CippComponents/CippQuarantineDetails.test.jsx b/tests/components/CippComponents/CippQuarantineDetails.test.jsx new file mode 100644 index 000000000000..cab1975d1dec --- /dev/null +++ b/tests/components/CippComponents/CippQuarantineDetails.test.jsx @@ -0,0 +1,180 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../test-utils' +import { api, apiCallMock, getResult } from '../../mocks/api-call' +import { CippQuarantineDetails } from '../../../src/components/CippComponents/CippQuarantineDetails' + +vi.mock('../../../src/api/ApiCall', async () => + (await import('../../mocks/api-call')).apiCallMock() +) + +import TimeAgo from 'javascript-time-ago' +import en from 'javascript-time-ago/locale/en' +try { + TimeAgo.addDefaultLocale(en) +} catch (e) { + /* already added */ +} + +// producer shapes: row is Get-QuarantineMessage output enriched by Add-CIPPQuarantineMessageProperties, +// analyzed is Invoke-ListMailQuarantineMessageDetails Results[0] (analyzedEmails or header fallback) +const quarantineRow = { + Identity: + '5e5e5e5e-1111-2222-3333-444455556666\\c81d4a2e-1111-2222-3333-444455556666', + NetworkMessageId: '5e5e5e5e-1111-2222-3333-444455556666', + Tenant: 'fabrikam.com', + CustomerId: 'customer-1', + Subject: 'Suspicious invoice', + ReceivedTime: '2026-06-01T10:00:00Z', + Expires: '2026-07-01T10:00:00Z', + Type: 'HighConfPhish', + ReleaseStatus: 'NOTRELEASED', + PolicyType: 'AntiPhish', + PolicyName: 'Default AntiPhish', + SenderAddress: 'bad@evil.example', + RecipientAddress: ['user@fabrikam.com'], + Size: 2048, + Direction: 'Inbound', + EntityType: 'Email', + MessageId: '', + QuarantinedUser: 'user@fabrikam.com', + Reported: false, +} + +const analyzed = { + recipientEmailAddress: 'user@fabrikam.com', + internetMessageId: '', + returnPath: 'bounce@evil.example', + directionality: 'Inbound', + language: 'en', + spamConfidenceLevel: -1, + bulkComplaintLevel: 1, + threatTypes: ['Malware'], + detectionMethods: ['File detonation'], + primaryOverrideSource: 'None', + policyAction: 'Quarantine', + senderDetail: { + displayName: 'Evil Sender', + mailFromAddress: 'bad@evil.example', + fromAddress: 'bad@evil.example', + ipv4: '203.0.113.5', + location: 'US', + }, + originalDelivery: { + originalThreats: ['Malware'], + location: 'Quarantine', + action: 'Quarantined', + }, + latestDelivery: { + latestThreats: ['Malware'], + location: 'Quarantine', + action: 'Quarantined', + }, + authenticationDetails: { + dmarc: 'fail', + dkim: 'pass', + senderPolicyFramework: 'softfail', + compositeAuthentication: 'fail', + }, + urls: [ + { + url: 'https://evil.example/pay', + threatType: 'Malware', + detectionMethod: 'Detonated', + }, + ], + attachments: [ + { + fileName: 'invoice.pdf', + contentType: 'application/pdf', + fileSize: 1024, + sha256: + 'aa11bb22cc33dd44ee55ff6677889900aabbccddeeff00112233445566778899', + threatType: 'Malware', + malwareFamily: 'TestFamily', + }, + ], +} + +const detailsResult = (metadata = {}) => + getResult({ data: { Results: [analyzed], Metadata: metadata } }) + +const defaultMetadata = { Available: true, Source: 'Defender' } +const headersResult = detailsResult({ Available: true, Source: 'Headers' }) +const defenderResult = detailsResult(defaultMetadata) + +describe('CippQuarantineDetails', () => { + it('shows the header-parsed fallback notice and targets the row tenant for enrichment', () => { + let detailOpts = null + api.get = (opts) => { + if (opts.url === '/api/ListMailQuarantineMessageDetails') { + detailOpts = opts + return headersResult + } + return getResult() + } + renderWithProviders() + + expect( + screen.getByText(/Showing details parsed from the message headers/) + ).toBeInTheDocument() + expect(detailOpts.data.tenantFilter).toBe('fabrikam.com') + expect(detailOpts.data.Identity).toBe(quarantineRow.Identity) + // fallback fields render from the analyzed-shaped object + expect(screen.getAllByText('Fail').length).toBeGreaterThan(0) + expect(screen.getByText('Softfail')).toBeInTheDocument() + }) + + it('colors phishing and malware reason chips as error', () => { + api.get = () => defenderResult + renderWithProviders() + expect( + screen + .getAllByText('HighConfPhish') + .find((el) => el.closest('[class*="MuiChip-colorError"]')) + ).toBeTruthy() + + api.get = () => defenderResult + renderWithProviders( + + ) + expect( + screen + .getAllByText('Malware') + .find((el) => el.closest('[class*="MuiChip-colorError"]')) + ).toBeTruthy() + }) + + it('colors spam and bulk reason chips as warning', () => { + api.get = () => defenderResult + renderWithProviders( + + ) + expect( + screen + .getAllByText('Spam') + .find((el) => el.closest('[class*="MuiChip-colorWarning"]')) + ).toBeTruthy() + + api.get = () => defenderResult + renderWithProviders( + + ) + expect( + screen + .getAllByText('Bulk') + .find((el) => el.closest('[class*="MuiChip-colorWarning"]')) + ).toBeTruthy() + }) + + it('renders URL and attachment verdict tables from the analyzed enrichment', () => { + api.get = () => defenderResult + renderWithProviders() + + expect(screen.getByText('https://evil.example/pay')).toBeInTheDocument() + expect(screen.getByText('Detonated')).toBeInTheDocument() + expect(screen.getByText('invoice.pdf')).toBeInTheDocument() + expect(screen.getByText('TestFamily')).toBeInTheDocument() + expect(screen.getByText('1.0 KB')).toBeInTheDocument() + }) +}) diff --git a/tests/components/CippComponents/CippQuarantineTable.test.jsx b/tests/components/CippComponents/CippQuarantineTable.test.jsx new file mode 100644 index 000000000000..84bd7efb7c20 --- /dev/null +++ b/tests/components/CippComponents/CippQuarantineTable.test.jsx @@ -0,0 +1,98 @@ +import React from 'react' +import { act, screen } from '@testing-library/react' +import { renderWithProviders, settingsWith } from '../../test-utils' +import { api, apiCallMock, getResult } from '../../mocks/api-call' +import { CippQuarantineTable } from '../../../src/components/CippComponents/CippQuarantineTable' + +const tableProps = vi.hoisted(() => ({ current: null })) +vi.mock('../../../src/api/ApiCall', async () => + (await import('../../mocks/api-call')).apiCallMock() +) +vi.mock('../../../src/components/CippComponents/CippTablePage.jsx', () => ({ + CippTablePage: (props) => { + tableProps.current = props + return
    + }, +})) + +const quarantineRow = { + Identity: + '5e5e5e5e-1111-2222-3333-444455556666\\c81d4a2e-1111-2222-3333-444455556666', + NetworkMessageId: '5e5e5e5e-1111-2222-3333-444455556666', + Tenant: 'fabrikam.com', + Subject: 'Suspicious invoice', + MessageId: '', + ReceivedTime: '2026-06-01T10:00:00Z', + RecipientAddress: ['user@fabrikam.com'], + ReleaseStatus: 'NOTRELEASED', +} + +describe('CippQuarantineTable', () => { + it('gates email-only actions to the Email tab and passes the entity type to the API', () => { + api.get = () => getResult() + const { unmount } = renderWithProviders( + + ) + const { actions, apiData } = tableProps.current + const labels = actions.map((action) => action.label) + + expect(labels).toContain('Release') + expect(labels).toContain('Delete from Quarantine') + expect(labels).not.toContain('Preview Message') + expect(labels).not.toContain('Deny') + expect(labels).not.toContain('Block Sender') + expect(labels).not.toContain('Submit to Microsoft for Review') + expect(labels).not.toContain('Open Email Entity in Defender') + expect(apiData.EntityType).toBe('Teams') + unmount() + + renderWithProviders() + const emailLabels = tableProps.current.actions.map((action) => action.label) + expect(emailLabels).toContain('Preview Message') + expect(emailLabels).toContain('Deny') + expect(emailLabels).toContain('Submit to Microsoft for Review') + expect(emailLabels).toContain('Block Sender') + expect(emailLabels).toContain('Open Email Entity in Defender') + }) + + it('targets the row tenant for per-message calls in the AllTenants view', async () => { + const callOpts = [] + api.get = (opts) => { + callOpts.push(opts) + return getResult() + } + renderWithProviders(, { + settings: settingsWith({ currentTenant: 'AllTenants' }), + }) + + const preview = tableProps.current.actions.find( + (action) => action.label === 'Preview Message' + ) + await act(async () => preview.customFunction(quarantineRow)) + + const contentsCall = callOpts.find( + (opts) => + opts.url === '/api/ListMailQuarantineMessage' && + opts.data?.Identity === quarantineRow.Identity + ) + expect(contentsCall).toBeTruthy() + expect(contentsCall.data.tenantFilter).toBe('fabrikam.com') + }) + + it('renders the raw message headers in the headers dialog', async () => { + const headerText = + 'Received: from mail.evil.example\r\nX-CIPP-Test: present' + api.get = (opts) => + opts.url === '/api/ListMailQuarantineMessageHeader' + ? getResult({ data: { Header: headerText } }) + : getResult() + renderWithProviders() + + const viewHeaders = tableProps.current.actions.find( + (action) => action.label === 'View Message Headers' + ) + await act(async () => viewHeaders.customFunction(quarantineRow)) + + expect(screen.getByText(/X-CIPP-Test: present/)).toBeInTheDocument() + }) +}) diff --git a/tests/components/CippComponents/CippReportToolbar.stories.jsx b/tests/components/CippComponents/CippReportToolbar.stories.jsx new file mode 100644 index 000000000000..20f37116f888 --- /dev/null +++ b/tests/components/CippComponents/CippReportToolbar.stories.jsx @@ -0,0 +1,99 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Box } from '@mui/material' +import { CippReportToolbar } from '../../../src/components/CippComponents/CippReportToolbar' + +const testSuites = [ + { + id: 'ztna', + name: 'Zero Trust Network Access Tests', + description: "Microsoft's comprehensive security assessment", + type: 'builtin', + source: 'file', + }, + { + id: 'custom-1', + name: 'My Custom Suite', + description: 'A tenant-specific suite', + type: 'custom', + source: 'table', + }, +] + +const handlers = [ + http.get('*/api/ListTestReports', () => HttpResponse.json(testSuites)), + http.get('*/api/ListAvailableTests', () => + HttpResponse.json({ IdentityTests: [], DevicesTests: [], CustomTests: [] }) + ), +] + +export default { + title: 'Components/CippComponents/CippReportToolbar', + component: CippReportToolbar, + tags: ['autodocs'], + parameters: { msw: { handlers } }, + decorators: [ + (Story) => ( + + + + ), + ], +} + +// The toolbar picks its layout from useIsMobileLayout (a media query), and no story in this +// repo sets a viewport — so the mobile variant is shown by constraining the container and +// documenting the difference rather than by faking the breakpoint. +export const Desktop = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('every suite action is an inline button', async () => { + await waitFor(() => + expect(canvas.getByRole('button', { name: 'Refresh' })).toBeInTheDocument() + ) + expect(canvas.getByRole('button', { name: 'Delete' })).toBeInTheDocument() + expect(canvas.getByRole('button', { name: 'Create Suite' })).toBeInTheDocument() + expect(canvas.getByRole('button', { name: 'Refresh test suites' })).toBeInTheDocument() + expect(canvas.queryByRole('button', { name: 'Test suite actions' })).toBeNull() + }) + }, +} + +// Regression guard for the overflow this refactor fixed: the selector must be allowed to +// shrink (minWidth: 0) so the trailing Delete button stays inside the row. +export const NarrowDesktopKeepsButtonsInView = { + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('the last button is not pushed past the container edge', async () => { + const deleteButton = await waitFor(() => canvas.getByRole('button', { name: 'Delete' })) + const row = deleteButton.closest('div[class*="MuiBox"]').parentElement + expect(deleteButton.getBoundingClientRect().right).toBeLessThanOrEqual( + Math.ceil(row.getBoundingClientRect().right) + 1 + ) + }) + }, +} + +export const SuiteSelection = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('the default suite is selected once the list loads', async () => { + // Opening the popper is left to CippAutocomplete's own stories — driving it from here + // crashes the browser tab in this harness. + await waitFor(() => + expect(canvas.getByRole('combobox')).toHaveValue('Zero Trust Network Access Tests') + ) + }) + }, +} diff --git a/tests/components/CippComponents/CippReportToolbar.test.jsx b/tests/components/CippComponents/CippReportToolbar.test.jsx new file mode 100644 index 000000000000..3d94facfa05d --- /dev/null +++ b/tests/components/CippComponents/CippReportToolbar.test.jsx @@ -0,0 +1,237 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => "table", +})); + +// One registration only — ApiCall and ApiCall.jsx resolve to the same module, so a second +// vi.mock for the extensioned path would silently replace this one. +const apiState = vi.hoisted(() => ({ reports: [], refetch: () => {}, reportsResult: null })); +const idlePaginated = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isLoading: false, + isError: false, + data: undefined, + fetchNextPage: () => {}, + refetch: () => {}, +})); +const idlePost = vi.hoisted(() => ({ + mutate: () => {}, + isPending: false, + isSuccess: false, + isError: false, + reset: () => {}, +})); +const idleGet = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isLoading: false, + isError: false, + data: undefined, + refetch: () => {}, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + // Stable result identity per test: a fresh literal each call loops the autocomplete's + // option-mapping effect (see tests/mocks/api-call.js). + ApiGetCall: ({ url }) => + url === "/api/ListTestReports" ? apiState.reportsResult : idleGet, + ApiGetCallWithPagination: () => idlePaginated, + ApiPostCall: () => idlePost, +})); + +const routerState = vi.hoisted(() => ({ push: vi.fn(), query: {} })); +vi.mock("next/router", () => ({ + useRouter: () => ({ + isReady: true, + pathname: "/dashboardv2", + query: routerState.query, + push: routerState.push, + }), +})); + +// The drawer pulls in the whole test-picker form; the toolbar contract under test is only +// "is it open, and with which suite" — so it's stubbed down to those observable facts. +const drawerRenders = vi.hoisted(() => ({ calls: [] })); +vi.mock("../../../src/components/CippComponents/CippAddTestReportDrawer", () => ({ + CippAddTestReportDrawer: (props) => { + drawerRenders.calls.push(props); + if (props.hideTrigger) { + return props.open ? ( +
    + {props.reportToEdit?.name ?? "no-report"} +
    + ) : null; + } + return ; + }, +})); + +vi.mock("../../../src/components/CippComponents/CippApiDialog", () => ({ + CippApiDialog: ({ createDialog, title }) => + createDialog?.open ?
    {title}
    : null, +})); + +import { CippReportToolbar } from "../../../src/components/CippComponents/CippReportToolbar"; + +const CUSTOM_SUITE = { + id: "custom-1", + name: "My Custom Suite", + description: "custom", + type: "custom", + source: "table", +}; +const BUILT_IN_SUITE = { + id: "ztna", + name: "Zero Trust Network Access Tests", + description: "built in", + type: "builtin", + source: "file", +}; + +const openActionSheet = async (user) => { + await user.click(screen.getByRole("button", { name: "Test suite actions" })); + const heading = await screen.findByText("Test suite actions"); + return within(heading.closest(".MuiDrawer-paper")); +}; + +describe("CippReportToolbar", () => { + beforeEach(() => { + layoutState.isMobile = false; + apiState.reports = [BUILT_IN_SUITE, CUSTOM_SUITE]; + apiState.refetch = vi.fn(); + apiState.reportsResult = { + isSuccess: true, + isFetching: false, + isLoading: false, + isError: false, + data: apiState.reports, + refetch: apiState.refetch, + }; + routerState.query = {}; + routerState.push = vi.fn(); + drawerRenders.calls = []; + }); + + it("renders the inline desktop action buttons", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create Suite" })).toBeInTheDocument(); + // The selector's inline "Refresh test suites" icon button is desktop-only too + expect(screen.getByRole("button", { name: "Refresh test suites" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Test suite actions" })).not.toBeInTheDocument(); + }); + + it("collapses to a sheet trigger + kebab on mobile — no text input, no keyboard", () => { + layoutState.isMobile = true; + routerState.query = { reportId: "ztna" }; + renderWithProviders(); + + expect(screen.getByRole("button", { name: "Test suite actions" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Refresh" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Refresh test suites" })).not.toBeInTheDocument(); + // the house pick-one pattern: a trigger, not an autocomplete + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /switch test suite/i })).toHaveTextContent( + "Zero Trust Network Access Tests" + ); + }); + + it("switches suite from the bottom sheet, routing shallowly", async () => { + layoutState.isMobile = true; + routerState.query = { reportId: "ztna" }; + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /switch test suite/i })); + const sheet = within((await screen.findByText("Test suite")).closest(".MuiDrawer-paper")); + // descriptions ride as secondary text, the current suite is checked + expect(sheet.getByText("custom")).toBeInTheDocument(); + expect(sheet.getByText("Zero Trust Network Access Tests").closest('[role="button"]')).toHaveClass( + "Mui-selected" + ); + + await user.click(sheet.getByText("My Custom Suite")); + await waitFor(() => + expect(routerState.push).toHaveBeenCalledWith( + expect.objectContaining({ query: expect.objectContaining({ reportId: "custom-1" }) }), + undefined, + { shallow: true } + ) + ); + }); + + it("offers all five suite actions in the sheet", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + ["Create Suite", "Run Tests", "Edit Suite", "Delete Suite", "Reload suite list"].forEach( + (label) => expect(sheet.getByText(label)).toBeInTheDocument() + ); + }); + + it("disables Edit and Delete with a visible reason for a built-in suite", async () => { + layoutState.isMobile = true; + routerState.query = { reportId: "ztna" }; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + expect(sheet.getByText("Built-in test suites cannot be edited")).toBeInTheDocument(); + expect(sheet.getByText("Built-in test suites cannot be deleted")).toBeInTheDocument(); + expect(sheet.getByText("Edit Suite").closest("[role='button']")).toHaveClass("Mui-disabled"); + }); + + it("opens the run-tests dialog and keeps it mounted after the sheet closes", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + await user.click(sheet.getByText("Run Tests")); + + expect(await screen.findByTestId("api-dialog")).toHaveTextContent("Refresh Test Data"); + await waitFor(() => + expect(screen.queryByText("Test suite actions")).not.toBeInTheDocument() + ); + expect(screen.getByTestId("api-dialog")).toBeInTheDocument(); + }); + + it("opens the edit drawer pre-filled with the selected custom suite", async () => { + layoutState.isMobile = true; + routerState.query = { reportId: "custom-1" }; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + await user.click(sheet.getByText("Edit Suite")); + + const drawer = await screen.findByTestId("drawer-edit"); + expect(drawer).toHaveTextContent("My Custom Suite"); + }); + + it("reloads the suite list from the sheet", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + await user.click(sheet.getByText("Reload suite list")); + + // the sheet hands off on its exit transition, so the call lands a beat later + await waitFor(() => expect(apiState.refetch).toHaveBeenCalled()); + }); +}); diff --git a/tests/components/CippComponents/CippSankey.test.jsx b/tests/components/CippComponents/CippSankey.test.jsx new file mode 100644 index 000000000000..85735229d851 --- /dev/null +++ b/tests/components/CippComponents/CippSankey.test.jsx @@ -0,0 +1,61 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { renderWithProviders, settingsWith } from "../../test-utils"; +import { createTheme } from "../../../src/theme"; + +// jsdom gives nivo's responsive wrapper a 0×0 parent, so nothing paints — capture the +// props instead and assert on the dark/light decisions they encode. +const captured = vi.hoisted(() => ({ props: null })); +vi.mock("@nivo/sankey", () => ({ + ResponsiveSankey: (props) => { + captured.props = props; + return null; + }, +})); + +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => false, +})); + +import { CippSankey } from "../../../src/components/CippComponents/CippSankey"; + +const data = { + nodes: [ + { id: "Users", nodeColor: "#f97316" }, + { id: "MFA", nodeColor: "#22c55e" }, + ], + links: [{ source: "Users", target: "MFA", value: 5 }], +}; + +const darkTheme = createTheme({ + colorPreset: "orange", + direction: "ltr", + paletteMode: "dark", + contrast: "high", +}); + +describe("CippSankey theming", () => { + // The app resolves currentTheme "browser" to the OS preference when building the MUI + // theme, so the *setting* can say "browser" while the page paints dark. Deciding + // darkness from the setting made the chart multiply its ribbons over a dark card — + // composited to black, i.e. an invisible chart until the user toggled the theme. + it("follows the painted palette, not the theme setting", () => { + renderWithProviders(, { + theme: darkTheme, + settings: settingsWith({ currentTheme: { value: "browser", label: "Browser default" } }), + }); + + expect(captured.props.linkBlendMode).toBe("lighten"); + expect(captured.props.labelTextColor).toBe("#ffffff"); + }); + + it("keeps multiply-over-white on an actually light page", () => { + renderWithProviders(, { + settings: settingsWith({ currentTheme: { value: "browser", label: "Browser default" } }), + }); + + expect(captured.props.linkBlendMode).toBe("multiply"); + expect(captured.props.labelTextColor).toBe("#000000"); + }); +}); diff --git a/tests/components/CippComponents/CippSettingsSideBar.test.jsx b/tests/components/CippComponents/CippSettingsSideBar.test.jsx new file mode 100644 index 000000000000..2362102662a9 --- /dev/null +++ b/tests/components/CippComponents/CippSettingsSideBar.test.jsx @@ -0,0 +1,46 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useForm } from 'react-hook-form' +import { renderWithProviders } from '../../test-utils' + +vi.mock('../../../src/api/ApiCall', async () => (await import('../../mocks/api-call')).apiCallMock()) +import { api, getResult, postResult } from '../../mocks/api-call' + +import { CippSettingsSideBar } from '../../../src/components/CippComponents/CippSettingsSideBar' + +const meResult = getResult({ data: { clientPrincipal: { userDetails: 'admin@contoso.com' } } }) +api.get = meResult + +// handleSaveChanges posts an explicit field allowlist, a preference missing from it saves +// as a silent no-op ("Settings saved successfully" toast, nothing stored) +const Harness = () => { + const formcontrol = useForm({ + defaultValues: { + user: { label: 'Current User', value: 'admin@contoso.com' }, + tableViewMode: { value: 'table', label: 'Always classic table' }, + tablePageSize: { value: '50', label: '50' }, + }, + }) + return +} + +describe('CippSettingsSideBar save allowlist', () => { + it('Save Changes posts tableViewMode with the settings blob', async () => { + const user = userEvent.setup() + api.post = postResult() + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: /save changes/i })) + + await waitFor(() => expect(api.post.mutate).toHaveBeenCalled()) + const payload = api.post.mutate.mock.calls[0][0] + expect(payload.data.user).toBe('admin@contoso.com') + expect(payload.data.currentSettings.tableViewMode).toEqual({ + value: 'table', + label: 'Always classic table', + }) + expect(payload.data.currentSettings.tablePageSize).toEqual({ value: '50', label: '50' }) + }) +}) diff --git a/tests/components/CippComponents/CippTabPicker.stories.jsx b/tests/components/CippComponents/CippTabPicker.stories.jsx new file mode 100644 index 000000000000..4303a3b6cfab --- /dev/null +++ b/tests/components/CippComponents/CippTabPicker.stories.jsx @@ -0,0 +1,162 @@ +import React from 'react' +import { within, userEvent, waitFor, expect } from 'storybook/test' +import { Box, Stack, Typography } from '@mui/material' +import { CippTabPicker } from '../../../src/components/CippComponents/CippTabPicker' +import { TabNavigationContext } from '../../../src/layouts/tab-navigation-context' +import { shrinkToPhoneViewport } from '../../viewport' + +// tenant/manage — the worst group in the app for label length. 30 characters on the longest. +const TABS = [ + { label: 'Edit Tenant', path: '/tenant/manage/edit', icon: 'Settings' }, + { label: 'Manage Drift', path: '/tenant/manage/drift', icon: 'Sync' }, + { label: 'Configuration Backup', path: '/tenant/manage/backup', icon: 'Backup' }, + { label: 'Applied Standards Report', path: '/tenant/manage/standards', icon: 'Assessment' }, + { + label: 'Policies and Settings Deployed', + path: '/tenant/manage/policies', + icon: 'Assessment', + }, +] + +const withTabs = + (currentPath = '/tenant/manage/policies', tabs = TABS) => + (Story) => ( + {}, + actions: [], + claim: () => {}, + release: () => {}, + isActionCornerClaimed: false, + }} + > + + + ) + +export default { + title: 'Components/CippComponents/CippTabPicker', + component: CippTabPicker, + tags: ['autodocs'], +} + +// The default, and what every tabbed page gets: one full-width control in the slot the +// desktop tab bar occupied. Same control, same place, every page. +export const BlockAtPhoneWidth = { + decorators: [withTabs()], + render: () => ( + + + + ), + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + const picker = canvas.getByRole('button', { name: /switch view/i }) + + await step('the trigger names the current view', async () => { + await expect(picker).toHaveAccessibleName('Policies and Settings Deployed switch view') + }) + + if (!onAPhone) return + + await step('the longest label in the app fits without widening the page', async () => { + const host = canvasElement.querySelector('[data-testid="block-host"]') + await waitFor(() => expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth)) + // full width of the gutter box, so the control is unmistakably a control + const style = getComputedStyle(host) + const content = + host.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) + await expect(picker.getBoundingClientRect().width).toBeGreaterThan(content - 1) + }) + + // Heading clothes: the chevron rides beside the text like a title's disclosure + // affordance, not pinned to the far edge like a form field's. + await step('the chevron sits beside the label, not at the far edge', async () => { + const chevron = picker.querySelector('svg:last-of-type') + const labelEl = within(picker).getByText('Policies and Settings Deployed') + const gapToLabel = chevron.getBoundingClientRect().left - labelEl.getBoundingClientRect().right + await expect(gapToLabel).toBeLessThan(24) + }) + }, +} + +// The one exception: HeaderedTabbedLayout's title row is empty on its right half below md, +// so the picker rides there and navigation costs no vertical space at all. +export const CompactInTitleRow = { + decorators: [withTabs()], + render: () => ( + + + + + Contoso Manufacturing Holdings GmbH + + + + + + ), + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const picker = canvas.getByRole('button', { name: /switch view/i }) + + await step('a 30-char label beside a long title does not widen the row', async () => { + const host = canvasElement.querySelector('[data-testid="title-row-host"]') + await waitFor(() => expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth)) + // and it stays a control rather than eating the heading's half of the row + await expect(picker.getBoundingClientRect().width).toBeLessThanOrEqual( + host.clientWidth / 2 + 1 + ) + }) + }, +} + +// A single destination is not navigation — View Group and View Device have one tab each. +export const SingleTabRendersNothing = { + decorators: [withTabs('/identity/groups/group', [TABS[0]])], + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.queryByRole('button', { name: /switch view/i })).toBeNull() + }, +} + +export const OpensTheSheet = { + decorators: [withTabs('/tenant/manage/edit')], + render: () => ( + + + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + // The trigger names the current view and so does its row in the sheet — scope to the + // sheet, or every current-tab query matches twice. + let sheet + + await step('every destination is a full-width row, none scrolled off an edge', async () => { + await userEvent.click(canvas.getByRole('button', { name: /switch view/i })) + const body = within(document.body) + await waitFor(() => expect(body.getByText('Views')).toBeInTheDocument()) + sheet = within(body.getByText('Views').closest('.MuiDrawer-paper')) + await expect(sheet.getByText('Configuration Backup')).toBeInTheDocument() + await expect(sheet.getByText('Policies and Settings Deployed')).toBeInTheDocument() + }) + + await step('the current view is checked', async () => { + const current = sheet.getByText('Edit Tenant').closest('[role="button"]') + await expect(current).toHaveClass('Mui-selected') + }) + }, +} diff --git a/tests/components/CippComponents/CippUserSwitcher.test.jsx b/tests/components/CippComponents/CippUserSwitcher.test.jsx new file mode 100644 index 000000000000..a26931fe95f4 --- /dev/null +++ b/tests/components/CippComponents/CippUserSwitcher.test.jsx @@ -0,0 +1,104 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../test-utils"; + +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})); + +// Stable identities (tests/mocks/api-call.js): fresh objects per call re-render forever +const routerState = vi.hoisted(() => { + const router = { + push: () => {}, + pathname: "/identity/administration/users/user", + query: { userId: "user-1", tenantFilter: "contoso.com" }, + }; + return { router }; +}); +vi.mock("next/router", () => ({ useRouter: () => routerState.router })); + +const apiState = vi.hoisted(() => ({ + result: { isFetching: false, isSuccess: true, data: { Results: [] } }, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + ApiGetCall: () => apiState.result, +})); + +import { CippUserSwitcher } from "../../../src/components/CippComponents/CippUserSwitcher"; + +const users = [ + { id: "user-1", displayName: "Ada Lovelace", userPrincipalName: "ada@contoso.com" }, + { id: "user-2", displayName: "Grace Hopper", userPrincipalName: "grace@contoso.com" }, + { id: "user-3", displayName: "Alan Turing", userPrincipalName: "alan@contoso.com" }, +]; + +const renderSwitcher = () => + renderWithProviders( + + ); + +describe("CippUserSwitcher", () => { + beforeEach(() => { + layoutState.isMobile = false; + routerState.router.push = vi.fn(); + apiState.result = { isFetching: false, isSuccess: true, data: { Results: users } }; + }); + + it("keeps the visible name in the accessible name", () => { + renderSwitcher(); + expect( + screen.getByRole("button", { name: /Ada Lovelace switch user/i }) + ).toBeInTheDocument(); + }); + + it("switches only the userId, keeping route and tenant", async () => { + const user = userEvent.setup(); + renderSwitcher(); + + await user.click(screen.getByRole("button", { name: /switch user/i })); + await user.click(await screen.findByText("Grace Hopper")); + + expect(routerState.router.push).toHaveBeenCalledWith({ + pathname: "/identity/administration/users/user", + query: { userId: "user-2", tenantFilter: "contoso.com" }, + }); + }); + + it("treats picking the current user as a no-op", async () => { + const user = userEvent.setup(); + renderSwitcher(); + + await user.click(screen.getByRole("button", { name: /switch user/i })); + // the popover lists the current user too — pick the row, not the trigger's own text + const rows = await screen.findAllByText("Ada Lovelace"); + await user.click(rows[rows.length - 1]); + + expect(routerState.router.push).not.toHaveBeenCalled(); + }); + + it("filters by name or UPN", async () => { + const user = userEvent.setup(); + renderSwitcher(); + + await user.click(screen.getByRole("button", { name: /switch user/i })); + await user.type(await screen.findByPlaceholderText(/search users/i), "alan@"); + + const list = screen.getByRole("list"); + expect(within(list).getByText("Alan Turing")).toBeInTheDocument(); + expect(within(list).queryByText("Grace Hopper")).not.toBeInTheDocument(); + }); + + it("uses the bottom sheet on mobile", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderSwitcher(); + + await user.click(screen.getByRole("button", { name: /switch user/i })); + const sheet = (await screen.findByText("Grace Hopper")).closest(".MuiDrawer-paper"); + expect(sheet).not.toBeNull(); + }); +}); diff --git a/tests/components/CippComponents/SecureScoreCard.test.jsx b/tests/components/CippComponents/SecureScoreCard.test.jsx index bd47877ebda4..9184157d519e 100644 --- a/tests/components/CippComponents/SecureScoreCard.test.jsx +++ b/tests/components/CippComponents/SecureScoreCard.test.jsx @@ -1,7 +1,19 @@ import React from 'react' import { screen } from '@testing-library/react' import { renderWithTheme } from '../../test-utils' -import { SecureScoreCard } from '../../../src/components/CippComponents/SecureScoreCard' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +import { + SecureScoreCard, + secureScoreAxisProps, +} from '../../../src/components/CippComponents/SecureScoreCard' const scoreData = [ { createdDateTime: '2026-07-01T00:00:00Z', currentScore: 40, maxScore: 100 }, @@ -10,6 +22,36 @@ const scoreData = [ ] describe('SecureScoreCard', () => { + beforeEach(() => { + layoutState.isMobile = false + }) + + // recharts reads its axis children's props without mounting them, so there is no element to + // assert against — the config is exported and tested directly. + const ticks = ['Jul 1', 'Jul 15', 'Jul 29'] + + // interval 0 draws a label for every point. Thirteen dates fit across a desktop card and + // overlap into one smear at 390px, which is what "Jul 27Jul 28Jul 29" looks like. + it('labels every point on desktop', () => { + const axis = secureScoreAxisProps({ isMobile: false, ticks }) + + expect(axis.x.interval).toBe(0) + expect(axis.x.ticks).toBe(ticks) + expect(axis.x.tick.fontSize).toBe(12) + expect(axis.y.width).toBeUndefined() + }) + + it('hands x-axis spacing back to recharts on a narrow chart', () => { + const axis = secureScoreAxisProps({ isMobile: true, ticks }) + + expect(axis.x.interval).toBe('preserveStartEnd') + expect(axis.x.ticks).toBeUndefined() + expect(axis.x.minTickGap).toBeGreaterThan(5) + expect(axis.x.tick.fontSize).toBeLessThan(12) + // and the y-axis gutter narrows so the plot keeps the width it has + expect(axis.y.width).toBeLessThan(40) + }) + it('does not trigger the recharts zero-size warning on first render', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/tests/components/CippFormPages/CippFormPage.test.jsx b/tests/components/CippFormPages/CippFormPage.test.jsx index 62e38136817a..be8fbbe205a9 100644 --- a/tests/components/CippFormPages/CippFormPage.test.jsx +++ b/tests/components/CippFormPages/CippFormPage.test.jsx @@ -1,90 +1,109 @@ -import React from 'react' -import { screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { useForm } from 'react-hook-form' -import { renderWithProviders } from '../../test-utils' -import CippFormPage from '../../../src/components/CippFormPages/CippFormPage' -import CippFormComponent from '../../../src/components/CippComponents/CippFormComponent' +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { useForm } from "react-hook-form"; +import { renderWithProviders } from "../../test-utils"; -// capture the submit payload, network layer is not under test here -const apiState = vi.hoisted(() => ({ mutate: null })) +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, +})); -vi.mock('../../../src/api/ApiCall', () => ({ - ApiPostCall: () => ({ - mutate: apiState.mutate, - isPending: false, - isSuccess: false, - isIdle: true, - isError: false, - isFetching: false, - data: undefined, - reset: () => {}, - }), - // CippApiResults polls job status through ApiGetCall, keep it inert - ApiGetCall: () => ({ - isSuccess: false, - isPending: true, - isFetching: false, - isError: false, - data: undefined, - }), -})) +// Stable identities: CippFormPage has a useEffect keyed on the router object itself that +// resets the form — a fresh object per call re-renders forever (tests/mocks/api-call.js) +const routerState = vi.hoisted(() => { + const router = { push: () => {}, back: () => {}, query: {} }; + return { push: router.push, pathname: "/cipp/sam-roles", router }; +}); +vi.mock("next/navigation", () => ({ + useRouter: () => routerState.router, + usePathname: () => routerState.pathname, + useSearchParams: () => new URLSearchParams(""), +})); +vi.mock("next/router", () => ({ + useRouter: () => routerState.router, +})); -const Harness = ({ defaultValues = { displayName: '', notes: '' }, ...pageProps }) => { - const formControl = useForm({ mode: 'onChange', defaultValues }) +// Stable identities: a fresh object per call re-renders forever (tests/mocks/api-call.js) +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + isIdle: true, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { TabbedLayout } from "../../../src/layouts/TabbedLayout"; +import CippFormPage from "../../../src/components/CippFormPages/CippFormPage"; + +const tabOptions = [ + { label: "SAM App Roles", path: "/cipp/sam-roles" }, + { label: "SSO", path: "/cipp/sso" }, +]; + +const Harness = (formPageProps) => { + const formControl = useForm({ mode: "onChange" }); return ( - - + - - - ) -} + postUrl="/api/x" + queryKey="x" + {...formPageProps} + > +
    form content
    +
    + + ); +}; -describe('CippFormPage', () => { +describe("CippFormPage title vs the mobile tab picker", () => { beforeEach(() => { - apiState.mutate = vi.fn() - }) - - it('renders the page type, title, and form children', () => { - renderWithProviders() + layoutState.isMobile = false; + routerState.pathname = "/cipp/sam-roles"; + }); - expect(screen.getByRole('heading', { name: 'Add - User' })).toBeInTheDocument() - expect(screen.getByRole('textbox', { name: 'Display Name' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled() - }) + // Same defect class as CippPageCard: the picker trigger already says "SAM App Roles" + // right above this h4, so the page opened with its own name printed twice in a row. + it("stands its title down when the picker already says it", () => { + layoutState.isMobile = true; + renderWithProviders(); - it('renders a custom page type and hides it on request', () => { - const { unmount } = renderWithProviders() - expect(screen.getByRole('heading', { name: 'Edit - User' })).toBeInTheDocument() - unmount() + expect(screen.getAllByText("SAM App Roles")).toHaveLength(1); + expect( + screen.queryByRole("heading", { level: 4, name: "SAM App Roles" }) + ).not.toBeInTheDocument(); + }); - renderWithProviders() - expect(screen.getByRole('heading', { name: 'User' })).toBeInTheDocument() - }) + // With the page-type prefix the rendered text is "Add - SAM App Roles", which is not what + // the picker says — so it still renders. + it("keeps a title the prefix makes different", () => { + layoutState.isMobile = true; + renderWithProviders(); - it('submits form values to postUrl and strips empty fields', async () => { - const user = userEvent.setup() - renderWithProviders() + expect( + screen.getByRole("heading", { level: 4, name: "Add - SAM App Roles" }) + ).toBeInTheDocument(); + }); - await user.type(screen.getByRole('textbox', { name: 'Display Name' }), 'John Doe') - const submit = screen.getByRole('button', { name: 'Submit' }) - await waitFor(() => { - expect(submit).toBeEnabled() - }) - await user.click(submit) + it("keeps its title on desktop", () => { + renderWithProviders(); - await waitFor(() => { - expect(apiState.mutate).toHaveBeenCalledTimes(1) - }) - // notes stayed '', removeEmpty drops it from the payload - expect(apiState.mutate).toHaveBeenCalledWith({ - url: '/api/AddUser', - data: { displayName: 'John Doe' }, - }) - }) -}) + expect(screen.getByRole("heading", { level: 4, name: "SAM App Roles" })).toBeInTheDocument(); + }); +}); diff --git a/tests/components/CippPdf/CippPdfPreview.test.jsx b/tests/components/CippPdf/CippPdfPreview.test.jsx new file mode 100644 index 000000000000..575499cace03 --- /dev/null +++ b/tests/components/CippPdf/CippPdfPreview.test.jsx @@ -0,0 +1,128 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../test-utils' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +// Building a real PDF in jsdom is neither possible nor the point: what is under test is which +// branch renders and what it hands the user. Stable identities — a fresh object per call +// re-renders forever. +const pdfState = vi.hoisted(() => ({ + instance: { loading: false, error: null, url: 'blob:http://localhost/report-1', blob: { size: 1_572_864 } }, + viewerProps: null, +})) +vi.mock('@react-pdf/renderer', () => ({ + PDFViewer: (props) => { + pdfState.viewerProps = props + return
    {props.children}
    + }, + usePDF: () => [pdfState.instance], +})) + +import { CippPdfPreview } from '../../../src/components/CippPdf/CippPdfPreview' + +const doc =
    document
    + +const render = (props = {}) => + renderWithProviders( + + {doc} + + ) + +describe('CippPdfPreview', () => { + beforeEach(() => { + layoutState.isMobile = false + pdfState.viewerProps = null + pdfState.instance = { + loading: false, + error: null, + url: 'blob:http://localhost/report-1', + blob: { size: 1_572_864 }, + } + }) + + it('renders the embedded viewer on desktop', () => { + render() + expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument() + expect(screen.getByTestId('report-doc')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /open report/i })).not.toBeInTheDocument() + }) + + // title/fileName/viewerKey are ours, not react-pdf's — forwarding them would land unknown + // attributes on the iframe and warn. + it('does not leak its own props onto the desktop viewer', () => { + render({ style: { border: 'none' }, showToolbar: true, showDownload: true }) + expect(pdfState.viewerProps).not.toHaveProperty('title') + expect(pdfState.viewerProps).not.toHaveProperty('fileName') + expect(pdfState.viewerProps).not.toHaveProperty('viewerKey') + expect(pdfState.viewerProps).not.toHaveProperty('showDownload') + expect(pdfState.viewerProps.showToolbar).toBe(true) + }) + + // iOS renders a PDF in an iframe as a fixed first-page preview that cannot be scrolled, so + // below md the document goes to the platform viewer instead of being embedded. + it('hands off to the platform viewer on mobile instead of embedding', () => { + layoutState.isMobile = true + render() + + expect(screen.queryByTestId('pdf-viewer')).not.toBeInTheDocument() + + const open = screen.getByRole('link', { name: /open report/i }) + expect(open).toHaveAttribute('href', 'blob:http://localhost/report-1') + expect(open).toHaveAttribute('target', '_blank') + // a real anchor, not window.open in a handler — that is what popup blockers stop + expect(open.tagName).toBe('A') + }) + + // Six of the eight hosts already put a Download in their dialog actions; showing one here + // as well is exactly the duplicate that appeared on a phone. + it('offers no download of its own by default', () => { + layoutState.isMobile = true + render() + + expect(screen.queryByRole('link', { name: /download/i })).not.toBeInTheDocument() + }) + + it('offers a download named after the report where the host has none', () => { + layoutState.isMobile = true + render({ showDownload: true }) + + const download = screen.getByRole('link', { name: /download/i }) + expect(download).toHaveAttribute('download', 'Executive_Report.pdf') + expect(download).toHaveAttribute('href', 'blob:http://localhost/report-1') + }) + + it('names the report and its size', () => { + layoutState.isMobile = true + render() + + expect(screen.getByText('Executive Report - Contoso')).toBeInTheDocument() + expect(screen.getByText(/1\.5 MB/)).toBeInTheDocument() + }) + + it('shows progress while the document is still building', () => { + layoutState.isMobile = true + pdfState.instance = { loading: true, error: null, url: null, blob: null } + render() + + expect(screen.getByRole('progressbar')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /open report/i })).not.toBeInTheDocument() + }) + + it('surfaces a generation failure rather than an empty frame', () => { + layoutState.isMobile = true + pdfState.instance = { loading: false, error: 'boom', url: null, blob: null } + render() + + expect(screen.getByText(/could not be generated/i)).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /open report/i })).not.toBeInTheDocument() + }) +}) diff --git a/tests/components/CippPdf/ReportDialogActions.stories.jsx b/tests/components/CippPdf/ReportDialogActions.stories.jsx new file mode 100644 index 000000000000..ec80d2f4fec8 --- /dev/null +++ b/tests/components/CippPdf/ReportDialogActions.stories.jsx @@ -0,0 +1,68 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { Box, Button, DialogActions, Typography } from '@mui/material' +import { Download } from '@mui/icons-material' +import { shrinkToPhoneViewport } from '../../viewport' + +/** + * The report dialogs' action row, reproduced — the dialogs themselves need too much data to + * mount. Below md the caption and two buttons cannot share a line at 390px, so the row stacks; + * this holds the contract that the buttons then span the same width as each other. + */ +const ActionsRow = () => ( + + :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, + }} + > + + + Sections enabled: 7 of 9 + + + + + + +) + +export default { + title: 'Components/CippPdf/ReportDialogActions', + tags: ['autodocs'], +} + +export const StackedAtPhoneWidth = { + render: () => , + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const host = canvasElement.querySelector('[data-testid="actions-host"]') + + const primary = canvas.getByRole('button', { name: /download pdf/i }) + const secondary = canvas.getByRole('button', { name: /^close$/i }) + + await step('the two buttons share one width and one left edge', async () => { + await waitFor(() => { + const a = primary.getBoundingClientRect() + const b = secondary.getBoundingClientRect() + expect(Math.abs(a.width - b.width)).toBeLessThanOrEqual(1) + expect(Math.abs(a.left - b.left)).toBeLessThanOrEqual(1) + expect(Math.abs(a.right - b.right)).toBeLessThanOrEqual(1) + }) + }) + + await step('and nothing pushes the row wider than the screen', async () => { + await expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth) + }) + }, +} diff --git a/tests/components/CippSettings/CippPermissionReport.test.jsx b/tests/components/CippSettings/CippPermissionReport.test.jsx new file mode 100644 index 000000000000..eb13775528d8 --- /dev/null +++ b/tests/components/CippSettings/CippPermissionReport.test.jsx @@ -0,0 +1,86 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})); + +// Stable identities — a fresh object per call re-renders forever +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { CippPermissionReport } from "../../../src/components/CippSettings/CippPermissionReport"; + +const renderReport = () => + renderWithProviders( {}} />); + +describe("CippPermissionReport report actions", () => { + beforeEach(() => { + layoutState.isMobile = false; + }); + + it("keeps the button row inline on desktop, with no FAB", () => { + renderReport(); + expect(screen.getByRole("button", { name: /export report/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /page actions/i })).not.toBeInTheDocument(); + }); + + // Three contained buttons stacked full-width at 390px read as a banner wall before any + // content — page-level utilities belong in the page-actions FAB sheet on mobile. + it("moves the buttons into the FAB sheet on mobile", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderReport(); + + const fab = screen.getByRole("button", { name: /page actions/i }); + // not on the page until the sheet opens + expect(screen.queryByRole("button", { name: /export report/i })).not.toBeInTheDocument(); + + await user.click(fab); + expect(await screen.findByText("Report")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /export report/i })).toBeInTheDocument(); + expect(screen.getByText(/import report/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /paste report/i })).toBeInTheDocument(); + + // uniform with every other sheet action: list rows, not contained buttons in a sheet + expect(document.querySelector(".MuiDrawer-paper .MuiButton-contained")).toBeNull(); + expect( + screen.getByRole("button", { name: /export report/i }).classList.contains("MuiListItemButton-root") + ).toBe(true); + }); + + // The sheet sits at modal + 1 — if a row tap didn't close it, the export dialog would + // open UNDERNEATH it. ListItemButton is a div[role=button], which the close selector + // originally missed. + it("closes the sheet when a row opens its dialog", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderReport(); + + await user.click(screen.getByRole("button", { name: /page actions/i })); + const exportRow = await screen.findByRole("button", { name: /export report/i }); + await user.click(exportRow); + + // keepMounted keeps rows in the DOM; closed means hidden + await vi.waitFor(() => expect(screen.getByText(/paste report/i)).not.toBeVisible()); + }); +}); diff --git a/tests/components/CippTable/CIPPTableToptoolbar.test.jsx b/tests/components/CippTable/CIPPTableToptoolbar.test.jsx index fcaa292b66f9..bfa93e89bb11 100644 --- a/tests/components/CippTable/CIPPTableToptoolbar.test.jsx +++ b/tests/components/CippTable/CIPPTableToptoolbar.test.jsx @@ -279,6 +279,96 @@ describe('CIPPTableToptoolbar - preset list refresh', () => { expect(screen.getByRole('button', { name: 'Filters (1)' })).toBeInTheDocument() }) + // Regression: the restore effect used to key on getRequestData.isFetching, re-arming its + // 100ms timer on every fetch settle (once per page of an auto-paginated load) and + // overwriting whatever the user had just applied with the persisted filter. + it('does not clobber a user filter applied after the persisted one was restored', async () => { + const user = userEvent.setup() + renderGraphTable({}, { + settings: settingsWith({ + persistFilters: true, + setLastUsedFilter: vi.fn(), + lastUsedFilters: { + '': { type: 'column', value: [{ id: 'department', value: 'IT' }], name: 'IT only' }, + }, + }), + }) + // persisted "IT only" lands first + await waitFor(() => { + expect(screen.getByText('1-2 of 2')).toBeInTheDocument() + }, { timeout: 5000 }) + + // user switches to the other preset + await user.click(screen.getByRole('button', { name: /Filters/ })) + await user.click(await screen.findByRole('menuitem', { name: 'Sales only' })) + await waitFor(() => { + expect(screen.getByText('1-1 of 1')).toBeInTheDocument() + }) + + // well past the restore timer: the persisted filter must not come back + await new Promise((resolve) => setTimeout(resolve, 400)) + expect(screen.getByText('1-1 of 1')).toBeInTheDocument() + }, 30000) + + it('syncs the search box when a global preset is applied and cleared', async () => { + const user = userEvent.setup() + renderGraphTable({ + filters: [{ filterName: 'Named Alice', value: 'alice', type: 'global' }], + }) + await screen.findByText('1-3 of 3') + + await user.click(screen.getByRole('button', { name: /Filters/ })) + await user.click(await screen.findByRole('menuitem', { name: 'Named Alice' })) + await waitFor(() => { + expect(screen.getByPlaceholderText('Search...')).toHaveValue('alice') + }) + + // tapping the active preset again clears the slot — and the box with it + await user.click(screen.getByRole('button', { name: /Filters/ })) + await user.click(await screen.findByRole('menuitem', { name: 'Named Alice' })) + await waitFor(() => { + expect(screen.getByPlaceholderText('Search...')).toHaveValue('') + }) + }, 30000) + + // filterList was state-initialised from the prop and never re-synced, so pages that + // compute `filters` asynchronously showed an empty preset list forever + it('picks up filters that arrive after the first render', async () => { + const user = userEvent.setup() + presetsResult = graphPresetResult + + const LateFilters = () => { + const [filters, setFilters] = React.useState([]) + return ( + <> + + + + ) + } + + renderWithProviders() + await screen.findByText('1-3 of 3') + + await user.click(screen.getByRole('button', { name: /Filters/ })) + expect(screen.queryByRole('menuitem', { name: 'IT only' })).toBeNull() + await user.keyboard('{Escape}') + + await user.click(screen.getByRole('button', { name: 'load filters' })) + await user.click(screen.getByRole('button', { name: /Filters/ })) + expect(await screen.findByRole('menuitem', { name: 'IT only' })).toBeInTheDocument() + // the fetched graph preset is not lost when the prop-driven list arrives + expect(screen.getByRole('menuitem', { name: 'Widget View' })).toBeInTheDocument() + }, 30000) + it('renaming an applied graph preset keeps it marked active', async () => { const user = userEvent.setup() renderGraphTable() @@ -298,3 +388,25 @@ describe('CIPPTableToptoolbar - preset list refresh', () => { }) }, 30000) }) + +describe('CIPPTableToptoolbar desktop export', () => { + it('Export menu carries the row exports and opens the API response viewer', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + await screen.findByText('Users') + + await user.click(screen.getByRole('button', { name: /Export/ })) + await screen.findByRole('menuitem', { name: 'Export to CSV' }) + expect(screen.getByRole('menuitem', { name: 'Export to PDF' })).toBeInTheDocument() + + await user.click(screen.getByRole('menuitem', { name: 'View API Response' })) + await screen.findByText('API Response') + }) +}) diff --git a/tests/components/CippTable/CippDataTable.test.jsx b/tests/components/CippTable/CippDataTable.test.jsx index bdc5023185bf..2b7c1dd21658 100644 --- a/tests/components/CippTable/CippDataTable.test.jsx +++ b/tests/components/CippTable/CippDataTable.test.jsx @@ -1,8 +1,10 @@ import React from 'react' -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { vi } from 'vitest' import { renderWithProviders } from '../../test-utils' import { CippDataTable } from '../../../src/components/CippTable/CippDataTable' +import { resetOverlayHistory } from '../../../src/utils/overlay-history' const basicData = [ { displayName: 'Alice Smith', mail: 'alice@contoso.com', department: 'IT', accountEnabled: true }, @@ -310,3 +312,727 @@ describe('CippDataTable', () => { expect(container.querySelector('table')).not.toBeNull() }) }) + +// A card shows a title, subtitle and a few chips/details — on pages that never configured +// an offCanvas the rest of the row used to be unreachable in card view. +describe('CippDataTable card view without an offCanvas', () => { + const wideData = [ + { + displayName: 'Alice Smith', + mail: 'alice@contoso.com', + department: 'IT', + jobTitle: 'Engineer', + city: 'Seattle', + country: 'US', + accountEnabled: true, + }, + ] + const columns = ['displayName', 'mail', 'department', 'jobTitle', 'city', 'country'] + + it('opens an extended-info drawer from a card tap showing every shown column', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + + // fields that never fit on the card are present in the drawer + await waitFor(() => expect(screen.getAllByText(/Engineer/).length).toBeGreaterThan(0)) + expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0) + }) + + // The test-detail pages render their own drawer body (offCanvas.children) — prepending + // the generic property list on top of it repeated Risk/Status above a body that already + // presents them. + it('lets a custom drawer body own the drawer, without the generic property list', async () => { + const user = userEvent.setup() + renderWithProviders( +
    rich detail body
    , + }} + /> + ) + + await waitFor(() => + expect(screen.getByText('Applications do not have client secrets configured')).toBeInTheDocument() + ) + await user.click(screen.getByText('Applications do not have client secrets configured')) + + // scope to the drawer that holds the body — the toolbar's Edit Filters offcanvas is + // also a mounted .MuiDrawer-paper and sorts first in the DOM + const body = await screen.findByTestId('rich-body') + const drawer = body.closest('.MuiDrawer-paper') + expect(drawer.textContent).toContain('rich detail body') + // no generic property list stacked above the page's own body + expect(drawer.textContent).not.toMatch(/Risk/) + }) + + // Retired: the extended-info drawer's action buttons. Pages still carry `actions` in their + // offCanvas configs (the Users page spreads userActions in), and the config is spread onto + // the drawer — so the retirement has to survive the spread, not just the explicit prop. + it('keeps retired drawer actions out even when the page config carries them', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + + // drawer is open (property list rendered) but the actions block is gone + await waitFor(() => expect(screen.getAllByText(/alice@contoso.com/).length).toBeGreaterThan(0)) + expect(screen.queryByText('View User')).not.toBeInTheDocument() + }) + + it('formats fallback values the way their table cells do', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + + // 'text' mode would flatten the boolean to the string "Yes"; the cell renderer uses an icon. + // Anchored: unanchored, this would also pass on "notcontoso.com" — and CodeQL flags it. + await waitFor(() => expect(screen.getAllByText(/^contoso\.com$/).length).toBeGreaterThan(0)) + expect(screen.queryByText('Yes')).toBeNull() + }) + + it('spells out portal links instead of showing a bare icon', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Contoso')).toBeInTheDocument()) + await user.click(screen.getByText('Contoso')) + + const link = await screen.findByRole('link', { name: /open portal/i }) + expect(link).toHaveAttribute('href', 'https://admin.cloud.microsoft/?delegatedOrg=contoso') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('links portal values on the card itself, scheme-less ones included', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Contoso')).toBeInTheDocument()) + // rendered on the card, without opening the drawer + const link = await screen.findByRole('link', { name: /open portal/i }) + expect(link).toHaveAttribute('href', 'https://contoso-admin.sharepoint.com') + }) + + it('merges the page offCanvas fields with the remaining visible columns', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + await waitFor(() => expect(screen.getByText('User Details')).toBeInTheDocument()) + + // curated fields present, and the ones it left out are appended rather than dropped + expect(screen.getAllByText(/^alice@contoso\.com$/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/Engineer/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0) + }) + + it('does not repeat a field that appears in both lists', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + await waitFor(() => expect(screen.getByText('User Details')).toBeInTheDocument()) + + // scoped to the drawer — the card behind it renders its own Department row + const drawer = screen.getByText('User Details').closest('.MuiDrawer-paper') + expect(drawer).not.toBeNull() + expect(within(drawer).getAllByText('Department').length).toBe(1) + }) + + it('leaves a page-supplied offCanvas in charge', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + + // the page's own drawer opens — the fallback never substitutes for a configured one + await waitFor(() => expect(screen.getByText('User Details')).toBeInTheDocument()) + expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0) + }) +}) + +// The offcanvas walks the rows with Prev/Next and reports "N of M". Both come from the +// table's row model, and both used to be read from a mirror of it kept in state. +describe('CippDataTable offcanvas row navigation', () => { + // Deliberately unsorted: the display order and the arrival order differ. + const people = [ + { displayName: 'Carol Williams', mail: 'carol@contoso.com' }, + { displayName: 'Alice Smith', mail: 'alice@contoso.com' }, + { displayName: 'Bob Johnson', mail: 'bob@contoso.com' }, + ] + + // The Prev/Next bar and the position caption only render below md. + const useMobileViewport = () => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + cache.set(query, { + matches: query.includes('max-width'), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } + } + + const Table = (props) => ( + + ) + + // Rows land from the API after the table has already mounted — the normal case. + const AsyncTable = (props) => { + const [data, setData] = React.useState([]) + return ( + <> + + + + ) + } + + beforeEach(() => { + useMobileViewport() + }) + + afterEach(() => { + resetOverlayHistory() + delete window.matchMedia + }) + + it('counts rows that arrived after the table mounted', async () => { + const user = userEvent.setup() + renderWithProviders() + + await user.click(screen.getByRole('button', { name: 'Load rows' })) + await waitFor(() => expect(screen.getByText('Carol Williams')).toBeInTheDocument()) + await user.click(screen.getByText('Carol Williams')) + + expect(await screen.findByText('1 of 3')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /prev/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /next/i })).toBeEnabled() + }) + + it('numbers rows in the order they are shown, not the order they arrived', async () => { + const user = userEvent.setup() + renderWithProviders( +
    + ) + + // Sorted, Carol is last on screen — so she is the last row, with nowhere to go next. + await waitFor(() => expect(screen.getByText('Carol Williams')).toBeInTheDocument()) + await user.click(screen.getByText('Carol Williams')) + + expect(await screen.findByText('3 of 3')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled() + }) + + it('counts only the rows left after a search', async () => { + const user = userEvent.setup() + const withTwoBobs = [ + ...people, + { displayName: 'Bob Marley', mail: 'bob.marley@contoso.com' }, + ] + renderWithProviders( +
    + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.type(screen.getByRole('searchbox', { name: 'Search' }), 'bob') + await waitFor(() => expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument()) + + await user.click(screen.getByText('Bob Marley')) + + // the sorted model is built from the FILTERED rows, so the search narrows the walk + // too: two Bobs, not four people. + expect(await screen.findByText('2 of 2')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /prev/i })).toBeEnabled() + }) + + it('steps to the next row as displayed', async () => { + const user = userEvent.setup() + renderWithProviders( +
    + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + expect(await screen.findByText('1 of 3')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /next/i })) + + // Bob follows Alice on screen; Carol is where the raw arrival order would have landed. + expect(await screen.findByText('2 of 3')).toBeInTheDocument() + // scoped by the drawer's own heading — the toolbar renders a filter Drawer too + const drawer = screen.getByText('Extended Info').closest('.MuiDrawer-paper') + expect(within(drawer).getByText('bob@contoso.com')).toBeInTheDocument() + }) +}) + +// the narrow-table height measurement reads viewport-relative positions, so the toggle +// aligns the card surface with the scrolling ancestor's top before the table flips in +describe('CippDataTable cards->table toggle scroll', () => { + const useMobileViewport = () => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + cache.set(query, { + matches: query.includes('max-width'), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } + } + + beforeEach(() => { + useMobileViewport() + }) + + afterEach(() => { + delete window.matchMedia + }) + + it('keeps a mid-page table in view instead of yanking the page to the top', async () => { + const user = userEvent.setup() + renderWithProviders( +
    + +
    + ) + await waitFor(() => expect(screen.getByTestId('cipp-card-view')).toBeInTheDocument()) + + // surface sits below the scroller's viewport top, page already scrolled + const scroller = screen.getByTestId('scroller') + const surface = screen.getByTestId('cipp-card-view') + scroller.getBoundingClientRect = () => ({ top: 64 }) + surface.getBoundingClientRect = () => ({ top: 300 }) + scroller.scrollTop = 120 + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + + // prior scroll plus the surface's offset from the scroller viewport top + expect(scroller.scrollTop).toBe(120 + (300 - 64)) + }) +}) + +describe('CippDataTable subTables', () => { + const parentRows = [{ id: 'parent-1', displayName: 'Finance' }] + const relatedRows = [{ id: 'child-1', displayName: 'Jane Doe' }] + + it('injects a button column that opens a nested table', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'View' })) + + const dialog = await screen.findByRole('dialog') + await waitFor(() => { + expect(within(dialog).getByText('Related for Finance')).toBeInTheDocument() + }) + await waitFor(() => { + expect(within(dialog).getByText('Jane Doe')).toBeInTheDocument() + }) + }) + + it('runs nested row and bulk actions with the parent row attached', async () => { + const rowFn = vi.fn() + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'View' })) + + const dialog = await screen.findByRole('dialog') + await waitFor(() => expect(within(dialog).getByText('Jane Doe')).toBeInTheDocument()) + + await user.click(within(dialog).getByRole('button', { name: 'Row actions' })) + await user.click(await screen.findByText('Remove')) + + expect(rowFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'child-1', + displayName: 'Jane Doe', + parent: expect.objectContaining({ id: 'parent-1', displayName: 'Finance' }), + }), + expect.anything(), + expect.anything() + ) + + rowFn.mockClear() + await user.click(within(dialog).getByRole('button', { name: 'Select' })) + await user.click(within(dialog).getByRole('checkbox', { name: 'Select Jane Doe' })) + await user.click(within(dialog).getByRole('button', { name: 'Actions' })) + await user.click(await screen.findByText('Remove')) + + expect(rowFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'child-1', + parent: expect.objectContaining({ id: 'parent-1' }), + }), + expect.anything(), + expect.anything() + ) + }) + + it('replaces a data column that shares the subTable id', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'View' })) + + const dialog = await screen.findByRole('dialog') + await waitFor(() => { + expect(within(dialog).getByText('Jane Doe')).toBeInTheDocument() + }) + expect(within(dialog).queryByText('stale')).not.toBeInTheDocument() + }) + + it('does not show a subTable column unless it is listed in simpleColumns', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'View' })).not.toBeInTheDocument() + }) + + it('shows cachedColumn instead of the nested table button when that field is on the data', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'View members' })).not.toBeInTheDocument() + expect(screen.getByText('Jane, Bob')).toBeInTheDocument() + }) + + it('renders cached report columns in table view without a stale column order crash', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.getByRole('columnheader', { name: 'Members' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View members' })).not.toBeInTheDocument() + }) + + it('still shows the nested table button when cachedColumn is configured but missing from the data', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'View members' })).toBeInTheDocument() + }) + + it('shows the nested table button when cachedColumn exists but is empty (live API shape)', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'View members' })).toBeInTheDocument() + }) + + it('renders a declarative nested cardButton from table config', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'View' })) + + const nested = await screen.findByRole('dialog') + const addButton = await within(nested).findByRole('button', { name: 'Add Members' }) + await user.click(addButton) + + expect(await screen.findByText('Add Members for Finance?')).toBeInTheDocument() + }) +}) diff --git a/tests/components/CippTable/CippDataTableButton.stories.jsx b/tests/components/CippTable/CippDataTableButton.stories.jsx index 87a7fb897397..b348d2de5625 100644 --- a/tests/components/CippTable/CippDataTableButton.stories.jsx +++ b/tests/components/CippTable/CippDataTableButton.stories.jsx @@ -1,3 +1,4 @@ +import { http, HttpResponse } from 'msw' import { within, expect, userEvent, waitFor } from 'storybook/test' import CippDataTableButton from '../../../src/components/CippTable/CippDataTableButton' @@ -54,3 +55,52 @@ export const EmptyData = { data: null, }, } + +export const LiveNestedTable = { + parameters: { + msw: { + handlers: [ + http.get('/api/TestRelated', () => + HttpResponse.json({ + Results: [ + { id: 'rel-1', displayName: 'Related one' }, + { id: 'rel-2', displayName: 'Related two' }, + ], + }) + ), + http.post('/api/ExecTestRelated', () => HttpResponse.json({ Results: 'ok' })), + ], + }, + }, + args: { + row: { id: 'parent-1', displayName: 'Finance' }, + label: 'View', + title: 'Related for [displayName]', + queryKey: 'related-[id]', + api: { + url: '/api/TestRelated', + data: { someId: '[id]' }, + dataKey: 'Results', + }, + simpleColumns: ['displayName'], + actions: [ + { + label: 'Remove', + type: 'POST', + url: '/api/ExecTestRelated', + data: { childId: 'id', parentId: 'parent.id' }, + confirmText: 'Remove [displayName] from [parent.displayName]?', + }, + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + await step('opens a live nested table on click', async () => { + await userEvent.click(canvas.getByRole('button', { name: 'View' })) + const root = within(document.body) + await waitFor(() => { + expect(root.getByRole('dialog')).toBeVisible() + }) + }) + }, +} diff --git a/tests/components/CippTable/CippDataTableButton.test.jsx b/tests/components/CippTable/CippDataTableButton.test.jsx index c42bbd76de0c..3a8172f07229 100644 --- a/tests/components/CippTable/CippDataTableButton.test.jsx +++ b/tests/components/CippTable/CippDataTableButton.test.jsx @@ -3,8 +3,22 @@ import { screen, within, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../../test-utils' import CippDataTableButton from '../../../src/components/CippTable/CippDataTableButton' +import { ApiGetCallWithPagination } from '../../../src/api/ApiCall' +import { api, paginatedResult } from '../../mocks/api-call' + +vi.mock('../../../src/api/ApiCall', async () => (await import('../../mocks/api-call')).apiCallMock()) + +const idlePaginated = paginatedResult([], { isSuccess: false }) +const relatedRows = [{ id: 'rel-1', name: 'Related one' }] +const relatedResult = paginatedResult(relatedRows) describe('CippDataTableButton', () => { + beforeEach(() => { + ApiGetCallWithPagination.mockClear() + api.paginated = (opts) => + opts?.url === '/api/TestRelated' ? relatedResult : idlePaginated + }) + it('shows item count and opens dialog on click', async () => { const user = userEvent.setup() renderWithProviders( @@ -80,4 +94,57 @@ describe('CippDataTableButton', () => { expect(button).toHaveTextContent('No items') expect(button).toBeDisabled() }) + + it('does not fetch live related data until the button is clicked', async () => { + const user = userEvent.setup() + const parentRow = { id: 'parent-1', displayName: 'Finance' } + + renderWithProviders( + + ) + + expect(screen.getByRole('button', { name: 'View' })).toBeEnabled() + expect( + ApiGetCallWithPagination.mock.calls.some((call) => call[0]?.url === '/api/TestRelated') + ).toBe(false) + + await user.click(screen.getByRole('button', { name: 'View' })) + + const dialog = await screen.findByRole('dialog') + expect(dialog).toBeInTheDocument() + await waitFor(() => { + expect( + ApiGetCallWithPagination.mock.calls.some((call) => call[0]?.url === '/api/TestRelated') + ).toBe(true) + }) + + const relatedCall = ApiGetCallWithPagination.mock.calls.find( + (call) => call[0]?.url === '/api/TestRelated' + ) + expect(relatedCall[0].data.someId).toBe('parent-1') + expect(relatedCall[0].queryKey).toBe('related-parent-1') + }) + + it('disables the live button when condition is false', () => { + renderWithProviders( + row.id === 'other'} + api={{ url: '/api/TestRelated', dataKey: 'Results' }} + /> + ) + expect(screen.getByRole('button', { name: 'View' })).toBeDisabled() + }) }) diff --git a/tests/components/CippTable/CippDiagnosticsFilter.test.jsx b/tests/components/CippTable/CippDiagnosticsFilter.test.jsx new file mode 100644 index 000000000000..b12e89ea8492 --- /dev/null +++ b/tests/components/CippTable/CippDiagnosticsFilter.test.jsx @@ -0,0 +1,47 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderWithProviders } from '../../test-utils' +import CippDiagnosticsFilter from '../../../src/components/CippTable/CippDiagnosticsFilter' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +// Stable identities: a fresh object per call changes on every render and spins a loop. +const idleGet = vi.hoisted(() => ({ data: [], isFetching: false, isSuccess: true })) +const idlePost = vi.hoisted(() => ({ mutate: () => {}, isPending: false })) +const idlePaginated = vi.hoisted(() => ({ data: undefined, isFetching: false })) +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: () => idleGet, + ApiPostCall: () => idlePost, + ApiGetCallWithPagination: () => idlePaginated, +})) + +beforeEach(() => { + layoutState.isMobile = false +}) + +describe('CippDiagnosticsFilter', () => { + // `rows` is a DOM attribute, so this cannot come from a responsive sx value. + // MUI renders a hidden shadow textarea beside the real one; only the real one carries + // the rows attribute this test is about. + const queryBox = (container) => + Array.from(container.querySelectorAll('textarea')).find((el) => el.hasAttribute('rows')) + + it('shortens the KQL box on a phone', () => { + layoutState.isMobile = true + const { container } = renderWithProviders( {}} />) + + expect(queryBox(container)).toHaveAttribute('rows', '6') + }) + + it('keeps twelve rows on desktop', () => { + const { container } = renderWithProviders( {}} />) + + expect(queryBox(container)).toHaveAttribute('rows', '12') + }) +}) diff --git a/tests/components/CippTable/CippGraphExplorerFilter.test.jsx b/tests/components/CippTable/CippGraphExplorerFilter.test.jsx index 497382cdcaee..49df07503eb8 100644 --- a/tests/components/CippTable/CippGraphExplorerFilter.test.jsx +++ b/tests/components/CippTable/CippGraphExplorerFilter.test.jsx @@ -276,4 +276,48 @@ describe('CippGraphExplorerFilter', () => { expect(onSubmitFilter.mock.calls[0][0]).toEqual({ version: 'beta' }) }) }) + + // Seeding from endpointFilter moved out of the render body (it updated the subscribed + // Controller mid-render, which the browser reports as "Cannot update a component while + // rendering a different component"). These cover the behaviour that move had to preserve — + // the warning itself doesn't reproduce under jsdom, so it can't be asserted here. + describe('endpointFilter prop', () => { + it('seeds the endpoint field from the prop', async () => { + renderWithProviders( + + ) + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('users') + }) + }) + + it('submits the seeded endpoint', async () => { + const onSubmitFilter = vi.fn() + const user = userEvent.setup() + renderWithProviders( + + ) + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('users') + }) + + await user.click(screen.getByRole('button', { name: 'Apply Filter' })) + await waitFor(() => { + expect(onSubmitFilter).toHaveBeenCalledTimes(1) + }) + expect(onSubmitFilter.mock.calls[0][0]).toMatchObject({ endpoint: 'users' }) + }) + + it('leaves the endpoint field empty when no endpointFilter is given', async () => { + renderWithProviders() + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('') + }) + }) + }) }) diff --git a/tests/components/CippTable/CippMobileCardList.stories.jsx b/tests/components/CippTable/CippMobileCardList.stories.jsx new file mode 100644 index 000000000000..f23120ccf7b0 --- /dev/null +++ b/tests/components/CippTable/CippMobileCardList.stories.jsx @@ -0,0 +1,423 @@ +import React from 'react' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Box, Button } from '@mui/material' +import { Add, Block, Delete, Edit } from '@mui/icons-material' +import { CippDataTable } from '../../../src/components/CippTable/CippDataTable' +import { SettingsProvider } from '../../../src/contexts/settings-context' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport' + +// most stories force cards via the viewMode prop; TableViewToggle shrinks the real viewport instead, since the toggle needs no explicit prop +const users = [ + { + id: 'u-1', + displayName: 'Alice Smith', + userPrincipalName: 'alice@contoso.com', + mail: 'alice@contoso.com', + department: 'IT', + jobTitle: 'Engineer', + accountEnabled: true, + createdDateTime: '2024-01-15T10:30:00Z', + }, + { + id: 'u-2', + displayName: 'Bob Johnson', + userPrincipalName: 'bob@contoso.com', + mail: 'bob@contoso.com', + department: 'Sales', + jobTitle: 'Account Manager', + accountEnabled: true, + createdDateTime: '2024-03-22T14:15:00Z', + }, + { + id: 'u-3', + displayName: 'Carol Williams', + userPrincipalName: 'carol@contoso.com', + mail: 'carol@contoso.com', + department: 'IT', + jobTitle: 'Director', + accountEnabled: false, + createdDateTime: '2023-11-01T09:00:00Z', + }, +] + +const manyUsers = Array.from({ length: 120 }, (_, i) => ({ + id: `bulk-${i}`, + displayName: `User ${String(i).padStart(3, '0')}`, + userPrincipalName: `user${i}@contoso.com`, + mail: `user${i}@contoso.com`, + department: i % 2 ? 'Sales' : 'IT', + accountEnabled: i % 5 !== 0, +})) + +const simpleColumns = ['displayName', 'userPrincipalName', 'accountEnabled', 'department', 'jobTitle'] + +const actions = [ + { label: 'Edit user', icon: , link: '/identity/administration/users/edit?id=[id]' }, + { label: 'Block sign-in', icon: , type: 'POST', url: '/api/ExecDisableUser' }, + { label: 'Delete user', icon: , type: 'POST', url: '/api/RemoveUser', color: 'error' }, +] + +export default { + title: 'Components/CippTable/CippMobileCardList', + component: CippDataTable, + tags: ['autodocs'], + args: { + viewMode: 'cards', + maxHeightOffset: '100px', + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} + +export const Default = { + args: { + title: 'Users', + data: users, + simpleColumns, + actions, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('one card per row, titled by the name column', async () => { + await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument()) + expect(canvas.getByText('Carol Williams')).toBeInTheDocument() + // no
    in card view + expect(canvasElement.querySelector('table')).toBeNull() + }) + + await step('row kebab opens the action sheet with the page actions', async () => { + const kebabs = canvas.getAllByRole('button', { name: /row actions/i }) + await userEvent.click(kebabs[0]) + const body = within(document.body) + await waitFor(() => expect(body.getByText('Block sign-in')).toBeInTheDocument()) + expect(body.getByText('Delete user')).toBeInTheDocument() + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(body.queryByRole('dialog')).toBeNull()) + }) + + await step('Filters opens the shared bottom sheet with the card fields', async () => { + const body = within(document.body) + await userEvent.click(canvas.getByRole('button', { name: 'Table options' })) + const filterSheet = await body.findByRole('dialog') + expect(within(filterSheet).getByText('Fields shown')).toBeInTheDocument() + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(body.queryByRole('dialog')).toBeNull()) + }) + }, +} + +export const SelectMode = { + args: { + title: 'Users', + data: users, + simpleColumns, + actions, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument()) + + await step('Select reveals per-card checkboxes and the bulk bar', async () => { + await userEvent.click(canvas.getByRole('button', { name: /select/i })) + const checkboxes = await canvas.findAllByRole('checkbox') + await userEvent.click(checkboxes[0]) + await waitFor(() => expect(canvasElement.textContent).toContain('1 selected')) + }) + }, +} + +export const PageActionsFab = { + args: { + title: 'Users', + data: users, + simpleColumns, + cardButton: ( + + + + + ), + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument()) + + await step('cardButton children live behind the FAB', async () => { + await userEvent.click(body.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(body.getByRole('button', { name: 'Add User' })).toBeInTheDocument()) + expect(body.getByRole('button', { name: 'Bulk Add' })).toBeInTheDocument() + }) + }, +} + +export const LoadMore = { + args: { + title: 'Users', + data: manyUsers, + simpleColumns: ['displayName', 'userPrincipalName', 'accountEnabled'], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('starts at the configured page size', async () => { + await waitFor(() => expect(canvasElement.textContent).toContain('Showing 25 of 120'), { + timeout: 10000, + }) + }) + + await step('Load more grows the same list rather than paging', async () => { + await userEvent.click(canvas.getByRole('button', { name: /load 50 more/i })) + await waitFor(() => expect(canvasElement.textContent).toContain('Showing 75 of 120')) + // still one continuous list — no pagination control appeared + expect(canvas.queryByRole('button', { name: /go to next page/i })).toBeNull() + }) + }, +} + +export const EmptyAfterFilter = { + args: { + title: 'Users', + data: users, + simpleColumns, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument()) + + await step('a search with no matches offers to clear filters', async () => { + await userEvent.type(canvas.getByPlaceholderText(/search/i), 'zzzzz') + await waitFor( + () => expect(canvas.getByRole('button', { name: /clear filters/i })).toBeInTheDocument(), + { timeout: 3000 } + ) + }) + }, +} + +// The pair that proves "one table instance, two presentations": same data, same filter, +// same resulting row set — only the presentation differs. +const FILTERED_DEPARTMENT = 'IT' + +// The search box is debounced 200ms, so the filter landing is observed by waiting for the +// excluded row to disappear — the included rows are on screen before the filter applies. +const applyDepartmentSearch = async (canvas) => { + await userEvent.type(canvas.getByPlaceholderText(/search/i), FILTERED_DEPARTMENT) + await waitFor(() => expect(canvas.queryByText('Bob Johnson')).toBeNull(), { timeout: 5000 }) + return [canvas.getByText('Alice Smith'), canvas.getByText('Carol Williams')] +} + +export const DesktopTable = { + args: { + title: 'Users', + viewMode: 'table', + data: users, + simpleColumns, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('table view lists exactly the IT users', async () => { + expect(canvasElement.querySelector('table')).not.toBeNull() + const matched = await applyDepartmentSearch(canvas) + expect(matched).toHaveLength(2) + }) + }, +} + +export const MobileCards = { + args: { + title: 'Users', + viewMode: 'cards', + data: users, + simpleColumns, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('card view yields the identical row set from the same state', async () => { + expect(canvasElement.querySelector('table')).toBeNull() + const matched = await applyDepartmentSearch(canvas) + expect(matched).toHaveLength(2) + }) + }, +} + +export const TableViewToggle = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + // shrink for real: a viewMode prop would also force cards, but hides the toggle (precedence rule) + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + await canvas.findByText('Alice Smith') + if (!onAPhone) { + return + } + + // 'Alice Smith' renders in both branches, so the card list itself has to settle + await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + await userEvent.click(await canvas.findByRole('button', { name: 'Toggle table view' })) + await waitFor(() => { + expect(canvasElement.querySelector('table')).not.toBeNull() + expect(canvas.queryByTestId('cipp-mobile-card-list')).toBeNull() + }) + + // real MRT table mounts with the page's configured columns + await waitFor(() => expect(canvas.getAllByRole('columnheader').length).toBeGreaterThan(0)) + const headerText = canvas.getAllByRole('columnheader').map((cell) => cell.textContent) + expect(headerText.some((text) => text.includes('Display Name'))).toBe(true) + + // transient: the toggle never persists + const persisted = JSON.parse(window.localStorage.getItem('app.settings')) + expect(persisted.tableViewMode).toBe('auto') + + // phone table bar: kebab opens the shared sheet, which carries refresh. + // MUI's Tooltip stamps the 'Refresh data' aria-label onto the wrapping span, so that's + // the queryable anchor for the desktop refresh button (the IconButton has no name of its own) + expect(canvasElement.querySelector('[aria-label="Refresh data"]')).toBeNull() + const optionsButton = canvas.getByRole('button', { name: 'Table options' }) + await userEvent.click(optionsButton) + const filterSheet = await within(document.body).findByRole('dialog') + expect(within(filterSheet).getByText('Fields shown')).toBeInTheDocument() + expect(within(filterSheet).getByText('Reset all filters')).toBeInTheDocument() + expect(within(filterSheet).getByText('Refresh data')).toBeInTheDocument() + // the sheet owns page size on phones, current size marked active + expect(within(filterSheet).getByText('Rows per page')).toBeInTheDocument() + const activeSize = within(filterSheet).getByText('25').closest('.MuiChip-root') + expect(activeSize.className).toContain('MuiChip-filled') + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(within(document.body).queryByRole('dialog')).toBeNull()) + + // same aria-label, now the desktop toolbar's "way back" button + await userEvent.click(canvas.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + }, +} + +export const TableViewToggleWithActions = { + // render ignores the meta's default args (viewMode: 'cards' would hide the toggle button) + render: () => ( + + + + + } + /> + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + const body = within(document.body) + await canvas.findByText('Alice Smith') + if (!onAPhone) { + return + } + + await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + await userEvent.click(await canvas.findByRole('button', { name: 'Toggle table view' })) + await waitFor(() => { + expect(canvasElement.querySelector('table')).not.toBeNull() + expect(canvas.queryByTestId('cipp-mobile-card-list')).toBeNull() + }) + + // narrow table view: cardButton lives behind the page actions FAB, absent from the canvas until opened + expect(canvas.queryByRole('button', { name: 'Add User' })).toBeNull() + const fab = await body.findByRole('button', { name: 'Page actions' }) + + await userEvent.click(fab) + await waitFor(() => expect(body.getByRole('button', { name: 'Add User' })).toBeInTheDocument()) + }, +} + +export const TableViewToggleBulkActionsInHeader = { + // render ignores the meta's default args, same reason as the sibling toggle stories + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + await canvas.findByText('Alice Smith') + if (!onAPhone) { + return + } + + await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + await userEvent.click(await canvas.findByRole('button', { name: 'Toggle table view' })) + await waitFor(() => { + expect(canvasElement.querySelector('table')).not.toBeNull() + expect(canvas.queryByTestId('cipp-mobile-card-list')).toBeNull() + }) + + const firstRow = await waitFor(() => { + const row = canvasElement.querySelector('tbody tr') + expect(row).not.toBeNull() + return row + }) + await userEvent.click(within(firstRow).getByRole('checkbox')) + + // the mostly-empty header row is where the narrow toolbar's selection UI lands + const header = canvasElement.querySelector('.MuiCardHeader-root') + await waitFor(() => { + expect(within(header).getByText(/rows selected/)).toBeInTheDocument() + expect(within(header).getByRole('button', { name: 'Bulk Actions' })).toBeInTheDocument() + }) + // exactly one Bulk Actions button on screen — it moved, it did not duplicate + expect(canvas.getAllByRole('button', { name: 'Bulk Actions' })).toHaveLength(1) + + await userEvent.click(within(header).getByRole('button', { name: 'Bulk Actions' })) + const body = within(document.body) + await waitFor(() => expect(body.getByText('Delete user')).toBeInTheDocument()) + await userEvent.keyboard('{Escape}') + }, +} + +export const DesktopBulkActionsStayInToolbar = { + args: { + title: 'Users', + viewMode: 'table', + data: users, + simpleColumns, + actions, + }, + play: async ({ canvasElement }) => { + await growToDesktopViewport() + const canvas = within(canvasElement) + await waitFor(() => expect(canvasElement.querySelector('table')).not.toBeNull()) + await canvas.findByText('Alice Smith') + + const firstRow = canvasElement.querySelector('tbody tr') + await userEvent.click(within(firstRow).getByRole('checkbox')) + + await waitFor(() => expect(canvas.getByRole('button', { name: 'Bulk Actions' })).toBeInTheDocument()) + // the header exists (title-only, no cardButton on this story) but never received the portal + const header = canvasElement.querySelector('.MuiCardHeader-root') + expect(within(header).queryByRole('button', { name: 'Bulk Actions' })).toBeNull() + }, +} diff --git a/tests/components/CippTable/CippMobileCardList.test.jsx b/tests/components/CippTable/CippMobileCardList.test.jsx new file mode 100644 index 000000000000..32d26d258999 --- /dev/null +++ b/tests/components/CippTable/CippMobileCardList.test.jsx @@ -0,0 +1,368 @@ +import React from 'react' +import { vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Button } from '@mui/material' +import { renderWithProviders, settingsWith } from '../../test-utils' + +// jsdom matchMedia never matches, so this overrides only useIsNarrowForTables for the FAB pivot, useTableViewMode stays real +const narrowState = vi.hoisted(() => ({ narrow: false })) +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useIsNarrowForTables: () => narrowState.narrow } +}) + +import { CippDataTable } from '../../../src/components/CippTable/CippDataTable' + +// wide enough that full mode overflows into "+N more fields" +const users = [ + { + displayName: 'Alice Smith', + userPrincipalName: 'alice@contoso.com', + department: 'IT', + jobTitle: 'Engineer', + city: 'Seattle', + country: 'US', + accountEnabled: true, + }, +] +const columns = [ + 'displayName', + 'userPrincipalName', + 'department', + 'jobTitle', + 'city', + 'country', + 'accountEnabled', +] + +// no viewMode prop, so settings.tableViewMode='cards' forces cards but leaves the toggle allowed +const renderCards = (settings = {}, componentProps = {}) => + renderWithProviders( + , + { settings: settingsWith({ tableViewMode: 'cards', ...settings }) } + ) + +describe('CippMobileCardList card anatomy', () => { + it('shows the slotted anatomy: overflow counter, secondary slot as bare text', async () => { + renderCards() + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + // details cap at 3 of the 4 remaining columns -> 1 overflow + expect(screen.getByText(/more field/)).toBeInTheDocument() + // secondary slot is bare text, its column label never renders + expect(screen.queryByText('User Principal Name')).not.toBeInTheDocument() + }) +}) + +describe('CippMobileCardList status chips', () => { + // The identity/device/custom test tables: Result went to the chips row but Risk fell to + // the detail rows — two chips organised by two different systems on one card, and the + // detail-grid "High" said nothing about what was high. + it('keeps Result and Risk together in the chips row, and labels the mute one', async () => { + renderWithProviders( + , + { settings: settingsWith({ tableViewMode: 'cards' }) } + ) + await waitFor(() => + expect(screen.getByText('Tenant has M365 Copilot prerequisites')).toBeInTheDocument() + ) + + const passed = screen.getByText('Passed') + const high = screen.getByText('High') + // both chips share one container — Risk is not off in the details grid + expect(high.closest('.MuiStack-root')).toBe(passed.closest('.MuiStack-root')) + // "High" alone doesn't say what is high; "Passed" speaks for itself + expect(screen.getByText('Risk')).toBeInTheDocument() + expect(screen.queryByText('Result')).not.toBeInTheDocument() + }) +}) + +describe('CippMobileCardList table view toggle', () => { + afterEach(() => { + narrowState.narrow = false + }) + + it('opens the table view and the way back restores cards, never touching settings', async () => { + // narrow viewport: the round trip must hand back the card view intact + narrowState.narrow = true + const user = userEvent.setup() + const handleUpdate = vi.fn() + const { container } = renderCards({ handleUpdate }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument() + expect(screen.getByText('Department')).toBeInTheDocument() + expect(screen.getByText(/more field/)).toBeInTheDocument() + + // jsdom renders no MRT header/row text, so just check the table mounts and cards unmount + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()) + expect(screen.queryByTestId('cipp-mobile-card-list')).not.toBeInTheDocument() + + // same aria-label, now the desktop toolbar's "way back" button + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + // full card content is back: detail rows and the overflow counter + expect(screen.getByText('Department')).toBeInTheDocument() + expect(screen.getByText(/more field/)).toBeInTheDocument() + + // transient: the view toggle never persists + expect(handleUpdate).not.toHaveBeenCalled() + }) + + it('the toggled table keeps every configured column visible', async () => { + narrowState.narrow = true + const user = userEvent.setup() + renderCards() + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Columns' })).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'Columns' })) + + // Columns menu reads table.getAllColumns(), unaffected by the virtualized header row + const menu = within(screen.getAllByRole('menu')[0]) + const checkbox = (name) => within(menu.getByRole('menuitem', { name })).getByRole('checkbox') + for (const name of [ + 'Display Name', + 'User Principal Name', + 'Account Enabled', + 'Department', + 'Job Title', + 'City', + 'Country', + ]) { + expect(checkbox(name)).toBeChecked() + } + }) + + it('a Fields shown toggle in the shared filter sheet changes what the card renders', async () => { + const user = userEvent.setup() + renderCards() + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + // department is a detail row on the card before the toggle + expect(screen.getByText('Department')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Table options' })) + await screen.findByText('Fields shown') + // the sheet is a portal appended to body, so its entry sorts after the card's label + await user.click(screen.getAllByText('Department').at(-1)) + await user.click(screen.getByRole('button', { name: 'Done' })) + + await waitFor(() => expect(screen.queryByText('Department')).not.toBeInTheDocument()) + }) + + // "Fields shown" is a checkbox per column — a dozen rows on a wide table — so anything + // after it starts a long scroll down. The table utilities (refresh, export, reset) are what + // people open this sheet for far more often than field toggles. + it('puts the table utilities above the Fields shown list, not below it', async () => { + const user = userEvent.setup() + renderCards() + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Table options' })) + const fields = await screen.findByText('Fields shown') + const refresh = screen.getByText('Refresh data') + + // DOCUMENT_POSITION_FOLLOWING = 4: fields comes after refresh in the DOM + expect(refresh.compareDocumentPosition(fields) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4) + expect( + screen.getByText('Reset all filters').compareDocumentPosition(fields) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBe(4) + }) + + it('an explicit viewMode prop hides the toggle button', async () => { + renderWithProviders( + , + { settings: settingsWith() } + ) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'Toggle table view' })).not.toBeInTheDocument() + }) + + // Regression: the cards branch and the table branch are two alternating CIPPTableToptoolbar + // instances (only one mounts at a time), so activeFilters/searchValue/restoredFiltersRef used + // to live in the toolbar's own useState and reset on every flip. The table-branch kebab is + // unreachable here (mdDown from useMediaQuery never matches in jsdom, and useCompactMode stays + // false since offsetWidth/scrollWidth are always 0) — reopening the sheet after the round trip + // is the observable proxy for "state survived the two remounts". + it('an applied preset and its badge survive a flip to table and back', async () => { + narrowState.narrow = true + const user = userEvent.setup() + const presetFilters = [ + { filterName: 'IT department', value: [{ id: 'department', value: 'IT' }], type: 'column' }, + ] + renderCards({}, { filters: presetFilters }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Table options' })) + const sheet = await screen.findByRole('dialog') + await user.click(within(sheet).getByText('IT department')) + await user.click(within(sheet).getByRole('button', { name: 'Done' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + + // preset active before the flip — the sheet's aria-hidden overlay is gone now + await waitFor(() => { + expect(within(screen.getByRole('button', { name: 'Table options' })).getByText('1')).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(document.querySelector('table')).not.toBeNull()) + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + // badge count survived both remounts + expect(within(screen.getByRole('button', { name: 'Table options' })).getByText('1')).toBeInTheDocument() + + // the preset chip is marked active too + await user.click(screen.getByRole('button', { name: 'Table options' })) + const reopened = await screen.findByRole('dialog') + const chip = within(reopened).getByText('IT department').closest('.MuiChip-root') + expect(chip.className).toContain('MuiChip-filled') + }, 15000) + + it('a manual field-visibility change survives a flip, even with preferred columns saved for the page', async () => { + narrowState.narrow = true + const user = userEvent.setup() + // router mock resolves pageName to '' in tests, matching CIPPTableToptoolbar.test.jsx's convention + const allColumnsVisible = Object.fromEntries(columns.map((c) => [c, true])) + renderCards({ columnDefaults: { '': allColumnsVisible } }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + expect(screen.getByText('Department')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Table options' })) + await screen.findByText('Fields shown') + await user.click(screen.getAllByText('Department').at(-1)) + await user.click(screen.getByRole('button', { name: 'Done' })) + await waitFor(() => expect(screen.queryByText('Department')).not.toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(document.querySelector('table')).not.toBeNull()) + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + // the manual hide must not be reverted by the saved preferred-columns set on remount + expect(screen.queryByText('Department')).not.toBeInTheDocument() + }, 15000) +}) + +describe('CippMobileCardList table-view page actions FAB', () => { + afterEach(() => { + narrowState.narrow = false + }) + + it('narrow viewport moves cardButton into the actions FAB once toggled to table view', async () => { + narrowState.narrow = true + const user = userEvent.setup() + renderCards({}, { cardButton: }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Page actions' })).toBeInTheDocument()) + + // action content stays behind the FAB until opened + expect(screen.queryByRole('button', { name: 'Add user' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Add user' })).toBeInTheDocument()) + }) + + it('desktop viewport keeps cardButton in the header, no FAB', async () => { + const user = userEvent.setup() + renderCards({}, { cardButton: }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Add user' })).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'Page actions' })).not.toBeInTheDocument() + }) +}) + +// The Card header hosts a portal target for the toolbar's bulk-actions UI on narrow +// viewports (CIPPTableToptoolbar's bulkActionsSlot). Row selection itself can't be driven +// here: CippDataTable's table renders with enableRowVirtualization + enableColumnVirtualization +// always on, and jsdom never reports a nonzero container size, so react-virtual computes an +// empty range — thead and tbody both mount with zero cells (verified: no checkboxes, no +// columnheaders, table-view page actions FAB tests above only ever check for the
    +// element itself, never header/row content). Selecting a row to exercise the portal is +// covered in the CippMobileCardList.stories.jsx browser story instead. +describe('CippMobileCardList table-view header mounts as the bulk-actions portal target', () => { + afterEach(() => { + narrowState.narrow = false + }) + + it('narrow + hideTitle + cardButton: header still mounts even though the FAB owns cardButton', async () => { + narrowState.narrow = true + const user = userEvent.setup() + const { container } = renderCards( + {}, + { hideTitle: true, cardButton: } + ) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()) + + // headerAction is undefined here (FAB owns cardButton), so the gate has to key off + // cardButton directly or this mounts nothing and the portal target never exists + expect(container.querySelector('.MuiCardHeader-root')).not.toBeNull() + }) + + it('desktop + hideTitle + cardButton: header mounts with cardButton in it, same as before', async () => { + const user = userEvent.setup() + const { container } = renderCards( + {}, + { hideTitle: true, cardButton: } + ) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()) + + const header = container.querySelector('.MuiCardHeader-root') + expect(header).not.toBeNull() + expect(within(header).getByRole('button', { name: 'Add user' })).toBeInTheDocument() + }) +}) + +describe('CippMobileCardList data source controls', () => { + afterEach(() => { + narrowState.narrow = false + }) + + it('renders in the Table options sheet, not in the page actions FAB', async () => { + narrowState.narrow = true + const user = userEvent.setup() + renderCards( + {}, + { dataSourceControls: Live badge, cardButton: } + ) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Table options' })) + const filterSheet = await within(document.body).findByRole('dialog') + expect(within(filterSheet).getByText('Data source')).toBeInTheDocument() + expect(within(filterSheet).getByText('Live badge')).toBeInTheDocument() + + await user.click(within(filterSheet).getByRole('button', { name: 'Done' })) + await waitFor(() => expect(within(document.body).queryByRole('dialog')).not.toBeInTheDocument()) + + // narrow + table view: cardButton lives behind the FAB, dataSourceControls must not follow it there + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Page actions' })).toBeInTheDocument()) + + // and the table's card header must not double-render them (sheet is the only narrow home) + expect(screen.queryByText('Live badge')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Page actions' })) + const fabSheet = await within(document.body).findByRole('dialog') + await waitFor(() => expect(within(fabSheet).getByRole('button', { name: 'Add user' })).toBeInTheDocument()) + expect(within(fabSheet).queryByText('Live badge')).not.toBeInTheDocument() + }) +}) diff --git a/tests/components/CippTable/CippQueueTracker.stories.jsx b/tests/components/CippTable/CippQueueTracker.stories.jsx index 2c6ecf62b631..077059ccad5d 100644 --- a/tests/components/CippTable/CippQueueTracker.stories.jsx +++ b/tests/components/CippTable/CippQueueTracker.stories.jsx @@ -1,193 +1,87 @@ -import { fn, within, expect, userEvent, waitFor } from 'storybook/test' +import React from 'react' import { http, HttpResponse } from 'msw' +import { within, userEvent, waitFor, expect } from 'storybook/test' import { CippQueueTracker } from '../../../src/components/CippTable/CippQueueTracker' +import { shrinkToPhoneViewport } from '../../viewport' -const queueResponses = { - 'test-queue-running': { - PartitionKey: 'CippQueue', - RowKey: 'test-queue-running', - Name: 'Processing Users', - Status: 'Running', - TotalTasks: 10, - CompletedTasks: 6, - RunningTasks: 1, - FailedTasks: 0, - PercentComplete: 60.0, - PercentFailed: 0, - PercentRunning: 10.0, - Timestamp: '2026-04-08T10:00:00Z', - Tasks: [ - { Name: 'Process user 1', Status: 'Completed', Timestamp: '2026-04-08T10:00:01Z' }, - { Name: 'Process user 2', Status: 'Completed', Timestamp: '2026-04-08T10:00:02Z' }, - { Name: 'Process user 3', Status: 'Completed', Timestamp: '2026-04-08T10:00:03Z' }, - { Name: 'Process user 4', Status: 'Completed', Timestamp: '2026-04-08T10:00:04Z' }, - { Name: 'Process user 5', Status: 'Completed', Timestamp: '2026-04-08T10:00:05Z' }, - { Name: 'Process user 6', Status: 'Completed', Timestamp: '2026-04-08T10:00:06Z' }, - { Name: 'Process user 7', Status: 'Running', Timestamp: '2026-04-08T10:00:07Z' }, - { Name: 'Process user 8', Status: 'Pending', Timestamp: '2026-04-08T10:00:08Z' }, - { Name: 'Process user 9', Status: 'Pending', Timestamp: '2026-04-08T10:00:09Z' }, - { Name: 'Process user 10', Status: 'Pending', Timestamp: '2026-04-08T10:00:10Z' }, - ], - }, - 'test-queue-done': { - PartitionKey: 'CippQueue', - RowKey: 'test-queue-done', - Name: 'User Processing', - Status: 'Completed', - TotalTasks: 5, - CompletedTasks: 5, - RunningTasks: 0, - FailedTasks: 0, - PercentComplete: 100.0, - PercentFailed: 0, - PercentRunning: 0, - Timestamp: '2026-04-08T10:00:00Z', - Tasks: [ - { Name: 'Process user 1', Status: 'Completed', Timestamp: '2026-04-08T10:00:01Z' }, - { Name: 'Process user 2', Status: 'Completed', Timestamp: '2026-04-08T10:00:02Z' }, - { Name: 'Process user 3', Status: 'Completed', Timestamp: '2026-04-08T10:00:03Z' }, - { Name: 'Process user 4', Status: 'Completed', Timestamp: '2026-04-08T10:00:04Z' }, - { Name: 'Process user 5', Status: 'Completed', Timestamp: '2026-04-08T10:00:05Z' }, - ], - }, - 'test-queue-failed': { - PartitionKey: 'CippQueue', - RowKey: 'test-queue-failed', - Name: 'Failed Operation', - Status: 'Failed', - TotalTasks: 5, - CompletedTasks: 2, - RunningTasks: 0, - FailedTasks: 1, - PercentComplete: 40.0, - PercentFailed: 20.0, - PercentRunning: 0, - Timestamp: '2026-04-08T10:00:00Z', - Tasks: [ - { Name: 'Process user 1', Status: 'Completed', Timestamp: '2026-04-08T10:00:01Z' }, - { Name: 'Process user 2', Status: 'Completed', Timestamp: '2026-04-08T10:00:02Z' }, - { Name: 'Process user 3', Status: 'Failed', Timestamp: '2026-04-08T10:00:03Z' }, - { Name: 'Process user 4', Status: 'Pending', Timestamp: '2026-04-08T10:00:04Z' }, - { Name: 'Process user 5', Status: 'Pending', Timestamp: '2026-04-08T10:00:05Z' }, - ], - }, +// The task names are tenant default domains — one unbreakable token each, and the test +// tenants are the longest of them. +const queue = { + QueueId: 'q-1', + Name: 'Users (All Tenants)', + Status: 'Running', + PercentComplete: 20.3, + TotalTasks: 133, + CompletedTasks: 27, + RunningTasks: 4, + FailedTasks: 0, + Tasks: [ + { + Name: 'cyberdraintesttenant024.onmicrosoft.com', + Status: 'Completed', + Timestamp: '2026-08-12T23:15:33Z', + }, + { + Name: 'cyberdraintesttenant023.onmicrosoft.com', + Status: 'Running', + Timestamp: '2026-08-12T23:15:31Z', + }, + { + Name: 'cyberdraintesttenant022.onmicrosoft.com', + Status: 'Completed', + Timestamp: '2026-08-12T23:15:34Z', + }, + ], } -// Single handler that returns different data based on QueueId query param. -// Matches actual Invoke-ListCippQueue response shape. -const queueHandler = http.get('/api/ListCippQueue', ({ request }) => { - const url = new URL(request.url) - const queueId = url.searchParams.get('QueueId') - const data = queueResponses[queueId] - if (data) { - return HttpResponse.json([data]) - } - return HttpResponse.json([]) -}) +const handlers = [http.get('*/api/ListCippQueue', () => HttpResponse.json([queue]))] export default { title: 'Components/CippTable/CippQueueTracker', component: CippQueueTracker, tags: ['autodocs'], - args: { - onQueueComplete: fn(), - }, - beforeEach({ msw }) { - msw.use(queueHandler) - }, + parameters: { msw: { handlers } }, } -// Idle: no queueId, component renders nothing (returns null). -export const Idle = { - args: { - queueId: null, - queryKey: 'storybook-idle', - title: 'Queue Tracker', - }, +export const PhoneWidth = { + render: () => , play: async ({ canvasElement, step }) => { - await step('no queueId renders nothing', async () => { - // Component returns null when no queueId, canvas should be empty - await new Promise((r) => setTimeout(r, 500)) - expect(canvasElement.querySelector('button')).toBeNull() - }) - }, -} + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + const body = within(document.body) -export const InProgress = { - args: { - queueId: 'test-queue-running', - queryKey: 'storybook-running', - title: 'Processing Users', - }, - play: async ({ canvasElement, step }) => { - const root = within(document.body) - - await step('tracker button appears once queue data loads', async () => { - await waitFor(() => { - expect(canvasElement.querySelector('button')).not.toBeNull() - }) - await userEvent.click(canvasElement.querySelector('button')) + await step('the tracker opens the queue offcanvas', async () => { + const trigger = await canvas.findByRole('button') + await userEvent.click(trigger) + await waitFor(() => expect(body.getByText('Task Details')).toBeInTheDocument()) }) - await step('offcanvas shows running progress and the active task', async () => { - await waitFor(() => { - expect(root.getByText('Processing Users')).toBeVisible() - }) - expect(root.getByText(/60\.0%/)).toBeVisible() - expect(root.getByText('Process user 7')).toBeVisible() - }) - }, -} + if (!onAPhone) return -export const Completed = { - args: { - queueId: 'test-queue-done', - queryKey: 'storybook-done', - title: 'User Processing', - }, - play: async ({ canvasElement, args, step }) => { - const root = within(document.body) - - await step('open the tracker offcanvas', async () => { - await waitFor(() => { - expect(canvasElement.querySelector('button')).not.toBeNull() - }) - await userEvent.click(canvasElement.querySelector('button')) - }) - - await step('shows 100% and fires onQueueComplete', async () => { - await waitFor(() => { - expect(root.getByText('User Processing')).toBeVisible() - }) - expect(root.getByText(/100\.0%/)).toBeVisible() - await waitFor(() => { - expect(args.onQueueComplete).toHaveBeenCalled() - }) - }) - }, -} + // scope to one task card — statuses repeat across cards and in the stats row + const card = (name) => within(body.getByText(name).closest('.MuiBox-root')) -export const Failed = { - args: { - queueId: 'test-queue-failed', - queryKey: 'storybook-failed', - title: 'Failed Operation', - }, - play: async ({ canvasElement, step }) => { - const root = within(document.body) - - await step('open the tracker offcanvas', async () => { + await step('a full tenant domain does not push its status pill off the card', async () => { + const paper = body.getByText('Task Details').closest('.MuiDrawer-paper') + const pill = card('cyberdraintesttenant024.onmicrosoft.com').getByText(/^completed$/i) await waitFor(() => { - expect(canvasElement.querySelector('button')).not.toBeNull() + // the pill is intact inside the drawer, not clipped at its right edge + expect(pill.getBoundingClientRect().right).toBeLessThanOrEqual( + paper.getBoundingClientRect().right + ) + expect(paper.scrollWidth).toBeLessThanOrEqual(paper.clientWidth) }) - await userEvent.click(canvasElement.querySelector('button')) }) - await step('shows the failed operation and its failed task', async () => { - await waitFor(() => { - expect(root.getByText('Failed Operation')).toBeVisible() - }) - expect(root.getByText('Process user 3')).toBeVisible() + await step('and there is real space between the name and the pill', async () => { + const name = body.getByText('cyberdraintesttenant023.onmicrosoft.com') + const running = card('cyberdraintesttenant023.onmicrosoft.com').getByText(/^running$/i) + const nameBox = name.getBoundingClientRect() + const pillBox = running.getBoundingClientRect() + // either beside it with a gap, or wrapped below it — never overlapping + const besideWithGap = pillBox.left - nameBox.right >= 4 + const below = pillBox.top >= nameBox.bottom - 1 + await expect(besideWithGap || below).toBe(true) }) }, } diff --git a/tests/components/CippTable/order-columns-by-selection.test.js b/tests/components/CippTable/order-columns-by-selection.test.js new file mode 100644 index 000000000000..c5618a0c8cf0 --- /dev/null +++ b/tests/components/CippTable/order-columns-by-selection.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { orderColumnsBySelection } from '../../../src/components/CippTable/CippDataTable' + +// MRT reads initialState.columnOrder once; when the graph filter swaps the $select list +// after mount, new columns appended last — and the card view fills its three detail slots +// in column order, so the field the user just selected was the one overflowing into +// "+N more". Selection order has to win. +describe('orderColumnsBySelection', () => { + const all = ['displayName', 'userPrincipalName', 'mail', 'signInActivity.lastSuccessfulSignInDateTime', 'proxyAddresses'] + + it('puts the selection first, in selection order', () => { + expect( + orderColumnsBySelection(all, ['signInActivity.lastSuccessfulSignInDateTime', 'displayName']) + ).toEqual([ + 'signInActivity.lastSuccessfulSignInDateTime', + 'displayName', + 'userPrincipalName', + 'mail', + 'proxyAddresses', + ]) + }) + + it('ignores selected ids that have no column, keeps the rest stable', () => { + expect(orderColumnsBySelection(all, ['nope', 'mail'])).toEqual([ + 'mail', + 'displayName', + 'userPrincipalName', + 'signInActivity.lastSuccessfulSignInDateTime', + 'proxyAddresses', + ]) + }) + + it('is a no-op shape when nothing is selected', () => { + expect(orderColumnsBySelection(all, [])).toEqual(all) + }) +}) diff --git a/tests/components/CippTable/util-columnsFromAPI.test.jsx b/tests/components/CippTable/util-columnsFromAPI.test.jsx index 3840f4bd7b58..9023485aeb4f 100644 --- a/tests/components/CippTable/util-columnsFromAPI.test.jsx +++ b/tests/components/CippTable/util-columnsFromAPI.test.jsx @@ -13,6 +13,22 @@ describe('utilColumnsFromAPI', () => { expect(ids).toContain('department') }) + it('includes assigned license filter options found after the heuristic sample', () => { + const businessPremiumSku = 'cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46' + const data = Array.from({ length: 51 }, (_, index) => ({ + assignedLicenses: index === 50 ? [{ skuId: businessPremiumSku }] : [], + })) + + const licenseColumn = utilColumnsFromAPI(data).find( + (column) => column.id === 'assignedLicenses' + ) + + expect(licenseColumn.filterSelectOptions).toContainEqual({ + label: 'Microsoft 365 Business Premium', + value: businessPremiumSku, + }) + }) + it('generates columns for nested object properties', () => { const data = [ { info: { city: 'Seattle', state: 'WA' }, name: 'Test' }, diff --git a/tests/components/CippTable/util-mobile-card-slots.test.js b/tests/components/CippTable/util-mobile-card-slots.test.js new file mode 100644 index 000000000000..0a6ff5416cfa --- /dev/null +++ b/tests/components/CippTable/util-mobile-card-slots.test.js @@ -0,0 +1,146 @@ +import { getMobileCardSlots, isStatusLike } from '../../../src/components/CippTable/util-mobile-card-slots' + +// Shorthand column factory mirroring what table.getVisibleLeafColumns() yields +const col = (id, def = {}) => ({ id, columnDef: { id, ...def } }) +const bool = (id) => col(id, { sortingFn: 'boolean', filterVariant: 'select', filterSelectOptions: ['Yes', 'No'] }) + +const ids = (cols) => cols.map((c) => c.id) + +// The real /identity/administration/users simpleColumns, in page order. +// accountEnabled mirrors its explicit get-cipp-filter-variant case: select variant, +// alphanumeric sorting, NO options — only the STATUS_FIELDS id match can catch it. +const USERS_COLUMNS = [ + col('accountEnabled', { filterVariant: 'select', sortingFn: 'alphanumeric', filterFn: 'equals' }), + col('userPrincipalName'), + col('displayName'), + col('mail'), + col('businessPhones'), + col('proxyAddresses'), + col('assignedLicenses'), + col('licenseAssignmentStates'), + col('userType', { filterVariant: 'select', filterSelectOptions: ['Member', 'Guest'] }), +] + +describe('getMobileCardSlots', () => { + it('resolves the users page correctly — never titles cards "Yes"', () => { + const slots = getMobileCardSlots(USERS_COLUMNS) + expect(slots.primary.id).toBe('displayName') + expect(slots.secondary.id).toBe('userPrincipalName') + expect(ids(slots.chips)).toEqual(['accountEnabled', 'userType']) + expect(ids(slots.details)).toEqual(['mail', 'businessPhones', 'proxyAddresses']) + expect(ids(slots.rest)).toEqual(['assignedLicenses', 'licenseAssignmentStates']) + expect(slots.restCount).toBe(2) + }) + + it('filters out mrt-* utility columns', () => { + const slots = getMobileCardSlots([col('mrt-row-select'), col('displayName'), col('mrt-row-actions')]) + expect(slots.primary.id).toBe('displayName') + expect(slots.secondary).toBeNull() + expect(slots.restCount).toBe(0) + }) + + it('handles an empty column set', () => { + expect(getMobileCardSlots([])).toEqual({ + primary: null, + secondary: null, + chips: [], + details: [], + rest: [], + restCount: 0, + }) + expect(getMobileCardSlots(undefined).primary).toBeNull() + }) + + it('handles a single column', () => { + const slots = getMobileCardSlots([col('Tenant')]) + expect(slots.primary.id).toBe('Tenant') + expect(slots.secondary).toBeNull() + expect(slots.chips).toEqual([]) + expect(slots.details).toEqual([]) + }) + + it('falls back to first non-status textual column when nothing matches NAME_FIELDS', () => { + const slots = getMobileCardSlots([bool('isCompliant'), col('osVersion'), col('manufacturer')]) + expect(slots.primary.id).toBe('osVersion') + expect(ids(slots.chips)).toEqual(['isCompliant']) + expect(ids(slots.details)).toEqual(['manufacturer']) + }) + + it('falls back to the first column when everything is status-like', () => { + const slots = getMobileCardSlots([bool('enabled'), bool('isCompliant')]) + expect(slots.primary.id).toBe('enabled') + expect(ids(slots.chips)).toEqual(['isCompliant']) + }) + + it('caps chips at 3 and details at 3, remainder goes to rest', () => { + const slots = getMobileCardSlots([ + col('displayName'), + bool('a'), bool('b'), bool('c'), bool('d'), + col('e'), col('f'), col('g'), col('h'), + ]) + expect(ids(slots.chips)).toEqual(['a', 'b', 'c']) + // 'd' overflowed the chip cap — it flows into details ("whatever remains"), not rest + expect(ids(slots.details)).toEqual(['d', 'e', 'f']) + expect(ids(slots.rest)).toEqual(['g', 'h']) + }) + + it('respects mobileCard overrides for every slot', () => { + const slots = getMobileCardSlots(USERS_COLUMNS, { + primary: 'userPrincipalName', + secondary: 'mail', + chips: ['userType'], + details: ['assignedLicenses'], + }) + expect(slots.primary.id).toBe('userPrincipalName') + expect(slots.secondary.id).toBe('mail') + expect(ids(slots.chips)).toEqual(['userType']) + expect(ids(slots.details)).toEqual(['assignedLicenses']) + // everything unassigned lands in rest + expect(ids(slots.rest)).toEqual([ + 'accountEnabled', + 'displayName', + 'businessPhones', + 'proxyAddresses', + 'licenseAssignmentStates', + ]) + }) + + it('ignores override ids that are not visible and empty override arrays fall through to rest', () => { + const slots = getMobileCardSlots(USERS_COLUMNS, { primary: 'notAColumn', chips: [], details: [] }) + expect(slots.primary.id).toBe('displayName') // heuristic fallback + expect(slots.chips).toEqual([]) + expect(slots.details).toEqual([]) + expect(slots.restCount).toBe(7) + }) + + it('secondary never duplicates primary', () => { + const slots = getMobileCardSlots([col('RowKey'), col('Timestamp')]) + // RowKey matches both NAME_FIELDS and IDENTIFIER_FIELDS — must not appear twice + expect(slots.primary.id).toBe('RowKey') + expect(slots.secondary).toBeNull() + expect(ids(slots.details)).toEqual(['Timestamp']) + }) +}) + +describe('isStatusLike', () => { + it('detects boolean sortingFn (the get-cipp-filter-variant signal)', () => { + expect(isStatusLike(bool('anything'))).toBe(true) + }) + it('detects known status ids case-insensitively', () => { + expect(isStatusLike(col('complianceState'))).toBe(true) + expect(isStatusLike(col('Severity'))).toBe(true) + // the identity/device/custom test tables — Risk fell to the detail rows while Result sat + // in the chips row, two chips organised by two different systems on one card + expect(isStatusLike(col('Risk'))).toBe(true) + expect(isStatusLike(col('Result'))).toBe(true) + }) + it('detects small select filters, rejects large ones', () => { + expect(isStatusLike(col('x', { filterVariant: 'select', filterSelectOptions: ['a', 'b'] }))).toBe(true) + expect( + isStatusLike(col('x', { filterVariant: 'select', filterSelectOptions: ['a', 'b', 'c', 'd', 'e', 'f', 'g'] })) + ).toBe(false) + }) + it('rejects plain text columns', () => { + expect(isStatusLike(col('displayName'))).toBe(false) + }) +}) diff --git a/tests/components/CippTable/util-subTables.test.js b/tests/components/CippTable/util-subTables.test.js new file mode 100644 index 000000000000..bb3ee02a32a2 --- /dev/null +++ b/tests/components/CippTable/util-subTables.test.js @@ -0,0 +1,62 @@ +import { + dataHasPopulatedColumn, + resolveSubTableSimpleColumns, + subTableIsSelected, + subTableShowsCachedColumn, + getSubTableDisplayColumnIds, + columnOrderHasStaleIds, +} from '../../../src/components/CippTable/util-subTables' + +const membersSub = { + id: 'members', + header: 'Members', + cachedColumn: 'membersCsv', +} + +describe('util-subTables', () => { + it('selects a subTable only when its id is in simpleColumns', () => { + expect(subTableIsSelected(membersSub, ['displayName', 'members'])).toBe(true) + expect(subTableIsSelected(membersSub, ['displayName'])).toBe(false) + expect(subTableIsSelected(membersSub, [])).toBe(true) + }) + + it('uses the cached column when that field is present on the data', () => { + const cached = [{ id: '1', membersCsv: 'Jane, Bob' }] + const live = [{ id: '1', displayName: 'Finance' }] + const liveWithEmptyCsv = [{ id: '1', displayName: 'Finance', membersCsv: '' }] + + expect(dataHasPopulatedColumn(cached, 'membersCsv')).toBe(true) + expect(dataHasPopulatedColumn(liveWithEmptyCsv, 'membersCsv')).toBe(false) + expect(subTableShowsCachedColumn(membersSub, cached)).toBe(true) + expect(subTableShowsCachedColumn(membersSub, live)).toBe(false) + expect(subTableShowsCachedColumn(membersSub, liveWithEmptyCsv)).toBe(false) + expect( + resolveSubTableSimpleColumns(['displayName', 'members'], [membersSub], cached) + ).toEqual(['displayName', 'membersCsv']) + expect( + resolveSubTableSimpleColumns(['displayName', 'members'], [membersSub], live) + ).toEqual(['displayName', 'members']) + }) + + it('maps subTables to the active display column ids', () => { + const cached = [{ id: '1', membersCsv: 'Jane, Bob' }] + const live = [{ id: '1', displayName: 'Finance' }] + + expect( + getSubTableDisplayColumnIds([membersSub], ['displayName', 'members'], cached) + ).toEqual(['membersCsv']) + expect( + getSubTableDisplayColumnIds([membersSub], ['displayName', 'members'], live) + ).toEqual(['members']) + }) + + it('detects stale column order ids that are not on the table', () => { + expect(columnOrderHasStaleIds(['displayName', 'members'], ['displayName', 'membersCsv'])).toBe( + true + ) + expect( + columnOrderHasStaleIds(['displayName', 'membersCsv'], ['displayName', 'membersCsv']) + ).toBe(false) + expect(columnOrderHasStaleIds(['mrt-row-select', 'displayName'], ['displayName'])).toBe(false) + }) +}) diff --git a/tests/components/CippTable/util-tablemode.test.jsx b/tests/components/CippTable/util-tablemode.test.jsx index 8497f9d0dde4..790704246c9d 100644 --- a/tests/components/CippTable/util-tablemode.test.jsx +++ b/tests/components/CippTable/util-tablemode.test.jsx @@ -42,6 +42,26 @@ describe('utilTableMode', () => { expect(result.muiPaginationProps.rowsPerPageOptions).toBeDefined() }) + it('narrow table slims the footer so it cannot wrap below MRT 720px pivot', () => { + const result = utilTableMode({}, false, null, [], false, null, '380px', defaultSettings, 'table', true) + expect(result.muiPaginationProps.showRowsPerPage).toBe(false) + expect(result.muiPaginationProps.showFirstButton).toBe(false) + expect(result.muiPaginationProps.showLastButton).toBe(false) + }) + + it('narrow table page-scrolls instead of keeping an inner scroll viewport', () => { + const result = utilTableMode({}, false, null, [], false, null, '380px', defaultSettings, 'table', true) + expect(result.muiTableContainerProps.sx.maxHeight).toBe('none') + }) + + it('wide table keeps the full footer and the viewport-budget maxHeight', () => { + const result = utilTableMode({}, false, null, [], false, null, '380px', defaultSettings, 'table', false) + expect(result.muiPaginationProps.showRowsPerPage).toBeUndefined() + expect(result.muiPaginationProps.showFirstButton).toBeUndefined() + expect(result.muiPaginationProps.showLastButton).toBeUndefined() + expect(result.muiTableContainerProps.sx.maxHeight).toBe('calc(100vh - 380px)') + }) + it('returns table container height config', () => { const result = utilTableMode({}, false, null, [], false, null, '500px', defaultSettings) expect(result.muiTableContainerProps).toBeDefined() diff --git a/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx b/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx new file mode 100644 index 000000000000..5ad28e86c866 --- /dev/null +++ b/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx @@ -0,0 +1,75 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { within, expect, userEvent } from 'storybook/test' +import { useForm } from 'react-hook-form' +import { CippWizardAutopilotImport } from '../../../src/components/CippWizard/CippWizardAutopilotImport' +import { shrinkToPhoneViewport } from '../../viewport' + +// The six the real autopilot wizard passes — the count is what made the row overflow. +const fields = [ + { friendlyName: 'Serialnumber', propertyName: 'SerialNumber' }, + { friendlyName: 'Manufacturer', propertyName: 'oemManufacturerName' }, + { friendlyName: 'Model', propertyName: 'modelName' }, + { friendlyName: 'Product ID', propertyName: 'productKey' }, + { friendlyName: 'Hardware hash', propertyName: 'hardwareHash' }, + { friendlyName: 'Group Tag', propertyName: 'groupTag' }, +] + +const handlers = [ + http.get('*/api/ListGraphRequest', () => HttpResponse.json({ Results: [] })), + http.get('*/api/ListGraphExplorerPresets', () => HttpResponse.json({ Results: [] })), +] + +const Harness = () => { + const formControl = useForm({ mode: 'onChange', defaultValues: { autopilotData: [] } }) + return ( + {}} + onPreviousStep={() => {}} + /> + ) +} + +export default { + title: 'Components/CippWizard/CippWizardAutopilotImport', + component: CippWizardAutopilotImport, + parameters: { msw: { handlers } }, +} + +// A 32px badge, six 150px fields and a 48px delete came to ~1010px in a row whose only +// concession was overflowX:auto — a nested sideways scroller inside a full-screen dialog. +// Whether it fits now is a claim only a real browser can settle. +export const PhoneWidth = { + render: () => , + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const body = within(document.body) + + // At phone width the table is a card list, so the import buttons are behind the FAB + // rather than in a card header — the same route a user takes. + if (onAPhone) { + await userEvent.click(await body.findByRole('button', { name: 'Page actions' })) + } + await userEvent.click(await body.findByRole('button', { name: /manual import/i })) + const dialog = await body.findByRole('dialog') + if (!onAPhone) return + + const rows = dialog.querySelectorAll('[data-testid="manual-row"]') + expect(rows.length).toBeGreaterThan(0) + rows.forEach((row) => { + expect(row.scrollWidth).toBeLessThanOrEqual(row.clientWidth) + }) + + // and the fields are stacked, not side by side + const inputs = rows[0].querySelectorAll('input') + expect(inputs.length).toBe(fields.length) + expect(inputs[1].getBoundingClientRect().top).toBeGreaterThan( + inputs[0].getBoundingClientRect().bottom + ) + }, +} diff --git a/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx b/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx new file mode 100644 index 000000000000..3720e883f933 --- /dev/null +++ b/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx @@ -0,0 +1,85 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useForm } from 'react-hook-form' +import { renderWithProviders } from '../../test-utils' +import { CippWizardAutopilotImport } from '../../../src/components/CippWizard/CippWizardAutopilotImport' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +// partial mock: real module spread first, so new exports keep working here +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: vi.fn(() => ({ data: undefined, isFetching: false, isSuccess: false })), + ApiPostCall: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + ApiGetCallWithPagination: vi.fn(() => ({ data: undefined, isFetching: false })), +})) + +// The six the real autopilot wizard passes — the count is the point. +const fields = [ + { friendlyName: 'Serialnumber', propertyName: 'SerialNumber' }, + { friendlyName: 'Manufacturer', propertyName: 'oemManufacturerName' }, + { friendlyName: 'Model', propertyName: 'modelName' }, + { friendlyName: 'Product ID', propertyName: 'productKey' }, + { friendlyName: 'Hardware hash', propertyName: 'hardwareHash' }, + { friendlyName: 'Group Tag', propertyName: 'groupTag' }, +] + +const Harness = () => { + const formControl = useForm({ mode: 'onChange', defaultValues: { autopilotData: [] } }) + return ( + {}} + onPreviousStep={() => {}} + /> + ) +} + +const openManualImport = async () => { + const user = userEvent.setup() + renderWithProviders() + await user.click(await screen.findByRole('button', { name: /manual import/i })) + return within(await screen.findByRole('dialog')) +} + +beforeEach(() => { + layoutState.isMobile = false +}) + +describe('CippWizardAutopilotImport manual entry', () => { + // Six 150px fields plus an index badge and a delete button come to ~1010px, which on a + // phone was reachable only by scrolling a nested container inside a full-screen dialog. + it('gives each device its own card on a phone', async () => { + layoutState.isMobile = true + const dialog = await openManualImport() + + expect(dialog.getByText('Device 1')).toBeInTheDocument() + expect(dialog.getByRole('button', { name: 'Remove device 1' })).toBeInTheDocument() + // every field still there, just stacked + fields.forEach((field) => { + expect(dialog.getByLabelText(field.friendlyName)).toBeInTheDocument() + }) + }) + + it('keeps the single scrolling row on desktop', async () => { + const dialog = await openManualImport() + + expect(dialog.queryByText('Device 1')).not.toBeInTheDocument() + expect(dialog.queryByRole('button', { name: 'Remove device 1' })).not.toBeInTheDocument() + fields.forEach((field) => { + expect(dialog.getByLabelText(field.friendlyName)).toBeInTheDocument() + }) + }) +}) diff --git a/tests/components/CippWizard/CippWizardPage.stories.jsx b/tests/components/CippWizard/CippWizardPage.stories.jsx new file mode 100644 index 000000000000..5fd81bb92a3b --- /dev/null +++ b/tests/components/CippWizard/CippWizardPage.stories.jsx @@ -0,0 +1,83 @@ +import React from 'react' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Typography } from '@mui/material' +import CippWizardPage from '../../../src/components/CippWizard/CippWizardPage' +import { CippWizardStepButtons } from '../../../src/components/CippWizard/CippWizardStepButtons' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport' + +// A step that renders nothing but the shared button row — the layout under test is the +// wizard shell, not any particular step's form. +const Step = (props) => ( + <> + Step content + + +) + +// Five steps with the real wizards' label lengths; vacation mode is exactly this shape. +const steps = [ + { title: 'tenant', description: 'Tenant Selection', component: Step }, + { title: 'user', description: 'User Selection', component: Step }, + { title: 'actions', description: 'Vacation Actions', component: Step }, + { title: 'schedule', description: 'Schedule', component: Step }, + { title: 'review', description: 'Review & Submit', component: Step }, +] + +export default { + title: 'Components/CippWizard/CippWizardPage', + component: CippWizardPage, + parameters: { msw: { handlers: [] } }, +} + +const args = { postUrl: '/api/AddVacationMode', wizardTitle: 'Vacation Mode', steps } + +// jsdom has no layout engine, so overflow and stacking order are invisible to the unit +// tests. This is the only place they can be measured. +export const PhoneWidth = { + render: () => , + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + await canvas.findByText('Step content') + if (!onAPhone) return + + // the stepper is replaced, not merely restyled. findBy, not getBy: useMediaQuery reacts to + // the resize on a later tick, and "Step content" is in both branches so it settles nothing + await canvas.findByText('Step 1 of 5') + expect(canvasElement.querySelector('.MuiStepper-root')).toBeNull() + + // nothing in the card reaches past the screen + const card = canvasElement.querySelector('.MuiCard-root') + expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth) + + // advancing moves the bar + await userEvent.click(canvas.getByRole('button', { name: /next step/i })) + await waitFor(() => expect(canvas.getByText('Step 2 of 5')).toBeInTheDocument()) + + // column-reverse: the primary action sits above Back, and both span the card + const next = canvas.getByRole('button', { name: /next step/i }) + const back = canvas.getByRole('button', { name: /^back$/i }) + expect(back.getBoundingClientRect().top).toBeGreaterThan(next.getBoundingClientRect().top) + expect(next.getBoundingClientRect().width).toBeGreaterThan( + card.getBoundingClientRect().width * 0.7 + ) + }, +} + +// The other half of the contract: none of this reaches desktop. +export const DesktopWidth = { + render: () => , + play: async ({ canvasElement }) => { + // Claim the width rather than inherit it — PhoneWidth shares this page and shrinks it. + await growToDesktopViewport() + const canvas = within(canvasElement) + await canvas.findByText('Step content') + + await waitFor(() => expect(canvasElement.querySelector('.MuiStepper-root')).not.toBeNull()) + expect(canvas.queryByRole('progressbar')).toBeNull() + expect(canvas.queryByText('Step 1 of 5')).toBeNull() + + const card = canvasElement.querySelector('.MuiCard-root') + expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth) + }, +} diff --git a/tests/components/CippWizard/CippWizardVacationActions.test.jsx b/tests/components/CippWizard/CippWizardVacationActions.test.jsx index 043731c434e0..522c8e8744d5 100644 --- a/tests/components/CippWizard/CippWizardVacationActions.test.jsx +++ b/tests/components/CippWizard/CippWizardVacationActions.test.jsx @@ -148,6 +148,28 @@ describe('CippWizardVacationActions', () => { ).not.toBeInTheDocument() expect(formApi.getValues('enableCAExclusion')).toBeFalsy() }) + + it('offers the location alert exclusion without Conditional Access', async () => { + // Tenants without CA policies still get location-based audit alerts, so this switch + // must stand on its own rather than hide inside the CA branch. + renderWithProviders() + + expect( + screen.getByText('Exclude from location-based audit log alerts') + ).toBeInTheDocument() + + await setField('excludeLocationAuditAlerts', true) + + await waitFor(() => + expect( + screen.getByText(/does not require a Conditional Access policy/i) + ).toBeInTheDocument() + ) + expect( + screen.queryByText(/uses group-based exclusions/i) + ).not.toBeInTheDocument() + expect(formApi.getValues('enableCAExclusion')).toBeFalsy() + }) }) // The out-of-office branch renders a rich-text editor that does not mount under jsdom diff --git a/tests/components/CippWizard/wizard-steps.test.jsx b/tests/components/CippWizard/wizard-steps.test.jsx new file mode 100644 index 000000000000..19db18ab9e5f --- /dev/null +++ b/tests/components/CippWizard/wizard-steps.test.jsx @@ -0,0 +1,100 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../../test-utils"; +import { WizardSteps } from "../../../src/components/CippWizard/wizard-steps"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => "table", +})); + +const steps = [ + { title: "tenant", description: "Tenant Selection" }, + { title: "user", description: "User Selection" }, + { title: "actions", description: "Vacation Actions" }, + { title: "schedule", description: "Schedule" }, + { title: "review", description: "Review & Submit" }, +]; + +beforeEach(() => { + layoutState.isMobile = false; +}); + +describe("WizardSteps", () => { + it("keeps the full stepper on desktop", () => { + renderWithProviders(); + + expect(screen.getByText("Tenant Selection")).toBeInTheDocument(); + expect(screen.getByText("Review & Submit")).toBeInTheDocument(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + + it("collapses to a progress header on a phone", () => { + layoutState.isMobile = true; + renderWithProviders(); + + expect(screen.getByText("Step 3 of 5")).toBeInTheDocument(); + expect(screen.getByText("Vacation Actions")).toBeInTheDocument(); + // the other four steps are not competing for the same 326px + expect(screen.queryByText("Tenant Selection")).not.toBeInTheDocument(); + expect(screen.queryByText("Review & Submit")).not.toBeInTheDocument(); + + const bar = screen.getByRole("progressbar"); + expect(bar).toHaveAttribute("aria-valuenow", "60"); + }); + + // The vertical variant is not wizard navigation: GDAP onboarding feeds it server-side + // steps where each step's message and pass/fail state IS the content. + it("leaves the vertical status list alone on a phone", () => { + layoutState.isMobile = true; + const onboarding = [ + { title: "invite", description: "Invite accepted", error: false }, + { title: "roles", description: "Role assignment failed: insufficient privileges", error: true }, + ]; + renderWithProviders(); + + expect(screen.getByText("Invite accepted")).toBeInTheDocument(); + expect( + screen.getByText("Role assignment failed: insufficient privileges") + ).toBeInTheDocument(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + + it("carries the current step's error and loading states into the bar", () => { + layoutState.isMobile = true; + const failing = [{ description: "Deploying" }, { description: "Failed", error: true }]; + const { unmount } = renderWithProviders( + + ); + expect(screen.getByRole("progressbar").className).toMatch(/colorError/); + unmount(); + + const running = [{ description: "Deploying", loading: true }]; + renderWithProviders(); + expect(screen.getByRole("progressbar").className).toMatch(/indeterminate/); + }); + + it("survives an activeStep past the end of the visible steps", () => { + layoutState.isMobile = true; + // handleNext counts against the unfiltered step list, so this really happens on wizards + // whose steps are conditionally hidden. + renderWithProviders( + + ); + + expect(screen.getByText("Step 3 of 3")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "100"); + }); + + it("renders nothing broken for an empty step list", () => { + layoutState.isMobile = true; + renderWithProviders(); + + expect(screen.getByText("No steps")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "0"); + }); +}); diff --git a/tests/components/ExecutiveReportButton.test.jsx b/tests/components/ExecutiveReportButton.test.jsx index 0c522fc1d59d..cf86374b85bc 100644 --- a/tests/components/ExecutiveReportButton.test.jsx +++ b/tests/components/ExecutiveReportButton.test.jsx @@ -1,5 +1,5 @@ import React from 'react' -import { screen } from '@testing-library/react' +import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test-utils' import { ExecutiveReportButton } from '../../src/components/ExecutiveReportButton' @@ -99,4 +99,50 @@ describe('ExecutiveReportButton', () => { expect(onClick).toHaveBeenCalled() expect(await screen.findByRole('dialog')).toBeInTheDocument() }) + + // The 320px config rail would leave the preview about 70px wide on a phone, so below md it + // moves into a drawer. Both homes render the same panel, and the toggles have to keep + // working from the drawer. + describe('section configuration on a phone', () => { + const openSections = async () => { + renderWithProviders() + await userEvent.click(screen.getByRole('button', { name: /executive summary/i })) + await screen.findByRole('dialog') + await userEvent.click(screen.getByRole('button', { name: 'Report sections' })) + // jsdom applies no media queries, so the desktop rail is in the document too — every + // query here has to be scoped to the drawer or it matches both copies. + return within(document.querySelector('.MuiDrawer-paper')) + } + + it('opens the sections panel in a drawer', async () => { + const drawer = await openSections() + + expect(drawer.getByText('Report Sections')).toBeVisible() + expect(drawer.getByText('Executive Summary')).toBeVisible() + expect(drawer.getByText('Shadow AI Report')).toBeVisible() + }) + + it('toggles a section from inside the drawer', async () => { + const drawer = await openSections() + + const deviceRow = drawer.getByText('Device Management').closest('.MuiPaper-root') + const toggle = within(deviceRow).getByRole('switch') + expect(toggle).toBeChecked() + + await userEvent.click(toggle) + + expect(toggle).not.toBeChecked() + // the footer count is the shared state both panels read + expect(screen.getByText(/Sections enabled: 6 of 9/)).toBeInTheDocument() + }) + + it('lifts the drawer above the dialog that opened it', async () => { + await openSections() + + // A stock Drawer sits below a Dialog and would open behind the preview. + const drawer = document.querySelector('.MuiDrawer-root') + expect(drawer).not.toBeNull() + expect(window.getComputedStyle(drawer).zIndex).toBe('1301') + }) + }) }) diff --git a/tests/components/PrivateRoute.test.jsx b/tests/components/PrivateRoute.test.jsx index 14aa32ef9caa..a8f8f4dc51a8 100644 --- a/tests/components/PrivateRoute.test.jsx +++ b/tests/components/PrivateRoute.test.jsx @@ -84,6 +84,26 @@ describe('PrivateRoute', () => { expect(screen.queryByText('app content')).not.toBeInTheDocument() }) + it('shows the server explanation when a signed-in identity is denied (e.g. IP blocked)', async () => { + // real SWA session, but CIPP refused the caller and said why - the wording must + // surface instead of the misleading "session expired" prompt + authState.swa = result({ data: swaPrincipal() }) + authState.me = result({ + data: { + clientPrincipal: null, + permissions: [], + message: 'Your IP address (203.0.113.7) is not in the allowed range for your role(s)', + }, + }) + renderRoute() + + await waitFor(() => { + expect(screen.getByText(/not in the allowed range/)).toBeInTheDocument() + }) + expect(screen.getByText('Access Denied')).toBeInTheDocument() + expect(screen.queryByText('Sign in to CIPP')).not.toBeInTheDocument() + }) + it('shows the sign-in page when the session has no identity in either shape', async () => { // settled /.auth/me with neither clientPrincipal nor easyauth array authState.swa = result({ data: {} }) diff --git a/tests/components/ReleaseNotesDialog.test.jsx b/tests/components/ReleaseNotesDialog.test.jsx index e74a1c767f51..2468b0f51af8 100644 --- a/tests/components/ReleaseNotesDialog.test.jsx +++ b/tests/components/ReleaseNotesDialog.test.jsx @@ -1,5 +1,5 @@ import React from 'react' -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test-utils' @@ -8,6 +8,13 @@ import { renderWithProviders } from '../test-utils' const versionState = vi.hoisted(() => ({ version: '10.8.2' })) vi.mock('../../public/version.json', () => ({ default: versionState })) +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})) + vi.mock('../../src/api/ApiCall', async () => (await import('../mocks/api-call')).apiCallMock()) import { api, getResult } from '../mocks/api-call' @@ -48,6 +55,7 @@ const PERMANENT_HIDE_KEY = 'cipp_release_notice_permanently_hidden' const flushEffects = () => new Promise((resolve) => setTimeout(resolve, 0)) beforeEach(() => { + layoutState.isMobile = false versionState.version = '10.8.2' api.get = catalogResult window.localStorage.clear() @@ -55,18 +63,36 @@ beforeEach(() => { }) describe('ReleaseNotesDialog', () => { - it('opens on the .0 base release even when running a hotfix build', async () => { + // A hotfix release body is only the delta since the feature release, so opening on it tells + // the user almost nothing. Display the newest vX.Y.0 instead — dismissal still tracks the + // running tag, which is what the reopen-forever bug hinged on (see the next test). + it('opens on the newest .0 release, not on a hotfix', async () => { renderWithProviders() - expect(await screen.findByText('Release notes for v10.8.0 - Ramos Melon Fizz')).toBeInTheDocument() - expect(screen.getByText('Notes for the base release of the 10.8 series')).toBeInTheDocument() + expect( + await screen.findByDisplayValue('v10.9.0 - Something Newer') + ).toBeInTheDocument() + expect(screen.queryByText('Notes for the hotfix that is actually running')).toBeNull() + }) + + it('still lets you pick a hotfix release from the picker', async () => { + const user = userEvent.setup() + renderWithProviders() + await screen.findByDisplayValue('v10.9.0 - Something Newer') + + await user.click(screen.getByRole('combobox')) + await user.click(await screen.findByText('v10.8.2 - Hotfix')) + + expect( + await screen.findByText('Notes for the hotfix that is actually running') + ).toBeInTheDocument() }) it('stays dismissed on reload after "Don\'t show until next release"', async () => { const user = userEvent.setup() const { unmount } = renderWithProviders() - await screen.findByText('Release notes for v10.8.0 - Ramos Melon Fizz') + await screen.findByDisplayValue('v10.9.0 - Something Newer') await user.click(screen.getByRole('button', { name: "Don't show until next release" })) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) @@ -87,7 +113,45 @@ describe('ReleaseNotesDialog', () => { renderWithProviders() - expect(await screen.findByText('Release notes for v10.9.0 - Something Newer')).toBeInTheDocument() + expect(await screen.findByDisplayValue('v10.9.0 - Something Newer')).toBeInTheDocument() + }) + + // On phones the two low-emphasis actions live behind the kebab as bottom-sheet rows — + // the same actions treatment as the rest of the mobile surface. + it('puts GitHub and permanent dismiss behind the kebab sheet on mobile', async () => { + layoutState.isMobile = true + const user = userEvent.setup() + renderWithProviders() + // the house pick-one pattern: a trigger, not a text input — no keyboard to summon + const trigger = await screen.findByRole('button', { name: /switch release/i }) + expect(trigger).toHaveTextContent('v10.9.0 - Something Newer') + expect(screen.queryByDisplayValue('v10.9.0 - Something Newer')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'More options' })) + // the desktop footer's copy is only display:none'd by a media query jsdom can't + // evaluate — scope to the sheet's drawer paper + const github = await screen.findByRole('link', { name: /view release notes on github/i }) + expect(github).toHaveAttribute('href', 'https://github.com/CyberDrain/CIPP/releases/tag/v10.9.0') + const sheet = within(github.closest('.MuiDrawer-paper')) + + await user.click(sheet.getByText("Don't show again")) + await flushEffects() + expect(window.localStorage.getItem(PERMANENT_HIDE_KEY)).toBe('true') + }) + + it('switches release from the mobile sheet', async () => { + layoutState.isMobile = true + const user = userEvent.setup() + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: /switch release/i })) + const sheet = within((await screen.findByText('Release')).closest('.MuiDrawer-paper')) + await user.click(sheet.getByText('v10.8.2 - Hotfix')) + + expect(await screen.findByText('Notes for the hotfix that is actually running')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /switch release/i })).toHaveTextContent( + 'v10.8.2 - Hotfix' + ) }) it('falls back to the .0 notes when the running version has no release of its own', async () => { @@ -95,7 +159,7 @@ describe('ReleaseNotesDialog', () => { renderWithProviders() - expect(await screen.findByText('Release notes for v10.9.0 - Something Newer')).toBeInTheDocument() + expect(await screen.findByDisplayValue('v10.9.0 - Something Newer')).toBeInTheDocument() }) it('honours a permanent dismissal', async () => { diff --git a/tests/hooks/use-actions-dispatch.test.jsx b/tests/hooks/use-actions-dispatch.test.jsx new file mode 100644 index 000000000000..41226846eb64 --- /dev/null +++ b/tests/hooks/use-actions-dispatch.test.jsx @@ -0,0 +1,172 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../test-utils"; +import { useActionsDispatch } from "../../src/hooks/use-actions-dispatch"; +import { CippApiDialog } from "../../src/components/CippComponents/CippApiDialog"; + +// Stable identities: a fresh object per call changes on every render and spins a loop. +// CippApiDialog calls reset() on open, so the post result needs the full shape. +const idlePost = vi.hoisted(() => ({ + mutate: vi.fn(), + reset: vi.fn(), + isPending: false, + isSuccess: false, + isError: false, + data: undefined, + error: null, +})); +const idleGet = vi.hoisted(() => ({ + data: undefined, + isFetching: false, + isLoading: false, + isSuccess: false, + isError: false, + refetch: vi.fn(), +})); +const idlePaginated = vi.hoisted(() => ({ + data: undefined, + isFetching: false, + isSuccess: false, + isError: false, + fetchNextPage: vi.fn(), + refetch: vi.fn(), +})); +const postOptions = vi.hoisted(() => []); +vi.mock("../../src/api/ApiCall", () => ({ + ApiPostCall: (options) => { + postOptions.push(options); + return idlePost; + }, + ApiGetCall: () => idleGet, + ApiGetCallWithPagination: () => idlePaginated, +})); + +// `dialog` is a fragment holding whichever surface the action needs, so reach past it. +const dialogPropsOf = (dialog) => + React.Children.toArray(dialog?.props?.children).find((child) => child?.type === CippApiDialog) + ?.props; + +const Harness = ({ actions, data = { id: "1" }, queryKeys, onDialogProps }) => { + const { visibleActions, dispatch, dialog } = useActionsDispatch({ actions, data, queryKeys }); + onDialogProps?.(dialogPropsOf(dialog)); + return ( + <> + {visibleActions.map((action) => ( + + ))} + {dialog} + + ); +}; + +beforeEach(() => { + idlePost.mutate.mockClear(); + postOptions.length = 0; +}); + +describe("useActionsDispatch", () => { + // The hook set ready:true before branching, which mounted CippApiDialog with + // api.noConfirm true; the dialog's mount effect then auto-submitted into the same + // customFunction the hook had just called directly. + it("runs a noConfirm customFunction exactly once per tap", async () => { + const user = userEvent.setup(); + const customFunction = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Refresh Data" })); + + await waitFor(() => expect(customFunction).toHaveBeenCalledTimes(1)); + // and it stays at one — the auto-submit effect must not fire on a later commit + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(customFunction).toHaveBeenCalledTimes(1); + }); + + // The dialog instance was reused and its auto-submit effect keys on + // [api.noConfirm, api.link], so a repeat of the same action left the deps unchanged and + // silently did nothing. + it("runs again when the same action is dispatched twice", async () => { + const user = userEvent.setup(); + const customFunction = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Refresh Data" })); + await waitFor(() => expect(customFunction).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("button", { name: "Refresh Data" })); + + await waitFor(() => expect(customFunction).toHaveBeenCalledTimes(2)); + }); + + it("passes the caller's queryKeys through to the dialog", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Edit" })); + + // The dialog builds its mutation from relatedQueryKeys; without it the invalidation + // falls back to the hardcoded "Confirmation" title and the page never refreshes. + await waitFor(() => { + expect(postOptions.at(-1)?.relatedQueryKeys).toBe("Tenant History"); + }); + }); + + // The action was spread last, so any key it happened to carry silently beat the explicit + // prop — and every unknown key was forwarded onto the DOM by CippApiDialog. + it("does not let the action object override explicit dialog props", async () => { + const user = userEvent.setup(); + const props = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Edit" })); + + await waitFor(() => { + const last = props.mock.calls.at(-1)?.[0]; + expect(last?.row).toEqual({ id: "42" }); + }); + }); + + it("drops the dialog again once it closes", async () => { + const user = userEvent.setup(); + const props = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Edit" })); + await waitFor(() => expect(props.mock.calls.at(-1)?.[0]).toBeTruthy()); + + await user.keyboard("{Escape}"); + + // Left mounted, it holds a live mutation, an API subscription and a form instance for + // as long as the page lives — and on HeaderedTabbedLayout the page never unmounts. + await waitFor(() => expect(props.mock.calls.at(-1)?.[0]).toBeUndefined()); + }); + + it("hands a customComponent action to that component instead of a confirm dialog", async () => { + const user = userEvent.setup(); + const customComponent = vi.fn(() =>
    custom surface
    ); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open" })); + + expect(await screen.findByTestId("custom")).toBeInTheDocument(); + }); +}); diff --git a/tests/hooks/use-breakpoint.test.jsx b/tests/hooks/use-breakpoint.test.jsx new file mode 100644 index 000000000000..ed8d92fbb183 --- /dev/null +++ b/tests/hooks/use-breakpoint.test.jsx @@ -0,0 +1,106 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { renderWithProviders, settingsWith } from '../test-utils' +import { useIsMobileLayout, useTableViewMode } from '../../src/hooks/use-breakpoint' + +// jsdom has no width-based matchMedia, so useIsMobileLayout is always false here — +// which is exactly why the explicit settings/prop path must exist and is what we test. +const Probe = (props) =>
    {useTableViewMode(props)}
    + +const renderMode = (props, settings) => + renderWithProviders(, settings ? { settings: settingsWith(settings) } : undefined) + +describe('useTableViewMode', () => { + it("defaults to 'table' on desktop-width (auto + not mobile)", () => { + renderMode() + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) + + it('settings.tableViewMode=cards forces cards', () => { + renderMode({}, { tableViewMode: 'cards' }) + expect(screen.getByTestId('mode')).toHaveTextContent('cards') + }) + + it('accepts {value,label} shaped settings', () => { + renderMode({}, { tableViewMode: { value: 'cards', label: 'Card list' } }) + expect(screen.getByTestId('mode')).toHaveTextContent('cards') + }) + + it('per-call viewMode prop beats settings', () => { + renderMode({ viewMode: 'table' }, { tableViewMode: 'cards' }) + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) + + it('simple always forces table, even against explicit cards', () => { + renderMode({ viewMode: 'cards', simple: true }, { tableViewMode: 'cards' }) + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) + + it('invalid mode values fall back to auto behavior', () => { + renderMode({}, { tableViewMode: 'bogus' }) + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) +}) + +// Width-aware stub so the two thresholds can be told apart. MUI asks in '@media (max-width:Npx)' +// form; anything it doesn't ask about is left unmatched. +const atWidth = (width) => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + const max = /max-width:\s*([\d.]+)px/.exec(query) + const min = /min-width:\s*([\d.]+)px/.exec(query) + cache.set(query, { + matches: (!max || width <= parseFloat(max[1])) && (!min || width >= parseFloat(min[1])), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } +} + +afterEach(() => { + delete window.matchMedia +}) + +const SplitProbe = () => ( + <> +
    {String(useIsMobileLayout())}
    +
    {useTableViewMode()}
    + +) + +// The two thresholds are deliberately different. One query for both breaks an end either way: +// at md the 900-1200 band loses the side nav with no hamburger to open the drawer, at lg +// desktop-width tables become card lists. +describe('the chrome/table split', () => { + it('treats the 900-1200 band as mobile chrome but keeps tables tabular', () => { + atWidth(1000) + renderWithProviders() + + expect(screen.getByTestId('chrome')).toHaveTextContent('true') + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) + + it('moves both to mobile on a phone', () => { + atWidth(800) + renderWithProviders() + + expect(screen.getByTestId('chrome')).toHaveTextContent('true') + expect(screen.getByTestId('mode')).toHaveTextContent('cards') + }) + + it('leaves both on desktop above lg', () => { + atWidth(1300) + renderWithProviders() + + expect(screen.getByTestId('chrome')).toHaveTextContent('false') + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) +}) diff --git a/tests/hooks/use-history-dismiss.test.jsx b/tests/hooks/use-history-dismiss.test.jsx new file mode 100644 index 000000000000..b3bb32e8aa4d --- /dev/null +++ b/tests/hooks/use-history-dismiss.test.jsx @@ -0,0 +1,93 @@ +import React, { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../test-utils"; +import { useHistoryDismiss } from "../../src/hooks/use-history-dismiss"; +import { resetOverlayHistory } from "../../src/utils/overlay-history"; + +const nextPop = () => + new Promise((resolve) => window.addEventListener("popstate", resolve, { once: true })); + +// The back gesture is history navigation; jsdom traverses asynchronously, and the resulting +// state update belongs inside act(). +const swipeBack = async () => { + await act(async () => { + const settled = nextPop(); + window.history.back(); + await settled; + }); +}; + +const Harness = ({ enabled = true, onClose }) => { + const [open, setOpen] = useState(false); + const close = () => { + setOpen(false); + onClose?.(); + }; + useHistoryDismiss(open, close, enabled); + return ( + <> + + {open && ( + <> +
    Row details
    + + + )} + + ); +}; + +afterEach(() => { + resetOverlayHistory(); +}); + +describe("useHistoryDismiss", () => { + it("dismisses the overlay on a back press instead of navigating the page", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open details" })); + expect(screen.getByTestId("overlay")).toBeInTheDocument(); + + await swipeBack(); + + expect(screen.queryByTestId("overlay")).not.toBeInTheDocument(); + }); + + it("gives the entry back when the overlay closes on its own", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open details" })); + await act(async () => { + const settled = nextPop(); + await user.click(screen.getByRole("button", { name: "Close details" })); + await settled; + }); + + // Closed once, by the button — and the history entry went with it, so the next back + // press is the page's again rather than a dead tap. + expect(onClose).toHaveBeenCalledTimes(1); + expect(window.history.state?.__cippOverlay).toBeUndefined(); + }); + + it("stays out of history when disabled", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open details" })); + // Somewhere to go back to, so the gesture is a real navigation attempt. + window.history.pushState({}, ""); + await swipeBack(); + + // Desktop keeps today's behaviour: back belongs to the router, not the overlay. + expect(screen.getByTestId("overlay")).toBeInTheDocument(); + }); +}); diff --git a/tests/hooks/use-sheet-handoff.test.jsx b/tests/hooks/use-sheet-handoff.test.jsx new file mode 100644 index 000000000000..5d9276ef5987 --- /dev/null +++ b/tests/hooks/use-sheet-handoff.test.jsx @@ -0,0 +1,64 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ListItemButton, ListItemText } from "@mui/material"; +import { renderWithProviders } from "../test-utils"; +import { CippBottomSheet } from "../../src/components/CippComponents/CippBottomSheet"; +import { useSheetHandoff } from "../../src/hooks/use-sheet-handoff"; + +// A sheet row that closes the sheet and opens another Modal in the same tick leaves two +// Modals in flight; the outgoing Drawer restores scroll lock and aria-hidden on top of the +// overlay that just opened. The handoff waits for the exit before running the action. +const Harness = ({ onAction }) => { + const [open, setOpen] = React.useState(false); + const sheet = useSheetHandoff(() => setOpen(false)); + return ( + <> + + + sheet.run(onAction)}> + + + + + ); +}; + +describe("useSheetHandoff", () => { + it("runs the action only after the sheet has finished closing", async () => { + const user = userEvent.setup(); + const onAction = vi.fn(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open sheet" })); + await user.click(await screen.findByText("Do the thing")); + + // the tap closes the sheet immediately, but the action is still parked + expect(onAction).not.toHaveBeenCalled(); + + await waitFor(() => expect(onAction).toHaveBeenCalledTimes(1)); + expect(screen.queryByText("Do the thing")).not.toBeInTheDocument(); + }); + + it("drops the parked action when the sheet is dismissed instead", async () => { + const user = userEvent.setup(); + const onAction = vi.fn(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open sheet" })); + await screen.findByText("Do the thing"); + await user.keyboard("{Escape}"); + + await waitFor(() => expect(screen.queryByText("Do the thing")).not.toBeInTheDocument()); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(onAction).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/layouts/AccountPopover.test.jsx b/tests/layouts/AccountPopover.test.jsx new file mode 100644 index 000000000000..ebf14be9b693 --- /dev/null +++ b/tests/layouts/AccountPopover.test.jsx @@ -0,0 +1,99 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../test-utils"; +import { cippPrincipal } from "../mocks/fixtures"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/", + useRouter: () => ({ push: vi.fn() }), +})); + +// jsdom has no width-based matchMedia, so the nav pivot is driven by mocking the hook, and +// MUI's own useMediaQuery answers false there, i.e. the >= md side of the popover's mdDown +// gate. that pairing is the 900-1199 band: nav collapsed, still above md. +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})); + +// stable identities, a fresh object per call re-renders forever +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +const meResult = vi.hoisted(() => ({ + isSuccess: true, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + refetch: () => {}, +})); +vi.mock("../../src/api/ApiCall", () => ({ + ApiGetCall: ({ url }) => (url === "/api/me" ? meResult : idle), + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { AccountPopover } from "../../src/layouts/account-popover"; + +const renderPopover = () => { + const onThemeSwitch = vi.fn(); + const onOpenSearch = vi.fn(); + renderWithProviders( + + ); + return { onThemeSwitch, onOpenSearch }; +}; + +// avatar fallback glyph for john@contoso.com, the popover's only trigger +const openPopover = async () => userEvent.click(await screen.findByText("J")); + +describe("AccountPopover", () => { + beforeEach(() => { + layoutState.isMobile = false; + meResult.data = cippPrincipal(["editor"]); + }); + + it("offers universal search and the theme toggle whenever the top bar hides their icons", async () => { + layoutState.isMobile = true; + const { onThemeSwitch, onOpenSearch } = renderPopover(); + + await openPopover(); + await userEvent.click(screen.getByText("Universal Search")); + expect(onOpenSearch).toHaveBeenCalled(); + + await openPopover(); + await userEvent.click(screen.getByText("Dark Mode")); + expect(onThemeSwitch).toHaveBeenCalled(); + }); + + it("leaves search and theme to the top bar while it still renders their icons", async () => { + renderPopover(); + + await openPopover(); + expect(screen.queryByText("Universal Search")).toBeNull(); + expect(screen.queryByText("Dark Mode")).toBeNull(); + }); + + it("does not repeat the signed-in identity that the trigger is already showing", async () => { + layoutState.isMobile = true; + renderPopover(); + + await openPopover(); + expect(screen.getAllByText("john@contoso.com")).toHaveLength(1); + }); +}); diff --git a/tests/layouts/HeaderedTabbedLayout.test.jsx b/tests/layouts/HeaderedTabbedLayout.test.jsx new file mode 100644 index 000000000000..c3cfeb1b7346 --- /dev/null +++ b/tests/layouts/HeaderedTabbedLayout.test.jsx @@ -0,0 +1,127 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ mdDown: true })); +vi.mock("../../src/hooks/use-breakpoint", () => ({ + useIsMobileLayout: () => layoutState.mdDown, + useIsTabletLayout: () => false, + useTableViewMode: () => "table", +})); + +vi.mock("next/router", () => ({ + useRouter: () => ({ query: {}, push: vi.fn(), pathname: "/tenant/manage/edit" }), +})); +vi.mock("next/navigation", () => ({ usePathname: () => "/tenant/manage/edit" })); + +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { HeaderedTabbedLayout } from "../../src/layouts/HeaderedTabbedLayout"; + +const tabOptions = [ + { label: "Edit Tenant", path: "/tenant/manage/edit", icon: "Settings" }, + { label: "Manage Drift", path: "/tenant/manage/drift", icon: "Sync" }, +]; + +const actions = [ + { + label: "Reset Password", + type: "POST", + url: "/api/ExecResetPass", + confirmText: "Reset the password?", + }, +]; + +const renderLayout = (props = {}) => + renderWithProviders( + +
    page content
    +
    + ); + +describe("HeaderedTabbedLayout mobile header", () => { + beforeEach(() => { + layoutState.mdDown = true; + }); + + it("keeps the header Actions menu on desktop and drops it on mobile", async () => { + renderLayout(); + expect(screen.queryByRole("button", { name: "Actions" })).not.toBeInTheDocument(); + + layoutState.mdDown = false; + renderLayout(); + await waitFor(() => + expect(screen.getAllByRole("button", { name: "Actions" }).length).toBeGreaterThan(0) + ); + }); + + // The title row's right half is empty below md — that is the slot the picker takes, so + // navigation costs no vertical space and does not depend on a FAB being on screen. + it("puts the tab picker in the title row on mobile, and tabs on desktop", async () => { + renderLayout(); + const picker = screen.getByRole("button", { name: /switch view/i }); + expect(picker).toHaveAccessibleName("Edit Tenant switch view"); + expect(screen.queryByRole("tab")).not.toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(picker); + const sheet = within((await screen.findByText("Views")).closest(".MuiDrawer-paper")); + expect(sheet.getByText("Manage Drift")).toBeInTheDocument(); + + layoutState.mdDown = false; + renderLayout(); + await waitFor(() => + expect(screen.getByRole("tab", { name: /Manage Drift/ })).toBeInTheDocument() + ); + }); + + // A FAB is for actions. With none to carry there is nothing to put in the corner. + it("renders no FAB when the page has no actions", () => { + renderLayout({ actions: [] }); + expect(screen.queryByRole("button", { name: /Page actions/ })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /switch view/i })).toBeInTheDocument(); + }); + + // The sheet closing and the overlay opening happen in one tick; MUI's modal manager has + // to settle the unmounting Drawer before the new one registers, or the overlay never + // becomes interactive. + it("opens the action's overlay from the sheet and leaves it open", async () => { + const user = userEvent.setup(); + renderLayout(); + + await user.click(screen.getByRole("button", { name: "Page actions" })); + await user.click(await screen.findByText("Reset Password")); + + // sheet goes away — keepMounted keeps its rows in the DOM, so closed means hidden + await waitFor(() => expect(screen.getByText("Reset Password")).not.toBeVisible()); + + // and the confirmation overlay is present and stays present + const confirm = await screen.findByText(/Reset the password\?/i, {}, { timeout: 3000 }); + expect(confirm).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(screen.getByText(/Reset the password\?/i)).toBeInTheDocument(); + }); +}); diff --git a/tests/layouts/MobileNav.stories.jsx b/tests/layouts/MobileNav.stories.jsx new file mode 100644 index 000000000000..1c4977bbc1ea --- /dev/null +++ b/tests/layouts/MobileNav.stories.jsx @@ -0,0 +1,189 @@ +import React, { useState } from 'react' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Box, Button } from '@mui/material' +import { MobileNav } from '../../src/layouts/mobile-nav' +import { shrinkToPhoneViewport } from '../viewport' + +const items = [ + { title: 'Dashboard', path: '/' }, + { + title: 'Identity Management', + path: '/identity', + items: [ + { title: 'Users', path: '/identity/administration/users' }, + { title: 'Groups', path: '/identity/administration/groups' }, + { title: 'Devices', path: '/identity/administration/devices' }, + ], + }, + { + title: 'Tenant Administration', + path: '/tenant', + items: [ + { title: 'Tenants', path: '/tenant/administration/tenants' }, + { title: 'Alerts', path: '/tenant/administration/alert-configuration' }, + ], + }, + { title: 'Tools', path: '/tools' }, + { title: 'Settings', path: '/cipp/settings' }, +] + +// Mirrors the open/close state Layout owns (layouts/index.js useMobileNav), so the drawer +// behaves here exactly as it does in the app. +const Harness = (props) => { + const [open, setOpen] = useState(false) + return ( + + + setOpen(true)} + onClose={() => setOpen(false)} + {...props} + /> + + ) +} + +export default { + title: 'Layouts/MobileNav', + component: MobileNav, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, +} + +export const Default = { + render: () => , +} + +// Only a real browser can settle this: jsdom runs no transitions, so the frame the close +// animation starts from does not exist there. +export const DragClosesFromWhereItWasLeft = { + render: () => , + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + + await userEvent.click(canvas.getByTestId('open-nav')) + const paper = await waitFor(() => { + const node = document.querySelector('.MuiDrawer-paper') + expect(node).not.toBeNull() + return node + }) + if (!onAPhone) { + return + } + await waitFor(() => + expect(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41).toBe(0) + ) + + // Dispatched on a node inside the paper and left to bubble: MUI reads event.target to + // decide the gesture started in the drawer, so firing at the document bails immediately. + const target = paper.querySelector('nav') ?? paper + const at = (clientX) => + new Touch({ identifier: 1, target, clientX, clientY: 400, pageX: clientX, pageY: 400 }) + const fire = (type, clientX) => + target.dispatchEvent( + new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type === 'touchend' ? [] : [at(clientX)], + changedTouches: [at(clientX)], + }) + ) + + // MUI flags "maybe swiping" in React state on touchstart and ignores moves until that has + // been applied, so the gesture has to be spread across ticks like a real one. + const tick = () => new Promise((resolve) => setTimeout(resolve, 30)) + fire('touchstart', 300) + await tick() + for (const x of [285, 230, 160, 80, 40]) { + fire('touchmove', x) + await tick() + } + + const draggedTo = new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41 + expect(draggedTo).toBeLessThan(-100) + fire('touchend', 40) + + // The exit has to continue from where the finger let go. Slide probes the paper's + // untranslated position when the exit starts (Slide.js getTranslateValue), and the browser + // takes that probe as the transition's start, which snaps the drawer wide open first. + const firstExitFrame = await new Promise((resolve) => { + requestAnimationFrame(() => + requestAnimationFrame(() => + resolve(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41) + ) + ) + }) + expect(firstExitFrame).toBeLessThan(draggedTo * 0.6) + + await waitFor(() => + expect(document.querySelector('.MuiDrawer-root').getAttribute('aria-hidden')).toBe('true') + ) + }, +} + +// enough rows to overflow a phone-height drawer once the group is expanded +const tallItems = [ + { title: 'Dashboard', path: '/' }, + { + title: 'CIPP', + path: '/cipp', + items: [ + { title: 'Custom Data', path: '/cipp/custom-data' }, + { + title: 'Advanced', + path: '/cipp/advanced', + items: [ + { title: 'Super Admin', path: '/cipp/advanced/super-admin/tenant-mode' }, + { title: 'Container Management', path: '/cipp/advanced/container-management/status' }, + { title: 'Authentication', path: '/cipp/advanced/authentication' }, + { title: 'Timers', path: '/cipp/advanced/timers' }, + ], + }, + { title: 'Settings', path: '/cipp/settings' }, + { title: 'Preferences', path: '/cipp/preferences' }, + ], + }, + ...Array.from({ length: 14 }, (_, index) => ({ + title: `Section ${index + 1}`, + path: `/section-${index + 1}`, + })), +] + +export const NavListIsTheOnlyScroller = { + render: () => , + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + + await userEvent.click(canvas.getByTestId('open-nav')) + const paper = await waitFor(() => { + const node = document.querySelector('.MuiDrawer-paper') + expect(node).not.toBeNull() + return node + }) + if (!onAPhone) { + return + } + await waitFor(() => + expect(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41).toBe(0) + ) + + await userEvent.click(await within(paper).findByText('CIPP')) + + // the list has to overflow, or the paper assertion below would pass for the wrong reason + const scroller = paper.querySelector('.simplebar-content-wrapper') + await waitFor(() => + expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight + 200) + ) + + // a scrollable paper carries the pinned sponsor up with it and leaves blank drawer below + expect(paper.scrollHeight).toBeLessThanOrEqual(paper.clientHeight + 1) + }, +} diff --git a/tests/layouts/MobileNav.test.jsx b/tests/layouts/MobileNav.test.jsx new file mode 100644 index 000000000000..6dc18ba06808 --- /dev/null +++ b/tests/layouts/MobileNav.test.jsx @@ -0,0 +1,77 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { act } from '@testing-library/react' +import { renderWithProviders, settingsWith } from '../test-utils' + +vi.mock('next/navigation', () => ({ + usePathname: () => '/', + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(''), +})) + +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})) +vi.mock('../../src/api/ApiCall', () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})) + +import { MobileNav } from '../../src/layouts/mobile-nav' + +const items = [{ title: 'Dashboard', path: '/' }] + +// MUI binds touchstart/touchmove/touchend on the document, so the swipe lifecycle is driven +// with native events; userEvent emits pointer/mouse, which SwipeableDrawer ignores. +const touch = (el, type, x = 5, y = 200) => { + const event = new Event(type, { bubbles: true, cancelable: true }) + const point = { pageX: x, pageY: y, clientX: x, clientY: y } + Object.defineProperty(event, 'touches', { value: type === 'touchend' ? [] : [point] }) + Object.defineProperty(event, 'changedTouches', { value: [point] }) + act(() => { + el.dispatchEvent(event) + }) +} + +const renderNav = (props = {}) => { + const onOpen = vi.fn() + const onClose = vi.fn() + renderWithProviders( + , + { settings: settingsWith({ bookmarkSidebar: false }) } + ) + return { onOpen, onClose } +} + +describe('MobileNav', () => { + it('renders no edge swipe area', () => { + renderNav() + expect(document.querySelector('.PrivateSwipeArea-root')).toBeNull() + }) + + // MUI forces the modal open while a swipe is in progress (maybeSwiping), and a touch with no + // movement never sets isSwiping, so handleBodyTouchEnd bails before onOpen/onClose. The drawer + // animates in and straight back out with the app's open state untouched. + it('leaves the drawer closed on a left-edge tap', () => { + const { onOpen, onClose } = renderNav() + const target = document.querySelector('.PrivateSwipeArea-root') ?? document.body + const drawer = document.querySelector('.MuiDrawer-root') + expect(drawer.getAttribute('aria-hidden')).toBe('true') + + touch(target, 'touchstart') + expect(drawer.getAttribute('aria-hidden')).toBe('true') + + touch(target, 'touchend') + expect(drawer.getAttribute('aria-hidden')).toBe('true') + expect(onOpen).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/tests/layouts/TabbedLayout.test.jsx b/tests/layouts/TabbedLayout.test.jsx new file mode 100644 index 000000000000..f913a511e505 --- /dev/null +++ b/tests/layouts/TabbedLayout.test.jsx @@ -0,0 +1,261 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Button } from "@mui/material"; +import { renderWithProviders } from "../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false, viewMode: "table" })); +// partial mock: real module spread first, so new exports keep working here +vi.mock("../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => layoutState.viewMode, +})); + +const routerState = vi.hoisted(() => ({ push: vi.fn(), pathname: "/dashboardv2" })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: routerState.push }), + usePathname: () => routerState.pathname, + useSearchParams: () => new URLSearchParams(""), +})); + +// Stable identities: a fresh object per call re-renders forever (tests/mocks/api-call.js) +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { TabbedLayout } from "../../src/layouts/TabbedLayout"; +import { CippPageActionsFab } from "../../src/components/CippComponents/CippPageActionsFab"; +import { CippDataTable } from "../../src/components/CippTable/CippDataTable"; + +const tabOptions = [ + { label: "Overview", path: "/dashboardv2", icon: "Dashboard" }, + { label: "Identity", path: "/dashboardv2/identity", icon: "Person" }, + { label: "Devices", path: "/dashboardv2/devices", icon: "Devices" }, +]; + +const picker = () => screen.getByRole("button", { name: /switch view/i }); +const queryPickers = () => screen.queryAllByRole("button", { name: /switch view/i }); + +// The trigger names the current view and so does its row in the sheet — scope sheet +// assertions to the sheet, or every current-tab query matches twice. +const openPicker = async (user) => { + await user.click(picker()); + const sheet = await screen.findByText("Views"); + return within(sheet.closest(".MuiDrawer-paper")); +}; + +describe("TabbedLayout", () => { + beforeEach(() => { + layoutState.isMobile = false; + layoutState.viewMode = "table"; + routerState.push = vi.fn(); + routerState.pathname = "/dashboardv2"; + }); + + it("renders a tab bar on desktop and no picker", () => { + renderWithProviders( + +
    page content
    +
    + ); + + expect(screen.getByRole("tab", { name: /Overview/ })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /Devices/ })).toBeInTheDocument(); + expect(queryPickers()).toHaveLength(0); + }); + + it("replaces the tab bar with a picker in the content flow on mobile", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders( + +
    page content
    +
    + ); + + expect(screen.queryByRole("tab")).not.toBeInTheDocument(); + // the trigger names where you are; the sheet is where the rest live + expect(picker()).toHaveAccessibleName("Overview switch view"); + + const sheet = await openPicker(user); + tabOptions.forEach((tab) => expect(sheet.getByText(tab.label)).toBeInTheDocument()); + }); + + // pages/index.js re-exports the dashboard, so it renders at "/" while every tab path is + // /dashboardv2/... — no match meant the trigger fell back to "Views" and the sheet had no + // check. An aliased route belongs to the tab whose page it re-exports: the first one. + it("treats an aliased route as the first tab instead of showing no selection", async () => { + layoutState.isMobile = true; + routerState.pathname = "/"; + const user = userEvent.setup(); + renderWithProviders( + +
    page content
    +
    + ); + + expect(picker()).toHaveAccessibleName("Overview switch view"); + + const sheet = await openPicker(user); + expect(sheet.getByText("Overview").closest('[role="button"]')).toHaveClass("Mui-selected"); + + // and tapping the aliased tab is still a no-op, not a navigation loop + await user.click(sheet.getByText("Overview")); + expect(routerState.push).not.toHaveBeenCalled(); + }); + + it("navigates when a tab row is tapped, and does nothing for the current tab", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders( + +
    page content
    +
    + ); + + let sheet = await openPicker(user); + await user.click(sheet.getByText("Devices")); + expect(routerState.push).toHaveBeenCalledWith("/dashboardv2/devices"); + + routerState.push = vi.fn(); + sheet = await openPicker(user); + await user.click(sheet.getByText("Overview")); + expect(routerState.push).not.toHaveBeenCalled(); + }); + + // A single destination is not navigation — View Group and View Device have one tab each and + // used to get a FAB whose sheet offered the page you were already on. + it("renders no picker when there is only one destination", () => { + layoutState.isMobile = true; + renderWithProviders( + +
    page content
    +
    + ); + + expect(queryPickers()).toHaveLength(0); + }); + + it("counts visible tabs, not configured ones, when deciding to render", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + const gated = [tabOptions[0], { label: "Diagnostics", path: "/x", advanced: true }]; + + // one real tab plus one the user's advanced setting hides — nothing to switch between + const { unmount } = renderWithProviders( + +
    page content
    +
    + ); + expect(queryPickers()).toHaveLength(0); + unmount(); + + renderWithProviders( + +
    page content
    +
    + ); + const sheet = await openPicker(user); + expect(sheet.getByText("Overview")).toBeInTheDocument(); + expect(sheet.queryByText("Diagnostics")).not.toBeInTheDocument(); + }); + + // One control, one place, on every tabbed page — never annexing a heading that happens to + // be nearby on some page types and not others. + it("draws exactly one picker, in its own row, whatever the page renders", async () => { + layoutState.isMobile = true; + layoutState.viewMode = "cards"; + renderWithProviders( + + + + ); + + await waitFor(() => expect(screen.getByText("Relationships")).toBeInTheDocument()); + expect(queryPickers()).toHaveLength(1); + // the page's own heading is still a heading, not a control + expect(picker()).not.toHaveTextContent("Relationships"); + }); + + // Destinations used to ride in this sheet. A FAB is for a screen's primary action. + it("no longer puts destinations in the page FAB", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders( + + + + + + ); + + // the layout adds no FAB of its own any more — this one is the page's, and navigation + // sits in the content flow beside it + const fabs = screen.getAllByRole("button", { name: /Page actions/ }); + expect(fabs).toHaveLength(1); + expect(picker()).toBeInTheDocument(); + + // the sheet is a modal, so it aria-hides the page behind it — assert on its contents only + await user.click(fabs[0]); + expect(await screen.findByRole("button", { name: "Add Variable" })).toBeInTheDocument(); + expect(screen.queryByText("Identity")).not.toBeInTheDocument(); + expect(screen.queryByText("Devices")).not.toBeInTheDocument(); + expect(screen.queryByText("Views")).not.toBeInTheDocument(); + }); + + // The defect the FAB placement caused: the card list claimed the corner during select mode + // but drew no FAB there, and the layout stood down because the corner was claimed — leaving + // no way at all to reach the other views until selection ended. + it("keeps navigation reachable while a card list is in select mode", async () => { + layoutState.isMobile = true; + layoutState.viewMode = "cards"; + const user = userEvent.setup(); + renderWithProviders( + + + + ); + + await waitFor(() => expect(queryPickers()).toHaveLength(1)); + + await user.click(screen.getByRole("button", { name: /^Select$/ })); + await waitFor(() => + expect(screen.getByRole("button", { name: /^Cancel$/ })).toBeInTheDocument() + ); + + // this is the assertion the FAB placement could not satisfy + expect(queryPickers()).toHaveLength(1); + const sheet = await openPicker(user); + expect(sheet.getByText("Devices")).toBeInTheDocument(); + }); +}); diff --git a/tests/layouts/header-overflow.stories.jsx b/tests/layouts/header-overflow.stories.jsx new file mode 100644 index 000000000000..3ee8d203204b --- /dev/null +++ b/tests/layouts/header-overflow.stories.jsx @@ -0,0 +1,90 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { Box, Stack, SvgIcon, Typography } from '@mui/material' +import { Mail, Fingerprint, CalendarToday } from '@mui/icons-material' +import { CippCopyToClipBoard } from '../../src/components/CippComponents/CippCopyToClipboard' +import { shrinkToPhoneViewport } from '../viewport' + +/** + * Reproduces HeaderedTabbedLayout's mobile header markup — it cannot render the layout + * itself, which needs next/router and this Storybook runs on @storybook/react-vite. Keep the + * two in step: this exists to hold the CSS contract that lets a copy-chip truncate. + * + * A guest UPN is the worst case in the app: `user_domain.onmicrosoft.com#EXT#@tenant...` is + * one unbreakable token, roughly 60 characters, and it ran off the right edge of the screen. + */ +const GUEST_UPN = 'jduprey_7ngn50.onmicrosoft.com#EXT#@1h81wz.onmicrosoft.com' + +const SubtitleItem = ({ icon, children }) => ( + + + {icon} + + + {children} + + +) + +export default { + title: 'Layouts/HeaderedTabbedLayout/MobileHeader', + tags: ['autodocs'], +} + +export const GuestUpnDoesNotSpill = { + render: () => ( + + + + + + jduprey + + + + + }> + + + }> + + + }>Created: 1 month ago + + + + ), + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + + await step('the guest UPN chip truncates instead of widening the page', async () => { + const host = canvasElement.querySelector('[data-testid="header-host"]') + await waitFor(() => expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth)) + await expect(document.documentElement.scrollWidth).toBeLessThanOrEqual( + document.documentElement.clientWidth + ) + }) + + await step('and it is still the full value on the clipboard, not a truncated one', async () => { + // the label is elided in CSS only — the text node keeps the whole UPN + await expect(canvas.getByText(GUEST_UPN)).toBeInTheDocument() + }) + }, +} diff --git a/tests/layouts/notification-badge.stories.jsx b/tests/layouts/notification-badge.stories.jsx new file mode 100644 index 000000000000..5287412947fb --- /dev/null +++ b/tests/layouts/notification-badge.stories.jsx @@ -0,0 +1,92 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { Avatar, Badge, IconButton, Stack, SvgIcon } from '@mui/material' +import BellIcon from '@heroicons/react/24/outline/BellIcon' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../viewport' + +/** + * The top bar's right-hand cluster, reproduced — `TopNav` itself pulls in the router, the + * tenant list and half a dozen API hooks. Keep this in step with `notifications-popover.js` + * and `top-nav.js`; it exists to hold one thing, which is that the notification dot belongs + * to the bell and not to the avatar beside it. + */ +const Cluster = ({ mobile }) => ( + + + + + + + + + + J + + +) + +export default { + title: 'Layouts/TopNav/NotificationBadge', + tags: ['autodocs'], +} + +const dotAndAvatar = (canvasElement) => ({ + bell: canvasElement.querySelector('.MuiBadge-root'), + dot: canvasElement.querySelector('.MuiBadge-badge'), + avatar: canvasElement.querySelector('[data-testid="account-avatar"]'), +}) + +export const DotStaysWithTheBellOnAPhone = { + render: () => , + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const { bell, dot, avatar } = dotAndAvatar(canvasElement) + + await step('the dot sits inside the bell, not over the gap to the avatar', async () => { + await waitFor(() => { + const d = dot.getBoundingClientRect() + const b = bell.getBoundingClientRect() + const a = avatar.getBoundingClientRect() + expect(d.right).toBeLessThanOrEqual(b.right + 0.5) + expect(d.top).toBeGreaterThanOrEqual(b.top - 0.5) + // and there is real space left between it and the avatar + expect(a.left - d.right).toBeGreaterThan(4) + }) + }) + }, +} + +// The md values are MUI's own, so the badge keeps hanging off the corner above the breakpoint. +export const DotKeepsItsCornerOnDesktop = { + render: () => , + play: async ({ canvasElement, step }) => { + const onDesktop = await growToDesktopViewport() + if (!onDesktop) return + const { bell, dot } = dotAndAvatar(canvasElement) + + await step('the dot still overhangs the button', async () => { + await waitFor(() => { + const d = dot.getBoundingClientRect() + const b = bell.getBoundingClientRect() + expect(d.right).toBeGreaterThan(b.right) + }) + }) + }, +} diff --git a/tests/lint/mobile-layout-patterns.test.js b/tests/lint/mobile-layout-patterns.test.js new file mode 100644 index 000000000000..30fe14b26f3e --- /dev/null +++ b/tests/lint/mobile-layout-patterns.test.js @@ -0,0 +1,253 @@ +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +// Two MUI patterns account for nearly every mobile layout bug in this app, and both are +// invisible on a desktop screen — so they ship freely and only surface as a phone report. +// This walks src/ and fails on either, which is cheaper than finding them one at a time. +// +// 1. / size={{ xs: N }} with N < 12 holds a desktop column split at 390px. +// 2. A Stack with flexWrap but no useFlexGap: MUI's `spacing` is a margin-left between +// children, and every wrapped row inherits it, so each new line starts indented. +// 3. A dashboard card pinned to a pixel height. That height exists to level two columns of +// a desktop grid; below lg the grid is a single column, so it levels nothing and clips +// instead — the Secure Score card lost its whole stats row off the bottom edge. + +const SRC = path.resolve(__dirname, "../../src"); + +// Dead Devias template code — nothing in pages/, components/ or layouts/ imports it. +const IGNORED_DIRS = new Set(["sections"]); + +const walk = (dir) => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + return IGNORED_DIRS.has(entry.name) ? [] : walk(full); + } + return /\.(js|jsx)$/.test(entry.name) ? [full] : []; + }); + +const rel = (file) => path.relative(SRC, file); + +// Commented-out JSX is not shipped markup — blank it (preserving newlines so reported +// line numbers stay accurate) rather than flagging code nobody renders. +const stripComments = (source) => + source + .replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, (m) => m.replace(/[^\n]/g, " ")) + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")); + +/** Opening tags for `name`, brace-aware so multi-line JSX props stay in one string. */ +const openingTags = (source, name) => { + const tags = []; + const re = new RegExp(`<${name}\\b`, "g"); + let match; + while ((match = re.exec(source))) { + let depth = 0; + for (let i = match.index; i < source.length; i += 1) { + const char = source[i]; + if (char === "{") depth += 1; + else if (char === "}") depth -= 1; + else if (char === ">" && depth === 0) { + const text = source.slice(match.index, i + 1); + const line = source.slice(0, match.index).split("\n").length; + tags.push({ text, line, endLine: line + text.split("\n").length - 1 }); + break; + } + } + } + return tags; +}; + +// Not every fixed split is a bug — a tile can be designed to sit two-up at 390px. Marking +// the site opts it out, deliberately, in the source, next to the reason, where +// `rg mobile-layout-ok` finds every one of them. Read from the RAW source because comments +// are stripped before matching, and counted on the tag's own lines or the three above it, +// since JSX has nowhere to put a comment between props. +const MARKER = "mobile-layout-ok"; +const LOOKBACK = 3; + +const isExempt = (marked, tag) => { + for (let line = tag.line - LOOKBACK; line <= tag.endLine; line += 1) { + if (marked.has(line)) return true; + } + return false; +}; + +/** Grid splits that survive a phone, as `line reason` strings. */ +export const gridOffenders = (rawSource) => { + const source = stripComments(rawSource); + const marked = new Set(); + rawSource.split("\n").forEach((text, index) => { + if (text.includes(MARKER)) marked.add(index + 1); + }); + + const offenders = []; + for (const tag of openingTags(source, "Grid")) { + if (isExempt(marked, tag)) continue; + const bare = tag.text.match(/\bsize=\{(\d+(?:\.\d+)?)\}/); + if (bare && Number(bare[1]) !== 12) { + offenders.push(`${tag.line} size={${bare[1]}}`); + } + const xs = tag.text.match(/\bsize=\{\{[^}]*?\bxs:\s*(\d+(?:\.\d+)?)/); + if (xs && Number(xs[1]) < 12) { + offenders.push(`${tag.line} xs: ${xs[1]}`); + } + // v1 props are silently inert under Grid v2 — the split never applied at all + if (/]*\bxs=\{/.test(tag.text)) { + offenders.push(`${tag.line} legacy xs= prop (inert under Grid v2)`); + } + } + return offenders; +}; + +/** + * Percent-width column splits on Box/Stack, as `line reason` strings. The flexbox sibling + * of the Grid rule above: `` beside `` holds a desktop + * split at 390px too — the role editor's summary pane sat off the right edge of a phone + * this way. A responsive object (`width={{ xs: "100%", xl: "30%" }}`) passes. + */ +export const percentSplitOffenders = (rawSource) => { + const source = stripComments(rawSource); + const marked = new Set(); + rawSource.split("\n").forEach((text, index) => { + if (text.includes(MARKER)) marked.add(index + 1); + }); + + const offenders = []; + for (const name of ["Box", "Stack"]) { + for (const tag of openingTags(source, name)) { + if (isExempt(marked, tag)) continue; + const percent = tag.text.match(/\bwidth=\{?"(\d{1,2})%"\}?/); + if (percent) offenders.push(`${tag.line} width="${percent[1]}%"`); + } + } + return offenders; +}; + +/** Dashboard card wrappers pinned to a pixel height, as `line reason` strings. */ +export const pinnedHeightOffenders = (rawSource) => { + const source = stripComments(rawSource); + const marked = new Set(); + rawSource.split("\n").forEach((text, index) => { + if (text.includes(MARKER)) marked.add(index + 1); + }); + + const offenders = []; + for (const tag of openingTags(source, "Box")) { + if (isExempt(marked, tag)) continue; + // `height: 450` — a bare number. `height: { xs: 'auto', lg: 450 }` is the fix, and + // minHeight/maxHeight are constraints rather than a pin, so both are left alone. + const pinned = tag.text.match(/[^a-zA-Z]height:\s*(\d+)\s*[,}]/); + if (pinned) offenders.push(`${tag.line} height: ${pinned[1]}`); + } + return offenders; +}; + +const files = walk(SRC); +const dashboardFiles = files.filter((file) => rel(file).startsWith(path.join("pages", "dashboardv2"))); + +describe("mobile layout patterns", () => { + it("has files to check", () => { + expect(files.length).toBeGreaterThan(100); + }); + + it("declares no Grid column split that survives a phone", () => { + const offenders = files.flatMap((file) => + gridOffenders(fs.readFileSync(file, "utf8")).map((offender) => `${rel(file)}:${offender}`) + ); + expect(offenders, `Use size={{ xs: 12, sm|md: N }} instead:\n${offenders.join("\n")}`).toEqual( + [] + ); + }); + + it("takes a marked split at its word", () => { + const split = " \n"; + expect(gridOffenders(split)).toEqual(["1 xs: 6"]); + // on a line above, which is the only place JSX leaves room for one + expect(gridOffenders(` // two-up by design: ${MARKER}\n${split}`)).toEqual([]); + // or among the props of a tag spanning several lines + expect( + gridOffenders(` \n`) + ).toEqual([]); + // but a marker further up the file does not blanket the rest of it + expect(gridOffenders(` // ${MARKER}\n\n\n\n\n${split}`)).toEqual(["6 xs: 6"]); + }); + + it("declares no percent-width flex split that survives a phone", () => { + const offenders = files.flatMap((file) => + percentSplitOffenders(fs.readFileSync(file, "utf8")).map( + (offender) => `${rel(file)}:${offender}` + ) + ); + expect( + offenders, + `A percent width on Box/Stack holds a desktop split at 390px. Use width={{ xs: "100%", md|xl: "N%" }} or a Grid:\n${offenders.join("\n")}` + ).toEqual([]); + }); + + it("reads a percent split only as a fixed string width", () => { + expect(percentSplitOffenders(` \n`)).toEqual(['1 width="30%"']); + expect(percentSplitOffenders(` \n`)).toEqual(['1 width="80%"']); + expect(percentSplitOffenders(` \n`)).toEqual([]); + expect(percentSplitOffenders(` \n`)).toEqual([]); + expect(percentSplitOffenders(` \n`)).toEqual([]); + expect(percentSplitOffenders(` // ${MARKER}\n \n`)).toEqual([]); + }); + + it("pins no dashboard card to a pixel height", () => { + expect(dashboardFiles.length).toBeGreaterThan(0); + const offenders = dashboardFiles.flatMap((file) => + pinnedHeightOffenders(fs.readFileSync(file, "utf8")).map( + (offender) => `${rel(file)}:${offender}` + ) + ); + expect( + offenders, + `Below lg the dashboard is one column, so a fixed height only clips. Use height: { xs: 'auto', lg: N }:\n${offenders.join("\n")}` + ).toEqual([]); + }); + + it("reads a pinned height only as a bare number", () => { + expect(pinnedHeightOffenders(` \n`)).toEqual(["1 height: 450"]); + expect(pinnedHeightOffenders(` \n`)).toEqual([]); + expect(pinnedHeightOffenders(` \n`)).toEqual([]); + expect(pinnedHeightOffenders(` \n`)).toEqual([]); + expect(pinnedHeightOffenders(` // ${MARKER}\n \n`)).toEqual([]); + }); + + // Side nav, the drawer that replaces it, the hamburger that opens the drawer and the content + // gutter are four gates on one decision. Any of them declaring its own query lets them + // disagree, and a width with no side nav and no way to open the drawer has no nav at all. + it("keys layout chrome off the shared breakpoint hook, not its own media query", () => { + const offenders = []; + for (const name of ["index.js", "top-nav.js"]) { + const source = stripComments(fs.readFileSync(path.join(SRC, "layouts", name), "utf8")); + source.split("\n").forEach((line, i) => { + if (/useMediaQuery\(.*breakpoints\.(down|up|between)\(/.test(line)) { + offenders.push(`layouts/${name}:${i + 1}`); + } + }); + } + expect( + offenders, + `Nav gates have to agree. Use useIsMobileLayout from hooks/use-breakpoint:\n${offenders.join("\n")}` + ).toEqual([]); + }); + + it("gives every wrapping Stack useFlexGap", () => { + const offenders = []; + for (const file of files) { + const source = stripComments(fs.readFileSync(file, "utf8")); + if (!source.includes("flexWrap")) continue; + for (const { text, line } of openingTags(source, "Stack")) { + if (!text.includes("flexWrap") || text.includes("useFlexGap")) continue; + if (/flexWrap[=:]\s*[{'"\s]*nowrap/.test(text)) continue; + offenders.push(`${rel(file)}:${line}`); + } + } + expect( + offenders, + `Stack spacing is a margin that wrapped rows inherit — add useFlexGap:\n${offenders.join("\n")}` + ).toEqual([]); + }); +}); diff --git a/tests/pages/MessageEncryptionPage.test.jsx b/tests/pages/MessageEncryptionPage.test.jsx new file mode 100644 index 000000000000..c378ae2bb994 --- /dev/null +++ b/tests/pages/MessageEncryptionPage.test.jsx @@ -0,0 +1,136 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../test-utils' +import Page from '../../src/pages/email/tools/message-encryption/index.js' + +vi.mock('../../src/api/ApiCall', async () => + (await import('../mocks/api-call')).apiCallMock() +) +import { api, getResult, paginatedResult, postResult } from '../mocks/api-call' + +const AZURE_RMS = 'https://5c6bb73b-1234.rms.na.aadrm.com/_wmcs/licensing' +const AD_RMS = 'https://rms.contoso.local/_wmcs/licensing' + +// stable identity per the mock's own warning: a fresh literal per call spins the +// effects that key off the data object +const irmConfig = (overrides = {}) => ({ + AzureRMSLicensingEnabled: true, + InternalLicensingEnabled: true, + ExternalLicensingEnabled: false, + SimplifiedClientAccessEnabled: false, + TransportDecryptionSetting: 'Optional', + JournalReportDecryptionEnabled: true, + LicensingLocation: [AZURE_RMS], + MessageEncryptionEnabled: true, + AdRmsDetected: false, + ...overrides, +}) + +describe('Message Encryption page', () => { + beforeEach(() => { + vi.clearAllMocks() + api.post = postResult() + api.paginated = paginatedResult([ + { displayName: 'Admin', UPN: 'admin@contoso.com' }, + { displayName: 'Helpdesk', UPN: 'helpdesk@contoso.com' }, + ]) + }) + + it('renders the current IRM state for the tenant', async () => { + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + expect(await screen.findByText('Current Configuration')).toBeInTheDocument() + expect(screen.getByText('Enabled')).toBeInTheDocument() + expect(screen.getByText(AZURE_RMS)).toBeInTheDocument() + }) + + it('hides the migration warning for a cloud-only tenant', async () => { + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + await screen.findByText('Current Configuration') + expect(screen.queryByText(/not compatible with/i)).not.toBeInTheDocument() + }) + + it('warns that AD RMS has to be migrated before message encryption can be used', async () => { + api.get = getResult({ + data: irmConfig({ + AzureRMSLicensingEnabled: false, + MessageEncryptionEnabled: false, + LicensingLocation: [AD_RMS], + AdRmsDetected: true, + }), + }) + renderWithProviders() + + expect(await screen.findByText(/not compatible with/i)).toBeInTheDocument() + expect( + screen.getByRole('link', { name: 'migrated to Azure RMS' }) + ).toBeInTheDocument() + }) + + it('keeps Run Test disabled until both mailboxes are selected', async () => { + const user = userEvent.setup() + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + const runTest = await screen.findByRole('button', { name: 'Run Test' }) + expect(runTest).toBeDisabled() + + await user.click(screen.getByRole('combobox', { name: 'Sender' })) + await user.click( + await screen.findByRole('option', { name: 'Admin (admin@contoso.com)' }) + ) + expect(runTest).toBeDisabled() + + await user.click(screen.getByRole('combobox', { name: 'Recipient' })) + await user.click( + await screen.findByRole('option', { + name: 'Helpdesk (helpdesk@contoso.com)', + }) + ) + expect(runTest).toBeEnabled() + }) + + it('posts the Test action with the entered addresses', async () => { + const user = userEvent.setup() + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + await user.click(await screen.findByRole('combobox', { name: 'Sender' })) + await user.click( + await screen.findByRole('option', { name: 'Admin (admin@contoso.com)' }) + ) + await user.click(screen.getByRole('combobox', { name: 'Recipient' })) + await user.click( + await screen.findByRole('option', { + name: 'Helpdesk (helpdesk@contoso.com)', + }) + ) + await user.click(screen.getByRole('button', { name: 'Run Test' })) + + expect(api.post.mutate).toHaveBeenCalledWith({ + url: '/api/ExecIRMConfiguration', + data: { + tenantFilter: 'testdomain.com', + Action: 'Test', + Sender: 'admin@contoso.com', + Recipient: 'helpdesk@contoso.com', + }, + }) + }) + + it('surfaces a load failure', async () => { + api.get = getResult({ isSuccess: false, isError: true, data: undefined }) + renderWithProviders() + + expect( + await screen.findByText(/Failed to load the IRM configuration/i) + ).toBeInTheDocument() + // no card, otherwise every undefined field renders as a confident "Disabled"/"No" + expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument() + }) +}) diff --git a/tests/pages/WorkerHealthPage.test.jsx b/tests/pages/WorkerHealthPage.test.jsx index 70233904da90..7f8bf6a8f392 100644 --- a/tests/pages/WorkerHealthPage.test.jsx +++ b/tests/pages/WorkerHealthPage.test.jsx @@ -1,6 +1,6 @@ import React from 'react' import { describe, it, expect, vi, beforeEach } from 'vitest' -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test-utils' import Page from '../../src/pages/cipp/advanced/container-management/worker-health.js' @@ -8,6 +8,7 @@ import Page from '../../src/pages/cipp/advanced/container-management/worker-heal vi.mock('../../src/api/ApiCall', async () => (await import('../mocks/api-call')).apiCallMock()) import { api, getResult, paginatedResult, postResult } from '../mocks/api-call' import { ApiGetCallWithPagination } from '../../src/api/ApiCall' +import { resetOverlayHistory } from '../../src/utils/overlay-history' // stable refs, see GraphExplorerPage.test.jsx (fresh literals per call loop the data-sync effects) const jobsResult = paginatedResult([ @@ -51,6 +52,68 @@ describe('Worker Health page - job queue preset filters', () => { }) }) + // Craft marks stale queue entries (task gone by dispatch time) as Skipped — same + // server-side filter contract as every other status. + it('Skipped toggle requests server-side filtering via the Status param', async () => { + const user = userEvent.setup() + renderWithProviders() + await screen.findByText('1-5 of 5') + + await user.click(screen.getByRole('button', { name: 'Skipped' })) + + await waitFor(() => { + const last = ApiGetCallWithPagination.mock.calls.at(-1)[0] + expect(last.queryKey).toBe('WorkerHealthJobs-2000-Skipped') + expect(last.data).toMatchObject({ Action: 'Jobs', Limit: '2000', Status: 'Skipped' }) + }) + }) + + // jsdom has no layout engine, so MRT's virtualized table renders no cells — drive the + // card view instead, where tapping a card opens the off-canvas (see CippDataTable.test.jsx). + it('opening a job card shows the off-canvas detail fields', async () => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + cache.set(query, { + matches: query.includes('max-width'), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } + try { + const user = userEvent.setup() + renderWithProviders() + + await waitFor(() => expect(screen.getByText('Job Five')).toBeInTheDocument()) + await user.click(screen.getByText('Job Five')) + + // Drawer title is the job name; scope assertions to the drawer since the card + // behind it renders some of the same text. + const drawer = await waitFor(() => { + const d = screen + .getAllByText('Job Five') + .map((el) => el.closest('.MuiDrawer-paper')) + .find(Boolean) + expect(d).toBeTruthy() + return d + }) + // Started Utc is not a table column, and the Id value is hidden from the table. + // Job Five never started, so its StartedUtc renders as N/A. + expect(within(drawer).getByText('Started Utc')).toBeInTheDocument() + expect(within(drawer).getByText('j5')).toBeInTheDocument() + } finally { + resetOverlayHistory() + delete window.matchMedia + } + }, 30000) // card list mount + drawer transition; default 5000ms testTimeout flakes under load (see GraphExplorerPage) + it('All toggle drops the Status param instead of sending an empty string', async () => { const user = userEvent.setup() renderWithProviders() diff --git a/tests/theme/input-zoom.test.js b/tests/theme/input-zoom.test.js new file mode 100644 index 000000000000..4a47e93e3e84 --- /dev/null +++ b/tests/theme/input-zoom.test.js @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { createTheme } from "../../src/theme"; + +// iOS Safari zooms the viewport when a focused input renders text below 16px, and it does +// not zoom back out afterwards. Every MUI input must reach 16px on coarse pointers. +const COARSE = "@media (pointer: coarse)"; + +describe("input font size on touch devices", () => { + const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" }); + + it.each(["MuiInputBase", "MuiFilledInput"])("%s inputs reach 16px on coarse pointers", (key) => { + const input = theme.components[key].styleOverrides.input; + expect(input.fontSize).toBeLessThan(16); // pointer devices stay compact + expect(input[COARSE]?.fontSize).toBe(16); + }); +}); diff --git a/tests/theme/mobile-gutters.test.js b/tests/theme/mobile-gutters.test.js new file mode 100644 index 000000000000..bf17a4fd4c6b --- /dev/null +++ b/tests/theme/mobile-gutters.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { createTheme } from "../../src/theme"; + +// Card gutters are set once in the theme and paid at every nesting level: a card inside an +// accordion inside a page card spends most of a phone's width on chrome before any content +// gets a pixel. These have to stay narrower below md — and desktop has to keep its 24px. +const MOBILE = "@media (max-width: 899.95px)"; + +describe("horizontal gutters on small screens", () => { + const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" }); + const root = (key) => theme.components[key].styleOverrides.root; + + it.each(["MuiCardContent", "MuiCardHeader", "MuiCardActions"])( + "%s trims its 24px gutters on a phone", + (key) => { + expect(root(key).paddingLeft).toBe(24); + expect(root(key)[MOBILE]?.paddingLeft).toBe(16); + expect(root(key)[MOBILE]?.paddingRight).toBe(16); + } + ); + + it.each(["MuiAccordionSummary", "MuiAccordionDetails"])( + "%s halves the padding it adds inside a card", + (key) => { + expect(root(key)[MOBILE]?.paddingLeft).toBe(8); + expect(root(key)[MOBILE]?.paddingRight).toBe(8); + } + ); + + // `:first-of-type` counts per element type, so an actions row of [caption div, button, + // button] gave the first button no margin and the second 16px. Invisible in a row; once the + // row stacks on a phone the two buttons sit at different left edges and different widths. + it("spaces dialog actions with gap on a phone, not a margin the stack inherits", () => { + const actions = root("MuiDialogActions"); + expect(actions["&>:not(:first-of-type)"].marginLeft).toBe(16); + expect(actions[MOBILE]?.["&>:not(:first-of-type)"]?.marginLeft).toBe(0); + expect(actions[MOBILE]?.gap).toBe(8); + expect(actions[MOBILE]?.paddingLeft).toBe(16); + }); + + it("leaves vertical rhythm alone — width is what runs out, not height", () => { + const content = root("MuiCardContent"); + expect(content.paddingTop).toBe(20); + expect(content[MOBILE]?.paddingTop).toBeUndefined(); + expect(content[MOBILE]?.paddingBottom).toBeUndefined(); + }); +}); diff --git a/tests/theme/tooltip-touch.test.jsx b/tests/theme/tooltip-touch.test.jsx new file mode 100644 index 000000000000..d7b306af5668 --- /dev/null +++ b/tests/theme/tooltip-touch.test.jsx @@ -0,0 +1,56 @@ +import React from "react"; +import { describe, it, expect } from "vitest"; +import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { Tooltip, Button } from "@mui/material"; +import { createTheme } from "../../src/theme"; +import { renderWithTheme } from "../test-utils"; + +// MUI's Tooltip attaches no touchmove and no scroll listener: handleTouchStart arms a 700ms +// timer that opens the tooltip, and only handleTouchEnd schedules the close. A press held +// through a scroll therefore opens one and nothing closes it while the finger is down. +describe("tooltips on touch", () => { + it("is disabled by default across the app", () => { + const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" }); + expect(theme.components.MuiTooltip.defaultProps.disableTouchListener).toBe(true); + }); + + // Real timers: MUI arms enterDelay inside the enterTouchDelay callback, and the nested + // pair does not advance reliably under fake ones — a faked version of this test passed + // with the fix removed, which is worse than no test. + it("does not open from a long press", async () => { + renderWithTheme( + + + + ); + + fireEvent.touchStart(screen.getByRole("button")); + await new Promise((resolve) => setTimeout(resolve, 400)); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("still opens on hover, where a tooltip belongs", async () => { + renderWithTheme( + + + + ); + + fireEvent.mouseOver(screen.getByRole("button")); + + expect(await screen.findByRole("tooltip")).toHaveTextContent("Users in this tenant"); + }); + + it("lets a site opt back in", async () => { + renderWithTheme( + + + + ); + + fireEvent.touchStart(screen.getByRole("button")); + + await waitFor(() => expect(screen.getByRole("tooltip")).toBeInTheDocument()); + }); +}); diff --git a/tests/utils/csv-field-values.test.js b/tests/utils/csv-field-values.test.js new file mode 100644 index 000000000000..027262e2be95 --- /dev/null +++ b/tests/utils/csv-field-values.test.js @@ -0,0 +1,68 @@ +import { + extractCsvColumnValues, + mergeCsvFormFields, + normalizeAutoCompleteValues, +} from '../../src/utils/csv-field-values' + +describe('csv-field-values', () => { + describe('extractCsvColumnValues', () => { + it('extracts values for a matching column (case-insensitive, trimmed header)', () => { + const rows = [ + { userPrincipalName: 'a@contoso.com' }, + { ' UserPrincipalName ': 'b@contoso.com' }, + { other: 'skip' }, + ] + expect(extractCsvColumnValues(rows, 'userPrincipalName')).toEqual([ + 'a@contoso.com', + 'b@contoso.com', + ]) + }) + + it('returns empty when the column header is missing', () => { + const rows = [{ 'User Principal Name': 'a@contoso.com' }] + expect(extractCsvColumnValues(rows, 'userPrincipalName')).toEqual([]) + }) + }) + + describe('normalizeAutoCompleteValues', () => { + it('flattens {label,value} objects to string values', () => { + expect( + normalizeAutoCompleteValues([ + { label: 'Alice', value: 'id-1' }, + { label: 'Bob', value: 'id-2' }, + ]) + ).toEqual(['id-1', 'id-2']) + }) + }) + + describe('mergeCsvFormFields', () => { + const fields = [ + { type: 'autoComplete', name: 'users', csvColumn: 'userPrincipalName' }, + ] + + it('merges autocomplete and CSV values and drops the companion field', () => { + const merged = mergeCsvFormFields( + { + users: [{ label: 'Alice', value: 'id-1' }], + users__csv: [{ userPrincipalName: 'csv@contoso.com' }], + }, + fields + ) + expect(merged).toEqual({ + users: ['id-1', 'csv@contoso.com'], + }) + }) + + it('yields an empty users array when CSV rows lack the configured column', () => { + const merged = mergeCsvFormFields( + { + users: [], + users__csv: [{ 'User Principal Name': 'a@contoso.com' }], + }, + fields + ) + expect(merged.users).toEqual([]) + expect(merged.users__csv).toBeUndefined() + }) + }) +}) diff --git a/tests/utils/get-filtered-portals.test.js b/tests/utils/get-filtered-portals.test.js new file mode 100644 index 000000000000..bbd1fbc0ad17 --- /dev/null +++ b/tests/utils/get-filtered-portals.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { getFilteredPortals } from "../../src/utils/get-filtered-portals"; +import Portals from "../../src/data/portals"; + +const names = (portals) => portals.map((p) => p.name); + +// Pre-existing mismatch, documented rather than fixed here: portals.json splits Power +// Platform into _Admin/_Maker entries, while the defaults map (and the preferences toggle, +// and dashboardv1) still key on the un-suffixed Power_Platform_Portal — so those two are +// filtered out for everyone and their preference toggle controls nothing. +const UNREACHABLE_BY_DEFAULT = ["Power_Platform_Portal_Admin", "Power_Platform_Portal_Maker"]; +const defaultVisible = names(Portals).filter((n) => !UNREACHABLE_BY_DEFAULT.includes(n)); + +describe("getFilteredPortals", () => { + it("returns every default-on portal when settings carry no preferences", () => { + expect(names(getFilteredPortals({}))).toEqual(defaultVisible); + }); + + it("tolerates undefined settings", () => { + expect(names(getFilteredPortals(undefined))).toEqual(defaultVisible); + }); + + it("hides a portal turned off in UserSpecificSettings", () => { + const result = getFilteredPortals({ + UserSpecificSettings: { portalLinks: { Exchange_Portal: false } }, + }); + + expect(names(result)).not.toContain("Exchange_Portal"); + expect(names(result)).toContain("M365_Portal"); + }); + + it("falls back to tenant-level portalLinks when no user-specific ones exist", () => { + const result = getFilteredPortals({ portalLinks: { Azure_Portal: false } }); + + expect(names(result)).not.toContain("Azure_Portal"); + expect(names(result)).toContain("M365_Portal"); + }); + + it("prefers UserSpecificSettings over tenant-level portalLinks", () => { + const result = getFilteredPortals({ + portalLinks: { Teams_Portal: false }, + UserSpecificSettings: { portalLinks: { Entra_Portal: false } }, + }); + + // The user-specific object wins outright — the tenant-level opt-out is not merged in. + expect(names(result)).toContain("Teams_Portal"); + expect(names(result)).not.toContain("Entra_Portal"); + }); +}); diff --git a/tests/utils/impersonation.test.js b/tests/utils/impersonation.test.js new file mode 100644 index 000000000000..2c6dda9c046e --- /dev/null +++ b/tests/utils/impersonation.test.js @@ -0,0 +1,119 @@ +import { + getImpersonatedRole, + subscribeImpersonation, + enterImpersonation, + exitImpersonation, + impersonationCacheParams, +} from '../../src/utils/impersonation' + +const KEY = 'cipp_impersonate_role' + +describe('impersonation store', () => { + let reloadSpy + + beforeEach(() => { + window.localStorage.clear() + // jsdom's location.reload is not configurable via vi.spyOn directly + reloadSpy = vi.fn() + Object.defineProperty(window, 'location', { + value: { ...window.location, reload: reloadSpy }, + writable: true, + }) + }) + + it('is null by default and reflects the stored role', () => { + expect(getImpersonatedRole()).toBeNull() + window.localStorage.setItem(KEY, 'helpdesk') + expect(getImpersonatedRole()).toBe('helpdesk') + }) + + it('enterImpersonation lowercases, stores, clears caches and reloads', () => { + window.localStorage.setItem('REACT_QUERY_OFFLINE_CACHE', 'x') + window.localStorage.setItem('REACT_QUERY_OFFLINE_CACHE_extra', 'y') + window.localStorage.setItem('app.settings', 'keep-me') + const queryClient = { clear: vi.fn() } + + enterImpersonation('HelpDesk', queryClient) + + expect(window.localStorage.getItem(KEY)).toBe('helpdesk') + expect(queryClient.clear).toHaveBeenCalledTimes(1) + expect(window.localStorage.getItem('REACT_QUERY_OFFLINE_CACHE')).toBeNull() + expect(window.localStorage.getItem('REACT_QUERY_OFFLINE_CACHE_extra')).toBeNull() + expect(window.localStorage.getItem('app.settings')).toBe('keep-me') + expect(reloadSpy).toHaveBeenCalledTimes(1) + }) + + it('exitImpersonation removes the key, clears caches and reloads', () => { + window.localStorage.setItem(KEY, 'helpdesk') + window.localStorage.setItem('REACT_QUERY_OFFLINE_CACHE', 'x') + const queryClient = { clear: vi.fn() } + + exitImpersonation(queryClient) + + expect(window.localStorage.getItem(KEY)).toBeNull() + expect(window.localStorage.getItem('REACT_QUERY_OFFLINE_CACHE')).toBeNull() + expect(reloadSpy).toHaveBeenCalledTimes(1) + }) + + it('notifies subscribers on enter and exit, and unsubscribe works', () => { + const listener = vi.fn() + const unsubscribe = subscribeImpersonation(listener) + + enterImpersonation('readonly', { clear: vi.fn() }) + expect(listener).toHaveBeenCalledTimes(1) + + exitImpersonation({ clear: vi.fn() }) + expect(listener).toHaveBeenCalledTimes(2) + + unsubscribe() + enterImpersonation('editor', { clear: vi.fn() }) + expect(listener).toHaveBeenCalledTimes(2) + }) + + it('impersonationCacheParams segregates the Craft cache key only while impersonating', () => { + expect(impersonationCacheParams()).toEqual({}) + window.localStorage.setItem(KEY, 'helpdesk') + expect(impersonationCacheParams()).toEqual({ _imp: 'helpdesk' }) + }) + + it('survives a throwing localStorage without crashing', () => { + const original = window.localStorage + Object.defineProperty(window, 'localStorage', { + value: { + getItem: () => { + throw new Error('denied') + }, + setItem: () => { + throw new Error('denied') + }, + removeItem: () => { + throw new Error('denied') + }, + }, + configurable: true, + }) + + expect(getImpersonatedRole()).toBeNull() + expect(() => exitImpersonation({ clear: vi.fn() })).not.toThrow() + + Object.defineProperty(window, 'localStorage', { value: original, configurable: true }) + }) +}) + +describe('buildVersionedHeaders impersonation header', () => { + beforeEach(() => { + window.localStorage.clear() + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ version: '1.0' }) }) + }) + + it('adds x-cipp-impersonate-role only while impersonating', async () => { + const { buildVersionedHeaders } = await import('../../src/utils/cippVersion') + + const plain = await buildVersionedHeaders() + expect(plain['x-cipp-impersonate-role']).toBeUndefined() + + window.localStorage.setItem(KEY, 'helpdesk') + const impersonated = await buildVersionedHeaders() + expect(impersonated['x-cipp-impersonate-role']).toBe('helpdesk') + }) +}) diff --git a/tests/utils/overlay-history.test.js b/tests/utils/overlay-history.test.js new file mode 100644 index 000000000000..a62820d5510a --- /dev/null +++ b/tests/utils/overlay-history.test.js @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + installOverlayHistory, + pushOverlayEntry, + releaseOverlayEntry, + resetOverlayHistory, +} from "../../src/utils/overlay-history"; + +// The shape Next's pages router keeps in history.state for the current route. +const routeState = (as) => ({ __N: true, url: as, as, key: `key-${as}`, options: {} }); + +// jsdom traverses asynchronously, same as a browser: back() queues the task and popstate +// lands later. Every assertion about a back press has to wait for it. +const nextPop = () => + new Promise((resolve) => window.addEventListener("popstate", resolve, { once: true })); + +const goBack = async () => { + const settled = nextPop(); + window.history.back(); + await settled; +}; + +// A browser fires a single popstate for a multi-entry jump, e.g. the long-press back menu. +const goTo = async (delta) => { + const settled = nextPop(); + window.history.go(delta); + await settled; +}; + +beforeEach(() => { + window.history.replaceState(routeState("/identity/users"), ""); +}); + +afterEach(() => { + resetOverlayHistory(); +}); + +describe("overlay history", () => { + it("closes the overlay on a back press instead of letting the page navigate", async () => { + const close = vi.fn(); + const url = window.location.href; + installOverlayHistory(); + pushOverlayEntry(close); + + // The entry sits at the same url — nothing about the page changed. + expect(window.location.href).toBe(url); + await goBack(); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it("keeps the pushed entry recognisable to Next's router", () => { + installOverlayHistory(); + pushOverlayEntry(vi.fn()); + + // Cloning the router's own state is what makes this entry survive a navigation away and + // back: Next ignores any history entry without __N, and would leave the app on a blank + // route if it landed on one. + expect(window.history.state.__N).toBe(true); + expect(window.history.state.as).toBe("/identity/users"); + }); + + it("dismisses one overlay per back press, deepest first", async () => { + const closeOuter = vi.fn(); + const closeInner = vi.fn(); + installOverlayHistory(); + pushOverlayEntry(closeOuter); + pushOverlayEntry(closeInner); + + await goBack(); + expect(closeInner).toHaveBeenCalledTimes(1); + expect(closeOuter).not.toHaveBeenCalled(); + + await goBack(); + expect(closeOuter).toHaveBeenCalledTimes(1); + }); + + it("takes its history entry back when the overlay is closed by hand", async () => { + const close = vi.fn(); + installOverlayHistory(); + const entry = pushOverlayEntry(close); + + const settled = nextPop(); + releaseOverlayEntry(entry); + await settled; + + // The component closed itself, so the callback must not fire again — and the entry is + // gone, so the user's next back press belongs to the page. + expect(close).not.toHaveBeenCalled(); + expect(window.history.state.__cippOverlay).toBeUndefined(); + }); + + it("leaves history alone when its entry has been buried by a navigation", () => { + const back = vi.spyOn(window.history, "back"); + const close = vi.fn(); + installOverlayHistory(); + const entry = pushOverlayEntry(close); + + // A link inside the overlay navigated: Next pushed a route entry over ours. + window.history.pushState(routeState("/identity/users/user"), ""); + releaseOverlayEntry(entry); + + // Popping here would drag the user back off the page they just opened. + expect(back).not.toHaveBeenCalled(); + back.mockRestore(); + }); +}); + +describe("overlay history / Next router handoff", () => { + // Next's own popstate listener is registered at app boot, before ours, and calls + // beforePopState from inside it. Registering this listener before installOverlayHistory + // reproduces that ordering — which matters, because the answer depends on state our + // listener is about to overwrite. + const withRouter = () => { + const answers = []; + let handler = null; + const listener = (event) => { + if (handler) answers.push(handler(event.state)); + }; + window.addEventListener("popstate", listener); + installOverlayHistory({ + beforePopState: (cb) => { + handler = cb; + }, + }); + return { + answers, + teardown: () => window.removeEventListener("popstate", listener), + }; + }; + + it("stops Next from re-rendering the route when the pop was ours", async () => { + const router = withRouter(); + pushOverlayEntry(vi.fn()); + + await goBack(); + + // false means "handled downstream". Letting Next through would emit route events and + // reset scroll — a long list would jump to the top every time a row was dismissed. + expect(router.answers).toEqual([false]); + router.teardown(); + }); + + it("leaves ordinary back presses to Next", async () => { + const router = withRouter(); + window.history.pushState(routeState("/identity/users"), ""); + + await goBack(); + + expect(router.answers).toEqual([true]); + router.teardown(); + }); + + it("leaves a real navigation to Next even with an overlay open", async () => { + window.history.replaceState(routeState("/identity/devices"), ""); + window.history.pushState(routeState("/identity/users"), ""); + const router = withRouter(); + const close = vi.fn(); + pushOverlayEntry(close); + + // The long-press back menu jumps straight past our entry to another route. That pop + // lands on a different page, so Next has to run — and the overlay closes with the page + // it belonged to. + await goTo(-2); + + expect(router.answers).toEqual([true]); + expect(close).toHaveBeenCalledTimes(1); + router.teardown(); + }); +}); diff --git a/tests/utils/permission-rules.test.js b/tests/utils/permission-rules.test.js new file mode 100644 index 000000000000..a78e863be3c8 --- /dev/null +++ b/tests/utils/permission-rules.test.js @@ -0,0 +1,151 @@ +import { + matchPattern, + flattenPermissionTree, + expandRules, + rulesToFlatMap, + flatMapToRules, + validateRulePattern, + buildRuleSuggestions, +} from '../../src/utils/permission-rules' + +// Shape returned by /api/ExecAPIPermissionList: Cat -> Obj -> Read|ReadWrite -> functions +const apiPermissions = { + CIPP: { + Core: { Read: {}, ReadWrite: {} }, + }, + Identity: { + User: { Read: {}, ReadWrite: {} }, + Device: { Read: {}, ReadWrite: {} }, + }, + Exchange: { + Mailbox: { Read: {}, ReadWrite: {} }, + }, +} + +const universe = flattenPermissionTree(apiPermissions) + +describe('matchPattern', () => { + it('mirrors PowerShell -like: multiple wildcards all expand', () => { + // The old implementation only replaced the first *; this pattern needs both. + expect(matchPattern('CIPP.*.Read*', 'CIPP.Core.ReadWrite')).toBe(true) + expect(matchPattern('*.Mailbox.*', 'Exchange.Mailbox.Read')).toBe(true) + }) + + it('treats dots as literal separators, not regex wildcards', () => { + expect(matchPattern('Identity.User.Read', 'IdentityXUserXRead')).toBe(false) + expect(matchPattern('Identity.User.Read', 'Identity.User.Read')).toBe(true) + }) + + it('is case-insensitive like -like', () => { + expect(matchPattern('identity.user.*', 'Identity.User.ReadWrite')).toBe(true) + }) + + it('anchors the pattern to the whole string', () => { + expect(matchPattern('Identity.User', 'Identity.User.Read')).toBe(false) + expect(matchPattern('*.Read', 'Identity.User.ReadWrite')).toBe(false) + }) +}) + +describe('flattenPermissionTree', () => { + it('lists every Cat.Obj.Level string, sorted', () => { + expect(universe).toEqual([ + 'CIPP.Core.Read', + 'CIPP.Core.ReadWrite', + 'Exchange.Mailbox.Read', + 'Exchange.Mailbox.ReadWrite', + 'Identity.Device.Read', + 'Identity.Device.ReadWrite', + 'Identity.User.Read', + 'Identity.User.ReadWrite', + ]) + }) + + it('handles a missing tree', () => { + expect(flattenPermissionTree(undefined)).toEqual([]) + }) +}) + +describe('expandRules', () => { + it('grants includes minus excludes, exclude wins', () => { + const { matched, excludedBy } = expandRules( + { Include: ['Identity.*'], Exclude: ['Identity.Device.*'] }, + universe, + ) + expect(matched).toEqual(['Identity.User.Read', 'Identity.User.ReadWrite']) + expect(excludedBy['Identity.Device.Read']).toBe('Identity.Device.*') + }) + + it('reports per-pattern match counts for the live preview', () => { + const { includeCounts, excludeCounts } = expandRules( + { Include: ['*.Read', 'Identity.Uesr.*'], Exclude: ['CIPP.*'] }, + universe, + ) + expect(includeCounts['*.Read']).toBe(4) + // Typo'd pattern matches nothing — this is what powers the zero-match warning. + expect(includeCounts['Identity.Uesr.*']).toBe(0) + expect(excludeCounts['CIPP.*']).toBe(1) + }) + + it('accepts autocomplete option objects as rule entries', () => { + const { matched } = expandRules( + { Include: [{ label: 'Identity.User.Read', value: 'Identity.User.Read' }], Exclude: [] }, + universe, + ) + expect(matched).toEqual(['Identity.User.Read']) + }) +}) + +describe('rulesToFlatMap', () => { + it('produces the editor grid map with ReadWrite beating Read', () => { + const flat = rulesToFlatMap({ Include: ['Identity.User.*'], Exclude: [] }, apiPermissions) + expect(flat['IdentityUser']).toBe('Identity.User.ReadWrite') + expect(flat['IdentityDevice']).toBe('Identity.Device.None') + }) + + it('floors CIPP.Core at Read so a saved snapshot never locks out sign-in', () => { + const flat = rulesToFlatMap({ Include: ['Exchange.*'], Exclude: [] }, apiPermissions) + expect(flat['CIPPCore']).toBe('CIPP.Core.Read') + }) + + it('honours excludes', () => { + const flat = rulesToFlatMap( + { Include: ['Identity.*'], Exclude: ['Identity.User.ReadWrite'] }, + apiPermissions, + ) + expect(flat['IdentityUser']).toBe('Identity.User.Read') + }) +}) + +describe('flatMapToRules', () => { + it('converts a grid map to concrete-string rules, dropping None', () => { + expect( + flatMapToRules({ + IdentityUser: 'Identity.User.ReadWrite', + IdentityDevice: 'Identity.Device.None', + CIPPCore: 'CIPP.Core.Read', + }), + ).toEqual({ Include: ['CIPP.Core.Read', 'Identity.User.ReadWrite'], Exclude: [] }) + }) +}) + +describe('validateRulePattern', () => { + it.each(['*', '*.Read', 'Identity.*', 'Identity.User.*', 'Identity.User.ReadWrite'])( + 'accepts %s', + (pattern) => expect(validateRulePattern(pattern)).toBe(true), + ) + + it.each(['', 'Identity.User.Read.Extra', 'Identity User', 'Identity..Read', 'a.b.c;drop'])( + 'rejects %s', + (pattern) => expect(validateRulePattern(pattern)).toBe(false), + ) +}) + +describe('buildRuleSuggestions', () => { + it('offers global, category and concrete patterns', () => { + const values = buildRuleSuggestions(apiPermissions).map((o) => o.value) + expect(values).toContain('*') + expect(values).toContain('Identity.*') + expect(values).toContain('Identity.User.*') + expect(values).toContain('Identity.User.ReadWrite') + }) +}) diff --git a/tests/utils/resolve-row-templates.test.js b/tests/utils/resolve-row-templates.test.js new file mode 100644 index 000000000000..e89ef97dace5 --- /dev/null +++ b/tests/utils/resolve-row-templates.test.js @@ -0,0 +1,121 @@ +import { + getNestedValue, + resolveRowTemplates, + attachParentRow, + getRowTenant, +} from '../../src/utils/resolve-row-templates' + +const row = { + id: 'abc-123', + displayName: 'Finance', + siteId: 'site-1', + nested: { mail: 'finance@contoso.com' }, +} + +describe('getNestedValue', () => { + it('reads a top-level field', () => { + expect(getNestedValue(row, 'id')).toBe('abc-123') + }) + + it('reads a dotted path', () => { + expect(getNestedValue(row, 'nested.mail')).toBe('finance@contoso.com') + }) + + it('returns undefined for a missing path', () => { + expect(getNestedValue(row, 'missing.path')).toBeUndefined() + }) +}) + +describe('resolveRowTemplates', () => { + it('replaces [id] in a string', () => { + expect(resolveRowTemplates('group-members-[id]', row)).toBe( + 'group-members-abc-123' + ) + }) + + it('replaces a nested path', () => { + expect(resolveRowTemplates('mail=[nested.mail]', row)).toBe( + 'mail=finance@contoso.com' + ) + }) + + it('leaves an unmatched token in place', () => { + expect(resolveRowTemplates('x-[unknown]', row)).toBe('x-[unknown]') + }) + + it('walks objects used as api.data', () => { + expect( + resolveRowTemplates( + { someId: '[id]', extra: true, siteId: '[siteId]' }, + row + ) + ).toEqual({ someId: 'abc-123', extra: true, siteId: 'site-1' }) + }) + + it('leaves booleans and numbers alone', () => { + expect(resolveRowTemplates(true, row)).toBe(true) + expect(resolveRowTemplates(999, row)).toBe(999) + }) + + it('walks arrays', () => { + expect(resolveRowTemplates(['[id]', 1], row)).toEqual(['abc-123', 1]) + }) +}) + +describe('attachParentRow', () => { + const parentRow = { id: 'group-1', displayName: 'Finance' } + + it('attaches the opening row as parent', () => { + expect(attachParentRow({ id: 'member-1' }, parentRow)).toEqual({ + id: 'member-1', + parent: parentRow, + }) + }) + + it('leaves a row unchanged when there is no parent', () => { + const child = { id: 'member-1' } + expect(attachParentRow(child, undefined)).toBe(child) + }) + + it('maps arrays', () => { + expect(attachParentRow([{ id: 'a' }, { id: 'b' }], parentRow)).toEqual([ + { id: 'a', parent: parentRow }, + { id: 'b', parent: parentRow }, + ]) + }) + + it('chains an existing parent when the opening row is not nested', () => { + const child = { id: 'member-1', parent: { id: 'api-parent' } } + expect(attachParentRow(child, parentRow).parent).toEqual({ + id: 'group-1', + displayName: 'Finance', + parent: { id: 'api-parent' }, + }) + }) + + it('keeps a nested table chain instead of overwriting it', () => { + const nestedParent = { id: 'member-1', parent: parentRow } + const grandchild = { id: 'license-1' } + expect(attachParentRow(grandchild, nestedParent).parent).toBe(nestedParent) + }) +}) + +describe('getRowTenant', () => { + it('returns the current tenant outside AllTenants', () => { + expect( + getRowTenant({ Tenant: 'other.com' }, 'contoso.com') + ).toBe('contoso.com') + }) + + it('prefers the row tenant in AllTenants', () => { + expect(getRowTenant({ Tenant: 'child.com' }, 'AllTenants')).toBe( + 'child.com' + ) + }) + + it('falls back to the nested parent tenant', () => { + expect( + getRowTenant({ parent: { Tenant: 'parent.com' } }, 'AllTenants') + ).toBe('parent.com') + }) +}) diff --git a/tests/viewport.js b/tests/viewport.js new file mode 100644 index 000000000000..4a6dff2ce578 --- /dev/null +++ b/tests/viewport.js @@ -0,0 +1,41 @@ +/** + * Resizes the story iframe, for stories that measure layout or drive a breakpoint. + * + * Three things this exists to get right: + * - The VIEWPORT has to shrink, not a wrapper element. MUI breakpoints are media queries, + * so a 390px-wide Box inside a desktop-width iframe still renders every `md` branch. + * - The import has to be lazy. At module scope `@vitest/browser/context` throws + * "can be imported only inside the Browser Mode", which breaks the story for anyone who + * opens it in the Storybook app rather than the test runner. + * - Every story shares one page. A story that shrinks the viewport and never restores it + * leaves the next story running at phone width — which is an ordering-dependent failure, + * so a desktop story must claim its width rather than assume it. + * + * Returns false when there is no runner driving the iframe, so a play function can skip + * measurements that would otherwise assert against whatever width Storybook happens to use. + * + * NOTE: resizing does not synchronously re-render. `useMediaQuery` updates from a matchMedia + * change listener, i.e. a tick later — so the first assertion that depends on the new + * breakpoint must be a `findBy*` or wrapped in `waitFor`, never a bare `getBy*`. Verified by + * probe: right after this resolves, the mobile branch is not in the DOM yet. A preceding + * `await` on something present in BOTH branches does not settle it — it only makes the race + * usually go your way, which is how CippWizardPage passed locally and failed in CI. + */ +const resize = async (width, height) => { + try { + const { page } = await import("@vitest/browser/context"); + await page.viewport(width, height); + // Measured: window.innerWidth is ALREADY the new value when this resolves — the width is + // not what lags. What lags is React: matchMedia listeners fire, useMediaQuery setStates, + // and the breakpoint branch renders a tick later. A frame here covers the common case; it + // is not a guarantee, which is why callers must still findBy/waitFor (see below). + await new Promise((resolve) => requestAnimationFrame(resolve)); + return true; + } catch { + return false; + } +}; + +export const shrinkToPhoneViewport = async (width = 390, height = 844) => resize(width, height); + +export const growToDesktopViewport = async (width = 1280, height = 900) => resize(width, height); diff --git a/yarn.lock b/yarn.lock index cdccb617fe34..db0f5d11f0dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1102,6 +1102,22 @@ resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz#798a33950d11226a0ebb6acafa60f5594424967f" integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA== +"@emnapi/core@1.11.2": + version "1.11.2" + resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz#fab0a0f3c492d11f5a9ac9065d0d73955ee1c1c9" + integrity sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA== + dependencies: + "@emnapi/wasi-threads" "1.2.2" + tslib "^2.4.0" + +"@emnapi/core@1.9.2": + version "1.9.2" + resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz#3870265ecffc7352d01ead62d8d83d8358a2d034" + integrity sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA== + dependencies: + "@emnapi/wasi-threads" "1.2.1" + tslib "^2.4.0" + "@emnapi/core@^1.4.3": version "1.9.1" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.1.tgz#2143069c744ca2442074f8078462e51edd63c7bd" @@ -1110,6 +1126,20 @@ "@emnapi/wasi-threads" "1.2.0" tslib "^2.4.0" +"@emnapi/runtime@1.11.2": + version "1.11.2" + resolved "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz#eb22f04d76febfdf4f87fdaff54c8a53f6bf0dbd" + integrity sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA== + dependencies: + tslib "^2.4.0" + +"@emnapi/runtime@1.9.2": + version "1.9.2" + resolved "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz#8b469a3db160817cadb1de9050211a9d1ea84fa2" + integrity sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw== + dependencies: + tslib "^2.4.0" + "@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0": version "1.9.1" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.1.tgz#115ff2a0d589865be6bd8e9d701e499c473f2a8d" @@ -1124,6 +1154,20 @@ dependencies: tslib "^2.4.0" +"@emnapi/wasi-threads@1.2.1": + version "1.2.1" + resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548" + integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.2.2": + version "1.2.2" + resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz#4c93becf5bfa3b13d1bbdcc06aee38321ad8139a" + integrity sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA== + dependencies: + tslib "^2.4.0" + "@emotion/babel-plugin@^11.13.5": version "11.13.5" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0" @@ -1241,265 +1285,135 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== -"@esbuild/aix-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" - integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== - -"@esbuild/aix-ppc64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" - integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== - -"@esbuild/android-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" - integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== - -"@esbuild/android-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" - integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== - -"@esbuild/android-arm@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" - integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== - -"@esbuild/android-arm@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" - integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== - -"@esbuild/android-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" - integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg== - -"@esbuild/android-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" - integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== - -"@esbuild/darwin-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" - integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw== - -"@esbuild/darwin-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" - integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== - -"@esbuild/darwin-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" - integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ== - -"@esbuild/darwin-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" - integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== - -"@esbuild/freebsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" - integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w== - -"@esbuild/freebsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" - integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== - -"@esbuild/freebsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" - integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== - -"@esbuild/freebsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" - integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== - -"@esbuild/linux-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" - integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== - -"@esbuild/linux-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" - integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== - -"@esbuild/linux-arm@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" - integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== - -"@esbuild/linux-arm@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" - integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== - -"@esbuild/linux-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" - integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg== - -"@esbuild/linux-ia32@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" - integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== - -"@esbuild/linux-loong64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" - integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q== - -"@esbuild/linux-loong64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" - integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== - -"@esbuild/linux-mips64el@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" - integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw== - -"@esbuild/linux-mips64el@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" - integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== - -"@esbuild/linux-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" - integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ== - -"@esbuild/linux-ppc64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" - integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== - -"@esbuild/linux-riscv64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" - integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ== - -"@esbuild/linux-riscv64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" - integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== - -"@esbuild/linux-s390x@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" - integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw== - -"@esbuild/linux-s390x@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" - integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== - -"@esbuild/linux-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" - integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA== - -"@esbuild/linux-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" - integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== - -"@esbuild/netbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" - integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w== - -"@esbuild/netbsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" - integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== - -"@esbuild/netbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" - integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw== - -"@esbuild/netbsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" - integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== - -"@esbuild/openbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" - integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A== - -"@esbuild/openbsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" - integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== - -"@esbuild/openbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" - integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg== - -"@esbuild/openbsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" - integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== - -"@esbuild/openharmony-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" - integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw== - -"@esbuild/openharmony-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" - integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== - -"@esbuild/sunos-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" - integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA== - -"@esbuild/sunos-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" - integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== - -"@esbuild/win32-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" - integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA== - -"@esbuild/win32-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" - integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== - -"@esbuild/win32-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" - integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw== - -"@esbuild/win32-ia32@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" - integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== - -"@esbuild/win32-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" - integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== - -"@esbuild/win32-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" - integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== +"@esbuild/aix-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc" + integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ== + +"@esbuild/android-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504" + integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A== + +"@esbuild/android-arm@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381" + integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg== + +"@esbuild/android-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801" + integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q== + +"@esbuild/darwin-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc" + integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw== + +"@esbuild/darwin-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de" + integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw== + +"@esbuild/freebsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e" + integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw== + +"@esbuild/freebsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7" + integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg== + +"@esbuild/linux-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d" + integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug== + +"@esbuild/linux-arm@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753" + integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w== + +"@esbuild/linux-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b" + integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ== + +"@esbuild/linux-loong64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98" + integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ== + +"@esbuild/linux-mips64el@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca" + integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA== + +"@esbuild/linux-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2" + integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ== + +"@esbuild/linux-riscv64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27" + integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA== + +"@esbuild/linux-s390x@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb" + integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg== + +"@esbuild/linux-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485" + integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ== + +"@esbuild/netbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1" + integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw== + +"@esbuild/netbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de" + integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw== + +"@esbuild/openbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef" + integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ== + +"@esbuild/openbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd" + integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw== + +"@esbuild/openharmony-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce" + integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q== + +"@esbuild/sunos-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4" + integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g== + +"@esbuild/win32-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532" + integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ== + +"@esbuild/win32-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7" + integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA== + +"@esbuild/win32-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87" + integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g== "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" @@ -2074,6 +1988,13 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" +"@napi-rs/wasm-runtime@^1.1.4", "@napi-rs/wasm-runtime@^1.1.6": + version "1.2.2" + resolved "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz#c70706532e5827c0932ca6bf43ee2c512f29c639" + integrity sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw== + dependencies: + "@tybys/wasm-util" "^0.10.3" + "@next/env@16.2.11": version "16.2.11" resolved "https://registry.npmjs.org/@next/env/-/env-16.2.11.tgz#9dea1a225a99b1636e5a7166237db1f979b6c532" @@ -2270,6 +2191,214 @@ resolved "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== +"@oxc-parser/binding-android-arm-eabi@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz#b75e796249ee22f632e40e942746c4bf648cee92" + integrity sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ== + +"@oxc-parser/binding-android-arm64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz#e264467fe39f80018f62fa0dae82db0b80260444" + integrity sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg== + +"@oxc-parser/binding-darwin-arm64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz#0576d35109c00dcc6277200ba2eca7b47e07f1b1" + integrity sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg== + +"@oxc-parser/binding-darwin-x64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz#efa1ba49075aa318ff540a1c2f8a442017417206" + integrity sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw== + +"@oxc-parser/binding-freebsd-x64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz#817ba3c508d751d94d6e6fd86af69ddaa27da531" + integrity sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA== + +"@oxc-parser/binding-linux-arm-gnueabihf@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz#b1c3096c654771998480316ef10d1e5d29edc79b" + integrity sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ== + +"@oxc-parser/binding-linux-arm-musleabihf@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz#c44a8f10e6c903685825aebf1289fc2086aed61e" + integrity sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g== + +"@oxc-parser/binding-linux-arm64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz#61c245abfab6f63045915b5c9cfa7d335ad7c440" + integrity sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ== + +"@oxc-parser/binding-linux-arm64-musl@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz#358bbd90e5c85b6c35125f5a6ff084e09b694c04" + integrity sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA== + +"@oxc-parser/binding-linux-ppc64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz#b7ea7b51bf54db4c42819187f760e069d433dac3" + integrity sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ== + +"@oxc-parser/binding-linux-riscv64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz#3a3b10d160988df50bbbcd631c6af39de3dd451d" + integrity sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ== + +"@oxc-parser/binding-linux-riscv64-musl@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz#3787d37e1d0a15ee239f51610298500321b31730" + integrity sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g== + +"@oxc-parser/binding-linux-s390x-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz#b71a16cbba115a4696498f9149bc54cc4e1df9cd" + integrity sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q== + +"@oxc-parser/binding-linux-x64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz#71527dd0284ba727d35a93c841c91192af3ebdec" + integrity sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ== + +"@oxc-parser/binding-linux-x64-musl@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz#16830afa4b001f349cebb93e12b278e72601cb3f" + integrity sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg== + +"@oxc-parser/binding-openharmony-arm64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz#a41c71d249cb597dc357038eb1cbe3ce732453f8" + integrity sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ== + +"@oxc-parser/binding-wasm32-wasi@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz#b1efcdb433b30ed4a3ad912fa03da3834bd4845d" + integrity sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ== + dependencies: + "@emnapi/core" "1.9.2" + "@emnapi/runtime" "1.9.2" + "@napi-rs/wasm-runtime" "^1.1.4" + +"@oxc-parser/binding-win32-arm64-msvc@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz#b62b5e328126323d41ae1ee7adc95537c4c4423a" + integrity sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw== + +"@oxc-parser/binding-win32-ia32-msvc@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz#dac30de6971dbe63aa5722be9a4cc070fd3c650e" + integrity sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw== + +"@oxc-parser/binding-win32-x64-msvc@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz#a2df879b0803f72b350a7567365cee5b8978edf0" + integrity sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w== + +"@oxc-project/types@^0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz#8374fcdfb4a641861218daa5700c447c00b66663" + integrity sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ== + +"@oxc-resolver/binding-android-arm-eabi@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz#5db3f0dcd659e1de664fb0ae912420839348309b" + integrity sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA== + +"@oxc-resolver/binding-android-arm64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz#fec00a8bc89afa9a164bad79b23c14b8c86f95cf" + integrity sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg== + +"@oxc-resolver/binding-darwin-arm64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz#b5e6e2c2bed585cbfd67b6e41eea7fce2a3da5f8" + integrity sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w== + +"@oxc-resolver/binding-darwin-x64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz#db759d6fadac262a7da21b1bfb712b0199c9cd18" + integrity sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q== + +"@oxc-resolver/binding-freebsd-x64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz#7fe0ab0725284aee9b6b6c45b81f0facf895fc62" + integrity sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw== + +"@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz#91a72b7987930c3acc5337237454ba0339f80333" + integrity sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg== + +"@oxc-resolver/binding-linux-arm-musleabihf@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz#72d04ad7d2227fb3ac71aa7933794bfb88194b7b" + integrity sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA== + +"@oxc-resolver/binding-linux-arm64-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz#b87faf59bde9ecff0b8288fe99a831eb7492f99d" + integrity sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA== + +"@oxc-resolver/binding-linux-arm64-musl@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz#2da6551c561bf2f2bed34c312a99e18d184a9f07" + integrity sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw== + +"@oxc-resolver/binding-linux-ppc64-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz#fda4558cc94e43fefdfa4f9b96391c30ec137adb" + integrity sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA== + +"@oxc-resolver/binding-linux-riscv64-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz#2fe243a5112d221021a8b2fccd7b36aa96adc946" + integrity sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw== + +"@oxc-resolver/binding-linux-riscv64-musl@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz#e95cb43856f7e9c4aa0afa438e043fdbf6c6d40d" + integrity sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w== + +"@oxc-resolver/binding-linux-s390x-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz#8e3ca765e1af7ccab8a61adc96323810f9770535" + integrity sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ== + +"@oxc-resolver/binding-linux-x64-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz#a2b14c1efc3252e705038bc23b7225a7cb434df2" + integrity sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg== + +"@oxc-resolver/binding-linux-x64-musl@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz#d42f14a6a286b0a81871e2be6ee2eeb3d044afce" + integrity sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw== + +"@oxc-resolver/binding-openharmony-arm64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz#1ce27bc037073b624c484e481ae61f4cc9b5cfbc" + integrity sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg== + +"@oxc-resolver/binding-wasm32-wasi@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz#9c818fd9512eed502da1972de1f8c9528b4c9d27" + integrity sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q== + dependencies: + "@emnapi/core" "1.11.2" + "@emnapi/runtime" "1.11.2" + "@napi-rs/wasm-runtime" "^1.1.6" + +"@oxc-resolver/binding-win32-arm64-msvc@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz#0e2bd6869ef554ffd3016594951f1f44f5f02617" + integrity sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg== + +"@oxc-resolver/binding-win32-x64-msvc@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz#d0649344fcd504dfaf7f3561a53617c38d98d789" + integrity sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw== + "@polka/url@^1.0.0-next.24": version "1.0.0-next.29" resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1" @@ -2698,7 +2827,7 @@ resolved "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz#b793d34b94f572c1d7d9e0f44fac4e0dbc9572ed" integrity sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ== -"@storybook/icons@^2.0.1": +"@storybook/icons@^2.0.1", "@storybook/icons@^2.0.2": version "2.1.0" resolved "https://registry.npmjs.org/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a" integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg== @@ -2954,7 +3083,7 @@ resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz#00409e743ac4eea9afe5b7708594d5fcebb00212" integrity sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw== -"@testing-library/dom@10.4.1": +"@testing-library/dom@10.4.1", "@testing-library/dom@^10.4.1": version "10.4.1" resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== @@ -2980,18 +3109,6 @@ picocolors "^1.1.1" redent "^3.0.0" -"@testing-library/jest-dom@^6.9.1": - version "6.10.0" - resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.10.0.tgz#8a76841e94b72d55d09d2a34b9db9d75da9cbc08" - integrity sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ== - dependencies: - "@adobe/css-tools" "^4.4.0" - aria-query "^5.0.0" - css.escape "^1.5.1" - dom-accessibility-api "^0.6.3" - picocolors "^1.1.1" - redent "^3.0.0" - "@testing-library/react@16.3.2": version "16.3.2" resolved "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz#672883b7acb8e775fc0492d9e9d25e06e89786d0" @@ -3004,25 +3121,20 @@ resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== -"@tiptap/core@^3.20.5": - version "3.27.3" - resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.27.3.tgz#001d05642579b8c4727fe9acd71ce5f4900a508c" - integrity sha512-TJj5929M96C1KlH796wS8MywfHDh49RhmakOyzyMMc9pFmRj9UXi1gj0TCXgsZtjEOG7B+m/DRvNOvnuvR9kmg== - "@tiptap/core@^3.29.2": version "3.29.2" resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.29.2.tgz#90d24591a9e7fb450ffb95ed6a42f529348e3e9e" integrity sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw== -"@tiptap/extension-blockquote@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-3.20.5.tgz#c64341fce14154b8c2785ead168d395436f953e7" - integrity sha512-0wU6H/MWWes0rGzgSW6MMU6YDs/3ofUDkqmqCqmb+Siu1ZD0bpzOYpBtujgOYDY8moB9+zCE3G9HSYGcmZxHew== +"@tiptap/extension-blockquote@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.29.2.tgz#e654719cee5b039a5b4af97226e2652f592d4196" + integrity sha512-ca4OzKDh0yaxg2+Z56bC2QnWsNsFp2YMRfVig1PDXyMVFMNJpLcnhxgq/9btn+xYAlYrj8RymOeCTYREOR6Zjg== -"@tiptap/extension-bold@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.20.5.tgz#b40e8e43db3123c5dee9864931f7f9ad1b1e07dc" - integrity sha512-hraiiWkF58n8Jy0Wl3OGwjCTrGWwZZxez/IlexrzKQ/nMFdjDpensZucWwu59zhAM9fqZwGSLDtCFuak03WKnA== +"@tiptap/extension-bold@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.29.2.tgz#cd03d51096caa9135ccbb31c2bae778b5fac50df" + integrity sha512-elYbGxJsYnBb4leqrcjdIJuiG380BcOgN+UUzvOv+qEjfGVzHodFOMBl3qnmD6urYHNu5/qQK2S0qSSRXKCLNQ== "@tiptap/extension-bubble-menu@^3.20.5": version "3.22.3" @@ -3031,119 +3143,119 @@ dependencies: "@floating-ui/dom" "^1.0.0" -"@tiptap/extension-bullet-list@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.5.tgz#dc53ab798a48c3aaf175752899f04cad2abc8ef3" - integrity sha512-MT3321R6F8AoVUEMJ5RiI0PQMenwvtmrSXoO1ehPCWq5TrSJLyXeZMJvZU+1CgfXk4XQU70RN78ib5+Zg+/FCg== +"@tiptap/extension-bullet-list@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.29.2.tgz#c25a6023c7ee13e35f76ffcc4fd436415e9fa0b7" + integrity sha512-3bWcCUPbCHv0XttlMdnAtXLNYWx2pblByMgxmGsaP9FU0QnslGXty6A6gHCqI33ygRg1vrA6U5Wtpwbi5aKu5g== -"@tiptap/extension-code-block@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-3.20.5.tgz#96daefd431f37a87eac33095d020937dd438fe6c" - integrity sha512-0YZnqfqZ1IjzKBM4aezw8j3LZWJFEfs4+mbizHNlnZSYpKzpESYLeaLWGO5SpqF9Z8tmYmSoCaf0fqi5LwgdIA== +"@tiptap/extension-code-block@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.29.2.tgz#fd91f2475d0c9e289ac98cc3210f86d869af4e16" + integrity sha512-w153ct8g6dLiPTdXQ6SOIMxX4SEo5Q50AmjdEEEcJ7ZcYUcde/ScSskLHfOYmyt5ZFAiyEwr121+pux+p3/oAQ== -"@tiptap/extension-code@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.20.5.tgz#c6c93fcb553ddb9e185316a4876f79b7d5d21171" - integrity sha512-jBZK/CfdMvg1gkNK/zNAk02IExpBPwUfNLRPiJvGhReL2Q73naKxZGQGp+5Lej9VaeFB70UKuRma/iIzuZbgsA== +"@tiptap/extension-code@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.29.2.tgz#89a1398ae5e0bebcd7833c0e73621f11ccaa4a44" + integrity sha512-c6W5UGuB7WNLpYocsgRzpO2OOTI4QjaI9jjHRMuty9z+s9DtaYM/HrRLNwVh6MopkHb+i/89Wkv8gCS34fftig== -"@tiptap/extension-document@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.20.5.tgz#24a15654057872db469da6b91584875dcda070ea" - integrity sha512-BpNGHtOTAjjs/6QbkrafMTlaJqb0gsPngFzd5rB0csxx7rYRE9nIEY+oZ44qMw161+2YB4u20L17SX2mUJANBw== +"@tiptap/extension-document@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.29.2.tgz#215d692d4b5b9d7bbc8db8f9b9d4221f2a57c9a1" + integrity sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ== -"@tiptap/extension-dropcursor@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.5.tgz#ea810297825b009c357559e66f5fd76e91e8c940" - integrity sha512-/lDG9OjvAv0ynmgFH17mt/GUeGT5bqu0iPW8JMgaRqlKawk+uUIv5SF5WkXS4SwxXih+hXdPEQD3PWZnxlQxAQ== +"@tiptap/extension-dropcursor@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.29.2.tgz#faa11b6d0d9312e964fcf0087fdc985ca57bc344" + integrity sha512-KKno7cU9r1HdR48CRrsDu69/1UjZdoslq/UcE+Kx+tdhAv/aljXMkRSNzGMrBNOBDmHRgS1+58zm21WQWdQzwA== "@tiptap/extension-floating-menu@^3.20.5": version "3.22.3" resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.22.3.tgz#c9a911b7784cb45d6f8e7260d77bf2015066e5a4" integrity sha512-0f8b4KZ3XKai8GXWseIYJGdOfQr3evtFbBo3U08zy2aYzMMXWG0zEF7qe5/oiYp2aZ95edjjITnEceviTsZkIg== -"@tiptap/extension-gapcursor@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.5.tgz#0fe2ffb1d7669fc4f5541a0c66342da4107b08f8" - integrity sha512-H+bRr+mqU/DQq1vfoMlppK1o+RbfSKYBMIcAMHWOez+C96MWfj5bhooVU2HLtl4XGmQxKGr3oEOCKDPdtRNThg== +"@tiptap/extension-gapcursor@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.29.2.tgz#c21fd638d9e4923f0260a7a5cfd4a38cf7c05216" + integrity sha512-8Q39UR4/Tit759IeW9xZIe3NMwN11GsuA3FLheDyyGn7RrW02HD3HhUDlazE54Ki4HoosjFmChPlN4Ik2ubdRQ== -"@tiptap/extension-hard-break@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.20.5.tgz#79a4409e81a35c9f8b664616a9b2ecbd4cb81953" - integrity sha512-+aILNDO7BsXf0IJ4/0BYh570usFK3Q1t/ZQd8zhHuO2ATeWeDVu1x2F+ouFS4X8fmoCcioMzw15aoz93GET6kQ== +"@tiptap/extension-hard-break@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.29.2.tgz#334ebaa9c0d2eed7df9037524eb0f6b15732fb1d" + integrity sha512-eUW3LN3fq8rXnjEUeI3D2QONYdLsU3yYQm4jxlErs2h4cfwrFjgf19VSUFVmm6LrFbbQ0OnDVPeVLL6iOwDw2w== -"@tiptap/extension-heading@^3.20.5", "@tiptap/extension-heading@^3.27.3": - version "3.27.3" - resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.3.tgz#8f3b0f0f0afd172b6879fad265d43761bc8caa4b" - integrity sha512-QHXnsNic6iId8pnsFZ8z4PkX5L+HCHa/D7rAi3nNWtPlSIAOxo4nKrALcB5/tHmY+XL8kEXKH3nsNLNEDLCYPg== +"@tiptap/extension-heading@^3.27.3", "@tiptap/extension-heading@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.29.2.tgz#a7b392d7dd2cd463d9eda7556aaf0bb297795ad9" + integrity sha512-6W4aIy70Mh7BNlbG9zZ5FBLhJhU2UUEzgZJ/jwYSCcB30o8McLxJSEjhtoHiX8R78Ah2/JzBGvIe5olZlbeE4A== -"@tiptap/extension-horizontal-rule@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.5.tgz#c21b2c7405f4aad7b507e36cc3394aba51ea2253" - integrity sha512-4UtpUHg8cRzxWjJUGtni5VnXYbhsO7ygf1H1pr4Rv63XMBg9lfYDeSwByIuVy9biEFP7eGEFnezzb5Zlh1btmQ== +"@tiptap/extension-horizontal-rule@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.29.2.tgz#a2afe200c29f9355fa1a79c96fc73bef016bbbd2" + integrity sha512-8/ZPzbB9X85Mc9/7xVLZupQKBr2UVcQTGr512xtqMW+XkCQRHCph46tRo828YE13IMWI5fWn/FaNCqXG9cULSw== -"@tiptap/extension-italic@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.20.5.tgz#c53436f05968b16eda6b8e0efbaebaf3f4587e3b" - integrity sha512-7bZCgdJVTvhR5vSmNgFQbGvgRoC6m26KcUpHqWiKA95kLL5Wk4YlMCIqdiDpvJ1eakeFEvDcGZvFLg5+1NiQ+w== +"@tiptap/extension-italic@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.29.2.tgz#3d169fdcc34a603304f14946c6f638ddaadc9af8" + integrity sha512-iH63V/5wsaMnY4Jz0+meaAGhaec4AiOzOduzl6ZZr5IyGhZ1kthyW84ELt0dyLI3hNceAUhaNWc+I7+vX0aoXA== -"@tiptap/extension-link@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.20.5.tgz#fbed2a1b82b0e9a73a2628782408135fbe698575" - integrity sha512-0PukrSYnHX2CrGSThlKfQWxpPWmL7QAvdpDUraKknGvVNSH7tUjchTshy5JdLrn/SQAU92REowRCB6zzCNEFjA== +"@tiptap/extension-link@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.29.2.tgz#a91ff8625cc609e9ba0bfb5d5052b4168436c4f5" + integrity sha512-DcVer5SqrexKCEP6Ip1UPxJUMvcRCCItSv0wxoGytanrimBh2smvcg6X0DWnjlsi5H0updhyl+atYCmmQXUIXA== dependencies: - linkifyjs "^4.3.2" + linkifyjs "^4.3.3" -"@tiptap/extension-list-item@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-3.20.5.tgz#3bbe5c8cc2a5f6ad7900803338b41a29e33409ba" - integrity sha512-pFJCGLIDEin1Xn6B3ctbrZvtYyALARE56ya4SmaNfnl+Hww5MfkRR40obbwYD3byA1yOpr+bECy+I2clQqzTDw== +"@tiptap/extension-list-item@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.29.2.tgz#febddb22badf0facebf3325b915f65f4a7c8f697" + integrity sha512-s8vBVHHFT0Qpu7CzAZ7S1kYmSiVaDvUNvMNcZUWnxj6VPfiwmx0eXd9FsjePrRCMoM5tFmPnhFTHDxY3D/eZeQ== -"@tiptap/extension-list-keymap@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.5.tgz#272077f1e1f55b4306583fcfa81d0208f8814a71" - integrity sha512-rmrQgOrUb0jKtFzVUfT0UNEST2sGM2Ve4lOl+1luh66RW6TD+gvgMk/qo12/Kffl9PUiqz8oYfk2qXCwFb6Bug== +"@tiptap/extension-list-keymap@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.29.2.tgz#cdb29a4ce7fdff9f986df713501272d9fc643930" + integrity sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA== -"@tiptap/extension-list@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.20.5.tgz#98acebb38d051790e97ebabcb93327ac8ecd6909" - integrity sha512-s+Y8Q7Orq+WQiwgFB/VPMYZe+6EAR2F69xCpvOynlzTInLO4cF6QpXomuGEYAZxLHe8ZBmeIaR7y8MH/OgjrDw== +"@tiptap/extension-list@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.29.2.tgz#53324e55f1c89efcf450b0c92f84361ced15e2ab" + integrity sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A== -"@tiptap/extension-ordered-list@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.5.tgz#c5b5abff89ec2b0bd82a8c62828dc317832a0e66" - integrity sha512-Y/RIE3AxUNYAFKGMM5FLlTVKxxBvOh4JlLp/qYsOCY2nJdH0Jopl2FpfBYc4xoJwFSk8BELJ4Ow0adcYb15ksg== +"@tiptap/extension-ordered-list@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.29.2.tgz#2053ddaef243e455bb1267fbe81c337487fa4771" + integrity sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg== -"@tiptap/extension-paragraph@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.20.5.tgz#4344d623213bbec5a025b8c5cb751979a1f3b293" - integrity sha512-mwuhwmff67IpGfOViyRvUC14IlkpsOnB+hSExVnq5+hCntjt/Cr2Z8GGOgzHeIM2FIS0UqX9Lv/b6ttUg4+Now== +"@tiptap/extension-paragraph@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.29.2.tgz#da8de494530843891c399d18ab15ea2b2f5e2ffb" + integrity sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ== -"@tiptap/extension-strike@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.20.5.tgz#a3689fc17ad89a23c88f11b27c7f53896caa54f3" - integrity sha512-uwhvmfS4ciGYJRLUg0AHbWsprMCwyWVWd2RXOLRm0ZQeWkvzonPXZhJvzIhIgsFkPLj/dsN5t0+LdiK4UQMnyA== +"@tiptap/extension-strike@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.29.2.tgz#fe17b6140955e8f45c7ad32c66539891be934441" + integrity sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg== "@tiptap/extension-table@^3.20.5": version "3.20.5" resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-3.20.5.tgz#bac3d76e1c5fc8a4672f1495532a934651f50ce8" integrity sha512-YvTB5OfGqjqHqutkSyywplouFvJwlsDTpZAjtAh5TzKfOan42aiVepmHVpteoQP6LH0mSjw69RndFMIYhIGmSQ== -"@tiptap/extension-text@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.20.5.tgz#48e1cb2ee149eef7857b6a3131a32c341f572f05" - integrity sha512-DMa9g5cH2d/Gx1KXtV7txTxaa6FBqgG8glmfug+N93VMb8sEZR1Yu1az++yAep4SGGq9GWIGZCUS3H6W66et6Q== +"@tiptap/extension-text@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.29.2.tgz#7f590043b9044bd7cbc478c65e211bc3f2538335" + integrity sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA== -"@tiptap/extension-underline@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.20.5.tgz#97321f4405b303f9d54d2716dec6ab5bf9bc493e" - integrity sha512-HMhr5KIAqZsEhlN8RxKHr/ql1a8OvBa9fLf69IwUVFolBcDExHWUtaEV/axYVRQJvvIy2oKGJxlJWDZ4hkotHQ== +"@tiptap/extension-underline@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.29.2.tgz#e3545cc020608fcb7bdde5f1136d3ee8e330cb4e" + integrity sha512-K7XwH/xS/5AIREWQ00VTEf/W5U0olp7j6wwit7cdd/8nHv6h6AGr1+iEApHKoLXWQZLfGQKzJlT9W61LAl+fHA== -"@tiptap/extensions@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.20.5.tgz#d2460b110deed4a71aca4c0d37816fc8845b22ad" - integrity sha512-c4am6SznqfMnbUNSh4MvufiD7cMLdqL1BArok22uBgSWkS1sB9RVBYe8+x0jrOkk0UPEVlzDHbQ+nU+WmIyS2Q== +"@tiptap/extensions@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.2.tgz#207c6ed79db1a5baec13f3fd8653b437fca2c8b6" + integrity sha512-BCz+FCAChSYtUe4BFj97HEO+nSK+J7GxbJgZG4Hg7DT/gI+hRyeNndU8efiQAx3WGdzsFi3UxRpcF1tTQM7iMQ== -"@tiptap/pm@^3.20.5", "@tiptap/pm@^3.29.2": +"@tiptap/pm@^3.29.2": version "3.29.2" resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.2.tgz#de461c6f8986ef807082f467cde2d8fdf1cce4bc" integrity sha512-GCOme7xHaS+DSoaA4CDcAD3l6JyBlvZhvCyfsy2Vp6j8tEoBkZWio7soYVosmlyn7zq8/64VeFZP5s47yfG7fQ== @@ -3174,35 +3286,35 @@ "@tiptap/extension-bubble-menu" "^3.20.5" "@tiptap/extension-floating-menu" "^3.20.5" -"@tiptap/starter-kit@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-3.20.5.tgz#67a6c7ed20b81f5746fc0552f4efc02bc6fbf684" - integrity sha512-L5E2TCGK0EiwmGIlwMsiwNTW1TLbfPF1Dsji4bSKRJnPbccZIMCB6qdId8v/Z+QGm85NVcBHeruQrDlKDddXBA== - dependencies: - "@tiptap/core" "^3.20.5" - "@tiptap/extension-blockquote" "^3.20.5" - "@tiptap/extension-bold" "^3.20.5" - "@tiptap/extension-bullet-list" "^3.20.5" - "@tiptap/extension-code" "^3.20.5" - "@tiptap/extension-code-block" "^3.20.5" - "@tiptap/extension-document" "^3.20.5" - "@tiptap/extension-dropcursor" "^3.20.5" - "@tiptap/extension-gapcursor" "^3.20.5" - "@tiptap/extension-hard-break" "^3.20.5" - "@tiptap/extension-heading" "^3.20.5" - "@tiptap/extension-horizontal-rule" "^3.20.5" - "@tiptap/extension-italic" "^3.20.5" - "@tiptap/extension-link" "^3.20.5" - "@tiptap/extension-list" "^3.20.5" - "@tiptap/extension-list-item" "^3.20.5" - "@tiptap/extension-list-keymap" "^3.20.5" - "@tiptap/extension-ordered-list" "^3.20.5" - "@tiptap/extension-paragraph" "^3.20.5" - "@tiptap/extension-strike" "^3.20.5" - "@tiptap/extension-text" "^3.20.5" - "@tiptap/extension-underline" "^3.20.5" - "@tiptap/extensions" "^3.20.5" - "@tiptap/pm" "^3.20.5" +"@tiptap/starter-kit@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.29.2.tgz#9e61bdfc628923e9c1cd5a6ed80a1d884ae1855c" + integrity sha512-oTu0tysiqk4zgjEtxRHjAQgxUKaAevZwueOWwSWubHdokqp7SpcbE5n9USJv89HKuTUDm3GjnQH6q8HNn/2DsA== + dependencies: + "@tiptap/core" "^3.29.2" + "@tiptap/extension-blockquote" "^3.29.2" + "@tiptap/extension-bold" "^3.29.2" + "@tiptap/extension-bullet-list" "^3.29.2" + "@tiptap/extension-code" "^3.29.2" + "@tiptap/extension-code-block" "^3.29.2" + "@tiptap/extension-document" "^3.29.2" + "@tiptap/extension-dropcursor" "^3.29.2" + "@tiptap/extension-gapcursor" "^3.29.2" + "@tiptap/extension-hard-break" "^3.29.2" + "@tiptap/extension-heading" "^3.29.2" + "@tiptap/extension-horizontal-rule" "^3.29.2" + "@tiptap/extension-italic" "^3.29.2" + "@tiptap/extension-link" "^3.29.2" + "@tiptap/extension-list" "^3.29.2" + "@tiptap/extension-list-item" "^3.29.2" + "@tiptap/extension-list-keymap" "^3.29.2" + "@tiptap/extension-ordered-list" "^3.29.2" + "@tiptap/extension-paragraph" "^3.29.2" + "@tiptap/extension-strike" "^3.29.2" + "@tiptap/extension-text" "^3.29.2" + "@tiptap/extension-underline" "^3.29.2" + "@tiptap/extensions" "^3.29.2" + "@tiptap/pm" "^3.29.2" "@tybys/wasm-util@^0.10.0": version "0.10.1" @@ -3211,6 +3323,13 @@ dependencies: tslib "^2.4.0" +"@tybys/wasm-util@^0.10.3": + version "0.10.3" + resolved "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d" + integrity sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== + dependencies: + tslib "^2.4.0" + "@types/aria-query@^5.0.1": version "5.0.4" resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" @@ -4051,9 +4170,9 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -attr-accept@^2.2.4: +attr-accept@^2.2.5: version "2.2.5" - resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e" + resolved "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e" integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ== available-typed-arrays@^1.0.7: @@ -5189,69 +5308,37 @@ es-toolkit@^1.39.3: resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.45.1.tgz#21b28b2bd43178fd4c9c937c445d5bcaccce907b" integrity sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw== -"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0": - version "0.27.7" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f" - integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w== +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "esbuild@^0.27.0 || ^0.28.0": + version "0.28.2" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" + integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA== optionalDependencies: - "@esbuild/aix-ppc64" "0.27.7" - "@esbuild/android-arm" "0.27.7" - "@esbuild/android-arm64" "0.27.7" - "@esbuild/android-x64" "0.27.7" - "@esbuild/darwin-arm64" "0.27.7" - "@esbuild/darwin-x64" "0.27.7" - "@esbuild/freebsd-arm64" "0.27.7" - "@esbuild/freebsd-x64" "0.27.7" - "@esbuild/linux-arm" "0.27.7" - "@esbuild/linux-arm64" "0.27.7" - "@esbuild/linux-ia32" "0.27.7" - "@esbuild/linux-loong64" "0.27.7" - "@esbuild/linux-mips64el" "0.27.7" - "@esbuild/linux-ppc64" "0.27.7" - "@esbuild/linux-riscv64" "0.27.7" - "@esbuild/linux-s390x" "0.27.7" - "@esbuild/linux-x64" "0.27.7" - "@esbuild/netbsd-arm64" "0.27.7" - "@esbuild/netbsd-x64" "0.27.7" - "@esbuild/openbsd-arm64" "0.27.7" - "@esbuild/openbsd-x64" "0.27.7" - "@esbuild/openharmony-arm64" "0.27.7" - "@esbuild/sunos-x64" "0.27.7" - "@esbuild/win32-arm64" "0.27.7" - "@esbuild/win32-ia32" "0.27.7" - "@esbuild/win32-x64" "0.27.7" - -"esbuild@^0.27.0 || ^0.28.0": - version "0.28.1" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" - integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== - optionalDependencies: - "@esbuild/aix-ppc64" "0.28.1" - "@esbuild/android-arm" "0.28.1" - "@esbuild/android-arm64" "0.28.1" - "@esbuild/android-x64" "0.28.1" - "@esbuild/darwin-arm64" "0.28.1" - "@esbuild/darwin-x64" "0.28.1" - "@esbuild/freebsd-arm64" "0.28.1" - "@esbuild/freebsd-x64" "0.28.1" - "@esbuild/linux-arm" "0.28.1" - "@esbuild/linux-arm64" "0.28.1" - "@esbuild/linux-ia32" "0.28.1" - "@esbuild/linux-loong64" "0.28.1" - "@esbuild/linux-mips64el" "0.28.1" - "@esbuild/linux-ppc64" "0.28.1" - "@esbuild/linux-riscv64" "0.28.1" - "@esbuild/linux-s390x" "0.28.1" - "@esbuild/linux-x64" "0.28.1" - "@esbuild/netbsd-arm64" "0.28.1" - "@esbuild/netbsd-x64" "0.28.1" - "@esbuild/openbsd-arm64" "0.28.1" - "@esbuild/openbsd-x64" "0.28.1" - "@esbuild/openharmony-arm64" "0.28.1" - "@esbuild/sunos-x64" "0.28.1" - "@esbuild/win32-arm64" "0.28.1" - "@esbuild/win32-ia32" "0.28.1" - "@esbuild/win32-x64" "0.28.1" + "@esbuild/aix-ppc64" "0.28.2" + "@esbuild/android-arm" "0.28.2" + "@esbuild/android-arm64" "0.28.2" + "@esbuild/android-x64" "0.28.2" + "@esbuild/darwin-arm64" "0.28.2" + "@esbuild/darwin-x64" "0.28.2" + "@esbuild/freebsd-arm64" "0.28.2" + "@esbuild/freebsd-x64" "0.28.2" + "@esbuild/linux-arm" "0.28.2" + "@esbuild/linux-arm64" "0.28.2" + "@esbuild/linux-ia32" "0.28.2" + "@esbuild/linux-loong64" "0.28.2" + "@esbuild/linux-mips64el" "0.28.2" + "@esbuild/linux-ppc64" "0.28.2" + "@esbuild/linux-riscv64" "0.28.2" + "@esbuild/linux-s390x" "0.28.2" + "@esbuild/linux-x64" "0.28.2" + "@esbuild/netbsd-arm64" "0.28.2" + "@esbuild/netbsd-x64" "0.28.2" + "@esbuild/openbsd-arm64" "0.28.2" + "@esbuild/openbsd-x64" "0.28.2" + "@esbuild/openharmony-arm64" "0.28.2" + "@esbuild/sunos-x64" "0.28.2" + "@esbuild/win32-arm64" "0.28.2" + "@esbuild/win32-ia32" "0.28.2" + "@esbuild/win32-x64" "0.28.2" escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" @@ -5634,12 +5721,10 @@ file-entry-cache@^8.0.0: dependencies: flat-cache "^4.0.0" -file-selector@^2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4" - integrity sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig== - dependencies: - tslib "^2.7.0" +file-selector@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/file-selector/-/file-selector-4.1.0.tgz#8759e5b0ef030c5cee36ea6f4b66cd9b23a40d86" + integrity sha512-Io1mP8CI3zec5Bxy3P3TxdrKnt35Cm8vNIHnZsvyj43l4YFjD4NRInBp240S5bDJQ0EP1jnh7nCAwXsO818OCg== fill-range@^7.1.1: version "7.1.1" @@ -6650,6 +6735,11 @@ json5@^2.2.2, json5@^2.2.3: resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsonc-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + jspdf-autotable@^5.0.8: version "5.0.8" resolved "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.8.tgz#b010dab34caf5eff60bbcd09a36d0608dc0a84ae" @@ -6734,10 +6824,10 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -linkifyjs@^4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1" - integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA== +linkifyjs@^4.3.3: + version "4.3.3" + resolved "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz#da08f0eeb4d89a24541d09591fbdcc211eb8fef0" + integrity sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg== locate-path@^6.0.0: version "6.0.0" @@ -7694,6 +7784,59 @@ own-keys@^1.0.1: object-keys "^1.1.1" safe-push-apply "^1.0.0" +oxc-parser@^0.127.0: + version "0.127.0" + resolved "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz#bb14600f5c59fb6b1fbac0ab6ff2cd3495a6df1d" + integrity sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA== + dependencies: + "@oxc-project/types" "^0.127.0" + optionalDependencies: + "@oxc-parser/binding-android-arm-eabi" "0.127.0" + "@oxc-parser/binding-android-arm64" "0.127.0" + "@oxc-parser/binding-darwin-arm64" "0.127.0" + "@oxc-parser/binding-darwin-x64" "0.127.0" + "@oxc-parser/binding-freebsd-x64" "0.127.0" + "@oxc-parser/binding-linux-arm-gnueabihf" "0.127.0" + "@oxc-parser/binding-linux-arm-musleabihf" "0.127.0" + "@oxc-parser/binding-linux-arm64-gnu" "0.127.0" + "@oxc-parser/binding-linux-arm64-musl" "0.127.0" + "@oxc-parser/binding-linux-ppc64-gnu" "0.127.0" + "@oxc-parser/binding-linux-riscv64-gnu" "0.127.0" + "@oxc-parser/binding-linux-riscv64-musl" "0.127.0" + "@oxc-parser/binding-linux-s390x-gnu" "0.127.0" + "@oxc-parser/binding-linux-x64-gnu" "0.127.0" + "@oxc-parser/binding-linux-x64-musl" "0.127.0" + "@oxc-parser/binding-openharmony-arm64" "0.127.0" + "@oxc-parser/binding-wasm32-wasi" "0.127.0" + "@oxc-parser/binding-win32-arm64-msvc" "0.127.0" + "@oxc-parser/binding-win32-ia32-msvc" "0.127.0" + "@oxc-parser/binding-win32-x64-msvc" "0.127.0" + +oxc-resolver@^11.19.1: + version "11.24.2" + resolved "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz#85c08d9f5797e600175fa8524d2d271c685d97cf" + integrity sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw== + optionalDependencies: + "@oxc-resolver/binding-android-arm-eabi" "11.24.2" + "@oxc-resolver/binding-android-arm64" "11.24.2" + "@oxc-resolver/binding-darwin-arm64" "11.24.2" + "@oxc-resolver/binding-darwin-x64" "11.24.2" + "@oxc-resolver/binding-freebsd-x64" "11.24.2" + "@oxc-resolver/binding-linux-arm-gnueabihf" "11.24.2" + "@oxc-resolver/binding-linux-arm-musleabihf" "11.24.2" + "@oxc-resolver/binding-linux-arm64-gnu" "11.24.2" + "@oxc-resolver/binding-linux-arm64-musl" "11.24.2" + "@oxc-resolver/binding-linux-ppc64-gnu" "11.24.2" + "@oxc-resolver/binding-linux-riscv64-gnu" "11.24.2" + "@oxc-resolver/binding-linux-riscv64-musl" "11.24.2" + "@oxc-resolver/binding-linux-s390x-gnu" "11.24.2" + "@oxc-resolver/binding-linux-x64-gnu" "11.24.2" + "@oxc-resolver/binding-linux-x64-musl" "11.24.2" + "@oxc-resolver/binding-openharmony-arm64" "11.24.2" + "@oxc-resolver/binding-wasm32-wasi" "11.24.2" + "@oxc-resolver/binding-win32-arm64-msvc" "11.24.2" + "@oxc-resolver/binding-win32-x64-msvc" "11.24.2" + p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -8173,14 +8316,13 @@ react-dom@19.2.8, "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0": dependencies: scheduler "^0.27.0" -react-dropzone@15.0.0: - version "15.0.0" - resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-15.0.0.tgz#bd03c7c2b14fe4ea9db1a9c74502b85339f2e505" - integrity sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg== +react-dropzone@20.0.0: + version "20.0.0" + resolved "https://registry.npmjs.org/react-dropzone/-/react-dropzone-20.0.0.tgz#75eade48bede945796aac3a25cba5488d5d640a9" + integrity sha512-Xw8tvvVPJQzj8ir5wivUMzA+G6R+aGhdU5KQzUMvVBlJNb26AW/0137VoYVmb5UgZcbhM9OCpjE4KOqqSL9QuQ== dependencies: - attr-accept "^2.2.4" - file-selector "^2.1.0" - prop-types "^15.8.1" + attr-accept "^2.2.5" + file-selector "^4.1.0" react-error-boundary@^6.1.2: version "6.1.2" @@ -8788,16 +8930,11 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.3: +semver@^7.5.3, semver@^7.7.1, semver@^7.7.3: version "7.8.5" resolved "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== -semver@^7.7.1, semver@^7.7.3: - version "7.7.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" - integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== - set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -9027,24 +9164,28 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" -storybook@10.3.5: - version "10.3.5" - resolved "https://registry.npmjs.org/storybook/-/storybook-10.3.5.tgz#77bc13217db7b3c2ba5a73c1f2d469bfc0675da1" - integrity sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw== +storybook@10.5.7: + version "10.5.7" + resolved "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz#adfc465e51f337291c095278c23f1b8024ef2da7" + integrity sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg== dependencies: "@storybook/global" "^5.0.0" - "@storybook/icons" "^2.0.1" - "@testing-library/jest-dom" "^6.9.1" + "@storybook/icons" "^2.0.2" + "@testing-library/dom" "^10.4.1" + "@testing-library/jest-dom" "6.9.1" "@testing-library/user-event" "^14.6.1" "@vitest/expect" "3.2.4" "@vitest/spy" "3.2.4" "@webcontainer/env" "^1.1.1" - esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0" + esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0" + jsonc-parser "^3.3.1" open "^10.2.0" + oxc-parser "^0.127.0" + oxc-resolver "^11.19.1" recast "^0.23.5" semver "^7.7.3" use-sync-external-store "^1.5.0" - ws "^8.18.0" + ws "^8.21.1" strict-event-emitter@^0.5.1: version "0.5.1" @@ -9434,7 +9575,7 @@ tsconfig-paths@^4.2.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -9946,10 +10087,10 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" -ws@^8.18.0, ws@^8.19.0: - version "8.21.1" - resolved "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz#045650cd4b1207809e7547146223c3814a9af586" - integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw== +ws@^8.19.0, ws@^8.21.1: + version "8.21.3" + resolved "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc" + integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw== wsl-utils@^0.1.0: version "0.1.0"