diff --git a/package-lock.json b/package-lock.json index de8833d0..c2855d0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ocotillo-ui", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ocotillo-ui", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "@base-ui-components/react": "^1.0.0-alpha.6", "@casl/ability": "^6.7.3", @@ -36,6 +36,7 @@ "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.0", "@tanstack/react-query": "^5.67.3", + "@tanstack/react-table": "^8.21.3", "@tiptap/extension-color": "^2.9.1", "@tiptap/pm": "^2.9.1", "@tiptap/react": "^2.9.1", @@ -8020,6 +8021,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index f426e70a..a6f219b4 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.0", "@tanstack/react-query": "^5.67.3", + "@tanstack/react-table": "^8.21.3", "@tiptap/extension-color": "^2.9.1", "@tiptap/pm": "^2.9.1", "@tiptap/react": "^2.9.1", @@ -145,4 +146,4 @@ "refine": { "projectId": "wCqQ1f-agx0FN-70pXIr" } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index b05236da..79cdcaf7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,9 @@ import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router' import { AppProviders } from '@/AppProviders' import { AppShell } from '@/components/AppShell' import { Callback, Login } from '@/components/Auth' +import { AccessConsentPage } from '@/pages/access/consent' +import { AccessDestinationsPage } from '@/pages/access/destinations' +import { AccessGrantsPage } from '@/pages/access/grants' import { ContentPage } from '@/pages/content' import { TypographyPage } from '@/pages/example/TypographyPage' import { Home } from '@/pages/home' @@ -61,6 +64,12 @@ const App: React.FC = () => ( path="/ogcapi" element={} /> + } /> + } + /> + } /> {/* TEMPORARY: example specimen pages */} } /> } /> diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 00000000..b01ab008 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Table({ className, ...props }: React.ComponentProps<'table'>) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) { + return ( + tr]:last:border-b-0', + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<'th'>) { + return ( +
[role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<'td'>) { + return ( + [role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<'caption'>) { + return ( +
+ ) +} + +export { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} diff --git a/src/config/navigation.ts b/src/config/navigation.ts index f9a9a124..ad528cbd 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -1,3 +1,4 @@ +import type { LucideIcon } from 'lucide-react' import { BookOpen, Database, @@ -10,9 +11,9 @@ import { Map as MapIcon, MapPin, Search, + ShieldCheck, Users, } from 'lucide-react' -import type { LucideIcon } from 'lucide-react' import type { PortalRole } from '@/utils/accessControl' /** @@ -149,6 +150,13 @@ export const RESOURCE_NAV: NavItem[] = [ resource: 'ocotillo.lexicon', roles: adminOnly, }, + { + label: 'Access Control', + href: '/access/grants', + icon: ShieldCheck, + resource: 'ocotillo.access-grants', + roles: adminOnly, + }, { label: 'Hydrograph Correction', href: '/ocotillo/hydrograph-correction', diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 2cc94a83..65afb3f6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,25 +1,30 @@ -export * from './useListPageDataGridAnalytics' export * from './useAbortableList' export * from './useAccessCapabilities' -export * from './useSearchHistory' +export * from './useAccessConsent' +export * from './useAccessDestinations' +export * from './useAccessGrants' export * from './useAll' export * from './useAllNotes' +export * from './useContainerMinWidth' export * from './useDebounce' export * from './useElevation' export * from './useGisArtifacts' +export * from './useGroups' export * from './useLayer' export * from './useLexicon' -export * from './useMostRecentObservation' +export * from './useListPageDataGridAnalytics' export * from './useMeasuredHeight' +export * from './useMostRecentObservation' export * from './useOSEPODInfo' export * from './usePrimaryAndSecondaryContact' -export * from './useWellPdfData' +export * from './useSearchHistory' +export * from './useSearchModalState' export * from './useSensor' export * from './useSensorDeploymentRows' +export * from './useSidebarPanelSync' export * from './useThingLayers' +export * from './useThingSearch' export * from './useUSGSSiteInfo' export * from './useViewportBbox' -export * from './useSearchModalState' -export * from './useSidebarPanelSync' export * from './useWellDetails' -export * from './useContainerMinWidth' +export * from './useWellPdfData' diff --git a/src/hooks/useAccessConsent.ts b/src/hooks/useAccessConsent.ts new file mode 100644 index 00000000..1f12977b --- /dev/null +++ b/src/hooks/useAccessConsent.ts @@ -0,0 +1,66 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { axiosCall, fetcher } from '@/providers/ocotillo-data-provider' +import { + type CreateConsentInput, + type PublicationConsent, + zPublicationConsent, + zPublicationConsentList, +} from '@/utils/accessConsent' + +/** + * Publication consent (`/access/consent`). + * + * Unlike grants, this route still requires `thing_id` — there is no + * consent-wide audit view — so the tab stays thing-scoped and does not fetch + * until one is supplied. + */ +export const useAccessConsent = ( + thingId: string, + options?: { includeRevoked?: boolean; enabled?: boolean } +) => + useQuery({ + queryKey: ['access-consent', thingId, options?.includeRevoked ?? false], + enabled: (options?.enabled ?? true) && /^\d+$/.test(thingId.trim()), + queryFn: async () => { + const response = await fetcher('access/consent', { + params: { + thing_id: Number(thingId.trim()), + include_revoked: options?.includeRevoked ?? false, + }, + }) + return zPublicationConsentList.parse(response.data) + }, + }) + +const useConsentMutation = ( + mutationFn: (variables: TVariables) => Promise +) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['access-consent'] }) + // A consent change moves what a destination may read, and that view is + // computed server-side from these rows. + queryClient.invalidateQueries({ queryKey: ['access-published-things'] }) + }, + }) +} + +export const useCreateConsent = () => + useConsentMutation(async (input: CreateConsentInput) => { + const response = await axiosCall('access/consent', { + method: 'POST', + data: input, + }) + return zPublicationConsent.parse(response.data) + }) + +export const useRevokeConsent = () => + useConsentMutation(async (consentId: number) => { + const response = await axiosCall(`access/consent/${consentId}/revocation`, { + method: 'POST', + }) + return zPublicationConsent.parse(response.data) + }) diff --git a/src/hooks/useAccessDestinations.ts b/src/hooks/useAccessDestinations.ts new file mode 100644 index 00000000..40ae66c0 --- /dev/null +++ b/src/hooks/useAccessDestinations.ts @@ -0,0 +1,65 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { axiosCall, fetcher } from '@/providers/ocotillo-data-provider' +import { + type CreateDestinationInput, + type Destination, + type PublishedThing, + zDestination, + zDestinationList, + zPublishedThingList, +} from '@/utils/accessDestinations' + +/** + * Destinations (`/access/destination`). + * + * Listing is viewer-level while registering is admin-only, so the consent tab + * can resolve destination names even for a reader who could not create one. + */ +export const useAccessDestinations = (options?: { enabled?: boolean }) => + useQuery({ + queryKey: ['access-destinations'], + enabled: options?.enabled ?? true, + queryFn: async () => { + const response = await fetcher('access/destination') + return zDestinationList.parse(response.data) + }, + }) + +export const useCreateDestination = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (input: CreateDestinationInput) => { + const response = await axiosCall('access/destination', { + method: 'POST', + data: input, + }) + return zDestination.parse(response.data) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['access-destinations'] }) + }, + }) +} + +/** + * What one destination may actually read, computed from consent rows at + * request time. A retired destination and one nobody has consented to both + * return an empty list — default deny, with no separate "unpublished" state — + * so the console cannot tell those apart and does not pretend to. + */ +export const usePublishedThings = ( + slug: string | undefined, + options?: { dataType?: string; enabled?: boolean } +) => + useQuery({ + queryKey: ['access-published-things', slug, options?.dataType ?? null], + enabled: (options?.enabled ?? true) && Boolean(slug), + queryFn: async () => { + const response = await fetcher( + `access/destination/${encodeURIComponent(slug as string)}/thing`, + options?.dataType ? { params: { data_type: options.dataType } } : {} + ) + return zPublishedThingList.parse(response.data) + }, + }) diff --git a/src/hooks/useAccessGrants.ts b/src/hooks/useAccessGrants.ts new file mode 100644 index 00000000..a2e50661 --- /dev/null +++ b/src/hooks/useAccessGrants.ts @@ -0,0 +1,70 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { axiosCall, fetcher } from '@/providers/ocotillo-data-provider' +import { + type CreateGrantInput, + type GrantFilters, + grantQueryParams, + type PermissionGrant, + type PermissionGrantPage, + zPermissionGrant, + zPermissionGrantPage, +} from '@/utils/accessGrants' + +/** + * ADR5 permission grants (`/access/grant`), for the operations console. + * + * Every filter the route takes is optional, so the bare call is the + * admin-wide audit view and each filter narrows it. Filters are sent only + * when set: an empty string is not the same question as "any", and passing + * one would match only grants whose field is literally empty. + * + * The route answers with a page rather than a list, so the query returns the + * envelope: the console needs `total` to tell a complete table from a truncated + * one. + */ +export const useAccessGrants = (filters: GrantFilters) => + useQuery({ + queryKey: ['access-grants', grantQueryParams(filters)], + queryFn: async () => { + const response = await fetcher('access/grant', { + params: grantQueryParams(filters), + }) + return zPermissionGrantPage.parse(response.data) + }, + }) + +/** + * Both mutations invalidate every grant list rather than patching the cache. + * A write can land outside the slice on screen — granting to a principal the + * current filter excludes — and a grant's rendered status depends on the + * server's clock, so the authoritative row is the one the next read returns. + */ +const useGrantMutation = ( + mutationFn: (variables: TVariables) => Promise +) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['access-grants'] }) + }, + }) +} + +export const useCreateGrant = () => + useGrantMutation(async (input: CreateGrantInput) => { + const response = await axiosCall('access/grant', { + method: 'POST', + data: input, + }) + return zPermissionGrant.parse(response.data) + }) + +export const useRevokeGrant = () => + useGrantMutation(async (grantId: number) => { + const response = await axiosCall(`access/grant/${grantId}/revocation`, { + method: 'POST', + }) + return zPermissionGrant.parse(response.data) + }) diff --git a/src/hooks/useGroups.ts b/src/hooks/useGroups.ts new file mode 100644 index 00000000..d5447c88 --- /dev/null +++ b/src/hooks/useGroups.ts @@ -0,0 +1,36 @@ +import { useList } from '@refinedev/core' +import type { GroupResponse } from '@/generated/types.gen' + +/** + * Groups, for pickers that need to name one. + * + * The API stores a group by id and the access routes take that id, but nobody + * administering access knows it — they know the name. Everything that asks for + * a group scope reads from here so the id stays an implementation detail. + */ +export const useGroups = () => { + const data = useList({ + resource: 'group', + dataProviderName: 'ocotillo', + // Well past the number of groups that exist; a picker that pages is worse + // than one that loads the lot once. + pagination: { pageSize: 500 }, + queryOptions: { + gcTime: 1000 * 60 * 5, + staleTime: 1000 * 60 * 2, + }, + }) + + const groups = [...(data.result?.data ?? [])].sort((a, b) => + a.name.localeCompare(b.name) + ) + + return { + groups, + isLoading: data.query.isLoading, + options: groups.map((group) => ({ + value: String(group.id), + label: group.name, + })), + } +} diff --git a/src/hooks/useThingSearch.ts b/src/hooks/useThingSearch.ts new file mode 100644 index 00000000..6bbe4314 --- /dev/null +++ b/src/hooks/useThingSearch.ts @@ -0,0 +1,25 @@ +import { useAutocomplete } from '@refinedev/mui' +import type { ThingResponse } from '@/generated/types.gen' + +/** + * Things, searched by name — the PointID an operator actually knows. + * + * The access routes take a thing id, but nobody administering consent thinks + * in ids. Search is server-side (`name contains …`) because the well list is + * far too long to hold in a picker. + */ +export const useThingSearch = () => { + const { autocompleteProps } = useAutocomplete({ + resource: 'thing', + dataProviderName: 'ocotillo', + onSearch: (value) => [ + { + field: 'name', + operator: 'contains', + value, + }, + ], + }) + + return autocompleteProps +} diff --git a/src/pages/access/AccessConsole.tsx b/src/pages/access/AccessConsole.tsx new file mode 100644 index 00000000..7354f2d1 --- /dev/null +++ b/src/pages/access/AccessConsole.tsx @@ -0,0 +1,110 @@ +import { + CircularProgress, + Container, + Stack, + Tab, + Tabs, + Typography, +} from '@mui/material' +import { useCan } from '@refinedev/core' +import { ErrorComponent } from '@refinedev/mui' +import type { ReactNode } from 'react' +import { Link as RouterLink, useLocation } from 'react-router' + +export const ACCESS_TABS = [ + { + path: '/access/grants', + label: 'Grants', + description: + 'Who may read, enter, correct, or administer each kind of data, and for how long. Revoking takes effect at the next read, not at the next sign-in.', + }, + { + path: '/access/destinations', + label: 'Destinations', + description: + 'The places published data is offered to, and what each one may currently read.', + }, + { + path: '/access/consent', + label: 'Consent', + description: + 'Where an owner agreed to publish one kind of data to one destination. Withdrawing stops the offer; copies already harvested are not recalled.', + }, +] as const + +/** + * Shared shell for the three access-control tabs. + * + * Grants, destinations, and consent are one subject read three ways — a grant + * says who may see data, consent says where it may go, and a destination is + * the place it goes — so they share a page rather than sitting in three + * unrelated corners of the nav. Each tab keeps its own route so a link into + * one still works. + * + * The whole console is gated once, here: every route underneath is + * admin-only, and checking in one place keeps a tab from rendering its + * loading state before deciding the reader is not allowed to see it. + */ +export const AccessConsole = ({ + activePath, + children, +}: { + activePath: (typeof ACCESS_TABS)[number]['path'] + children: ReactNode +}) => { + const { data: access, isLoading } = useCan({ + action: 'manage', + resource: 'ocotillo.access-grants', + }) + const location = useLocation() + const active = + ACCESS_TABS.find((tab) => location.pathname.startsWith(tab.path))?.path ?? + activePath + const description = ACCESS_TABS.find( + (tab) => tab.path === active + )?.description + + if (isLoading) { + return ( + + + + ) + } + + if (!access?.can) return + + // Full width, like the datasets table: eight columns of grants do not fit a + // reading-width container without wrapping every cell. + return ( + + + + Access Control + + {ACCESS_TABS.map((tab) => ( + + ))} + + {description ? ( + + {description} + + ) : null} + + {children} + + + ) +} diff --git a/src/pages/access/consent/index.tsx b/src/pages/access/consent/index.tsx new file mode 100644 index 00000000..8c218335 --- /dev/null +++ b/src/pages/access/consent/index.tsx @@ -0,0 +1,559 @@ +import { Add } from '@mui/icons-material' +import { + Alert, + Autocomplete, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + MenuItem, + Paper, + Stack, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material' +import { useState } from 'react' +import { ConfirmDialog } from '@/components/ConfirmDialog' +import { + useAccessConsent, + useAccessDestinations, + useCreateConsent, + useRevokeConsent, + useThingSearch, +} from '@/hooks' +import { AccessConsole } from '@/pages/access/AccessConsole' +import { + type ConsentFormErrors, + type CreateConsentInput, + describeConsentingContact, + type PublicationConsent, + sortConsent, + toCreateConsentInput, + validateConsentForm, +} from '@/utils/accessConsent' +import { + type Destination, + destinationLabel, + indexDestinationsById, + sortDestinations, +} from '@/utils/accessDestinations' +import { ACCESS_DATA_TYPES } from '@/utils/accessGrants' +import { + ACCESS_STATUS_COLORS, + ACCESS_STATUS_LABELS, + accessStatusOf, + isRevocable, + toDateInputValue, +} from '@/utils/accessLifecycle' + +/** + * Picks a thing by its PointID and hands back the id the API wants. + * + * `freeSolo`, because an id pasted from a ticket still has to work — anything + * typed that is not chosen from the list is treated as an id, which is what + * the field held before it could search by name. + */ +const ThingPicker = ({ + label, + helperText, + error, + inputValue, + onInputChange, + onSelect, + onEnter, +}: { + label: string + helperText?: string + error?: boolean + inputValue: string + onInputChange: (value: string) => void + onSelect: (thingId: string, name: string) => void + onEnter?: () => void +}) => { + const autocompleteProps = useThingSearch() + + return ( + { + autocompleteProps.onInputChange?.(event, next, reason) + onInputChange(next) + }} + getOptionLabel={(option) => + typeof option === 'string' ? option : option.name + } + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_event, option) => { + if (option && typeof option !== 'string') { + onSelect(String(option.id), option.name) + } + }} + renderInput={(params) => ( + { + if (event.key === 'Enter') onEnter?.() + }} + /> + )} + /> + ) +} + +export const AccessConsentPage = () => ( + + + +) + +const ConsentTab = () => { + // Unlike grants, `GET /access/consent` still requires a thing_id — there is + // no consent-wide audit view — so this tab stays thing-scoped and says so + // rather than looking broken before one is entered. + const [thingInput, setThingInput] = useState('') + const [thingId, setThingId] = useState('') + const [includeRevoked, setIncludeRevoked] = useState(false) + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [pendingRevoke, setPendingRevoke] = useState( + null + ) + const [today] = useState(() => new Date()) + + const destinations = useAccessDestinations() + const consent = useAccessConsent(thingId, { includeRevoked }) + const createConsent = useCreateConsent() + const revokeConsent = useRevokeConsent() + + const destinationsById = indexDestinationsById(destinations.data) + const rows = consent.data ? sortConsent(consent.data, today) : [] + + return ( + + + + setThingId(selectedId)} + onEnter={() => setThingId(thingInput.trim())} + /> + setIncludeRevoked(event.target.checked)} + /> + } + label="Include withdrawn" + /> + + + + + {destinations.data?.length === 0 ? ( + + No destination is registered yet. Consent names the destination it + publishes to, so register one first. + + ) : null} + + {revokeConsent.isError ? ( + + Failed to withdraw that consent. + {revokeConsent.error instanceof Error + ? ` ${revokeConsent.error.message}` + : null} + + ) : null} + + {!thingId ? ( + + ) : consent.isLoading ? ( + + + + Loading consent... + + + ) : consent.isError ? ( + + Failed to load consent for thing {thingId}. + {consent.error instanceof Error ? ` ${consent.error.message}` : null} + + ) : rows.length === 0 ? ( + + ) : ( + + )} + + setPendingRevoke(null)} + title="Withdraw this consent?" + text={ + pendingRevoke + ? `${ + destinationsById.get(pendingRevoke.destination_id)?.name ?? + `Destination ${pendingRevoke.destination_id}` + } will stop being offered "${pendingRevoke.data_type}" for thing ${pendingRevoke.thing_id}. Copies already harvested are not recalled.` + : '' + } + PrimaryActionBtnMsg="Withdraw" + onPrimaryAction={() => { + if (pendingRevoke) revokeConsent.mutate(pendingRevoke.id) + setPendingRevoke(null) + }} + /> + + {isDialogOpen ? ( + setIsDialogOpen(false)} + onSubmit={(input) => + createConsent.mutate(input, { + onSuccess: (created) => { + setIsDialogOpen(false) + // Record consent for a thing you were not looking at and the + // tab follows, so the new row is visible. + setThingInput(String(created.thing_id)) + setThingId(String(created.thing_id)) + }, + }) + } + isSubmitting={createConsent.isPending} + submitError={ + createConsent.isError + ? createConsent.error instanceof Error + ? createConsent.error.message + : 'The consent was rejected.' + : undefined + } + /> + ) : null} + + ) +} + +const EmptyState = ({ title, body }: { title: string; body: string }) => ( + + + {title} + + {body} + + + +) + +const ConsentTable = ({ + rows, + today, + destinationsById, + onRevoke, + revokingId, +}: { + rows: PublicationConsent[] + today: Date + destinationsById: Map + onRevoke: (consent: PublicationConsent) => void + revokingId: number | null +}) => ( + + + + + Destination + Data type + Dates + Consented by + Recorded by + Status + Actions + + + + {rows.map((consent) => { + const status = accessStatusOf(consent, today) + const destination = destinationsById.get(consent.destination_id) + + return ( + + + + {destination?.name ?? `Destination ${consent.destination_id}`} + + + {consent.data_type} + + + {consent.starts_at} → {consent.ends_at ?? 'no end'} + + + + + + {describeConsentingContact(consent)} + + {consent.notes ? ( + + {consent.notes} + + ) : null} + + + {consent.recorded_by} + + + + + {isRevocable(consent, today) ? ( + + ) : ( + + — + + )} + + + ) + })} + +
+
+) + +const ConsentDialog = ({ + destinations, + defaultThingId, + today, + onClose, + onSubmit, + isSubmitting, + submitError, +}: { + destinations: Destination[] + defaultThingId: string + today: Date + onClose: () => void + onSubmit: (input: CreateConsentInput) => void + isSubmitting: boolean + submitError?: string +}) => { + // What the picker shows, which is a PointID once one is chosen. The id it + // resolves to lives in the form. + const [thingInput, setThingInput] = useState(defaultThingId) + const [form, setForm] = useState({ + thing_id: defaultThingId, + destination_slug: destinations[0]?.slug ?? '', + data_type: 'water level', + contact_id: '', + starts_at: toDateInputValue(today), + ends_at: '', + notes: '', + }) + const [errors, setErrors] = useState({}) + + const set = (field: keyof typeof form) => (value: string) => + setForm((previous) => ({ ...previous, [field]: value })) + + const handleSubmit = () => { + const found = validateConsentForm(form) + setErrors(found) + if (Object.keys(found).length > 0) return + + onSubmit(toCreateConsentInput(form)) + } + + return ( + + Record publication consent + + + {submitError ? {submitError} : null} + + + { + setThingInput(value) + // Digits are an id; a partly typed name is not one yet, and + // sending it as though it were would fail at the API. + set('thing_id')(/^\d+$/.test(value.trim()) ? value.trim() : '') + }} + onSelect={(thingId, name) => { + set('thing_id')(thingId) + setThingInput(name) + }} + /> + set('destination_slug')(event.target.value)} + > + {destinations.map((destination) => ( + + {destinationLabel(destination)} + + ))} + + + + + set('data_type')(event.target.value)} + > + {ACCESS_DATA_TYPES.map((dataType) => ( + + {dataType} + + ))} + + set('contact_id')(event.target.value)} + /> + + + + set('starts_at')(event.target.value)} + /> + set('ends_at')(event.target.value)} + /> + + + set('notes')(event.target.value)} + /> + + + + + + + + ) +} diff --git a/src/pages/access/destinations/index.tsx b/src/pages/access/destinations/index.tsx new file mode 100644 index 00000000..be9a39bd --- /dev/null +++ b/src/pages/access/destinations/index.tsx @@ -0,0 +1,282 @@ +import { Add } from '@mui/icons-material' +import { + Alert, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + MenuItem, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material' +import { useState } from 'react' +import { useAccessDestinations, useCreateDestination } from '@/hooks' +import { AccessConsole } from '@/pages/access/AccessConsole' +import { + type CreateDestinationInput, + DESTINATION_KINDS, + type Destination, + type DestinationFormErrors, + sortDestinations, + toCreateDestinationInput, + validateDestinationForm, +} from '@/utils/accessDestinations' + +export const AccessDestinationsPage = () => ( + + + +) + +const DestinationsTab = () => { + const [isDialogOpen, setIsDialogOpen] = useState(false) + const destinations = useAccessDestinations() + const createDestination = useCreateDestination() + + const rows = destinations.data ? sortDestinations(destinations.data) : [] + + return ( + + + + + + + {destinations.isLoading ? ( + + + + Loading destinations... + + + ) : destinations.isError ? ( + + Failed to load destinations. + {destinations.error instanceof Error + ? ` ${destinations.error.message}` + : null} + + ) : rows.length === 0 ? ( + + + + No destinations registered + + + Register one before recording consent — consent names the + destination it publishes to. + + + + ) : ( + + )} + + {isDialogOpen ? ( + setIsDialogOpen(false)} + onSubmit={(input) => + createDestination.mutate(input, { + onSuccess: () => setIsDialogOpen(false), + }) + } + isSubmitting={createDestination.isPending} + submitError={ + createDestination.isError + ? createDestination.error instanceof Error + ? createDestination.error.message + : 'The destination was rejected.' + : undefined + } + /> + ) : null} + + ) +} + +const DestinationsTable = ({ rows }: { rows: Destination[] }) => ( + + + + + Destination + Kind + Description + Status + + + + {rows.map((destination) => ( + + ))} + +
+
+) + +const Row = ({ destination }: { destination: Destination }) => ( + + + + + {destination.name} + + + {destination.slug} + + + + {destination.destination_kind} + + {destination.description ? ( + + {destination.description} + + ) : ( + + — + + )} + + + + + +) + +const DestinationDialog = ({ + onClose, + onSubmit, + isSubmitting, + submitError, +}: { + onClose: () => void + onSubmit: (input: CreateDestinationInput) => void + isSubmitting: boolean + submitError?: string +}) => { + const [form, setForm] = useState({ + slug: '', + name: '', + destination_kind: 'public web', + description: '', + }) + const [errors, setErrors] = useState({}) + + const set = (field: keyof typeof form) => (value: string) => + setForm((previous) => ({ ...previous, [field]: value })) + + const handleSubmit = () => { + const found = validateDestinationForm(form) + setErrors(found) + if (Object.keys(found).length > 0) return + + onSubmit(toCreateDestinationInput(form)) + } + + return ( + + Register a destination + + + {submitError ? {submitError} : null} + + set('slug')(event.target.value)} + /> + set('destination_kind')(event.target.value)} + > + {DESTINATION_KINDS.map((kind) => ( + + {kind} + + ))} + + + set('name')(event.target.value)} + /> + set('description')(event.target.value)} + /> + + + + + + + + ) +} diff --git a/src/pages/access/grants/GrantDialog.tsx b/src/pages/access/grants/GrantDialog.tsx new file mode 100644 index 00000000..bbf8c6b0 --- /dev/null +++ b/src/pages/access/grants/GrantDialog.tsx @@ -0,0 +1,341 @@ +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + MenuItem, + Stack, + TextField, +} from '@mui/material' +import { useState } from 'react' +import { useGroups } from '@/hooks' +import { + ACCESS_DATA_TYPES, + capabilityFor, + DATA_CAPABILITIES, + type CreateGrantInput, + GRANT_SCOPE_TYPES, + type GrantFormErrors, + PRINCIPAL_TYPES, + scopeIdRequired, + scopeTypeFor, + SURFACE_CAPABILITY, + toCreateGrantInput, + UI_SURFACES, + validateGrantForm, +} from '@/utils/accessGrants' +import { toDateInputValue } from '@/utils/accessLifecycle' + +export type GrantFormState = { + principal_type: string + principal_id: string + capability: string + scope_type: string + scope_id: string + subject: string + data_type: string + ui_surface: string + starts_at: string + ends_at: string + reason: string +} + +export const emptyGrantForm = ( + today: Date, + principalId = '' +): GrantFormState => ({ + principal_type: 'user', + principal_id: principalId, + capability: 'read', + scope_type: 'global', + scope_id: '', + subject: 'data_type', + data_type: 'water level', + ui_surface: '', + starts_at: toDateInputValue(today), + ends_at: '', + reason: '', +}) + +/** + * Every axis of a grant is named explicitly — there is no wildcard data type + * on the API side, and the form does not invent one. An admin picks one + * capability over one data type in one scope, which is the unit the API + * stores and the unit that can later be revoked. + */ +export const GrantDialog = ({ + open, + onClose, + onSubmit, + today, + defaultPrincipalId, + isSubmitting, + submitError, +}: { + open: boolean + onClose: () => void + onSubmit: (input: CreateGrantInput) => void + today: Date + defaultPrincipalId: string + isSubmitting: boolean + submitError?: string +}) => { + const [form, setForm] = useState(() => + emptyGrantForm(today, defaultPrincipalId) + ) + const [errors, setErrors] = useState({}) + const groups = useGroups() + + const set = (field: keyof GrantFormState) => (value: string) => + setForm((previous) => ({ ...previous, [field]: value })) + + const handleSubmit = () => { + const found = validateGrantForm(form) + setErrors(found) + if (Object.keys(found).length > 0) return + + onSubmit(toCreateGrantInput(form)) + } + + const isSurface = form.subject === 'ui_surface' + // A screen grant carries `view`; the data verbs belong to a data grant. + const capability = capabilityFor(form.subject, form.capability) + // A screen grant is global whatever the scope select last held. + const scopeType = scopeTypeFor(form.subject, form.scope_type) + const needsScopeId = scopeIdRequired(scopeType) + + return ( + + Grant access + + + {submitError ? {submitError} : null} + + + set('principal_type')(event.target.value)} + > + {PRINCIPAL_TYPES.map((value) => ( + + {value} + + ))} + + set('principal_id')(event.target.value)} + /> + + + + set('capability')(event.target.value)} + > + {(isSurface ? [SURFACE_CAPABILITY] : DATA_CAPABILITIES).map( + (value) => ( + + {value} + + ) + )} + + set('subject')(event.target.value)} + > + a data type + a screen + + + + + {isSurface ? ( + set('ui_surface')(event.target.value)} + > + {UI_SURFACES.map((value) => ( + + {value} + + ))} + + ) : ( + set('data_type')(event.target.value)} + > + {ACCESS_DATA_TYPES.map((value) => ( + + {value} + + ))} + + )} + + + + set('scope_type')(event.target.value)} + > + {GRANT_SCOPE_TYPES.map((value) => ( + + {value} + + ))} + + {scopeType === 'group' ? ( + // An admin knows the group by name; the id is what the API + // stores, so the picker carries it and never shows it. + set('scope_id')(event.target.value)} + > + {groups.options.map((option) => ( + + {option.label} + + ))} + + ) : ( + set('scope_id')(event.target.value)} + /> + )} + + + + set('starts_at')(event.target.value)} + /> + set('ends_at')(event.target.value)} + /> + + + set('reason')(event.target.value)} + /> + + + + + + + + ) +} diff --git a/src/pages/access/grants/GrantsTable.tsx b/src/pages/access/grants/GrantsTable.tsx new file mode 100644 index 00000000..0347c39f --- /dev/null +++ b/src/pages/access/grants/GrantsTable.tsx @@ -0,0 +1,338 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from '@tanstack/react-table' +import { useMemo, useState } from 'react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { useGroups } from '@/hooks' +import { cn } from '@/lib/utils' +import { + describeScope, + describeSubject, + grantStatusOf, + isUiSurfaceGrant, + type PermissionGrant, + scopeIdRequired, +} from '@/utils/accessGrants' +import { + type AccessStatus, + ACCESS_STATUS_LABELS, + isRevocable, +} from '@/utils/accessLifecycle' + +const PAGE_SIZES = [10, 25, 50, 100] + +/** + * Status colours as Tailwind tokens rather than MUI palette names. The four + * statuses are the same four `accessLifecycle` derives; only the paint differs. + */ +const STATUS_CLASSES: Record = { + active: 'border-success/30 bg-success/10 text-success', + scheduled: 'border-primary/30 bg-primary/10 text-primary', + expired: 'border-border bg-muted text-muted-foreground', + revoked: 'border-destructive/30 bg-destructive/10 text-destructive', +} + +/** + * The grants table, on the shadcn table primitive with TanStack for the row + * model. Sorting stays in `sortGrants`, which orders by lifecycle first — that + * is a domain rule, not a column the reader should be able to undo. + */ +export const GrantsTable = ({ + rows, + today, + onRevoke, + revokingId, +}: { + rows: PermissionGrant[] + today: Date + onRevoke: (grant: PermissionGrant) => void + revokingId: number | null +}) => { + const { groups } = useGroups() + const groupNames = useMemo( + () => Object.fromEntries(groups.map((group) => [group.id, group.name])), + [groups] + ) + + const columns = useMemo[]>( + () => [ + { + id: 'principal', + header: 'Principal', + cell: ({ row }) => ( +
+
+ {row.original.principal_id} +
+
+ {row.original.principal_type} +
+
+ ), + }, + { + id: 'capability', + header: 'Capability', + cell: ({ row }) => row.original.capability, + }, + { + id: 'covers', + header: 'Covers', + cell: ({ row }) => { + const surface = isUiSurfaceGrant(row.original) + + return ( + + + + + {describeSubject(row.original)} + + + + + {surface + ? 'Screen grant: opens this nav item. Grants no write access.' + : 'Data grant: reaches this data type.'} + + + ) + }, + }, + { + id: 'scope', + header: 'Scope', + cell: ({ row }) => describeScope(row.original, groupNames), + }, + { + id: 'dates', + header: 'Dates', + cell: ({ row }) => ( + + {row.original.starts_at} → {row.original.ends_at ?? 'no end'} + + ), + }, + { + id: 'granted_by', + header: 'Granted by', + cell: ({ row }) => ( + // One line each, whatever the length: a long reason used to set the + // height of the whole row. +
+ + +
+ {row.original.granted_by} +
+
+ {row.original.granted_by} +
+ {row.original.reason ? ( + + +
+ {row.original.reason} +
+
+ {row.original.reason} +
+ ) : null} +
+ ), + }, + { + id: 'status', + header: 'Status', + cell: ({ row }) => { + const status = grantStatusOf(row.original, today) + + return ( + + + + {ACCESS_STATUS_LABELS[status]} + + + {row.original.revoked_at ? ( + + Revoked by {row.original.revoked_by ?? 'unknown'} + + ) : null} + + ) + }, + }, + { + id: 'actions', + header: () =>
Actions
, + cell: ({ row }) => ( +
+ {isRevocable(row.original, today) ? ( + + ) : ( + + )} +
+ ), + }, + ], + [groupNames, today, onRevoke, revokingId] + ) + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 25, + }) + + const table = useReactTable({ + data: rows, + columns, + state: { pagination }, + onPaginationChange: setPagination, + // Filtering happens before the rows arrive here, so a page that no longer + // exists is reset rather than left showing nothing. + autoResetPageIndex: true, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + const pageCount = table.getPageCount() + + return ( + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+ +
+
+ Rows per page + +
+ +
+ + Page {pagination.pageIndex + 1} of {Math.max(pageCount, 1)} + + + +
+
+
+
+ ) +} diff --git a/src/pages/access/grants/index.tsx b/src/pages/access/grants/index.tsx new file mode 100644 index 00000000..dc3fbdde --- /dev/null +++ b/src/pages/access/grants/index.tsx @@ -0,0 +1,404 @@ +import { Add, DesktopWindows, FilterAltOff, Storage } from '@mui/icons-material' +import { + Alert, + Button, + Chip, + CircularProgress, + FormControlLabel, + MenuItem, + Paper, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material' +import { useState } from 'react' +import { ConfirmDialog } from '@/components/ConfirmDialog' +import { + useAccessGrants, + useCreateGrant, + useGroups, + useRevokeGrant, +} from '@/hooks' +import { AccessConsole } from '@/pages/access/AccessConsole' +import { GrantDialog } from '@/pages/access/grants/GrantDialog' +import { GrantsTable } from '@/pages/access/grants/GrantsTable' +import { + ACCESS_DATA_TYPES, + CAPABILITIES, + GRANT_SUBJECTS, + type CreateGrantInput, + describeScope, + describeSubject, + GRANT_SCOPE_TYPES, + type GrantFilters, + GRANT_PAGE_SIZE, + isPartialPage, + isUnfiltered, + matchesFilters, + type PermissionGrant, + sortGrants, +} from '@/utils/accessGrants' + +/** + * Operations console for ADR5 permission grants. + * + * The page is organised around a single principal because the API is: there is + * no route that lists every grant, only `GET /access/grant?principal_id=…`. + * That is the right shape for the question this console answers — "what may + * this person, role, or key do, and why" — but it does mean an admin has to + * know who they are asking about before anything loads. + */ +export const AccessGrantsPage = () => ( + + + +) + +const GrantsTab = () => { + // `principal` is what is being typed; `filters.principalId` is what has + // been submitted. Keeping them apart stops a partially-typed subject from + // firing a request on every keystroke. The dropdowns have no such problem, + // so they apply on change. + const [principal, setPrincipal] = useState('') + const [filters, setFilters] = useState({}) + const [isDialogOpen, setIsDialogOpen] = useState(false) + // Revocation is not undoable through this console — the API has no + // un-revoke — so the button asks before it fires. + const [pendingRevoke, setPendingRevoke] = useState( + null + ) + const [today] = useState(() => new Date()) + // A grant written outside the slice on screen. The list refetches either + // way; this is what says so, rather than the row landing nowhere visible. + const [grantedOutOfView, setGrantedOutOfView] = + useState(null) + + const grants = useAccessGrants(filters) + const createGrant = useCreateGrant() + const revokeGrant = useRevokeGrant() + + const setFilter = ( + key: TKey, + value: GrantFilters[TKey] + ) => setFilters((previous) => ({ ...previous, [key]: value || undefined })) + + const clearFilters = () => { + setPrincipal('') + setFilters({}) + } + + const showOnlyPrincipal = (principalId: string) => { + setPrincipal(principalId) + setFilters({ + principalId, + includeRevoked: filters.includeRevoked, + }) + setGrantedOutOfView(null) + } + + const handleCreate = (input: CreateGrantInput) => { + createGrant.mutate(input, { + onSuccess: (grant) => { + setIsDialogOpen(false) + // The filters an admin set are theirs to change. Creating a grant + // refetches the list in place; it does not narrow the view to the new + // row, which would hide every grant they were already looking at. + setGrantedOutOfView(matchesFilters(grant, filters) ? null : grant) + }, + }) + } + + // Every filter is a query parameter now, including `subject`. They are + // applied again here because an API without that filter ignores it rather + // than refusing it, and a filter that quietly does nothing is a lie. + const rows = grants.data + ? sortGrants( + grants.data.items.filter((grant) => matchesFilters(grant, filters)), + today + ) + : [] + + return ( + + + + + + + {isPartialPage(grants.data) ? ( + + Showing the first {GRANT_PAGE_SIZE} of {grants.data?.total} grants. + Narrow by principal or capability to see the rest — sorting applies to + what is loaded. + + ) : null} + + {grantedOutOfView ? ( + setGrantedOutOfView(null)} + action={ + + } + > + Granted to {grantedOutOfView.principal_id}. The current filters do not + show it. + + ) : null} + + + + + setPrincipal(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + setFilter('principalId', principal.trim()) + } + }} + /> + setFilter('capability', value)} + /> + + setFilters((previous) => ({ + ...previous, + subject: value || undefined, + // A screen grant carries no data type, so the two filters + // together would always come back empty. + dataType: + value === 'ui_surface' ? undefined : previous.dataType, + })) + } + /> + setFilter('dataType', value)} + /> + setFilter('scopeType', value)} + /> + + + + setFilters((previous) => ({ + ...previous, + includeRevoked: event.target.checked, + })) + } + /> + } + label="Include revoked" + /> + + + + + + {revokeGrant.isError ? ( + + Failed to revoke that grant. + {revokeGrant.error instanceof Error + ? ` ${revokeGrant.error.message}` + : null} + + ) : null} + + {grants.isLoading ? ( + + + + Loading grants... + + + ) : grants.isError ? ( + + Failed to load grants. + {grants.error instanceof Error ? ` ${grants.error.message}` : null} + + ) : rows.length === 0 ? ( + + ) : ( + + )} + + setPendingRevoke(null)} + title="Revoke this grant?" + text={ + pendingRevoke + ? `${pendingRevoke.principal_id} will lose "${pendingRevoke.capability}" on ${describeSubject(pendingRevoke)} (${describeScope(pendingRevoke)}). This cannot be undone from here — restoring access means creating a new grant.` + : '' + } + PrimaryActionBtnMsg="Revoke" + onPrimaryAction={() => { + if (pendingRevoke) revokeGrant.mutate(pendingRevoke.id) + setPendingRevoke(null) + }} + /> + + {isDialogOpen ? ( + setIsDialogOpen(false)} + onSubmit={handleCreate} + today={today} + defaultPrincipalId={filters.principalId ?? principal.trim()} + isSubmitting={createGrant.isPending} + submitError={ + createGrant.isError + ? createGrant.error instanceof Error + ? createGrant.error.message + : 'The grant was rejected.' + : undefined + } + /> + ) : null} + + ) +} + +/** + * A filter that can be switched off. "Any" is the empty value the API means + * by omitting the parameter, so it is a real option rather than a cleared + * field the reader has to guess at. + */ +const FilterSelect = ({ + label, + value, + options, + labels, + disabled, + helperText, + onChange, +}: { + label: string + value: string + options: readonly string[] + /** For options whose stored value is not what an admin should read. */ + labels?: Record + disabled?: boolean + helperText?: string + onChange: (value: string) => void +}) => ( + onChange(event.target.value)} + sx={{ minWidth: 150 }} + > + Any + {options.map((option) => ( + + {labels?.[option] ?? option} + + ))} + +) + +const EmptyState = ({ title, body }: { title: string; body: string }) => ( + + + {title} + + {body} + + + +) + +const SUBJECT_FILTER_LABELS: Record = { + data_type: 'data grants', + ui_surface: 'screen grants', +} diff --git a/src/providers/access-control-provider.ts b/src/providers/access-control-provider.ts index 30bd82d4..0beb2e79 100644 --- a/src/providers/access-control-provider.ts +++ b/src/providers/access-control-provider.ts @@ -1,5 +1,6 @@ import { getAccessControlGroups } from '@/providers/authentik-provider' import { canAccessResource } from '@/utils' +import { isGrantableAction, isUiSurfaceGranted } from '@/utils/uiSurfaceGrants' type Actions = 'list' | 'show' | 'create' | 'edit' | 'delete' | 'manage' @@ -24,13 +25,24 @@ export const accessControlProvider = { ?.resource?.meta?.wip ) - return { - can: canAccessResource({ - groups, - resource: resource ?? '', - action: action as Actions, - isWip, - }), - } + const allowedByRole = canAccessResource({ + groups, + resource: resource ?? '', + action: action as Actions, + isWip, + }) + + // The role policy is the floor, and grants only ever raise it. Asking + // first means the common case costs nothing, and it means a grants + // outage cannot take a screen away from someone whose role allows it. + if (allowedByRole) return { can: true } + + // A WIP surface is hidden because it is not finished, not because of who + // is asking. No grant should reveal it. + if (isWip || !resource) return { can: false } + + if (!isGrantableAction(action)) return { can: false } + + return { can: await isUiSurfaceGranted(resource) } }, } diff --git a/src/providers/authentik-provider.ts b/src/providers/authentik-provider.ts index d885e02f..e4addfdb 100644 --- a/src/providers/authentik-provider.ts +++ b/src/providers/authentik-provider.ts @@ -22,6 +22,7 @@ import { IS_TESTING_AUTH, } from '@/config' import { normalizeAccessControlGroups } from '@/utils/accessControl' +import { resetUiSurfaceGrants } from '@/utils/uiSurfaceGrants' const gravatarUrl = (email: string) => { const hash = email.trim().toLowerCase() @@ -241,6 +242,11 @@ export const authentikAuthProvider: AuthProvider = { transientStore.pkceState = null clearPkceFallbacks() + // Surface-grant answers are cached per session and are about *this* + // caller. Leaving them would let the next person to sign in on this tab + // inherit the previous one's screens. + resetUiSurfaceGrants() + return { success: true, redirectTo: '/login' } }, diff --git a/src/test/pages/accessConsent.test.tsx b/src/test/pages/accessConsent.test.tsx new file mode 100644 index 00000000..b3980ec4 --- /dev/null +++ b/src/test/pages/accessConsent.test.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AccessConsentPage } from '@/pages/access/consent' +import { + type PublicationConsent, + zPublicationConsent, +} from '@/utils/accessConsent' +import { type Destination, zDestination } from '@/utils/accessDestinations' + +const { + useCanMock, + useAccessConsentMock, + useAccessDestinationsMock, + createMutateMock, + revokeMutateMock, +} = vi.hoisted(() => ({ + useCanMock: vi.fn(), + useAccessConsentMock: vi.fn(), + useAccessDestinationsMock: vi.fn(), + createMutateMock: vi.fn(), + revokeMutateMock: vi.fn(), +})) + +vi.mock('@refinedev/core', () => ({ + useCan: (...args: unknown[]) => useCanMock(...args), +})) + +vi.mock('@refinedev/mui', () => ({ + ErrorComponent: () =>
not authorized
, +})) + +vi.mock('react-router', async () => { + const { forwardRef } = await import('react') + + return { + Link: forwardRef( + ({ children, ...props }, ref) => ( + + {children} + + ) + ), + useLocation: () => ({ pathname: '/access/consent' }), + } +}) + +vi.mock('@/hooks', () => ({ + useAccessConsent: (...args: unknown[]) => useAccessConsentMock(...args), + useThingSearch: () => ({ + options: [ + { id: 512, name: 'MG-030' }, + { id: 513, name: 'MG-031' }, + ], + loading: false, + }), + useAccessDestinations: (...args: unknown[]) => + useAccessDestinationsMock(...args), + useCreateConsent: () => ({ + mutate: createMutateMock, + isPending: false, + isError: false, + error: null, + }), + useRevokeConsent: () => ({ + mutate: revokeMutateMock, + isPending: false, + isError: false, + error: null, + variables: undefined, + }), +})) + +const destination = (overrides: Partial = {}): Destination => + zDestination.parse({ + id: 3, + slug: 'ngwmn', + name: 'NGWMN', + destination_kind: 'harvester', + description: null, + active: true, + ...overrides, + }) + +const consent = ( + overrides: Partial = {} +): PublicationConsent => + zPublicationConsent.parse({ + id: 5, + thing_id: 42, + destination_id: 3, + data_type: 'water level', + contact_id: null, + recorded_by: 'admin@example.org', + notes: 'agreed by phone', + starts_at: '2026-01-01', + ends_at: null, + revoked_at: null, + revoked_by: null, + ...overrides, + }) + +const ok = (data: T) => ({ + data, + isLoading: false, + isError: false, + error: null, +}) + +const loadThing = async (user: ReturnType) => { + await user.type(screen.getByLabelText('Thing (PointID)'), '42') + await user.keyboard('{Enter}') +} + +beforeEach(() => { + useCanMock.mockReset().mockReturnValue({ + data: { can: true }, + isLoading: false, + }) + useAccessDestinationsMock.mockReset().mockReturnValue(ok([destination()])) + useAccessConsentMock.mockReset().mockReturnValue(ok([])) + createMutateMock.mockReset() + revokeMutateMock.mockReset() +}) + +describe('AccessConsentPage', () => { + it('asks for a thing id before querying, because the API requires one', () => { + render() + + expect(screen.getByText('Enter a thing id to begin')).toBeInTheDocument() + expect(useAccessConsentMock).toHaveBeenCalledWith( + '', + expect.objectContaining({ includeRevoked: false }) + ) + }) + + it('loads consent for the entered thing on Enter', async () => { + const user = userEvent.setup() + useAccessConsentMock.mockReturnValue(ok([consent()])) + render() + + await loadThing(user) + + expect(useAccessConsentMock).toHaveBeenCalledWith( + '42', + expect.objectContaining({ includeRevoked: false }) + ) + expect(screen.getByText('water level')).toBeInTheDocument() + }) + + it('resolves the destination name from the id the row carries', async () => { + const user = userEvent.setup() + useAccessConsentMock.mockReturnValue(ok([consent()])) + render() + + await loadThing(user) + + expect(screen.getByText('NGWMN')).toBeInTheDocument() + }) + + it('falls back to the id when the destination is unknown', async () => { + const user = userEvent.setup() + useAccessDestinationsMock.mockReturnValue(ok([])) + useAccessConsentMock.mockReturnValue(ok([consent()])) + render() + + await loadThing(user) + + expect(screen.getByText('Destination 3')).toBeInTheDocument() + }) + + it('names Bureau ownership rather than showing an empty contact', async () => { + const user = userEvent.setup() + useAccessConsentMock.mockReturnValue(ok([consent()])) + render() + + await loadThing(user) + + expect(screen.getByText('Bureau-owned')).toBeInTheDocument() + }) + + it('confirms before withdrawing, and says harvested copies stay', async () => { + const user = userEvent.setup() + useAccessConsentMock.mockReturnValue(ok([consent()])) + render() + + await loadThing(user) + await user.click(screen.getByRole('button', { name: 'Withdraw' })) + + expect(revokeMutateMock).not.toHaveBeenCalled() + const dialog = screen.getByRole('dialog') + expect( + within(dialog).getByText(/already harvested are not recalled/) + ).toBeInTheDocument() + + await user.click(within(dialog).getByRole('button', { name: 'Withdraw' })) + expect(revokeMutateMock).toHaveBeenCalledWith(5) + }) + + it('offers no withdraw control for consent already withdrawn', async () => { + const user = userEvent.setup() + useAccessConsentMock.mockReturnValue( + ok([consent({ revoked_at: '2026-02-01T00:00:00Z' })]) + ) + render() + + await loadThing(user) + + expect(screen.getByText('Revoked')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Withdraw' }) + ).not.toBeInTheDocument() + }) + + it('blocks recording consent when no destination exists', () => { + useAccessDestinationsMock.mockReturnValue(ok([])) + render() + + expect(screen.getByText(/register one first/i)).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /record consent/i }) + ).toBeDisabled() + }) + + it('records consent with a blank contact as Bureau-owned', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /record consent/i })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Thing (PointID)'), '42') + await user.click(within(dialog).getByRole('button', { name: 'Record' })) + + expect(createMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + thing_id: 42, + destination_slug: 'ngwmn', + contact_id: null, + }), + expect.anything() + ) + }) + + it('blocks a consent when the typed text resolved to no thing', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /record consent/i })) + const dialog = screen.getByRole('dialog') + // Half a PointID is not an id, and nothing was chosen from the list. + await user.type(within(dialog).getByLabelText('Thing (PointID)'), 'abc') + await user.click(within(dialog).getByRole('button', { name: 'Record' })) + + expect(createMutateMock).not.toHaveBeenCalled() + expect( + within(dialog).getByText(/thing id is required/i) + ).toBeInTheDocument() + }) + + it('records consent against a thing chosen by PointID', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /record consent/i })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Thing (PointID)'), 'MG-0') + await user.click(await screen.findByRole('option', { name: 'MG-030' })) + await user.click(within(dialog).getByRole('button', { name: 'Record' })) + + expect(createMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ thing_id: 512 }), + expect.anything() + ) + }) +}) diff --git a/src/test/pages/accessDestinations.test.tsx b/src/test/pages/accessDestinations.test.tsx new file mode 100644 index 00000000..ce372d4f --- /dev/null +++ b/src/test/pages/accessDestinations.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AccessDestinationsPage } from '@/pages/access/destinations' +import { type Destination, zDestination } from '@/utils/accessDestinations' + +const { useCanMock, useAccessDestinationsMock, createMutateMock } = vi.hoisted( + () => ({ + useCanMock: vi.fn(), + useAccessDestinationsMock: vi.fn(), + createMutateMock: vi.fn(), + }) +) + +vi.mock('@refinedev/core', () => ({ + useCan: (...args: unknown[]) => useCanMock(...args), +})) + +vi.mock('@refinedev/mui', () => ({ + ErrorComponent: () =>
not authorized
, +})) + +vi.mock('react-router', async () => { + const { forwardRef } = await import('react') + + return { + // MUI's Tab passes a ref through `component`, and the real react-router + // Link is a forwardRef. A plain function here warns instead of rendering. + Link: forwardRef( + ({ children, ...props }, ref) => ( + + {children} + + ) + ), + useLocation: () => ({ pathname: '/access/destinations' }), + } +}) + +vi.mock('@/hooks', () => ({ + useAccessDestinations: (...args: unknown[]) => + useAccessDestinationsMock(...args), + useCreateDestination: () => ({ + mutate: createMutateMock, + isPending: false, + isError: false, + error: null, + }), +})) + +const destination = (overrides: Partial = {}): Destination => + zDestination.parse({ + id: 1, + slug: 'ngwmn', + name: 'National Ground-Water Monitoring Network', + destination_kind: 'harvester', + description: 'Federal harvesting network.', + active: true, + ...overrides, + }) + +const ok = (data: T) => ({ + data, + isLoading: false, + isError: false, + error: null, +}) + +beforeEach(() => { + useCanMock.mockReset().mockReturnValue({ + data: { can: true }, + isLoading: false, + }) + useAccessDestinationsMock.mockReset().mockReturnValue(ok([destination()])) + createMutateMock.mockReset() +}) + +describe('AccessDestinationsPage', () => { + it('refuses the console to a non-admin', () => { + useCanMock.mockReturnValue({ data: { can: false }, isLoading: false }) + render() + + expect(screen.getByText('not authorized')).toBeInTheDocument() + }) + + it('lists registered destinations', () => { + render() + + expect( + screen.getByText('National Ground-Water Monitoring Network') + ).toBeInTheDocument() + expect(screen.getByText('ngwmn')).toBeInTheDocument() + expect(screen.getByText('Active')).toBeInTheDocument() + }) + + it('marks a retired destination', () => { + useAccessDestinationsMock.mockReturnValue( + ok([destination({ active: false })]) + ) + render() + + expect(screen.getByText('Retired')).toBeInTheDocument() + }) + + it('no longer offers a published-data view', () => { + render() + + expect(screen.queryByRole('button', { name: 'Show' })).toBeNull() + expect(screen.queryByText('Published data')).toBeNull() + }) + + it('registers a destination', async () => { + const user = userEvent.setup() + render() + + await user.click( + screen.getByRole('button', { name: /register destination/i }) + ) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Slug'), 'usgs') + await user.type(within(dialog).getByLabelText('Name'), 'USGS') + await user.click(within(dialog).getByRole('button', { name: 'Register' })) + + expect(createMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ slug: 'usgs', name: 'USGS' }), + expect.anything() + ) + }) + + it('blocks a slug that would not survive a URL path', async () => { + const user = userEvent.setup() + render() + + await user.click( + screen.getByRole('button', { name: /register destination/i }) + ) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Slug'), 'Not A Slug') + await user.type(within(dialog).getByLabelText('Name'), 'x') + await user.click(within(dialog).getByRole('button', { name: 'Register' })) + + expect(createMutateMock).not.toHaveBeenCalled() + expect(within(dialog).getByText(/lower-case letters/i)).toBeInTheDocument() + }) +}) diff --git a/src/test/pages/accessGrants.test.tsx b/src/test/pages/accessGrants.test.tsx new file mode 100644 index 00000000..f7f0535f --- /dev/null +++ b/src/test/pages/accessGrants.test.tsx @@ -0,0 +1,560 @@ +// @vitest-environment jsdom +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AccessGrantsPage } from '@/pages/access/grants' +import { type PermissionGrant, zPermissionGrant } from '@/utils/accessGrants' + +const { useCanMock, useAccessGrantsMock, createMutateMock, revokeMutateMock } = + vi.hoisted(() => ({ + useCanMock: vi.fn(), + useAccessGrantsMock: vi.fn(), + createMutateMock: vi.fn(), + revokeMutateMock: vi.fn(), + })) + +vi.mock('@refinedev/core', () => ({ + useCan: (...args: unknown[]) => useCanMock(...args), +})) + +vi.mock('@refinedev/mui', () => ({ + ErrorComponent: () =>
not authorized
, +})) + +vi.mock('react-router', async () => { + const { forwardRef } = await import('react') + + return { + // MUI's Tab passes a ref through `component`, and the real react-router + // Link is a forwardRef. A plain function here warns instead of rendering. + Link: forwardRef( + ({ children, ...props }, ref) => ( + + {children} + + ) + ), + useLocation: () => ({ pathname: '/access/grants' }), + } +}) + +vi.mock('@/hooks', () => ({ + useAccessGrants: (...args: unknown[]) => useAccessGrantsMock(...args), + useGroups: () => ({ + groups: [ + { id: 42, name: 'Roswell Basin' }, + { id: 7, name: 'Estancia Basin' }, + ], + isLoading: false, + options: [ + { value: '7', label: 'Estancia Basin' }, + { value: '42', label: 'Roswell Basin' }, + ], + }), + useCreateGrant: () => ({ + mutate: createMutateMock, + isPending: false, + isError: false, + error: null, + }), + useRevokeGrant: () => ({ + mutate: revokeMutateMock, + isPending: false, + isError: false, + error: null, + variables: undefined, + }), +})) + +const grant = (overrides: Partial = {}): PermissionGrant => + zPermissionGrant.parse({ + id: 7, + principal_type: 'user', + principal_id: 'ak-subject-1', + capability: 'read', + scope_type: 'thing', + scope_id: 42, + data_type: 'water level', + starts_at: '2026-01-01', + ends_at: null, + granted_by: 'admin@example.org', + reason: 'monitoring agreement', + revoked_at: null, + revoked_by: null, + ...overrides, + }) + +// The route answers with a page, and the hook hands that envelope through. +const listResult = (rows: PermissionGrant[], total = rows.length) => ({ + data: { items: rows, total, page: 1, size: 500 }, + isLoading: false, + isError: false, + error: null, +}) + +beforeEach(() => { + useCanMock.mockReset().mockReturnValue({ + data: { can: true }, + isLoading: false, + }) + useAccessGrantsMock.mockReset().mockReturnValue(listResult([])) + createMutateMock.mockReset() + revokeMutateMock.mockReset() +}) + +describe('AccessGrantsPage', () => { + it('refuses the page to a non-admin', () => { + useCanMock.mockReturnValue({ data: { can: false }, isLoading: false }) + render() + + expect(screen.getByText('not authorized')).toBeInTheDocument() + }) + + it('loads every grant with no filters applied', () => { + useAccessGrantsMock.mockReturnValue(listResult([grant()])) + render() + + expect(useAccessGrantsMock).toHaveBeenCalledWith({}) + expect(screen.getByText('ak-subject-1')).toBeInTheDocument() + }) + + it('applies the typed principal only on Enter', async () => { + const user = userEvent.setup() + render() + + await user.type(screen.getByLabelText('Principal'), 'ak-subject-1') + expect(useAccessGrantsMock).not.toHaveBeenCalledWith( + expect.objectContaining({ principalId: 'ak-subject-1' }) + ) + + await user.keyboard('{Enter}') + expect(useAccessGrantsMock).toHaveBeenCalledWith( + expect.objectContaining({ principalId: 'ak-subject-1' }) + ) + }) + + it('applies a dropdown filter immediately', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByLabelText('Capability')) + await user.click(screen.getByRole('option', { name: 'correct' })) + + expect(useAccessGrantsMock).toHaveBeenCalledWith( + expect.objectContaining({ capability: 'correct' }) + ) + }) + + it('sends no filter for the "Any" option', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByLabelText('Data type')) + await user.click(screen.getByRole('option', { name: 'water level' })) + await user.click(screen.getByLabelText('Data type')) + await user.click(screen.getByRole('option', { name: 'Any' })) + + expect(useAccessGrantsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ dataType: undefined }) + ) + }) + + it('clears every filter at once', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByLabelText('Scope')) + await user.click(screen.getByRole('option', { name: 'group' })) + await user.click(screen.getByRole('button', { name: /clear filters/i })) + + expect(useAccessGrantsMock).toHaveBeenLastCalledWith({}) + }) + + it('renders a grant row with its principal, scope and status', () => { + useAccessGrantsMock.mockReturnValue(listResult([grant()])) + render() + + expect(screen.getByText('ak-subject-1')).toBeInTheDocument() + expect(screen.getByText('thing 42')).toBeInTheDocument() + expect(screen.getByText('monitoring agreement')).toBeInTheDocument() + expect(screen.getByText('Active')).toBeInTheDocument() + }) + + it('says nothing matches when filters exclude everything', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByLabelText('Capability')) + await user.click(screen.getByRole('option', { name: 'administer' })) + + expect(screen.getByText('No grants match')).toBeInTheDocument() + }) + + it('distinguishes an empty catalogue from an empty filter result', () => { + render() + + expect(screen.getByText('No grants yet')).toBeInTheDocument() + }) + + it('confirms before revoking, and revokes on confirmation', async () => { + const user = userEvent.setup() + useAccessGrantsMock.mockReturnValue(listResult([grant()])) + render() + + await user.click(screen.getByRole('button', { name: 'Revoke' })) + + expect(revokeMutateMock).not.toHaveBeenCalled() + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByText('Revoke this grant?')).toBeInTheDocument() + + await user.click(within(dialog).getByRole('button', { name: 'Revoke' })) + expect(revokeMutateMock).toHaveBeenCalledWith(7) + }) + + it('does not revoke when the confirmation is cancelled', async () => { + const user = userEvent.setup() + useAccessGrantsMock.mockReturnValue(listResult([grant()])) + render() + + await user.click(screen.getByRole('button', { name: 'Revoke' })) + const dialog = screen.getByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) + + expect(revokeMutateMock).not.toHaveBeenCalled() + }) + + it('offers no revoke control for a grant already revoked', () => { + useAccessGrantsMock.mockReturnValue( + listResult([grant({ revoked_at: '2026-02-01T00:00:00Z' })]) + ) + render() + + expect(screen.getByText('Revoked')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Revoke' }) + ).not.toBeInTheDocument() + }) + + it('passes the include-revoked toggle through to the query', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('checkbox', { name: /include revoked/i })) + + await waitFor(() => { + expect(useAccessGrantsMock).toHaveBeenCalledWith( + expect.objectContaining({ includeRevoked: true }) + ) + }) + }) + + it('submits a grant from the dialog', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Grant access' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Principal'), 'ak-subject-9') + await user.click(within(dialog).getByRole('button', { name: 'Grant' })) + + expect(createMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + principal_id: 'ak-subject-9', + scope_type: 'global', + scope_id: null, + }), + expect.anything() + ) + }) + + it('submits a UI surface grant as a global grant with no data type', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Grant access' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Principal'), 'ak-subject-9') + + await user.click(within(dialog).getByLabelText('Grant covers')) + await user.click(screen.getByRole('option', { name: 'a screen' })) + await user.click(within(dialog).getByLabelText('Screen')) + await user.click(screen.getByRole('option', { name: 'ocotillo.lexicon' })) + await user.click(within(dialog).getByRole('button', { name: 'Grant' })) + + expect(createMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + principal_id: 'ak-subject-9', + capability: 'view', + scope_type: 'global', + scope_id: null, + data_type: null, + ui_surface: 'ocotillo.lexicon', + }), + expect.anything() + ) + }) + + it('will not submit a surface grant with no screen chosen', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Grant access' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Principal'), 'ak-subject-9') + + await user.click(within(dialog).getByLabelText('Grant covers')) + await user.click(screen.getByRole('option', { name: 'a screen' })) + await user.click(within(dialog).getByRole('button', { name: 'Grant' })) + + expect(createMutateMock).not.toHaveBeenCalled() + expect(within(dialog).getByText(/screen is required/i)).toBeInTheDocument() + }) + + it('keeps the current filters after a grant is created', async () => { + const user = userEvent.setup() + render() + + await user.type(screen.getByLabelText('Principal'), 'ak-subject-1') + await user.keyboard('{Enter}') + + await user.click(screen.getByRole('button', { name: 'Grant access' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Principal'), 'ak-subject-1') + await user.click(within(dialog).getByRole('button', { name: 'Grant' })) + + const [, options] = createMutateMock.mock.calls.at(-1) ?? [] + await act(async () => { + options.onSuccess(grant({ id: 9, principal_id: 'ak-subject-1' })) + }) + + // The list refetches under the same question the admin asked. + expect(useAccessGrantsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ principalId: 'ak-subject-1' }) + ) + expect(screen.queryByText(/current filters do not show it/i)).toBeNull() + }) + + it('says so when the new grant lands outside the current filters', async () => { + const user = userEvent.setup() + render() + + await user.type(screen.getByLabelText('Principal'), 'ak-subject-1') + await user.keyboard('{Enter}') + + await user.click(screen.getByRole('button', { name: 'Grant access' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Principal'), 'ak-subject-2') + await user.click(within(dialog).getByRole('button', { name: 'Grant' })) + + const [, options] = createMutateMock.mock.calls.at(-1) ?? [] + await act(async () => { + options.onSuccess(grant({ id: 9, principal_id: 'ak-subject-2' })) + }) + + expect(screen.getByText(/Granted to ak-subject-2/i)).toBeInTheDocument() + // Still the admin's own filter until they ask for the new one. + expect(useAccessGrantsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ principalId: 'ak-subject-1' }) + ) + + await user.click(screen.getByRole('button', { name: 'Show it' })) + + expect(useAccessGrantsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ principalId: 'ak-subject-2' }) + ) + expect(screen.queryByText(/current filters do not show it/i)).toBeNull() + }) + + it('keeps a long reason on one line and shows it in a tooltip', async () => { + const user = userEvent.setup() + const reason = 'a'.repeat(300) + useAccessGrantsMock.mockReturnValue(listResult([grant({ id: 11, reason })])) + render() + + const cell = screen.getByText(reason) + // Tailwind's `truncate` is what holds the row to one line; jsdom loads no + // stylesheet, so the class is the thing to assert. + expect(cell).toHaveClass('truncate') + + // Radix opens on focus as well as hover, and focus is what jsdom drives + // reliably — hover needs pointer events it does not implement. + fireEvent.focus(cell) + expect(await screen.findByRole('tooltip')).toHaveTextContent(reason) + }) + + it('tints a scoped grant row and leaves a global one plain', () => { + useAccessGrantsMock.mockReturnValue( + listResult([ + grant({ id: 21, principal_id: 'scoped-one', scope_type: 'thing' }), + grant({ + id: 22, + principal_id: 'global-one', + scope_type: 'global', + scope_id: null, + }), + ]) + ) + render() + + const scopedRow = screen.getByText('scoped-one').closest('tr') + const globalRow = screen.getByText('global-one').closest('tr') + + expect(scopedRow).toHaveClass('bg-warning/8') + expect(globalRow).not.toHaveClass('bg-warning/8') + }) + + it('marks a screen grant apart from a data grant', async () => { + const user = userEvent.setup() + useAccessGrantsMock.mockReturnValue( + listResult([ + grant({ + id: 31, + principal_id: 'screen-holder', + data_type: null, + ui_surface: 'ocotillo.lexicon', + }), + grant({ + id: 32, + principal_id: 'data-holder', + data_type: 'water level', + }), + ]) + ) + render() + + const screenRow = screen.getByText('screen-holder').closest('tr') + const dataRow = screen.getByText('data-holder').closest('tr') + + const screenBadge = within(screenRow as HTMLElement).getByText( + 'ocotillo.lexicon' + ) + const dataBadge = within(dataRow as HTMLElement).getByText('water level') + + expect(screenBadge).toBeInTheDocument() + expect(dataBadge).toBeInTheDocument() + + fireEvent.focus(screenBadge) + expect(await screen.findByRole('tooltip')).toHaveTextContent( + /opens this nav item/i + ) + }) + + it('filters the table down to screen grants', async () => { + const user = userEvent.setup() + useAccessGrantsMock.mockReturnValue( + listResult([ + grant({ + id: 41, + principal_id: 'screen-holder', + data_type: null, + ui_surface: 'ocotillo.lexicon', + }), + grant({ + id: 42, + principal_id: 'data-holder', + data_type: 'water level', + }), + ]) + ) + render() + + await user.click(screen.getByLabelText('Covers')) + await user.click(screen.getByRole('option', { name: 'screen grants' })) + + expect(screen.getByText('screen-holder')).toBeInTheDocument() + expect(screen.queryByText('data-holder')).toBeNull() + expect(screen.getByText('1 shown')).toBeInTheDocument() + // A screen grant has no data type, so that filter stops applying. MUI + // renders the select as a combobox div, which carries aria-disabled. + expect(screen.getByLabelText('Data type')).toHaveAttribute( + 'aria-disabled', + 'true' + ) + }) + + it('picks a group by name and sends its id', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Grant access' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Principal'), 'ak-subject-9') + + await user.click(within(dialog).getByLabelText('Scope')) + await user.click(screen.getByRole('option', { name: 'group' })) + await user.click(within(dialog).getByLabelText('Group')) + await user.click(screen.getByRole('option', { name: 'Roswell Basin' })) + await user.click(within(dialog).getByRole('button', { name: 'Grant' })) + + expect(createMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ scope_type: 'group', scope_id: 42 }), + expect.anything() + ) + }) + + it('shows a group scope by name', () => { + useAccessGrantsMock.mockReturnValue( + listResult([grant({ id: 51, scope_type: 'group', scope_id: 42 })]) + ) + render() + + expect(screen.getByText('group Roswell Basin')).toBeInTheDocument() + expect(screen.queryByText('group 42')).toBeNull() + }) + + it('pages the table rather than rendering every grant', async () => { + const user = userEvent.setup() + useAccessGrantsMock.mockReturnValue( + listResult( + Array.from({ length: 30 }, (_, index) => + grant({ + id: 100 + index, + principal_id: `holder-${String(index).padStart(2, '0')}`, + starts_at: `2026-01-${String((index % 28) + 1).padStart(2, '0')}`, + }) + ) + ) + ) + render() + + // 25 a page, so five rows wait on the second. + expect(screen.getAllByText(/^holder-/)).toHaveLength(25) + expect(screen.getByText('30 shown')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /next page/i })) + + expect(screen.getAllByText(/^holder-/)).toHaveLength(5) + }) + + it('says so when the API held rows back', () => { + useAccessGrantsMock.mockReturnValue(listResult([grant()], 900)) + render() + + expect(screen.getByText(/Showing the first 500 of 900/)).toBeInTheDocument() + }) + + it('blocks a scoped grant that names no scope id', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Grant access' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByLabelText('Principal'), 'ak-subject-9') + + await user.click(within(dialog).getByLabelText('Scope')) + await user.click(screen.getByRole('option', { name: 'thing' })) + await user.click(within(dialog).getByRole('button', { name: 'Grant' })) + + expect(createMutateMock).not.toHaveBeenCalled() + expect( + within(dialog).getByText(/thing id is required/i) + ).toBeInTheDocument() + }) +}) diff --git a/src/test/providers/accessControlProvider.uiSurface.test.ts b/src/test/providers/accessControlProvider.uiSurface.test.ts new file mode 100644 index 00000000..6e727991 --- /dev/null +++ b/src/test/providers/accessControlProvider.uiSurface.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { accessControlProvider } from '@/providers/access-control-provider' + +const { getGroupsMock, canAccessResourceMock, isUiSurfaceGrantedMock } = + vi.hoisted(() => ({ + getGroupsMock: vi.fn(), + canAccessResourceMock: vi.fn(), + isUiSurfaceGrantedMock: vi.fn(), + })) + +vi.mock('@/providers/authentik-provider', () => ({ + getAccessControlGroups: () => getGroupsMock(), +})) + +vi.mock('@/utils', () => ({ + canAccessResource: (...args: unknown[]) => canAccessResourceMock(...args), +})) + +vi.mock('@/utils/uiSurfaceGrants', async () => { + const actual = await vi.importActual< + typeof import('@/utils/uiSurfaceGrants') + >('@/utils/uiSurfaceGrants') + + return { + ...actual, + isUiSurfaceGranted: (...args: unknown[]) => isUiSurfaceGrantedMock(...args), + } +}) + +const can = (action: string, resource = 'ocotillo.lexicon', params?: unknown) => + accessControlProvider.can({ resource, action, params }) + +beforeEach(() => { + getGroupsMock.mockReset().mockReturnValue(['AMP.Viewer']) + canAccessResourceMock.mockReset().mockReturnValue(false) + isUiSurfaceGrantedMock.mockReset().mockResolvedValue(false) +}) + +describe('accessControlProvider — role policy is the floor', () => { + it('allows what the role allows without asking about grants', async () => { + canAccessResourceMock.mockReturnValue(true) + + await expect(can('list')).resolves.toEqual({ can: true }) + expect(isUiSurfaceGrantedMock).not.toHaveBeenCalled() + }) + + it('cannot subtract: a denied grant never overrides an allowing role', async () => { + canAccessResourceMock.mockReturnValue(true) + isUiSurfaceGrantedMock.mockResolvedValue(false) + + await expect(can('list')).resolves.toEqual({ can: true }) + }) +}) + +describe('accessControlProvider — grants widen', () => { + it('opens a screen the role denied when a grant names it', async () => { + isUiSurfaceGrantedMock.mockResolvedValue(true) + + await expect(can('list')).resolves.toEqual({ can: true }) + expect(isUiSurfaceGrantedMock).toHaveBeenCalledWith('ocotillo.lexicon') + }) + + it('stays denied when no grant names the screen', async () => { + await expect(can('list')).resolves.toEqual({ can: false }) + }) + + it('leaves the role decision standing when the grant lookup fails', async () => { + isUiSurfaceGrantedMock.mockResolvedValue(false) + + await expect(can('show')).resolves.toEqual({ can: false }) + }) +}) + +describe('accessControlProvider — what a surface grant may not do', () => { + it.each(['create', 'edit', 'delete', 'manage'])( + 'does not let seeing a screen confer %s', + async (action) => { + isUiSurfaceGrantedMock.mockResolvedValue(true) + + await expect(can(action)).resolves.toEqual({ can: false }) + expect(isUiSurfaceGrantedMock).not.toHaveBeenCalled() + } + ) + + it('does not reveal a WIP surface, which is hidden for a different reason', async () => { + isUiSurfaceGrantedMock.mockResolvedValue(true) + + const result = await can('list', 'water.dashboard', { + resource: { meta: { wip: true } }, + }) + + expect(result).toEqual({ can: false }) + expect(isUiSurfaceGrantedMock).not.toHaveBeenCalled() + }) + + it('asks nothing when there is no resource to name', async () => { + await expect( + accessControlProvider.can({ action: 'list' }) + ).resolves.toEqual({ can: false }) + expect(isUiSurfaceGrantedMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/test/setup.ts b/src/test/setup.ts index d56a211b..631f0323 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -4,6 +4,18 @@ import { checkMockServerHealth } from './mock-server' import { ocotilloDataProvider } from '@/providers/ocotillo-data-provider' process.env.NODE_ENV = 'test' + +// Radix positions its popper layers (tooltip, select, dropdown) by measuring +// the trigger, and jsdom implements no ResizeObserver to measure with. Guarded +// because the API contract tests run in the node environment, where there is +// no window to patch. +if (typeof window !== 'undefined') { + window.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver +} // Mock the authentication provider (for node api contract tests) vi.mock('@/providers/authentik-provider', () => ({ diff --git a/src/test/utils/accessConsent.test.ts b/src/test/utils/accessConsent.test.ts new file mode 100644 index 00000000..ac776625 --- /dev/null +++ b/src/test/utils/accessConsent.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + describeConsentingContact, + type PublicationConsent, + sortConsent, + toCreateConsentInput, + validateConsentForm, + zPublicationConsent, +} from '@/utils/accessConsent' + +const consent = ( + overrides: Partial = {} +): PublicationConsent => + zPublicationConsent.parse({ + id: 1, + thing_id: 42, + destination_id: 3, + data_type: 'water level', + contact_id: 9, + recorded_by: 'admin@example.org', + notes: null, + starts_at: '2026-01-01', + ends_at: null, + revoked_at: null, + revoked_by: null, + ...overrides, + }) + +const today = new Date('2026-06-15T12:00:00Z') + +describe('validateConsentForm', () => { + const form = { + thing_id: '42', + destination_slug: 'ngwmn', + contact_id: '', + starts_at: '2026-06-01', + ends_at: '', + } + + it('accepts a consent with no contact, which the API allows', () => { + expect(validateConsentForm(form)).toEqual({}) + }) + + it('requires a numeric thing id', () => { + expect(validateConsentForm({ ...form, thing_id: '' })).toHaveProperty( + 'thing_id' + ) + expect(validateConsentForm({ ...form, thing_id: 'abc' })).toHaveProperty( + 'thing_id' + ) + }) + + it('requires a destination', () => { + expect( + validateConsentForm({ ...form, destination_slug: '' }) + ).toHaveProperty('destination_slug') + }) + + it('rejects a non-numeric contact id but allows a blank one', () => { + expect(validateConsentForm({ ...form, contact_id: 'abc' })).toHaveProperty( + 'contact_id' + ) + expect(validateConsentForm({ ...form, contact_id: ' ' })).toEqual({}) + }) + + it('rejects an end date before the start date', () => { + expect( + validateConsentForm({ ...form, ends_at: '2026-05-01' }) + ).toHaveProperty('ends_at') + }) +}) + +describe('toCreateConsentInput', () => { + const form = { + thing_id: '42', + destination_slug: 'ngwmn', + data_type: 'water chemistry', + contact_id: ' ', + starts_at: '2026-06-01', + ends_at: '', + notes: ' agreed by phone ', + } + + it('sends a blank contact as null, not as a missing field', () => { + expect(toCreateConsentInput(form)).toEqual({ + thing_id: 42, + destination_slug: 'ngwmn', + data_type: 'water chemistry', + starts_at: '2026-06-01', + ends_at: null, + contact_id: null, + notes: 'agreed by phone', + }) + }) + + it('sends a supplied contact as a number', () => { + expect(toCreateConsentInput({ ...form, contact_id: '9' }).contact_id).toBe( + 9 + ) + }) +}) + +describe('describeConsentingContact', () => { + it('names Bureau ownership rather than showing a gap', () => { + expect(describeConsentingContact(consent({ contact_id: null }))).toBe( + 'Bureau-owned' + ) + expect(describeConsentingContact(consent({ contact_id: 9 }))).toBe('#9') + }) +}) + +describe('sortConsent', () => { + it('orders live consent ahead of withdrawn', () => { + const rows = sortConsent( + [ + consent({ id: 1, revoked_at: '2026-03-01T00:00:00Z' }), + consent({ id: 2 }), + ], + today + ) + + expect(rows.map((row) => row.id)).toEqual([2, 1]) + }) +}) diff --git a/src/test/utils/accessDestinations.test.ts b/src/test/utils/accessDestinations.test.ts new file mode 100644 index 00000000..200853e2 --- /dev/null +++ b/src/test/utils/accessDestinations.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { + type Destination, + destinationLabel, + indexDestinationsById, + sortDestinations, + toCreateDestinationInput, + validateDestinationForm, + zDestination, +} from '@/utils/accessDestinations' + +const destination = (overrides: Partial = {}): Destination => + zDestination.parse({ + id: 1, + slug: 'ngwmn', + name: 'National Ground-Water Monitoring Network', + destination_kind: 'harvester', + description: null, + active: true, + ...overrides, + }) + +describe('validateDestinationForm', () => { + const form = { slug: 'ngwmn', name: 'NGWMN' } + + it('accepts a well-formed destination', () => { + expect(validateDestinationForm(form)).toEqual({}) + }) + + it('requires a slug and a name', () => { + expect(validateDestinationForm({ slug: ' ', name: 'x' })).toHaveProperty( + 'slug' + ) + expect(validateDestinationForm({ slug: 'x', name: ' ' })).toHaveProperty( + 'name' + ) + }) + + it('rejects a slug that would not survive a URL path', () => { + expect( + validateDestinationForm({ ...form, slug: 'NG WMN/x' }) + ).toHaveProperty('slug') + }) + + it('accepts hyphens and underscores in a slug', () => { + expect( + validateDestinationForm({ ...form, slug: 'partner_agency-2' }) + ).toEqual({}) + }) + + it('enforces the API length caps', () => { + expect( + validateDestinationForm({ ...form, slug: 'a'.repeat(51) }) + ).toHaveProperty('slug') + expect( + validateDestinationForm({ ...form, name: 'a'.repeat(256) }) + ).toHaveProperty('name') + }) +}) + +describe('toCreateDestinationInput', () => { + it('trims and sends null rather than an empty description', () => { + expect( + toCreateDestinationInput({ + slug: ' ngwmn ', + name: ' NGWMN ', + destination_kind: 'harvester', + description: ' ', + }) + ).toEqual({ + slug: 'ngwmn', + name: 'NGWMN', + destination_kind: 'harvester', + description: null, + }) + }) +}) + +describe('sortDestinations', () => { + it('puts active destinations before retired ones, then sorts by slug', () => { + const rows = sortDestinations([ + destination({ id: 1, slug: 'zeta', active: true }), + destination({ id: 2, slug: 'alpha', active: false }), + destination({ id: 3, slug: 'beta', active: true }), + ]) + + expect(rows.map((row) => row.slug)).toEqual(['beta', 'zeta', 'alpha']) + }) +}) + +describe('indexDestinationsById', () => { + it('resolves the id a consent row carries', () => { + const index = indexDestinationsById([destination({ id: 7 })]) + + expect(index.get(7)?.slug).toBe('ngwmn') + expect(index.get(99)).toBeUndefined() + }) + + it('tolerates a list that has not loaded', () => { + expect(indexDestinationsById(undefined).size).toBe(0) + }) +}) + +describe('destinationLabel', () => { + it('names the destination and its slug', () => { + expect(destinationLabel(destination())).toBe( + 'National Ground-Water Monitoring Network (ngwmn)' + ) + }) +}) diff --git a/src/test/utils/accessGrants.test.ts b/src/test/utils/accessGrants.test.ts new file mode 100644 index 00000000..b45d9581 --- /dev/null +++ b/src/test/utils/accessGrants.test.ts @@ -0,0 +1,439 @@ +import { describe, expect, it } from 'vitest' +import { + describeScope, + GRANT_PAGE_SIZE, + describeSubject, + grantQueryParams, + grantStatusOf, + isUiSurfaceGrant, + isUnfiltered, + matchesFilters, + type PermissionGrant, + scopeIdRequired, + sortGrants, + toCreateGrantInput, + validateGrantForm, + zPermissionGrant, +} from '@/utils/accessGrants' +import { isRevocable, toDateInputValue } from '@/utils/accessLifecycle' + +const grant = (overrides: Partial = {}): PermissionGrant => + zPermissionGrant.parse({ + id: 1, + principal_type: 'user', + principal_id: 'ak-subject-1', + capability: 'read', + scope_type: 'global', + scope_id: null, + data_type: 'water level', + starts_at: '2026-01-01', + ends_at: null, + granted_by: 'admin@example.org', + reason: null, + revoked_at: null, + revoked_by: null, + ...overrides, + }) + +const today = new Date('2026-06-15T12:00:00Z') + +describe('grantStatusOf', () => { + it('is active inside an open-ended window', () => { + expect(grantStatusOf(grant(), today)).toBe('active') + }) + + it('is scheduled before the start date', () => { + expect(grantStatusOf(grant({ starts_at: '2026-09-01' }), today)).toBe( + 'scheduled' + ) + }) + + it('is expired after the end date', () => { + expect(grantStatusOf(grant({ ends_at: '2026-05-31' }), today)).toBe( + 'expired' + ) + }) + + it('is active on the end date itself', () => { + expect(grantStatusOf(grant({ ends_at: '2026-06-15' }), today)).toBe( + 'active' + ) + }) + + it('reports revoked ahead of any date reasoning', () => { + expect( + grantStatusOf( + grant({ starts_at: '2026-09-01', revoked_at: '2026-06-01T00:00:00Z' }), + today + ) + ).toBe('revoked') + }) +}) + +describe('isRevocable', () => { + it('allows revoking active and scheduled grants', () => { + expect(isRevocable(grant(), today)).toBe(true) + expect(isRevocable(grant({ starts_at: '2026-09-01' }), today)).toBe(true) + }) + + it('refuses expired and already-revoked grants', () => { + expect(isRevocable(grant({ ends_at: '2026-01-02' }), today)).toBe(false) + expect( + isRevocable(grant({ revoked_at: '2026-02-02T00:00:00Z' }), today) + ).toBe(false) + }) +}) + +describe('scope', () => { + it('requires a scope id only for group and thing scopes', () => { + expect(scopeIdRequired('global')).toBe(false) + expect(scopeIdRequired('group')).toBe(true) + expect(scopeIdRequired('thing')).toBe(true) + }) + + it('describes a scope with its id where one applies', () => { + expect(describeScope(grant())).toBe('global') + expect(describeScope(grant({ scope_type: 'thing', scope_id: 42 }))).toBe( + 'thing 42' + ) + }) +}) + +describe('sortGrants', () => { + it('orders active, then scheduled, then expired, then revoked', () => { + const rows = sortGrants( + [ + grant({ id: 1, revoked_at: '2026-03-01T00:00:00Z' }), + grant({ id: 2, ends_at: '2026-02-01' }), + grant({ id: 3, starts_at: '2026-12-01' }), + grant({ id: 4 }), + ], + today + ) + + expect(rows.map((row) => row.id)).toEqual([4, 3, 2, 1]) + }) + + it('puts the newest start date first within a status', () => { + const rows = sortGrants( + [ + grant({ id: 1, starts_at: '2026-01-01' }), + grant({ id: 2, starts_at: '2026-05-01' }), + ], + today + ) + + expect(rows.map((row) => row.id)).toEqual([2, 1]) + }) +}) + +describe('describeScope', () => { + const groupGrant = grant({ scope_type: 'group', scope_id: 42 }) + + it('names the group when the name is known', () => { + expect(describeScope(groupGrant, { 42: 'Roswell Basin' })).toBe( + 'group Roswell Basin' + ) + }) + + it('falls back to the id when it is not', () => { + expect(describeScope(groupGrant)).toBe('group 42') + expect(describeScope(groupGrant, { 7: 'Estancia Basin' })).toBe('group 42') + }) + + it('leaves other scopes alone', () => { + expect(describeScope(grant({ scope_type: 'global', scope_id: null }))).toBe( + 'global' + ) + expect( + describeScope(grant({ scope_type: 'thing', scope_id: 512 }), { + 512: 'not a group', + }) + ).toBe('thing 512') + }) +}) + +describe('matchesFilters', () => { + const row = grant({ + principal_id: 'ak-subject-1', + capability: 'read', + data_type: 'water level', + scope_type: 'global', + }) + + it('matches everything when nothing is filtered', () => { + expect(matchesFilters(row, {})).toBe(true) + }) + + it('matches on each filter the console offers', () => { + expect(matchesFilters(row, { principalId: ' ak-subject-1 ' })).toBe(true) + expect(matchesFilters(row, { principalId: 'ak-subject-2' })).toBe(false) + expect(matchesFilters(row, { capability: 'read' })).toBe(true) + expect(matchesFilters(row, { capability: 'enter' })).toBe(false) + expect(matchesFilters(row, { dataType: 'water level' })).toBe(true) + expect(matchesFilters(row, { dataType: 'site metadata' })).toBe(false) + expect(matchesFilters(row, { scopeType: 'global' })).toBe(true) + expect(matchesFilters(row, { scopeType: 'thing' })).toBe(false) + }) + + it('separates screen grants from data grants', () => { + const surface = grant({ data_type: null, ui_surface: 'ocotillo.lexicon' }) + + expect(matchesFilters(surface, { subject: 'ui_surface' })).toBe(true) + expect(matchesFilters(surface, { subject: 'data_type' })).toBe(false) + expect(matchesFilters(row, { subject: 'data_type' })).toBe(true) + expect(matchesFilters(row, { subject: 'ui_surface' })).toBe(false) + }) + + it('excludes a surface grant from a data type filter', () => { + const surface = grant({ data_type: null, ui_surface: 'ocotillo.lexicon' }) + + expect(matchesFilters(surface, { dataType: 'water level' })).toBe(false) + expect(matchesFilters(surface, {})).toBe(true) + }) +}) + +describe('validateGrantForm', () => { + const form = { + principal_id: 'ak-subject-1', + subject: 'data_type', + capability: 'read', + ui_surface: '', + scope_type: 'global', + scope_id: '', + starts_at: '2026-06-01', + ends_at: '', + } + + it('accepts a global grant with no scope id', () => { + expect(validateGrantForm(form)).toEqual({}) + }) + + it('requires a principal', () => { + expect(validateGrantForm({ ...form, principal_id: ' ' })).toHaveProperty( + 'principal_id' + ) + }) + + it('requires a scope id for a thing-scoped grant', () => { + expect(validateGrantForm({ ...form, scope_type: 'thing' })).toHaveProperty( + 'scope_id' + ) + }) + + it('rejects a non-numeric scope id', () => { + expect( + validateGrantForm({ ...form, scope_type: 'group', scope_id: 'abc' }) + ).toHaveProperty('scope_id') + }) + + it('rejects an end date before the start date', () => { + expect( + validateGrantForm({ ...form, ends_at: '2026-05-01' }) + ).toHaveProperty('ends_at') + }) + + it('rejects the screen verb over a data type', () => { + expect(validateGrantForm({ ...form, capability: 'view' })).toHaveProperty( + 'capability' + ) + }) + + it('requires a screen on a UI surface grant', () => { + expect( + validateGrantForm({ ...form, subject: 'ui_surface' }) + ).toHaveProperty('ui_surface') + expect( + validateGrantForm({ + ...form, + subject: 'ui_surface', + ui_surface: 'ocotillo.lexicon', + }) + ).toEqual({}) + }) + + it('asks for no scope id on a surface grant, whatever the scope select holds', () => { + expect( + validateGrantForm({ + ...form, + subject: 'ui_surface', + ui_surface: 'ocotillo.lexicon', + scope_type: 'thing', + }) + ).toEqual({}) + }) +}) + +describe('toCreateGrantInput', () => { + const form = { + principal_type: 'user', + principal_id: ' ak-subject-1 ', + capability: 'enter', + scope_type: 'global', + scope_id: '99', + subject: 'data_type', + data_type: 'water chemistry', + ui_surface: '', + starts_at: '2026-06-01', + ends_at: '', + reason: ' seasonal fieldwork ', + } + + it('drops the scope id on a global grant and trims free text', () => { + expect(toCreateGrantInput(form)).toEqual({ + principal_type: 'user', + principal_id: 'ak-subject-1', + capability: 'enter', + scope_type: 'global', + scope_id: null, + data_type: 'water chemistry', + ui_surface: null, + starts_at: '2026-06-01', + ends_at: null, + reason: 'seasonal fieldwork', + }) + }) + + it('sends a surface grant as global, with no data type and the view verb', () => { + expect( + toCreateGrantInput({ + ...form, + subject: 'ui_surface', + ui_surface: 'ocotillo.lexicon', + scope_type: 'thing', + capability: 'read', + }) + ).toMatchObject({ + capability: 'view', + scope_type: 'global', + scope_id: null, + data_type: null, + ui_surface: 'ocotillo.lexicon', + }) + }) + + it('leaves a data grant its own verb', () => { + expect( + toCreateGrantInput({ ...form, capability: 'correct' }).capability + ).toBe('correct') + }) + + it('sends the scope id as a number when the scope needs one', () => { + expect(toCreateGrantInput({ ...form, scope_type: 'thing' }).scope_id).toBe( + 99 + ) + }) + + it('sends null rather than an empty reason', () => { + expect(toCreateGrantInput({ ...form, reason: ' ' }).reason).toBeNull() + }) +}) + +describe('zPermissionGrant', () => { + it('accepts a lexicon term the console does not know about', () => { + expect( + grant({ data_type: 'soil gas', capability: 'audit' }).data_type + ).toBe('soil gas') + }) + + it('parses a surface grant, which carries no data type', () => { + const surfaceGrant = grant({ + data_type: null, + ui_surface: 'ocotillo.lexicon', + }) + + expect(isUiSurfaceGrant(surfaceGrant)).toBe(true) + expect(describeSubject(surfaceGrant)).toBe('ocotillo.lexicon') + }) + + it('describes a data grant by its data type', () => { + expect(describeSubject(grant({ data_type: 'water level' }))).toBe( + 'water level' + ) + }) +}) + +describe('toDateInputValue', () => { + it('formats a date for a date input', () => { + expect(toDateInputValue(new Date(2026, 0, 5))).toBe('2026-01-05') + }) +}) + +describe('grantQueryParams', () => { + it('sends only include_revoked when nothing is filtered', () => { + expect(grantQueryParams({})).toEqual({ + include_revoked: false, + size: GRANT_PAGE_SIZE, + }) + }) + + it('maps each filter to its query name', () => { + expect( + grantQueryParams({ + principalId: 'ak-subject-1', + capability: 'read', + subject: 'ui_surface', + dataType: 'water level', + scopeType: 'thing', + includeRevoked: true, + }) + ).toEqual({ + principal_id: 'ak-subject-1', + capability: 'read', + subject: 'ui_surface', + data_type: 'water level', + scope_type: 'thing', + include_revoked: true, + size: GRANT_PAGE_SIZE, + }) + }) + + it('omits an empty filter rather than sending an empty string', () => { + expect(grantQueryParams({ principalId: ' ', capability: '' })).toEqual({ + include_revoked: false, + size: GRANT_PAGE_SIZE, + }) + }) + + it('sends the subject as the route filter rather than only narrowing here', () => { + expect(grantQueryParams({ subject: 'ui_surface' }).subject).toBe( + 'ui_surface' + ) + expect(grantQueryParams({}).subject).toBeUndefined() + }) + + it('trims the principal it does send', () => { + expect(grantQueryParams({ principalId: ' ak-1 ' }).principal_id).toBe( + 'ak-1' + ) + }) +}) + +describe('isUnfiltered', () => { + it('treats include-revoked alone as still unfiltered', () => { + expect(isUnfiltered({ includeRevoked: true })).toBe(true) + }) + + it('is false once any narrowing filter is set', () => { + expect(isUnfiltered({ capability: 'read' })).toBe(false) + expect(isUnfiltered({ principalId: 'ak-1' })).toBe(false) + }) + + it('ignores a whitespace-only principal', () => { + expect(isUnfiltered({ principalId: ' ' })).toBe(true) + }) +}) + +describe('sortGrants across principals', () => { + it('groups equal-dated rows by principal', () => { + const rows = sortGrants( + [ + grant({ id: 1, principal_id: 'zeta' }), + grant({ id: 2, principal_id: 'alpha' }), + ], + today + ) + + expect(rows.map((row) => row.principal_id)).toEqual(['alpha', 'zeta']) + }) +}) diff --git a/src/test/utils/accessLifecycle.test.ts b/src/test/utils/accessLifecycle.test.ts new file mode 100644 index 00000000..6e77d921 --- /dev/null +++ b/src/test/utils/accessLifecycle.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { + accessStatusOf, + compareByLifecycle, + isRevocable, + type LifecycleRow, + toDateInputValue, + validateDateWindow, +} from '@/utils/accessLifecycle' + +const row = (overrides: Partial = {}): LifecycleRow => ({ + starts_at: '2026-01-01', + ends_at: null, + revoked_at: null, + ...overrides, +}) + +const today = new Date('2026-06-15T12:00:00Z') + +describe('accessStatusOf', () => { + it('is active inside an open-ended window', () => { + expect(accessStatusOf(row(), today)).toBe('active') + }) + + it('is scheduled before the start date', () => { + expect(accessStatusOf(row({ starts_at: '2026-09-01' }), today)).toBe( + 'scheduled' + ) + }) + + it('is expired after the end date', () => { + expect(accessStatusOf(row({ ends_at: '2026-05-31' }), today)).toBe( + 'expired' + ) + }) + + it('is active on the end date itself', () => { + expect(accessStatusOf(row({ ends_at: '2026-06-15' }), today)).toBe('active') + }) + + it('reports revoked ahead of any date reasoning', () => { + expect( + accessStatusOf( + row({ starts_at: '2026-09-01', revoked_at: '2026-06-01T00:00:00Z' }), + today + ) + ).toBe('revoked') + }) +}) + +describe('isRevocable', () => { + it('allows revoking active and scheduled rows', () => { + expect(isRevocable(row(), today)).toBe(true) + expect(isRevocable(row({ starts_at: '2026-09-01' }), today)).toBe(true) + }) + + it('refuses expired and already-revoked rows', () => { + expect(isRevocable(row({ ends_at: '2026-01-02' }), today)).toBe(false) + expect( + isRevocable(row({ revoked_at: '2026-02-02T00:00:00Z' }), today) + ).toBe(false) + }) +}) + +describe('compareByLifecycle', () => { + it('orders active, scheduled, expired, revoked', () => { + const rows = [ + row({ revoked_at: '2026-03-01T00:00:00Z' }), + row({ ends_at: '2026-02-01' }), + row({ starts_at: '2026-12-01' }), + row(), + ].sort((a, b) => compareByLifecycle(a, b, today)) + + expect(rows.map((entry) => accessStatusOf(entry, today))).toEqual([ + 'active', + 'scheduled', + 'expired', + 'revoked', + ]) + }) + + it('puts the newest start date first within a status', () => { + const rows = [ + row({ starts_at: '2026-01-01' }), + row({ starts_at: '2026-05-01' }), + ].sort((a, b) => compareByLifecycle(a, b, today)) + + expect(rows.map((entry) => entry.starts_at)).toEqual([ + '2026-05-01', + '2026-01-01', + ]) + }) +}) + +describe('validateDateWindow', () => { + it('rejects an end date before the start date', () => { + expect( + validateDateWindow({ starts_at: '2026-06-01', ends_at: '2026-05-01' }) + ).toHaveProperty('ends_at') + }) + + it('accepts an open-ended window', () => { + expect( + validateDateWindow({ starts_at: '2026-06-01', ends_at: '' }) + ).toEqual({}) + }) +}) + +describe('toDateInputValue', () => { + it('formats a date for a date input', () => { + expect(toDateInputValue(new Date(2026, 0, 5))).toBe('2026-01-05') + }) +}) diff --git a/src/test/utils/uiSurfaceGrants.test.ts b/src/test/utils/uiSurfaceGrants.test.ts new file mode 100644 index 00000000..21678cd0 --- /dev/null +++ b/src/test/utils/uiSurfaceGrants.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + isGrantableAction, + isUiSurfaceGranted, + resetUiSurfaceGrants, +} from '@/utils/uiSurfaceGrants' + +const { fetcherMock } = vi.hoisted(() => ({ fetcherMock: vi.fn() })) + +vi.mock('@/providers/ocotillo-data-provider', () => ({ + fetcher: (...args: unknown[]) => fetcherMock(...args), +})) + +const allowed = (value: boolean) => ({ data: { allowed: value } }) + +beforeEach(() => { + fetcherMock.mockReset() + resetUiSurfaceGrants() +}) + +describe('isUiSurfaceGranted', () => { + it('asks the decision route about one surface, as read', async () => { + fetcherMock.mockResolvedValue(allowed(true)) + + await expect(isUiSurfaceGranted('ocotillo.lexicon')).resolves.toBe(true) + expect(fetcherMock).toHaveBeenCalledWith('access/decision', { + params: { capability: 'view', ui_surface: 'ocotillo.lexicon' }, + }) + }) + + it('is false when no grant opens the surface', async () => { + fetcherMock.mockResolvedValue(allowed(false)) + + await expect(isUiSurfaceGranted('ocotillo.lexicon')).resolves.toBe(false) + }) + + it('denies by default when the call fails', async () => { + fetcherMock.mockRejectedValue(new Error('network down')) + + await expect(isUiSurfaceGranted('ocotillo.lexicon')).resolves.toBe(false) + }) + + it('denies when the answer is not the shape it expects', async () => { + fetcherMock.mockResolvedValue({ data: undefined }) + + await expect(isUiSurfaceGranted('ocotillo.lexicon')).resolves.toBe(false) + }) + + it('caches a resolved answer for the session', async () => { + fetcherMock.mockResolvedValue(allowed(true)) + + await isUiSurfaceGranted('ocotillo.lexicon') + await isUiSurfaceGranted('ocotillo.lexicon') + + expect(fetcherMock).toHaveBeenCalledTimes(1) + }) + + it('shares one request across a burst of callers', async () => { + fetcherMock.mockResolvedValue(allowed(true)) + + const answers = await Promise.all([ + isUiSurfaceGranted('ocotillo.lexicon'), + isUiSurfaceGranted('ocotillo.lexicon'), + isUiSurfaceGranted('ocotillo.lexicon'), + ]) + + expect(answers).toEqual([true, true, true]) + expect(fetcherMock).toHaveBeenCalledTimes(1) + }) + + it('keeps surfaces apart in the cache', async () => { + fetcherMock.mockImplementation( + (_url: string, config: { params: { ui_surface: string } }) => + Promise.resolve( + allowed(config.params.ui_surface === 'ocotillo.lexicon') + ) + ) + + await expect(isUiSurfaceGranted('ocotillo.lexicon')).resolves.toBe(true) + await expect(isUiSurfaceGranted('ocotillo.location')).resolves.toBe(false) + expect(fetcherMock).toHaveBeenCalledTimes(2) + }) + + it('forgets everything on reset', async () => { + fetcherMock.mockResolvedValue(allowed(true)) + + await isUiSurfaceGranted('ocotillo.lexicon') + resetUiSurfaceGrants() + await isUiSurfaceGranted('ocotillo.lexicon') + + expect(fetcherMock).toHaveBeenCalledTimes(2) + }) +}) + +describe('isGrantableAction', () => { + it('lets a surface grant widen reading', () => { + expect(isGrantableAction('list')).toBe(true) + expect(isGrantableAction('show')).toBe(true) + }) + + it('refuses to let seeing a screen become writing to it', () => { + for (const action of ['create', 'edit', 'delete', 'manage']) { + expect(isGrantableAction(action)).toBe(false) + } + }) +}) diff --git a/src/utils/accessConsent.ts b/src/utils/accessConsent.ts new file mode 100644 index 00000000..44db9c37 --- /dev/null +++ b/src/utils/accessConsent.ts @@ -0,0 +1,105 @@ +import { z } from 'zod' +import { compareByLifecycle, validateDateWindow } from '@/utils/accessLifecycle' + +/** + * Publication consent: the record that an owner agreed to publish one data + * type to one destination, for a period (ADR5). + * + * Hand-written zod, like `accessGrants.ts` and `accessDestinations.ts`. + */ + +export const zPublicationConsent = z.looseObject({ + id: z.number(), + thing_id: z.number(), + destination_id: z.number(), + data_type: z.string(), + contact_id: z.number().nullable(), + recorded_by: z.string(), + notes: z.string().nullable(), + starts_at: z.string(), + ends_at: z.string().nullable(), + revoked_at: z.string().nullable(), + revoked_by: z.string().nullable(), +}) + +export const zPublicationConsentList = z.array(zPublicationConsent) + +export type PublicationConsent = z.infer + +export type CreateConsentInput = { + thing_id: number + destination_slug: string + data_type: string + starts_at: string + ends_at?: string | null + contact_id?: number | null + notes?: string | null +} + +export type ConsentFormErrors = Partial< + Record<'thing_id' | 'destination_slug' | 'contact_id' | 'ends_at', string> +> + +const isWholeNumber = (value: string) => /^\d+$/.test(value.trim()) + +/** + * `contact_id` is deliberately optional on the API: it is null when the + * Bureau owns the well, because the decision was institutional and inventing + * a consenting contact would be a lie. The form treats blank as that null + * rather than as a missing field. + */ +export const validateConsentForm = (form: { + thing_id: string + destination_slug: string + contact_id: string + starts_at: string + ends_at: string +}): ConsentFormErrors => { + const errors: ConsentFormErrors = {} + + if (!form.thing_id.trim()) { + errors.thing_id = 'A thing id is required.' + } else if (!isWholeNumber(form.thing_id)) { + errors.thing_id = 'Thing id must be a whole number.' + } + + if (!form.destination_slug) { + errors.destination_slug = 'A destination is required.' + } + + if (form.contact_id.trim() && !isWholeNumber(form.contact_id)) { + errors.contact_id = 'Contact id must be a whole number.' + } + + return { ...errors, ...validateDateWindow(form) } +} + +export const toCreateConsentInput = (form: { + thing_id: string + destination_slug: string + data_type: string + contact_id: string + starts_at: string + ends_at: string + notes: string +}): CreateConsentInput => ({ + thing_id: Number(form.thing_id), + destination_slug: form.destination_slug, + data_type: form.data_type, + starts_at: form.starts_at, + ends_at: form.ends_at || null, + contact_id: form.contact_id.trim() ? Number(form.contact_id) : null, + notes: form.notes.trim() || null, +}) + +export const sortConsent = ( + rows: PublicationConsent[], + today: Date +): PublicationConsent[] => + [...rows].sort((a, b) => compareByLifecycle(a, b, today) || b.id - a.id) + +/** Blank rather than "unknown": the Bureau owning the well is not a gap. */ +export const describeConsentingContact = ( + consent: PublicationConsent +): string => + consent.contact_id === null ? 'Bureau-owned' : `#${consent.contact_id}` diff --git a/src/utils/accessControl.ts b/src/utils/accessControl.ts index 0ae8b3ad..be7e31aa 100644 --- a/src/utils/accessControl.ts +++ b/src/utils/accessControl.ts @@ -73,6 +73,17 @@ const resourcePolicies: Record = { delete: ['AMP.Admin', 'Geothermal.Admin'], manage: ['AMP.Admin', 'Geothermal.Admin'], }, + // Grants are the rule about who sees data, not data. Only an admin reads + // or writes them, so every action here is admin-only rather than following + // the viewer/editor ladder the data resources use. + 'ocotillo.access-grants': { + list: adminRoles, + show: adminRoles, + edit: adminRoles, + create: adminRoles, + delete: adminRoles, + manage: adminRoles, + }, 'ocotillo.lexicon': { list: adminRoles, show: adminRoles, @@ -248,6 +259,11 @@ export const canAccessResource = ({ return matchesPolicy(policy[action], capabilities.roles) } + if (resource === 'ocotillo.access-grants') { + const policy = resourcePolicies[resource] + return matchesPolicy(policy[action], capabilities.roles) + } + if (resource === 'ocotillo.lexicon') { const policy = resourcePolicies[resource] return matchesPolicy(policy[action], capabilities.roles) diff --git a/src/utils/accessDestinations.ts b/src/utils/accessDestinations.ts new file mode 100644 index 00000000..fc373cba --- /dev/null +++ b/src/utils/accessDestinations.ts @@ -0,0 +1,118 @@ +import { z } from 'zod' + +/** + * Destinations: the places published data is offered to (ADR5). + * + * Hand-written zod for the same reason as `accessGrants.ts` — the committed + * `openapi-auth.json` snapshot predates the `/access` routes. + * + * `destination_kind` is lexicon-backed on the API, so it parses as a plain + * string; the values below are pinned only to populate the form. + */ + +export const DESTINATION_KINDS = [ + 'public web', + 'harvester', + 'partner agency', +] as const + +export const zDestination = z.looseObject({ + id: z.number(), + slug: z.string(), + name: z.string(), + destination_kind: z.string(), + description: z.string().nullable(), + active: z.boolean(), +}) + +export const zDestinationList = z.array(zDestination) + +export type Destination = z.infer + +export type CreateDestinationInput = { + slug: string + name: string + destination_kind: string + description?: string | null +} + +/** + * One thing as a destination sees it. `properties` and `location` arrive + * already projected through the per-audience allowlist — a field nobody + * approved for this audience is absent rather than null — so the console + * shows what is there and never fills a gap in. + */ +export const zPublishedThing = z.looseObject({ + thing_id: z.number(), + data_types: z.array(z.string()), + properties: z.record(z.string(), z.unknown()).default({}), + location: z.record(z.string(), z.unknown()).default({}), +}) + +export const zPublishedThingList = z.array(zPublishedThing) + +export type PublishedThing = z.infer + +export type DestinationFormErrors = Partial> + +/** The API caps slug at 50 and name at 255, and answers 409 on a taken slug. */ +export const SLUG_MAX_LENGTH = 50 +export const NAME_MAX_LENGTH = 255 + +export const validateDestinationForm = (form: { + slug: string + name: string +}): DestinationFormErrors => { + const errors: DestinationFormErrors = {} + const slug = form.slug.trim() + + if (!slug) { + errors.slug = 'A slug is required.' + } else if (slug.length > SLUG_MAX_LENGTH) { + errors.slug = `Slug must be ${SLUG_MAX_LENGTH} characters or fewer.` + } else if (!/^[a-z0-9][a-z0-9_-]*$/.test(slug)) { + // The slug goes in a URL path (`/access/destination/{slug}/thing`), so it + // is checked here rather than discovered as a 404 later. + errors.slug = + 'Slug may use lower-case letters, digits, hyphens, and underscores.' + } + + if (!form.name.trim()) { + errors.name = 'A name is required.' + } else if (form.name.trim().length > NAME_MAX_LENGTH) { + errors.name = `Name must be ${NAME_MAX_LENGTH} characters or fewer.` + } + + return errors +} + +export const toCreateDestinationInput = (form: { + slug: string + name: string + destination_kind: string + description: string +}): CreateDestinationInput => ({ + slug: form.slug.trim(), + name: form.name.trim(), + destination_kind: form.destination_kind, + description: form.description.trim() || null, +}) + +/** Active first, then by slug — a retired destination is history, not a choice. */ +export const sortDestinations = (destinations: Destination[]): Destination[] => + [...destinations].sort( + (a, b) => + Number(b.active) - Number(a.active) || a.slug.localeCompare(b.slug) + ) + +export const destinationLabel = (destination: Destination): string => + `${destination.name} (${destination.slug})` + +/** + * Consent rows carry `destination_id`, not a slug, so the consent tab has to + * resolve names through the destination list it already loads. + */ +export const indexDestinationsById = ( + destinations: Destination[] | undefined +): Map => + new Map((destinations ?? []).map((row) => [row.id, row])) diff --git a/src/utils/accessGrants.ts b/src/utils/accessGrants.ts new file mode 100644 index 00000000..0180e0e5 --- /dev/null +++ b/src/utils/accessGrants.ts @@ -0,0 +1,397 @@ +import { z } from 'zod' +import { + type AccessStatus, + accessStatusOf, + compareByLifecycle, + validateDateWindow, +} from '@/utils/accessLifecycle' + +/** + * Client model for ADR5 permission grants (`/access/*` on the Ocotillo API). + * + * Hand-written, like `gisArtifacts.ts`: the committed `openapi-auth.json` + * snapshot predates the access-control routes, so `src/generated` cannot + * describe them. The shapes below mirror `schemas/access.py` on OcotilloAPI + * exactly. Once `/access` is in the deployed spec, refresh it, regenerate, and + * replace these with the generated zod schemas. + * + * The four enums are built from the API's lexicon at runtime, so their values + * are data rather than code on that side. They are pinned here because the + * console has to render a fixed set of choices; a value the API adds later + * parses fine (see `zGrantEnum`) and simply shows as itself. + */ + +/** + * Lexicon-backed enums arrive as plain strings. Parsing them as a bare string + * rather than a zod enum is deliberate: a term added to the API's lexicon must + * not make an entire grant list fail to load. + */ +const zGrantEnum = z.string() + +export const PRINCIPAL_TYPES = ['user', 'role', 'api key'] as const +export const CAPABILITIES = [ + 'read', + 'enter', + 'correct', + 'administer', + 'view', +] as const + +/** + * `view` is the screen verb and the rest are data verbs; the API keeps them + * apart and rejects either used over the wrong subject, so the form offers + * only the ones that can succeed. + */ +export const SURFACE_CAPABILITY = 'view' + +export const DATA_CAPABILITIES = CAPABILITIES.filter( + (capability) => capability !== SURFACE_CAPABILITY +) +export const GRANT_SCOPE_TYPES = ['global', 'group', 'thing'] as const +export const ACCESS_DATA_TYPES = [ + 'water chemistry', + 'water level', + 'well construction', + 'site metadata', +] as const + +/** + * Screens a grant may open, mirroring the API's `ui_surface` lexicon category. + * These are resource ids, the same strings `accessControl` policies key on, + * because that is what the nav item asks `/access/decision` about. + */ +export const UI_SURFACES = [ + 'ocotillo.map', + 'ocotillo.thing-well', + 'ocotillo.thing-well-projects', + 'ocotillo.thing-well-batch-export', + 'ocotillo.contact', + 'ocotillo.collections', + 'ocotillo.asset-unassociated', + 'ocotillo.location', + 'ocotillo.lexicon', + 'ocotillo.hydrograph-correction', + 'ocotillo.access-grants', +] as const + +/** + * What a grant is about: data the principal may reach, or a screen it may + * open. The API stores exactly one of the two and rejects both or neither, so + * the form picks between them rather than offering both at once. + */ +export const GRANT_SUBJECTS = ['data_type', 'ui_surface'] as const + +export type PrincipalType = (typeof PRINCIPAL_TYPES)[number] +export type Capability = (typeof CAPABILITIES)[number] +export type GrantScopeType = (typeof GRANT_SCOPE_TYPES)[number] +export type AccessDataType = (typeof ACCESS_DATA_TYPES)[number] +export type UiSurface = (typeof UI_SURFACES)[number] +export type GrantSubject = (typeof GRANT_SUBJECTS)[number] + +export const zPermissionGrant = z.looseObject({ + id: z.number(), + principal_type: zGrantEnum, + principal_id: z.string(), + capability: zGrantEnum, + scope_type: zGrantEnum, + scope_id: z.number().nullable(), + // Exactly one of these is set on any row the API returns. + data_type: zGrantEnum.nullable(), + ui_surface: zGrantEnum.nullable().default(null), + starts_at: z.string(), + ends_at: z.string().nullable(), + granted_by: z.string(), + reason: z.string().nullable(), + revoked_at: z.string().nullable(), + revoked_by: z.string().nullable(), +}) + +export const zPermissionGrantList = z.array(zPermissionGrant) + +/** + * `GET /access/grant` answers with a page, not a list: `{items, total, page, + * size, pages}`. Everything but `items` is read loosely — the console needs + * the total to know whether it is looking at everything. + */ +export const zPermissionGrantPage = z.looseObject({ + items: zPermissionGrantList, + total: z.number().nullable(), + page: z.number().nullable(), + size: z.number().nullable(), +}) + +export type PermissionGrantPage = z.infer + +export type PermissionGrant = z.infer + +export type CreateGrantInput = { + principal_type: string + principal_id: string + capability: string + scope_type: string + scope_id?: number | null + data_type?: string | null + ui_surface?: string | null + starts_at: string + ends_at?: string | null + reason?: string | null +} + +/** + * What a grant is doing right now. The API stores dates and a revocation + * stamp, not a status, because a grant's meaning depends on the day it is + * read — so the console derives this rather than caching it. + */ +export type GrantFilters = { + principalId?: string + capability?: string + /** + * Which kind of grant to show: `data_type` or `ui_surface`. Sent as the + * route's `subject` filter, and applied again to what comes back — an older + * API ignores a query parameter it does not know, and a filter that silently + * does nothing is worse than one that costs a pass over the rows. + */ + subject?: string + dataType?: string + scopeType?: string + includeRevoked?: boolean +} + +export type GrantQueryParams = { + principal_id?: string + capability?: string + subject?: string + data_type?: string + scope_type?: string + include_revoked: boolean + size: number +} + +/** + * How many grants the console asks for at once. + * + * The route pages at 25 by default and caps at 10000. Sorting is by lifecycle + * and runs over the whole result here, so asking for one page at a time would + * sort a slice and present it as the order. This asks for more than any + * principal will have and says so when the answer is short — see + * `isPartialPage`. + */ +export const GRANT_PAGE_SIZE = 500 + +/** + * Only set filters are sent. Every one is optional on the API, and an empty + * string is not the same question as "any" — it would match grants whose + * field is literally empty, which is none of them. + */ +export const grantQueryParams = (filters: GrantFilters): GrantQueryParams => { + const params: GrantQueryParams = { + include_revoked: filters.includeRevoked ?? false, + size: GRANT_PAGE_SIZE, + } + + const principalId = filters.principalId?.trim() + if (principalId) params.principal_id = principalId + if (filters.capability) params.capability = filters.capability + if (filters.subject) params.subject = filters.subject + if (filters.dataType) params.data_type = filters.dataType + if (filters.scopeType) params.scope_type = filters.scopeType + + return params +} + +/** + * Whether a grant would appear under the filters currently applied. + * + * Used after a create: the list refetches either way, but a grant written + * outside the slice on screen would otherwise land nowhere visible, and + * "I granted it and nothing happened" is the report that follows. Revocation + * state is not considered — a grant is never born revoked. + */ +export const matchesFilters = ( + grant: PermissionGrant, + filters: GrantFilters +): boolean => { + const principalId = filters.principalId?.trim() + + if (principalId && grant.principal_id !== principalId) return false + if (filters.capability && grant.capability !== filters.capability) + return false + if (filters.subject === 'ui_surface' && !grant.ui_surface) return false + if (filters.subject === 'data_type' && !grant.data_type) return false + if (filters.dataType && grant.data_type !== filters.dataType) return false + if (filters.scopeType && grant.scope_type !== filters.scopeType) return false + + return true +} + +/** + * Whether the API held back rows the console never saw. Silence here would be + * a table that looks complete and is not. + */ +export const isPartialPage = (page: PermissionGrantPage | undefined): boolean => + page !== undefined && page.total !== null && page.items.length < page.total + +/** True when the console is showing the unfiltered admin-wide audit view. */ +export const isUnfiltered = (filters: GrantFilters): boolean => + !filters.principalId?.trim() && + !filters.capability && + !filters.subject && + !filters.dataType && + !filters.scopeType + +export type GrantStatus = AccessStatus + +export const grantStatusOf = ( + grant: PermissionGrant, + today: Date +): GrantStatus => accessStatusOf(grant, today) + +/** + * A global grant covers everything and names no scope; a group- or + * thing-scoped one is meaningless without its id. The API enforces this in + * `domain/access.py` and answers 422 on `scope_id`; the console checks first + * so the form can say so before the round trip. + */ +export const scopeIdRequired = (scopeType: string): boolean => + scopeType === 'group' || scopeType === 'thing' + +/** + * The scope, as an admin would say it. A group is stored by id but known by + * name, so a name is used when one is to hand — and the id stands in when it + * is not, since a row that cannot resolve its group should still say which + * group it means. + */ +export const describeScope = ( + grant: PermissionGrant, + groupNames?: Record +): string => { + if (!scopeIdRequired(grant.scope_type)) return grant.scope_type + + if (grant.scope_type === 'group' && grant.scope_id !== null) { + const name = groupNames?.[grant.scope_id] + if (name) return `group ${name}` + } + + return `${grant.scope_type} ${grant.scope_id ?? '?'}` +} + +export type GrantFormErrors = Partial< + Record< + 'principal_id' | 'scope_id' | 'ends_at' | 'ui_surface' | 'capability', + string + > +> + +/** True for a grant that opens a screen rather than reaching data. */ +export const isUiSurfaceGrant = (grant: PermissionGrant): boolean => + Boolean(grant.ui_surface) + +/** + * What the grant is about, for the table. A row always has one of the two, but + * an API that grows a third subject should not render an empty cell. + */ +export const describeSubject = (grant: PermissionGrant): string => + grant.data_type ?? grant.ui_surface ?? 'unknown' + +/** + * A screen grant is app-wide: navigation is not scoped to a group or a thing, + * and the API answers 422 on `scope_type` for anything else. The form forces + * global rather than letting someone build a grant the API will refuse. + */ +export const scopeTypeFor = (subject: string, scopeType: string): string => + subject === 'ui_surface' ? 'global' : scopeType + +/** + * A screen grant carries `view` and nothing else — `read` over a screen is a + * second spelling of the same permission, and the API answers 422 for it. + */ +export const capabilityFor = (subject: string, capability: string): string => + subject === 'ui_surface' ? SURFACE_CAPABILITY : capability + +/** + * Validates what the console can know locally. Everything else — whether the + * principal exists, whether the scope id resolves — is the API's answer to + * give, and is surfaced from its 422 rather than guessed at here. + */ +export const validateGrantForm = (form: { + principal_id: string + subject: string + capability: string + ui_surface: string + scope_type: string + scope_id: string + starts_at: string + ends_at: string +}): GrantFormErrors => { + const errors: GrantFormErrors = {} + const scopeType = scopeTypeFor(form.subject, form.scope_type) + + if (form.subject === 'data_type' && form.capability === SURFACE_CAPABILITY) { + errors.capability = `'${SURFACE_CAPABILITY}' opens a screen, not a data type.` + } + + if (!form.principal_id.trim()) { + errors.principal_id = 'A principal is required.' + } + + if (form.subject === 'ui_surface' && !form.ui_surface) { + errors.ui_surface = 'A screen is required for a UI surface grant.' + } + + if (scopeIdRequired(scopeType)) { + if (!form.scope_id.trim()) { + errors.scope_id = `A ${scopeType} id is required for a ${scopeType}-scoped grant.` + } else if (!/^\d+$/.test(form.scope_id.trim())) { + errors.scope_id = 'Scope id must be a whole number.' + } + } + + return { ...errors, ...validateDateWindow(form) } +} + +export const toCreateGrantInput = (form: { + principal_type: string + principal_id: string + capability: string + scope_type: string + scope_id: string + subject: string + data_type: string + ui_surface: string + starts_at: string + ends_at: string + reason: string +}): CreateGrantInput => { + const isSurface = form.subject === 'ui_surface' + const scopeType = scopeTypeFor(form.subject, form.scope_type) + + return { + principal_type: form.principal_type, + principal_id: form.principal_id.trim(), + capability: capabilityFor(form.subject, form.capability), + scope_type: scopeType, + scope_id: scopeIdRequired(scopeType) ? Number(form.scope_id) : null, + // Exactly one subject reaches the API; sending both is a 422. + data_type: isSurface ? null : form.data_type, + ui_surface: isSurface ? form.ui_surface : null, + starts_at: form.starts_at, + ends_at: form.ends_at || null, + reason: form.reason.trim() || null, + } +} + +/** + * Grants sort by lifecycle first. The list spans principals now, so + * equal-dated rows group by who holds them rather than landing in insertion + * order. + */ +export const sortGrants = ( + grants: PermissionGrant[], + today: Date +): PermissionGrant[] => + [...grants].sort( + (a, b) => + compareByLifecycle(a, b, today) || + a.principal_id.localeCompare(b.principal_id) || + b.id - a.id + ) diff --git a/src/utils/accessLifecycle.ts b/src/utils/accessLifecycle.ts new file mode 100644 index 00000000..9b903304 --- /dev/null +++ b/src/utils/accessLifecycle.ts @@ -0,0 +1,93 @@ +/** + * The date-window lifecycle shared by permission grants and publication + * consent. + * + * Both rows carry `starts_at`, a nullable `ends_at`, and a nullable + * `revoked_at`, and neither stores a status: what the row means depends on + * the day it is read. Deriving it in one place keeps the grants tab and the + * consent tab from drifting on what "expired" means. + */ + +export type AccessStatus = 'active' | 'scheduled' | 'expired' | 'revoked' + +export type LifecycleRow = { + starts_at: string + ends_at: string | null + revoked_at: string | null +} + +const dayOf = (value: string): string => value.slice(0, 10) + +export const accessStatusOf = ( + row: LifecycleRow, + today: Date +): AccessStatus => { + if (row.revoked_at) return 'revoked' + + const day = dayOf(today.toISOString()) + if (dayOf(row.starts_at) > day) return 'scheduled' + if (row.ends_at && dayOf(row.ends_at) < day) return 'expired' + + return 'active' +} + +export const ACCESS_STATUS_LABELS: Record = { + active: 'Active', + scheduled: 'Scheduled', + expired: 'Expired', + revoked: 'Revoked', +} + +export const ACCESS_STATUS_COLORS: Record< + AccessStatus, + 'success' | 'info' | 'default' | 'error' +> = { + active: 'success', + scheduled: 'info', + expired: 'default', + revoked: 'error', +} + +/** Only a live or not-yet-started row is worth revoking. */ +export const isRevocable = (row: LifecycleRow, today: Date): boolean => { + const status = accessStatusOf(row, today) + return status === 'active' || status === 'scheduled' +} + +const STATUS_ORDER: Record = { + active: 0, + scheduled: 1, + expired: 2, + revoked: 3, +} + +/** + * Live rows first, then the ones that have not started, then history. Within + * a status the newest start date leads: an admin reading these pages is + * asking "what is in force now", not "what happened first". + */ +export const compareByLifecycle = ( + a: LifecycleRow, + b: LifecycleRow, + today: Date +): number => + STATUS_ORDER[accessStatusOf(a, today)] - + STATUS_ORDER[accessStatusOf(b, today)] || + b.starts_at.localeCompare(a.starts_at) + +/** `YYYY-MM-DD` for a date input, in local time rather than UTC. */ +export const toDateInputValue = (date: Date): string => { + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${date.getFullYear()}-${month}-${day}` +} + +export type LifecycleFormErrors = { ends_at?: string } + +export const validateDateWindow = (form: { + starts_at: string + ends_at: string +}): LifecycleFormErrors => + form.ends_at && form.starts_at && form.ends_at < form.starts_at + ? { ends_at: 'End date cannot fall before the start date.' } + : {} diff --git a/src/utils/uiSurfaceGrants.ts b/src/utils/uiSurfaceGrants.ts new file mode 100644 index 00000000..59210f9c --- /dev/null +++ b/src/utils/uiSurfaceGrants.ts @@ -0,0 +1,80 @@ +import { fetcher } from '@/providers/ocotillo-data-provider' +import { SURFACE_CAPABILITY } from '@/utils/accessGrants' + +/** + * UI-surface grants: the widen-only half of access control. + * + * A role policy (`canAccessResource`) decides what a role may reach. A grant + * can open one extra screen for one principal — `GET /access/decision` with a + * `ui_surface` and the `view` capability answers whether it does. + * + * Two rules this module exists to keep: + * + * * **Widen only.** This is asked only after the role policy has already said + * no, and its answer can only turn that into a yes. A grant never takes away + * what a role allows, so revoking one returns someone to exactly their role. + * * **Default deny on failure.** A network error, a 401, or an unparseable + * answer is a no, which leaves the role policy's decision standing. An + * access-control outage must not hand out screens, and — because it cannot + * subtract — it cannot lock an admin out either. + * + * `can()` is called for every nav item on every render, so answers are cached + * per surface for the session and in-flight requests are shared. Only surfaces + * the role policy already denied are ever asked about, which bounds this to + * one request per denied screen. + */ + +const cache = new Map>() + +/** Clear the cache. Call on sign-out; used by tests between cases. */ +export const resetUiSurfaceGrants = () => cache.clear() + +const askDecision = async (surface: string): Promise => { + try { + const response = await fetcher('access/decision', { + // `view` is the verb a surface grant carries. Asking with `read` matches + // no grant the API will store, so the answer would always be no. + params: { capability: SURFACE_CAPABILITY, ui_surface: surface }, + }) + return response.data?.allowed === true + } catch { + // Default deny. The role policy's answer stands. + return false + } +} + +/** + * Whether a grant opens this screen for the signed-in caller. + * + * A resolved answer is cached for the session: grants change rarely, and a + * revocation takes effect on the next sign-in rather than mid-session. That is + * the same bound the API documents for its own reads, and it is safe in the + * widen-only direction — the worst case is a screen staying visible slightly + * longer than the grant, and the data behind it is enforced server-side. + */ +export const isUiSurfaceGranted = (surface: string): Promise => { + const cached = cache.get(surface) + if (cached !== undefined) return Promise.resolve(cached) + + // Store the promise, not just the result, so a burst of nav items rendering + // at once shares one request rather than firing one apiece. + const pending = askDecision(surface).then((allowed) => { + cache.set(surface, allowed) + return allowed + }) + + cache.set(surface, pending) + return pending +} + +/** + * Actions a surface grant may widen. + * + * A surface grant is `read`: it says a screen may be seen, never that its + * records may be written. Letting it widen `create`/`edit`/`delete` would turn + * "can see this nav item" into edit rights, which is not what was granted. + */ +const READ_ACTIONS = new Set(['list', 'show']) + +export const isGrantableAction = (action: string): boolean => + READ_ACTIONS.has(action)