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 (
+
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"
+ />
+ }
+ onClick={() => setIsDialogOpen(true)}
+ disabled={destinations.data?.length === 0}
+ sx={{ flexShrink: 0 }}
+ >
+ Record consent
+
+
+
+
+ {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 (
+
+ )
+}
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 (
+
+
+
+ }
+ onClick={() => setIsDialogOpen(true)}
+ >
+ Register destination
+
+
+
+ {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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+
+
+ }
+ onClick={() => setIsDialogOpen(true)}
+ >
+ Grant access
+
+
+
+ {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"
+ />
+ }
+ onClick={clearFilters}
+ disabled={isUnfiltered(filters)}
+ >
+ Clear filters
+
+
+
+
+
+ {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 }}
+ >
+
+ {options.map((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)
|