From 1de20ad39420c34a7c01870d9ab0b93b3657662a Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 27 Aug 2026 16:36:19 -0700 Subject: [PATCH 1/7] feat(access): add an operations console for permission grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an admin-only page at /access/grants for the ADR5 permission grants the Ocotillo API exposes under /access. It opens on the admin-wide audit view, narrows by principal, capability, data type, scope, and revocation state, and creates and revokes grants. Every filter is optional on the route, so the bare call is the 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. "Any" is therefore a real option in each dropdown rather than a cleared field. The principal field applies on Enter rather than on each keystroke — the dropdowns have no such problem and apply on change. Grant status is derived rather than stored. The API keeps dates and a revocation stamp, not a status, because what a grant means depends on the day it is read; `grantStatusOf` resolves active / scheduled / expired / revoked against a single `today` captured once per page. Revocation asks for confirmation. There is no un-revoke on the API, so restoring access means creating a new grant, and the confirmation says that. Both mutations invalidate every grant list rather than patching the cache: a write can land outside the slice on screen. The four axes of a grant (principal type, capability, scope, data type) are built from the API's lexicon at runtime, so they are data on that side. The values are pinned here because the form needs a fixed set of choices, but the response parser takes them as plain strings: a term added to the lexicon later must not make an entire grant list fail to load. Schemas are hand-written zod, like `gisArtifacts.ts`. The committed `openapi-auth.json` snapshot predates the /access routes, so `src/generated` cannot describe them yet. Co-Authored-By: Claude Opus 5 --- src/App.tsx | 2 + src/config/navigation.ts | 10 +- src/hooks/index.ts | 1 + src/hooks/useAccessGrants.ts | 65 ++++ src/pages/access/grants/GrantDialog.tsx | 249 +++++++++++++ src/pages/access/grants/index.tsx | 463 ++++++++++++++++++++++++ src/test/pages/accessGrants.test.tsx | 258 +++++++++++++ src/test/utils/accessGrants.test.ts | 280 ++++++++++++++ src/utils/accessControl.ts | 16 + src/utils/accessGrants.ts | 258 +++++++++++++ 10 files changed, 1601 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useAccessGrants.ts create mode 100644 src/pages/access/grants/GrantDialog.tsx create mode 100644 src/pages/access/grants/index.tsx create mode 100644 src/test/pages/accessGrants.test.tsx create mode 100644 src/test/utils/accessGrants.test.ts create mode 100644 src/utils/accessGrants.ts diff --git a/src/App.tsx b/src/App.tsx index b05236da..66b76dff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ 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 { AccessGrantsPage } from '@/pages/access/grants' import { ContentPage } from '@/pages/content' import { TypographyPage } from '@/pages/example/TypographyPage' import { Home } from '@/pages/home' @@ -61,6 +62,7 @@ const App: React.FC = () => ( path="/ogcapi" element={} /> + } /> {/* TEMPORARY: example specimen pages */} } /> } /> diff --git a/src/config/navigation.ts b/src/config/navigation.ts index f9a9a124..0e73a3aa 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 Grants', + 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..e0ec7158 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,6 +1,7 @@ export * from './useListPageDataGridAnalytics' export * from './useAbortableList' export * from './useAccessCapabilities' +export * from './useAccessGrants' export * from './useSearchHistory' export * from './useAll' export * from './useAllNotes' diff --git a/src/hooks/useAccessGrants.ts b/src/hooks/useAccessGrants.ts new file mode 100644 index 00000000..e1ed3872 --- /dev/null +++ b/src/hooks/useAccessGrants.ts @@ -0,0 +1,65 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { axiosCall, fetcher } from '@/providers/ocotillo-data-provider' +import { + type CreateGrantInput, + type GrantFilters, + grantQueryParams, + type PermissionGrant, + zPermissionGrant, + zPermissionGrantList, +} 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. + */ +export const useAccessGrants = (filters: GrantFilters) => + useQuery({ + queryKey: ['access-grants', grantQueryParams(filters)], + queryFn: async () => { + const response = await fetcher('access/grant', { + params: grantQueryParams(filters), + }) + return zPermissionGrantList.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/pages/access/grants/GrantDialog.tsx b/src/pages/access/grants/GrantDialog.tsx new file mode 100644 index 00000000..ce1be59e --- /dev/null +++ b/src/pages/access/grants/GrantDialog.tsx @@ -0,0 +1,249 @@ +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + MenuItem, + Stack, + TextField, +} from '@mui/material' +import { useState } from 'react' +import { + ACCESS_DATA_TYPES, + CAPABILITIES, + type CreateGrantInput, + GRANT_SCOPE_TYPES, + type GrantFormErrors, + PRINCIPAL_TYPES, + scopeIdRequired, + toCreateGrantInput, + toDateInputValue, + validateGrantForm, +} from '@/utils/accessGrants' + +export type GrantFormState = { + principal_type: string + principal_id: string + capability: string + scope_type: string + scope_id: string + data_type: 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: '', + data_type: 'water level', + 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 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 needsScopeId = scopeIdRequired(form.scope_type) + + return ( + + Grant access + + + {submitError ? {submitError} : null} + + + set('principal_type')(event.target.value)} + > + {PRINCIPAL_TYPES.map((value) => ( + + {value} + + ))} + + set('principal_id')(event.target.value)} + /> + + + + set('capability')(event.target.value)} + > + {CAPABILITIES.map((value) => ( + + {value} + + ))} + + set('data_type')(event.target.value)} + > + {ACCESS_DATA_TYPES.map((value) => ( + + {value} + + ))} + + + + + set('scope_type')(event.target.value)} + > + {GRANT_SCOPE_TYPES.map((value) => ( + + {value} + + ))} + + set('scope_id')(event.target.value)} + /> + + + + set('starts_at')(event.target.value)} + /> + set('ends_at')(event.target.value)} + /> + + + set('reason')(event.target.value)} + /> + + + + + + + + ) +} diff --git a/src/pages/access/grants/index.tsx b/src/pages/access/grants/index.tsx new file mode 100644 index 00000000..edb00606 --- /dev/null +++ b/src/pages/access/grants/index.tsx @@ -0,0 +1,463 @@ +import { Add, FilterAltOff } from '@mui/icons-material' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + FormControlLabel, + MenuItem, + Paper, + Stack, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from '@mui/material' +import { useCan } from '@refinedev/core' +import { ErrorComponent } from '@refinedev/mui' +import { useState } from 'react' +import { ConfirmDialog } from '@/components/ConfirmDialog' +import { useAccessGrants, useCreateGrant, useRevokeGrant } from '@/hooks' +import { GrantDialog } from '@/pages/access/grants/GrantDialog' +import { + ACCESS_DATA_TYPES, + CAPABILITIES, + type CreateGrantInput, + describeScope, + GRANT_SCOPE_TYPES, + GRANT_STATUS_LABELS, + type GrantFilters, + type GrantStatus, + grantStatusOf, + isRevocable, + isUnfiltered, + type PermissionGrant, + sortGrants, +} from '@/utils/accessGrants' + +const STATUS_COLORS: Record< + GrantStatus, + 'success' | 'info' | 'default' | 'error' +> = { + active: 'success', + scheduled: 'info', + expired: 'default', + revoked: 'error', +} + +/** + * 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 { data: access, isLoading: isAccessLoading } = useCan({ + action: 'manage', + resource: 'ocotillo.access-grants', + }) + + // `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()) + + const grants = useAccessGrants(filters) + const createGrant = useCreateGrant() + const revokeGrant = useRevokeGrant() + + if (isAccessLoading) { + return ( + + + + ) + } + + if (!access?.can) return + + const setFilter = ( + key: TKey, + value: GrantFilters[TKey] + ) => setFilters((previous) => ({ ...previous, [key]: value || undefined })) + + const clearFilters = () => { + setPrincipal('') + setFilters({}) + } + + const handleCreate = (input: CreateGrantInput) => { + createGrant.mutate(input, { + onSuccess: (grant) => { + setIsDialogOpen(false) + // Grant to a principal the current filter excludes and the new row + // would land off-screen, so the console narrows to it instead. + setPrincipal(grant.principal_id) + setFilters({ + principalId: grant.principal_id, + includeRevoked: filters.includeRevoked, + }) + }, + }) + } + + const rows = grants.data ? sortGrants(grants.data, today) : [] + + return ( + + + + + + Access Grants + {grants.data ? ( + + ) : null} + + + 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. + + + + + + + + + setPrincipal(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + setFilter('principalId', principal.trim()) + } + }} + /> + setFilter('capability', value)} + /> + setFilter('dataType', value)} + /> + setFilter('scopeType', value)} + /> + + + + setFilters((previous) => ({ + ...previous, + includeRevoked: event.target.checked, + })) + } + /> + } + label="Include revoked" + /> + + + + + + {revokeGrant.isError ? ( + + Failed to revoke that grant. + {revokeGrant.error instanceof Error + ? ` ${revokeGrant.error.message}` + : null} + + ) : null} + + {grants.isLoading ? ( + + + + Loading grants... + + + ) : grants.isError ? ( + + Failed to load grants. + {grants.error instanceof Error ? ` ${grants.error.message}` : null} + + ) : rows.length === 0 ? ( + + ) : ( + + )} + + + setPendingRevoke(null)} + title="Revoke this grant?" + text={ + pendingRevoke + ? `${pendingRevoke.principal_id} will lose "${pendingRevoke.capability}" on ${pendingRevoke.data_type} (${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, + onChange, +}: { + label: string + value: string + options: readonly string[] + onChange: (value: string) => void +}) => ( + onChange(event.target.value)} + sx={{ minWidth: 150 }} + > + Any + {options.map((option) => ( + + {option} + + ))} + +) + +const EmptyState = ({ title, body }: { title: string; body: string }) => ( + + + {title} + + {body} + + + +) + +const GrantsTable = ({ + rows, + today, + onRevoke, + revokingId, +}: { + rows: PermissionGrant[] + today: Date + onRevoke: (grant: PermissionGrant) => void + revokingId: number | null +}) => ( + + + + + Principal + Capability + Data type + Scope + Dates + Granted by + Status + Actions + + + + {rows.map((grant) => { + const status = grantStatusOf(grant, today) + + return ( + + + + + {grant.principal_id} + + + {grant.principal_type} + + + + {grant.capability} + {grant.data_type} + {describeScope(grant)} + + + {grant.starts_at} → {grant.ends_at ?? 'no end'} + + + + + {grant.granted_by} + {grant.reason ? ( + + {grant.reason} + + ) : null} + + + + + + + + + {isRevocable(grant, today) ? ( + + ) : ( + + — + + )} + + + ) + })} + +
+
+) diff --git a/src/test/pages/accessGrants.test.tsx b/src/test/pages/accessGrants.test.tsx new file mode 100644 index 00000000..c4a47579 --- /dev/null +++ b/src/test/pages/accessGrants.test.tsx @@ -0,0 +1,258 @@ +// @vitest-environment jsdom +import { 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('@/hooks', () => ({ + useAccessGrants: (...args: unknown[]) => useAccessGrantsMock(...args), + 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, + }) + +const listResult = (rows: PermissionGrant[]) => ({ + data: rows, + 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('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/utils/accessGrants.test.ts b/src/test/utils/accessGrants.test.ts new file mode 100644 index 00000000..fcce9cff --- /dev/null +++ b/src/test/utils/accessGrants.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from 'vitest' +import { + describeScope, + grantQueryParams, + grantStatusOf, + isRevocable, + isUnfiltered, + type PermissionGrant, + scopeIdRequired, + sortGrants, + toCreateGrantInput, + toDateInputValue, + validateGrantForm, + zPermissionGrant, +} from '@/utils/accessGrants' + +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('validateGrantForm', () => { + const form = { + principal_id: 'ak-subject-1', + 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') + }) +}) + +describe('toCreateGrantInput', () => { + const form = { + principal_type: 'user', + principal_id: ' ak-subject-1 ', + capability: 'enter', + scope_type: 'global', + scope_id: '99', + data_type: 'water chemistry', + 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', + starts_at: '2026-06-01', + ends_at: null, + reason: 'seasonal fieldwork', + }) + }) + + 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') + }) +}) + +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 }) + }) + + it('maps each filter to its query name', () => { + expect( + grantQueryParams({ + principalId: 'ak-subject-1', + capability: 'read', + dataType: 'water level', + scopeType: 'thing', + includeRevoked: true, + }) + ).toEqual({ + principal_id: 'ak-subject-1', + capability: 'read', + data_type: 'water level', + scope_type: 'thing', + include_revoked: true, + }) + }) + + it('omits an empty filter rather than sending an empty string', () => { + expect(grantQueryParams({ principalId: ' ', capability: '' })).toEqual({ + include_revoked: false, + }) + }) + + 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/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/accessGrants.ts b/src/utils/accessGrants.ts new file mode 100644 index 00000000..27203327 --- /dev/null +++ b/src/utils/accessGrants.ts @@ -0,0 +1,258 @@ +import { z } from 'zod' + +/** + * 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'] as const +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 + +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 const zPermissionGrant = z.looseObject({ + id: z.number(), + principal_type: zGrantEnum, + principal_id: z.string(), + capability: zGrantEnum, + scope_type: zGrantEnum, + scope_id: z.number().nullable(), + data_type: zGrantEnum, + 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) + +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 + 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 + dataType?: string + scopeType?: string + includeRevoked?: boolean +} + +export type GrantQueryParams = { + principal_id?: string + capability?: string + data_type?: string + scope_type?: string + include_revoked: boolean +} + +/** + * 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, + } + + const principalId = filters.principalId?.trim() + if (principalId) params.principal_id = principalId + if (filters.capability) params.capability = filters.capability + if (filters.dataType) params.data_type = filters.dataType + if (filters.scopeType) params.scope_type = filters.scopeType + + return params +} + +/** True when the console is showing the unfiltered admin-wide audit view. */ +export const isUnfiltered = (filters: GrantFilters): boolean => + !filters.principalId?.trim() && + !filters.capability && + !filters.dataType && + !filters.scopeType + +export type GrantStatus = 'active' | 'scheduled' | 'expired' | 'revoked' + +const dayOf = (value: string): string => value.slice(0, 10) + +export const grantStatusOf = ( + grant: PermissionGrant, + today: Date +): GrantStatus => { + if (grant.revoked_at) return 'revoked' + + const day = dayOf(today.toISOString()) + if (dayOf(grant.starts_at) > day) return 'scheduled' + if (grant.ends_at && dayOf(grant.ends_at) < day) return 'expired' + + return 'active' +} + +export const GRANT_STATUS_LABELS: Record = { + active: 'Active', + scheduled: 'Scheduled', + expired: 'Expired', + revoked: 'Revoked', +} + +/** Only an active or scheduled grant is worth revoking. */ +export const isRevocable = (grant: PermissionGrant, today: Date): boolean => { + const status = grantStatusOf(grant, today) + return status === 'active' || status === 'scheduled' +} + +/** + * 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' + +export const describeScope = (grant: PermissionGrant): string => { + if (!scopeIdRequired(grant.scope_type)) return grant.scope_type + return `${grant.scope_type} ${grant.scope_id ?? '?'}` +} + +export type GrantFormErrors = Partial< + Record<'principal_id' | 'scope_id' | 'ends_at', string> +> + +/** + * 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 + scope_type: string + scope_id: string + starts_at: string + ends_at: string +}): GrantFormErrors => { + const errors: GrantFormErrors = {} + + if (!form.principal_id.trim()) { + errors.principal_id = 'A principal is required.' + } + + if (scopeIdRequired(form.scope_type)) { + if (!form.scope_id.trim()) { + errors.scope_id = `A ${form.scope_type} id is required for a ${form.scope_type}-scoped grant.` + } else if (!/^\d+$/.test(form.scope_id.trim())) { + errors.scope_id = 'Scope id must be a whole number.' + } + } + + if (form.ends_at && form.starts_at && form.ends_at < form.starts_at) { + errors.ends_at = 'End date cannot fall before the start date.' + } + + return errors +} + +export const toCreateGrantInput = (form: { + principal_type: string + principal_id: string + capability: string + scope_type: string + scope_id: string + data_type: string + starts_at: string + ends_at: string + reason: string +}): CreateGrantInput => ({ + principal_type: form.principal_type, + principal_id: form.principal_id.trim(), + capability: form.capability, + scope_type: form.scope_type, + scope_id: scopeIdRequired(form.scope_type) ? Number(form.scope_id) : null, + data_type: form.data_type, + starts_at: form.starts_at, + ends_at: form.ends_at || null, + reason: form.reason.trim() || null, +}) + +const STATUS_ORDER: Record = { + active: 0, + scheduled: 1, + expired: 2, + revoked: 3, +} + +/** + * Live grants first, then the ones that have not started, then history. Within + * a status the newest start date leads: an admin reading this page is asking + * "what is in force now", not "what happened first". + */ +export const sortGrants = ( + grants: PermissionGrant[], + today: Date +): PermissionGrant[] => + [...grants].sort((a, b) => { + const byStatus = + STATUS_ORDER[grantStatusOf(a, today)] - + STATUS_ORDER[grantStatusOf(b, today)] + if (byStatus !== 0) return byStatus + + return ( + b.starts_at.localeCompare(a.starts_at) || + // The list now spans principals, so equal-dated rows group by who holds + // them rather than landing in insertion order. + a.principal_id.localeCompare(b.principal_id) || + b.id - a.id + ) + }) + +/** `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}` +} From b89474747ef0c9325ec9d691ac57730708d43312 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 27 Aug 2026 17:59:19 -0700 Subject: [PATCH 2/7] feat(access): add destinations and consent to the access console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the console from one page to three tabs — Grants, Destinations, Consent — covering the rest of the ADR5 `/access` surface. They 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. Each tab keeps its own route so a link into one still works, and the admin gate is checked once in the shared shell rather than per tab. Destinations list, register, and expand to show what each one may currently read. That view is computed server-side from consent rows, and an empty list means default deny — either nobody has consented or the destination is retired. The API does not distinguish those, so neither does this; the retired case is called out only because the row already knows it. Consent stays thing-scoped. Unlike grants, `GET /access/consent` still requires `thing_id` — there is no consent-wide audit view — so the tab says so in its empty state rather than looking broken. Consent rows carry `destination_id` rather than a slug, so the tab resolves names through the destination list it already loads, and falls back to the raw id when a destination is missing. A blank consenting contact is sent as null rather than as a missing field: the API allows null because the Bureau owning the well is an institutional decision, and inventing a consenting contact would be a lie. The table says "Bureau-owned" instead of leaving a gap. Grants and consent share a date-window lifecycle — both carry `starts_at`, a nullable `ends_at`, and a nullable `revoked_at`, and neither stores a status — so that derivation moves to `accessLifecycle.ts` and both tabs read from it. This keeps the two from drifting on what "expired" means. Withdrawing consent asks for confirmation and says what withdrawal does not do: copies already harvested are not recalled. Co-Authored-By: Claude Opus 5 --- src/App.tsx | 7 + src/config/navigation.ts | 2 +- src/hooks/index.ts | 16 +- src/hooks/useAccessConsent.ts | 66 +++ src/hooks/useAccessDestinations.ts | 65 +++ src/pages/access/AccessConsole.tsx | 108 +++++ src/pages/access/consent/index.tsx | 487 +++++++++++++++++++++ src/pages/access/destinations/index.tsx | 418 ++++++++++++++++++ src/pages/access/grants/GrantDialog.tsx | 2 +- src/pages/access/grants/index.tsx | 316 ++++++------- src/test/pages/accessConsent.test.tsx | 250 +++++++++++ src/test/pages/accessDestinations.test.tsx | 191 ++++++++ src/test/pages/accessGrants.test.tsx | 17 + src/test/utils/accessConsent.test.ts | 124 ++++++ src/test/utils/accessDestinations.test.ts | 110 +++++ src/test/utils/accessGrants.test.ts | 3 +- src/test/utils/accessLifecycle.test.ts | 113 +++++ src/utils/accessConsent.ts | 105 +++++ src/utils/accessDestinations.ts | 118 +++++ src/utils/accessGrants.ts | 75 +--- src/utils/accessLifecycle.ts | 93 ++++ 21 files changed, 2442 insertions(+), 244 deletions(-) create mode 100644 src/hooks/useAccessConsent.ts create mode 100644 src/hooks/useAccessDestinations.ts create mode 100644 src/pages/access/AccessConsole.tsx create mode 100644 src/pages/access/consent/index.tsx create mode 100644 src/pages/access/destinations/index.tsx create mode 100644 src/test/pages/accessConsent.test.tsx create mode 100644 src/test/pages/accessDestinations.test.tsx create mode 100644 src/test/utils/accessConsent.test.ts create mode 100644 src/test/utils/accessDestinations.test.ts create mode 100644 src/test/utils/accessLifecycle.test.ts create mode 100644 src/utils/accessConsent.ts create mode 100644 src/utils/accessDestinations.ts create mode 100644 src/utils/accessLifecycle.ts diff --git a/src/App.tsx b/src/App.tsx index 66b76dff..79cdcaf7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,8 @@ 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' @@ -63,6 +65,11 @@ const App: React.FC = () => ( element={} /> } /> + } + /> + } /> {/* TEMPORARY: example specimen pages */} } /> } /> diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 0e73a3aa..ad528cbd 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -151,7 +151,7 @@ export const RESOURCE_NAV: NavItem[] = [ roles: adminOnly, }, { - label: 'Access Grants', + label: 'Access Control', href: '/access/grants', icon: ShieldCheck, resource: 'ocotillo.access-grants', diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e0ec7158..7bd2e334 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,26 +1,28 @@ -export * from './useListPageDataGridAnalytics' export * from './useAbortableList' export * from './useAccessCapabilities' +export * from './useAccessConsent' +export * from './useAccessDestinations' export * from './useAccessGrants' -export * from './useSearchHistory' export * from './useAll' export * from './useAllNotes' +export * from './useContainerMinWidth' export * from './useDebounce' export * from './useElevation' export * from './useGisArtifacts' 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 './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/pages/access/AccessConsole.tsx b/src/pages/access/AccessConsole.tsx new file mode 100644 index 00000000..1116e04c --- /dev/null +++ b/src/pages/access/AccessConsole.tsx @@ -0,0 +1,108 @@ +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 + + 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..eece625b --- /dev/null +++ b/src/pages/access/consent/index.tsx @@ -0,0 +1,487 @@ +import { Add } from '@mui/icons-material' +import { + Alert, + 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, +} 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' + +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 ( + + + + setThingInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') setThingId(thingInput.trim()) + }} + /> + setIncludeRevoked(event.target.checked)} + /> + } + label="Include withdrawn" + /> + + + + + {destinations.data?.length === 0 ? ( + + No destination is registered yet. Consent names the destination it + publishes to, so register one first. + + ) : null} + + {revokeConsent.isError ? ( + + Failed to withdraw that consent. + {revokeConsent.error instanceof Error + ? ` ${revokeConsent.error.message}` + : null} + + ) : null} + + {!thingId ? ( + + ) : consent.isLoading ? ( + + + + Loading consent... + + + ) : consent.isError ? ( + + Failed to load consent for thing {thingId}. + {consent.error instanceof Error ? ` ${consent.error.message}` : null} + + ) : rows.length === 0 ? ( + + ) : ( + + )} + + setPendingRevoke(null)} + title="Withdraw this consent?" + text={ + pendingRevoke + ? `${ + destinationsById.get(pendingRevoke.destination_id)?.name ?? + `Destination ${pendingRevoke.destination_id}` + } will stop being offered "${pendingRevoke.data_type}" for thing ${pendingRevoke.thing_id}. Copies already harvested are not recalled.` + : '' + } + PrimaryActionBtnMsg="Withdraw" + onPrimaryAction={() => { + if (pendingRevoke) revokeConsent.mutate(pendingRevoke.id) + setPendingRevoke(null) + }} + /> + + {isDialogOpen ? ( + setIsDialogOpen(false)} + onSubmit={(input) => + createConsent.mutate(input, { + onSuccess: (created) => { + setIsDialogOpen(false) + // Record consent for a thing you were not looking at and the + // tab follows, so the new row is visible. + setThingInput(String(created.thing_id)) + setThingId(String(created.thing_id)) + }, + }) + } + isSubmitting={createConsent.isPending} + submitError={ + createConsent.isError + ? createConsent.error instanceof Error + ? createConsent.error.message + : 'The consent was rejected.' + : undefined + } + /> + ) : null} + + ) +} + +const EmptyState = ({ title, body }: { title: string; body: string }) => ( + + + {title} + + {body} + + + +) + +const ConsentTable = ({ + rows, + today, + destinationsById, + onRevoke, + revokingId, +}: { + rows: PublicationConsent[] + today: Date + destinationsById: Map + onRevoke: (consent: PublicationConsent) => void + revokingId: number | null +}) => ( + + + + + Destination + Data type + Dates + Consented by + Recorded by + Status + Actions + + + + {rows.map((consent) => { + const status = accessStatusOf(consent, today) + const destination = destinationsById.get(consent.destination_id) + + return ( + + + + {destination?.name ?? `Destination ${consent.destination_id}`} + + + {consent.data_type} + + + {consent.starts_at} → {consent.ends_at ?? 'no end'} + + + + + + {describeConsentingContact(consent)} + + {consent.notes ? ( + + {consent.notes} + + ) : null} + + + {consent.recorded_by} + + + + + {isRevocable(consent, today) ? ( + + ) : ( + + — + + )} + + + ) + })} + +
+
+) + +const ConsentDialog = ({ + destinations, + defaultThingId, + today, + onClose, + onSubmit, + isSubmitting, + submitError, +}: { + destinations: Destination[] + defaultThingId: string + today: Date + onClose: () => void + onSubmit: (input: CreateConsentInput) => void + isSubmitting: boolean + submitError?: string +}) => { + const [form, setForm] = useState({ + thing_id: defaultThingId, + destination_slug: destinations[0]?.slug ?? '', + data_type: 'water level', + contact_id: '', + starts_at: toDateInputValue(today), + ends_at: '', + notes: '', + }) + const [errors, setErrors] = useState({}) + + const set = (field: keyof typeof form) => (value: string) => + setForm((previous) => ({ ...previous, [field]: value })) + + const handleSubmit = () => { + const found = validateConsentForm(form) + setErrors(found) + if (Object.keys(found).length > 0) return + + onSubmit(toCreateConsentInput(form)) + } + + return ( + + Record publication consent + + + {submitError ? {submitError} : null} + + + set('thing_id')(event.target.value)} + /> + set('destination_slug')(event.target.value)} + > + {destinations.map((destination) => ( + + {destinationLabel(destination)} + + ))} + + + + + set('data_type')(event.target.value)} + > + {ACCESS_DATA_TYPES.map((dataType) => ( + + {dataType} + + ))} + + set('contact_id')(event.target.value)} + /> + + + + set('starts_at')(event.target.value)} + /> + set('ends_at')(event.target.value)} + /> + + + set('notes')(event.target.value)} + /> + + + + + + + + ) +} diff --git a/src/pages/access/destinations/index.tsx b/src/pages/access/destinations/index.tsx new file mode 100644 index 00000000..05b981bd --- /dev/null +++ b/src/pages/access/destinations/index.tsx @@ -0,0 +1,418 @@ +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, + usePublishedThings, +} 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 [expandedSlug, setExpandedSlug] = useState(null) + const destinations = useAccessDestinations() + const createDestination = useCreateDestination() + + const rows = destinations.data ? sortDestinations(destinations.data) : [] + + return ( + + + + + + + {destinations.isLoading ? ( + + + + Loading destinations... + + + ) : destinations.isError ? ( + + Failed to load destinations. + {destinations.error instanceof Error + ? ` ${destinations.error.message}` + : null} + + ) : rows.length === 0 ? ( + + + + No destinations registered + + + Register one before recording consent — consent names the + destination it publishes to. + + + + ) : ( + + setExpandedSlug((previous) => (previous === slug ? null : slug)) + } + /> + )} + + {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, + expandedSlug, + onToggle, +}: { + rows: Destination[] + expandedSlug: string | null + onToggle: (slug: string) => void +}) => ( + + + + + Destination + Kind + Description + Status + Published data + + + + {rows.map((destination) => ( + onToggle(destination.slug)} + /> + ))} + +
+
+) + +const Row = ({ + destination, + isExpanded, + onToggle, +}: { + destination: Destination + isExpanded: boolean + onToggle: () => void +}) => ( + <> + + + + + {destination.name} + + + {destination.slug} + + + + {destination.destination_kind} + + {destination.description ? ( + + {destination.description} + + ) : ( + + — + + )} + + + + + + + + + {isExpanded ? ( + + + + + + ) : null} + +) + +/** + * What this destination may read, computed server-side from consent rows. + * + * An empty list means default deny — either nobody has consented or the + * destination is retired — and the API does not distinguish those, so neither + * does this. The retired case is called out only because the row already + * knows it. + */ +const PublishedThings = ({ + slug, + active, +}: { + slug: string + active: boolean +}) => { + const published = usePublishedThings(slug) + + if (published.isLoading) { + return ( + + + + Loading what {slug} may read... + + + ) + } + + if (published.isError) { + return ( + + Failed to load what {slug} may read. + + ) + } + + const rows = published.data ?? [] + + if (rows.length === 0) { + return ( + + {active + ? 'Nothing is published here yet. Consent is what opens this up.' + : 'This destination is retired, so it may read nothing.'} + + ) + } + + return ( + + + {rows.length} thing{rows.length === 1 ? '' : 's'} published to {slug} + + + {rows.slice(0, 25).map((thing) => ( + + + thing {thing.thing_id} + + {thing.data_types.map((dataType) => ( + + ))} + + ))} + + {rows.length > 25 ? ( + + Showing the first 25 of {rows.length}. + + ) : null} + + ) +} + +const DestinationDialog = ({ + onClose, + onSubmit, + isSubmitting, + submitError, +}: { + onClose: () => void + onSubmit: (input: CreateDestinationInput) => void + isSubmitting: boolean + submitError?: string +}) => { + const [form, setForm] = useState({ + slug: '', + name: '', + destination_kind: 'public web', + description: '', + }) + const [errors, setErrors] = useState({}) + + const set = (field: keyof typeof form) => (value: string) => + setForm((previous) => ({ ...previous, [field]: value })) + + const handleSubmit = () => { + const found = validateDestinationForm(form) + setErrors(found) + if (Object.keys(found).length > 0) return + + onSubmit(toCreateDestinationInput(form)) + } + + return ( + + Register a destination + + + {submitError ? {submitError} : null} + + set('slug')(event.target.value)} + /> + set('destination_kind')(event.target.value)} + > + {DESTINATION_KINDS.map((kind) => ( + + {kind} + + ))} + + + set('name')(event.target.value)} + /> + set('description')(event.target.value)} + /> + + + + + + + + ) +} diff --git a/src/pages/access/grants/GrantDialog.tsx b/src/pages/access/grants/GrantDialog.tsx index ce1be59e..85e73b79 100644 --- a/src/pages/access/grants/GrantDialog.tsx +++ b/src/pages/access/grants/GrantDialog.tsx @@ -19,9 +19,9 @@ import { PRINCIPAL_TYPES, scopeIdRequired, toCreateGrantInput, - toDateInputValue, validateGrantForm, } from '@/utils/accessGrants' +import { toDateInputValue } from '@/utils/accessLifecycle' export type GrantFormState = { principal_type: string diff --git a/src/pages/access/grants/index.tsx b/src/pages/access/grants/index.tsx index edb00606..3339881f 100644 --- a/src/pages/access/grants/index.tsx +++ b/src/pages/access/grants/index.tsx @@ -5,7 +5,6 @@ import { Button, Chip, CircularProgress, - Container, FormControlLabel, MenuItem, Paper, @@ -21,11 +20,10 @@ import { Tooltip, Typography, } from '@mui/material' -import { useCan } from '@refinedev/core' -import { ErrorComponent } from '@refinedev/mui' import { useState } from 'react' import { ConfirmDialog } from '@/components/ConfirmDialog' import { useAccessGrants, useCreateGrant, useRevokeGrant } from '@/hooks' +import { AccessConsole } from '@/pages/access/AccessConsole' import { GrantDialog } from '@/pages/access/grants/GrantDialog' import { ACCESS_DATA_TYPES, @@ -33,25 +31,18 @@ import { type CreateGrantInput, describeScope, GRANT_SCOPE_TYPES, - GRANT_STATUS_LABELS, type GrantFilters, type GrantStatus, grantStatusOf, - isRevocable, isUnfiltered, type PermissionGrant, sortGrants, } from '@/utils/accessGrants' - -const STATUS_COLORS: Record< - GrantStatus, - 'success' | 'info' | 'default' | 'error' -> = { - active: 'success', - scheduled: 'info', - expired: 'default', - revoked: 'error', -} +import { + ACCESS_STATUS_COLORS, + ACCESS_STATUS_LABELS, + isRevocable, +} from '@/utils/accessLifecycle' /** * Operations console for ADR5 permission grants. @@ -62,12 +53,13 @@ const STATUS_COLORS: Record< * 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 { data: access, isLoading: isAccessLoading } = useCan({ - action: 'manage', - resource: 'ocotillo.access-grants', - }) +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, @@ -86,16 +78,6 @@ export const AccessGrantsPage = () => { const createGrant = useCreateGrant() const revokeGrant = useRevokeGrant() - if (isAccessLoading) { - return ( - - - - ) - } - - if (!access?.can) return - const setFilter = ( key: TKey, value: GrantFilters[TKey] @@ -124,155 +106,141 @@ export const AccessGrantsPage = () => { const rows = grants.data ? sortGrants(grants.data, today) : [] return ( - - - + + + + + + + + + setPrincipal(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + setFilter('principalId', principal.trim()) + } + }} + /> + setFilter('capability', value)} + /> + setFilter('dataType', value)} + /> + setFilter('scopeType', value)} + /> - - - - - - - setPrincipal(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - setFilter('principalId', principal.trim()) + + setFilters((previous) => ({ + ...previous, + includeRevoked: event.target.checked, + })) } - }} - /> - setFilter('capability', value)} - /> - setFilter('dataType', value)} - /> - setFilter('scopeType', value)} - /> - - + } + label="Include revoked" + /> + - + Clear filters + - + + - {revokeGrant.isError ? ( - - Failed to revoke that grant. - {revokeGrant.error instanceof Error - ? ` ${revokeGrant.error.message}` - : null} - - ) : null} + {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 ? ( - - ) : ( - - )} - + {grants.isLoading ? ( + + + + Loading grants... + + + ) : grants.isError ? ( + + Failed to load grants. + {grants.error instanceof Error ? ` ${grants.error.message}` : null} + + ) : rows.length === 0 ? ( + + ) : ( + + )} { } /> ) : null} - + ) } @@ -432,8 +400,8 @@ const GrantsTable = ({ > diff --git a/src/test/pages/accessConsent.test.tsx b/src/test/pages/accessConsent.test.tsx new file mode 100644 index 00000000..fc94ac73 --- /dev/null +++ b/src/test/pages/accessConsent.test.tsx @@ -0,0 +1,250 @@ +// @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), + 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 id'), '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 id'), '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 with a non-numeric thing id', 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 id'), 'abc') + await user.click(within(dialog).getByRole('button', { name: 'Record' })) + + expect(createMutateMock).not.toHaveBeenCalled() + expect(within(dialog).getByText(/whole number/i)).toBeInTheDocument() + }) +}) diff --git a/src/test/pages/accessDestinations.test.tsx b/src/test/pages/accessDestinations.test.tsx new file mode 100644 index 00000000..7499b90c --- /dev/null +++ b/src/test/pages/accessDestinations.test.tsx @@ -0,0 +1,191 @@ +// @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, + usePublishedThingsMock, + createMutateMock, +} = vi.hoisted(() => ({ + useCanMock: vi.fn(), + useAccessDestinationsMock: vi.fn(), + usePublishedThingsMock: 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), + usePublishedThings: (...args: unknown[]) => usePublishedThingsMock(...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()])) + usePublishedThingsMock.mockReset().mockReturnValue(ok([])) + 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('points at consent when nothing is published yet', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Show' })) + + expect( + screen.getByText(/Consent is what opens this up/) + ).toBeInTheDocument() + }) + + it('explains an empty list for a retired destination differently', async () => { + const user = userEvent.setup() + useAccessDestinationsMock.mockReturnValue( + ok([destination({ active: false })]) + ) + render() + + await user.click(screen.getByRole('button', { name: 'Show' })) + + expect( + screen.getByText(/retired, so it may read nothing/) + ).toBeInTheDocument() + }) + + it('shows what a destination may read', async () => { + const user = userEvent.setup() + usePublishedThingsMock.mockReturnValue( + ok([ + { + thing_id: 42, + data_types: ['water level', 'site metadata'], + properties: {}, + location: {}, + }, + ]) + ) + render() + + await user.click(screen.getByRole('button', { name: 'Show' })) + + expect(screen.getByText('thing 42')).toBeInTheDocument() + expect(screen.getByText('water level')).toBeInTheDocument() + expect(screen.getByText(/1 thing published to ngwmn/)).toBeInTheDocument() + }) + + 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 index c4a47579..95e8aa67 100644 --- a/src/test/pages/accessGrants.test.tsx +++ b/src/test/pages/accessGrants.test.tsx @@ -21,6 +21,23 @@ 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), useCreateGrant: () => ({ 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 index fcce9cff..ec7d6ffa 100644 --- a/src/test/utils/accessGrants.test.ts +++ b/src/test/utils/accessGrants.test.ts @@ -3,16 +3,15 @@ import { describeScope, grantQueryParams, grantStatusOf, - isRevocable, isUnfiltered, type PermissionGrant, scopeIdRequired, sortGrants, toCreateGrantInput, - toDateInputValue, validateGrantForm, zPermissionGrant, } from '@/utils/accessGrants' +import { isRevocable, toDateInputValue } from '@/utils/accessLifecycle' const grant = (overrides: Partial = {}): PermissionGrant => zPermissionGrant.parse({ 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/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/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 index 27203327..70a6b8fb 100644 --- a/src/utils/accessGrants.ts +++ b/src/utils/accessGrants.ts @@ -1,4 +1,10 @@ import { z } from 'zod' +import { + type AccessStatus, + accessStatusOf, + compareByLifecycle, + validateDateWindow, +} from '@/utils/accessLifecycle' /** * Client model for ADR5 permission grants (`/access/*` on the Ocotillo API). @@ -116,35 +122,12 @@ export const isUnfiltered = (filters: GrantFilters): boolean => !filters.dataType && !filters.scopeType -export type GrantStatus = 'active' | 'scheduled' | 'expired' | 'revoked' - -const dayOf = (value: string): string => value.slice(0, 10) +export type GrantStatus = AccessStatus export const grantStatusOf = ( grant: PermissionGrant, today: Date -): GrantStatus => { - if (grant.revoked_at) return 'revoked' - - const day = dayOf(today.toISOString()) - if (dayOf(grant.starts_at) > day) return 'scheduled' - if (grant.ends_at && dayOf(grant.ends_at) < day) return 'expired' - - return 'active' -} - -export const GRANT_STATUS_LABELS: Record = { - active: 'Active', - scheduled: 'Scheduled', - expired: 'Expired', - revoked: 'Revoked', -} - -/** Only an active or scheduled grant is worth revoking. */ -export const isRevocable = (grant: PermissionGrant, today: Date): boolean => { - const status = grantStatusOf(grant, today) - return status === 'active' || status === 'scheduled' -} +): GrantStatus => accessStatusOf(grant, today) /** * A global grant covers everything and names no scope; a group- or @@ -190,11 +173,7 @@ export const validateGrantForm = (form: { } } - if (form.ends_at && form.starts_at && form.ends_at < form.starts_at) { - errors.ends_at = 'End date cannot fall before the start date.' - } - - return errors + return { ...errors, ...validateDateWindow(form) } } export const toCreateGrantInput = (form: { @@ -219,40 +198,18 @@ export const toCreateGrantInput = (form: { reason: form.reason.trim() || null, }) -const STATUS_ORDER: Record = { - active: 0, - scheduled: 1, - expired: 2, - revoked: 3, -} - /** - * Live grants first, then the ones that have not started, then history. Within - * a status the newest start date leads: an admin reading this page is asking - * "what is in force now", not "what happened first". + * 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) => { - const byStatus = - STATUS_ORDER[grantStatusOf(a, today)] - - STATUS_ORDER[grantStatusOf(b, today)] - if (byStatus !== 0) return byStatus - - return ( - b.starts_at.localeCompare(a.starts_at) || - // The list now spans principals, so equal-dated rows group by who holds - // them rather than landing in insertion order. + [...grants].sort( + (a, b) => + compareByLifecycle(a, b, today) || a.principal_id.localeCompare(b.principal_id) || b.id - a.id - ) - }) - -/** `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}` -} + ) 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.' } + : {} From cf9fe97518440713dc06775c850164cf706e3e21 Mon Sep 17 00:00:00 2001 From: jakeross Date: Fri, 28 Aug 2026 09:36:55 -0700 Subject: [PATCH 3/7] feat(access): let a grant open a nav item the role does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the UI half of ADR5 UI-surface grants. The access control provider now consults grants for a screen the role policy denied, so an admin can open one page for one person without inventing a role. Widen-only, in that order for a reason. The role policy is asked first and its yes is returned immediately: grants can only raise the floor, never lower it. That means revoking a grant returns someone to exactly what their role gives them, and an access-control outage cannot lock anyone out — a failed lookup denies, which leaves the role's answer standing. Two things a surface grant deliberately cannot do. It cannot confer writing: the grant is `read`, so only `list` and `show` consult it, and letting it widen create/edit/delete would turn "can see this nav item" into edit rights. And it cannot reveal a WIP surface, which is hidden because it is unfinished rather than because of who is asking. `can()` runs for every nav item on every render, so answers are cached per surface for the session and concurrent callers share one request. Only surfaces the role already denied are ever asked about, which bounds this to one request per denied screen. The cache is cleared on logout: it holds answers about one caller, and the next person to sign in on this tab must not inherit them. Consumes `GET /access/decision?capability=read&ui_surface=...`, which is viewer-level and answers about the caller themselves — unlike `/access/grant`, which is admin-only and could not answer this question for a non-admin. Co-Authored-By: Claude Opus 5 --- src/providers/access-control-provider.ts | 28 +++-- src/providers/authentik-provider.ts | 6 + .../accessControlProvider.uiSurface.test.ts | 102 +++++++++++++++++ src/test/utils/uiSurfaceGrants.test.ts | 106 ++++++++++++++++++ src/utils/uiSurfaceGrants.ts | 77 +++++++++++++ 5 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 src/test/providers/accessControlProvider.uiSurface.test.ts create mode 100644 src/test/utils/uiSurfaceGrants.test.ts create mode 100644 src/utils/uiSurfaceGrants.ts 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/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/utils/uiSurfaceGrants.test.ts b/src/test/utils/uiSurfaceGrants.test.ts new file mode 100644 index 00000000..b8ea62af --- /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: 'read', 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/uiSurfaceGrants.ts b/src/utils/uiSurfaceGrants.ts new file mode 100644 index 00000000..a49f5a9e --- /dev/null +++ b/src/utils/uiSurfaceGrants.ts @@ -0,0 +1,77 @@ +import { fetcher } from '@/providers/ocotillo-data-provider' + +/** + * 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` 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', { + params: { capability: 'read', 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) From f79ff6166e6db8f8f3e1d505fc58a12e4bf6d48c Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 29 Aug 2026 12:36:55 -0700 Subject: [PATCH 4/7] feat(access): grant a UI surface from the console, and stop narrowing on create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API has accepted `ui_surface` grants since the ui_surface column landed, but the console could not write one: the dialog only ever sent a data type. An admin who wanted to open a screen for someone had to POST /access/grant by hand. The dialog now asks what the grant covers — a data type or a screen — and shows the matching field. Choosing a screen forces the scope to global, which is the only scope the API accepts for one, and says why on the field. Exactly one subject reaches the API, since sending both is a 422: toCreateGrantInput sends `data_type: null` for a surface grant and `ui_surface: null` otherwise. scopeTypeFor() holds the global rule so the form and the payload cannot disagree about it. zPermissionGrant had data_type as a required string, so a surface row — which carries data_type null — would have failed to parse and taken the whole grant list down with it. It is nullable now, with ui_surface alongside, and the table's Data type column becomes Covers: the data type, or the screen. Creating a grant no longer rewrites the filters. It used to narrow the view to the new grant's principal, which dropped every other row on screen and read as "the table only shows the record I just added". The mutation already invalidates every grant list, so the refetch was never what the narrowing was for. A grant written outside the current filters now says so, with a button to narrow to it — the old behaviour, as a choice rather than a surprise. Co-Authored-By: Claude Opus 5 --- src/pages/access/grants/GrantDialog.tsx | 73 +++++++++++--- src/pages/access/grants/index.tsx | 48 ++++++++-- src/test/pages/accessGrants.test.tsx | 99 ++++++++++++++++++- src/test/utils/accessGrants.test.ts | 95 +++++++++++++++++++ src/utils/accessGrants.ts | 121 ++++++++++++++++++++---- 5 files changed, 399 insertions(+), 37 deletions(-) diff --git a/src/pages/access/grants/GrantDialog.tsx b/src/pages/access/grants/GrantDialog.tsx index 85e73b79..9650085e 100644 --- a/src/pages/access/grants/GrantDialog.tsx +++ b/src/pages/access/grants/GrantDialog.tsx @@ -18,7 +18,9 @@ import { type GrantFormErrors, PRINCIPAL_TYPES, scopeIdRequired, + scopeTypeFor, toCreateGrantInput, + UI_SURFACES, validateGrantForm, } from '@/utils/accessGrants' import { toDateInputValue } from '@/utils/accessLifecycle' @@ -29,7 +31,9 @@ export type GrantFormState = { capability: string scope_type: string scope_id: string + subject: string data_type: string + ui_surface: string starts_at: string ends_at: string reason: string @@ -44,7 +48,9 @@ export const emptyGrantForm = ( capability: 'read', scope_type: 'global', scope_id: '', + subject: 'data_type', data_type: 'water level', + ui_surface: '', starts_at: toDateInputValue(today), ends_at: '', reason: '', @@ -89,7 +95,10 @@ export const GrantDialog = ({ onSubmit(toCreateGrantInput(form)) } - const needsScopeId = scopeIdRequired(form.scope_type) + const isSurface = form.subject === 'ui_surface' + // A screen grant is global whatever the scope select last held. + const scopeType = scopeTypeFor(form.subject, form.scope_type) + const needsScopeId = scopeIdRequired(scopeType) return ( set('data_type')(event.target.value)} + label="Grant covers" + value={form.subject} + onChange={(event) => set('subject')(event.target.value)} > - {ACCESS_DATA_TYPES.map((value) => ( - - {value} - - ))} + a data type + a screen + + {isSurface ? ( + set('ui_surface')(event.target.value)} + > + {UI_SURFACES.map((value) => ( + + {value} + + ))} + + ) : ( + set('data_type')(event.target.value)} + > + {ACCESS_DATA_TYPES.map((value) => ( + + {value} + + ))} + + )} + + set('scope_type')(event.target.value)} > {GRANT_SCOPE_TYPES.map((value) => ( @@ -185,7 +236,7 @@ export const GrantDialog = ({ { 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() @@ -88,17 +94,23 @@ const GrantsTab = () => { 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) - // Grant to a principal the current filter excludes and the new row - // would land off-screen, so the console narrows to it instead. - setPrincipal(grant.principal_id) - setFilters({ - principalId: grant.principal_id, - includeRevoked: filters.includeRevoked, - }) + // 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) }, }) } @@ -127,6 +139,24 @@ const GrantsTab = () => { + {grantedOutOfView ? ( + setGrantedOutOfView(null)} + action={ + + } + > + Granted to {grantedOutOfView.principal_id}. The current filters do not + show it. + + ) : null} + @@ -344,7 +374,7 @@ const GrantsTable = ({ Principal Capability - Data type + Covers Scope Dates Granted by @@ -373,7 +403,7 @@ const GrantsTable = ({ {grant.capability} - {grant.data_type} + {describeSubject(grant)} {describeScope(grant)} diff --git a/src/test/pages/accessGrants.test.tsx b/src/test/pages/accessGrants.test.tsx index 95e8aa67..a123bac0 100644 --- a/src/test/pages/accessGrants.test.tsx +++ b/src/test/pages/accessGrants.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { render, screen, waitFor, within } from '@testing-library/react' +import { act, 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' @@ -255,6 +255,103 @@ describe('AccessGrantsPage', () => { ) }) + 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', + 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('blocks a scoped grant that names no scope id', async () => { const user = userEvent.setup() render() diff --git a/src/test/utils/accessGrants.test.ts b/src/test/utils/accessGrants.test.ts index ec7d6ffa..a48a0bac 100644 --- a/src/test/utils/accessGrants.test.ts +++ b/src/test/utils/accessGrants.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from 'vitest' import { describeScope, + describeSubject, grantQueryParams, grantStatusOf, + isUiSurfaceGrant, isUnfiltered, + matchesFilters, type PermissionGrant, scopeIdRequired, sortGrants, @@ -123,9 +126,42 @@ describe('sortGrants', () => { }) }) +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('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', + ui_surface: '', scope_type: 'global', scope_id: '', starts_at: '2026-06-01', @@ -159,6 +195,30 @@ describe('validateGrantForm', () => { validateGrantForm({ ...form, ends_at: '2026-05-01' }) ).toHaveProperty('ends_at') }) + + 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', () => { @@ -168,7 +228,9 @@ describe('toCreateGrantInput', () => { 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 ', @@ -182,12 +244,29 @@ describe('toCreateGrantInput', () => { 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', () => { + expect( + toCreateGrantInput({ + ...form, + subject: 'ui_surface', + ui_surface: 'ocotillo.lexicon', + scope_type: 'thing', + }) + ).toMatchObject({ + scope_type: 'global', + scope_id: null, + data_type: null, + ui_surface: 'ocotillo.lexicon', + }) + }) + it('sends the scope id as a number when the scope needs one', () => { expect(toCreateGrantInput({ ...form, scope_type: 'thing' }).scope_id).toBe( 99 @@ -205,6 +284,22 @@ describe('zPermissionGrant', () => { 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', () => { diff --git a/src/utils/accessGrants.ts b/src/utils/accessGrants.ts index 70a6b8fb..a1140717 100644 --- a/src/utils/accessGrants.ts +++ b/src/utils/accessGrants.ts @@ -38,10 +38,38 @@ export const ACCESS_DATA_TYPES = [ '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(), @@ -50,7 +78,9 @@ export const zPermissionGrant = z.looseObject({ capability: zGrantEnum, scope_type: zGrantEnum, scope_id: z.number().nullable(), - data_type: zGrantEnum, + // 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(), @@ -69,7 +99,8 @@ export type CreateGrantInput = { capability: string scope_type: string scope_id?: number | null - data_type: string + data_type?: string | null + ui_surface?: string | null starts_at: string ends_at?: string | null reason?: string | null @@ -115,6 +146,29 @@ export const grantQueryParams = (filters: GrantFilters): GrantQueryParams => { 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.dataType && grant.data_type !== filters.dataType) return false + if (filters.scopeType && grant.scope_type !== filters.scopeType) return false + + return true +} + /** True when the console is showing the unfiltered admin-wide audit view. */ export const isUnfiltered = (filters: GrantFilters): boolean => !filters.principalId?.trim() && @@ -144,9 +198,28 @@ export const describeScope = (grant: PermissionGrant): string => { } export type GrantFormErrors = Partial< - Record<'principal_id' | 'scope_id' | 'ends_at', string> + Record<'principal_id' | 'scope_id' | 'ends_at' | 'ui_surface', 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 + /** * Validates what the console can know locally. Everything else — whether the * principal exists, whether the scope id resolves — is the API's answer to @@ -154,20 +227,27 @@ export type GrantFormErrors = Partial< */ export const validateGrantForm = (form: { principal_id: string + subject: 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.principal_id.trim()) { errors.principal_id = 'A principal is required.' } - if (scopeIdRequired(form.scope_type)) { + 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 ${form.scope_type} id is required for a ${form.scope_type}-scoped grant.` + 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.' } @@ -182,21 +262,30 @@ export const toCreateGrantInput = (form: { 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 => ({ - principal_type: form.principal_type, - principal_id: form.principal_id.trim(), - capability: form.capability, - scope_type: form.scope_type, - scope_id: scopeIdRequired(form.scope_type) ? Number(form.scope_id) : null, - data_type: form.data_type, - starts_at: form.starts_at, - ends_at: form.ends_at || null, - reason: form.reason.trim() || null, -}) +}): 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: 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 From d0f1107258a274810d48593bc6ca9fb5d8b0a78b Mon Sep 17 00:00:00 2001 From: jakeross Date: Sat, 29 Aug 2026 21:01:18 -0700 Subject: [PATCH 5/7] feat(access): work the console the way an admin reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pass over the access console driven by using it rather than building it. The grants table moves onto the shadcn table primitive with TanStack for the row model and pagination. `ui/table.tsx` is copied verbatim from refactor/wells-contacts-shadcn-table and `@tanstack/react-table` is pinned to the version that branch already chose, so the two agree when PR #347 lands and this page can move onto the shared DataTable then. Cells are shadcn Badge, Tooltip and Button; the rest of the page is still MUI. The table now reads as an operator would ask about it: * Rows are one line tall. A long reason used to set the height of its row; address and reason truncate, and the tooltip carries the full text. * A screen grant and a data grant no longer look alike — each says which it is, and a Covers filter narrows to one kind. That filter is applied to the fetched rows rather than the query, because the API filters on an exact ui_surface and not on whether a grant names one at all. * A group scope reads as its name, falling back to the id when the name has not loaded, and the grant dialog picks a group by name instead of asking for an id nobody knows. * Scoped grants are tinted, since a grant over one group or one thing is a different animal from a portal-wide one. * The table pages, and the console runs the full window width. On the consent tab, a thing is chosen by PointID through a server-side search. An id still works — pasting one from a ticket has to keep working — but typed text that is not digits and was not chosen from the list no longer submits as though it were an id. The destinations tab loses its published-data expansion. Two fixes found on the way: the revoke confirmation interpolated `data_type` directly and read "on null" for a screen grant, and the tooltip triggers were mouse-only, so their content was unreachable by keyboard. Radix positions its popper with a ResizeObserver that jsdom does not implement, so the test setup stubs one, guarded like the createObjectURL stub beside it. Co-Authored-By: Claude Opus 5 --- package-lock.json | 38 ++- package.json | 3 +- src/components/ui/table.tsx | 114 +++++++ src/hooks/index.ts | 2 + src/hooks/useGroups.ts | 36 +++ src/hooks/useThingSearch.ts | 25 ++ src/pages/access/AccessConsole.tsx | 4 +- src/pages/access/consent/index.tsx | 108 +++++-- src/pages/access/destinations/index.tsx | 216 +++---------- src/pages/access/grants/GrantDialog.tsx | 55 +++- src/pages/access/grants/GrantsTable.tsx | 338 +++++++++++++++++++++ src/pages/access/grants/index.tsx | 179 ++++------- src/test/pages/accessConsent.test.tsx | 36 ++- src/test/pages/accessDestinations.test.tsx | 65 +--- src/test/pages/accessGrants.test.tsx | 199 +++++++++++- src/test/setup.ts | 12 + src/test/utils/accessGrants.test.ts | 35 +++ src/utils/accessGrants.ts | 26 +- 18 files changed, 1095 insertions(+), 396 deletions(-) create mode 100644 src/components/ui/table.tsx create mode 100644 src/hooks/useGroups.ts create mode 100644 src/hooks/useThingSearch.ts create mode 100644 src/pages/access/grants/GrantsTable.tsx 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/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 00000000..b01ab008 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Table({ className, ...props }: React.ComponentProps<'table'>) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) { + return ( + tr]:last:border-b-0', + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<'th'>) { + return ( +
[role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<'td'>) { + return ( + [role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<'caption'>) { + return ( +
+ ) +} + +export { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 7bd2e334..65afb3f6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -9,6 +9,7 @@ export * from './useContainerMinWidth' export * from './useDebounce' export * from './useElevation' export * from './useGisArtifacts' +export * from './useGroups' export * from './useLayer' export * from './useLexicon' export * from './useListPageDataGridAnalytics' @@ -22,6 +23,7 @@ export * from './useSensor' export * from './useSensorDeploymentRows' export * from './useSidebarPanelSync' export * from './useThingLayers' +export * from './useThingSearch' export * from './useUSGSSiteInfo' export * from './useViewportBbox' export * from './useWellDetails' 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 index 1116e04c..7354f2d1 100644 --- a/src/pages/access/AccessConsole.tsx +++ b/src/pages/access/AccessConsole.tsx @@ -74,8 +74,10 @@ export const AccessConsole = ({ 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 diff --git a/src/pages/access/consent/index.tsx b/src/pages/access/consent/index.tsx index eece625b..8c218335 100644 --- a/src/pages/access/consent/index.tsx +++ b/src/pages/access/consent/index.tsx @@ -1,6 +1,7 @@ import { Add } from '@mui/icons-material' import { Alert, + Autocomplete, Button, Chip, CircularProgress, @@ -29,6 +30,7 @@ import { useAccessDestinations, useCreateConsent, useRevokeConsent, + useThingSearch, } from '@/hooks' import { AccessConsole } from '@/pages/access/AccessConsole' import { @@ -55,6 +57,68 @@ import { 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 = () => ( @@ -90,17 +154,13 @@ const ConsentTab = () => { spacing={2} alignItems={{ sm: 'center' }} > - setThingInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') setThingId(thingInput.trim()) - }} + setThingId(selectedId)} + onEnter={() => setThingId(thingInput.trim())} /> { + // 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 ?? '', @@ -379,14 +442,23 @@ const ConsentDialog = ({ {submitError ? {submitError} : null} - set('thing_id')(event.target.value)} + inputValue={thingInput} + onInputChange={(value) => { + setThingInput(value) + // Digits are an id; a partly typed name is not one yet, and + // sending it as though it were would fail at the API. + set('thing_id')(/^\d+$/.test(value.trim()) ? value.trim() : '') + }} + onSelect={(thingId, name) => { + set('thing_id')(thingId) + setThingInput(name) + }} /> ( const DestinationsTab = () => { const [isDialogOpen, setIsDialogOpen] = useState(false) - const [expandedSlug, setExpandedSlug] = useState(null) const destinations = useAccessDestinations() const createDestination = useCreateDestination() @@ -102,13 +97,7 @@ const DestinationsTab = () => { ) : ( - - setExpandedSlug((previous) => (previous === slug ? null : slug)) - } - /> + )} {isDialogOpen ? ( @@ -133,15 +122,7 @@ const DestinationsTab = () => { ) } -const DestinationsTable = ({ - rows, - expandedSlug, - onToggle, -}: { - rows: Destination[] - expandedSlug: string | null - onToggle: (slug: string) => void -}) => ( +const DestinationsTable = ({ rows }: { rows: Destination[] }) => ( @@ -150,173 +131,56 @@ const DestinationsTable = ({ Kind Description Status - Published data {rows.map((destination) => ( - onToggle(destination.slug)} - /> + ))}
) -const Row = ({ - destination, - isExpanded, - onToggle, -}: { - destination: Destination - isExpanded: boolean - onToggle: () => void -}) => ( - <> - - - - - {destination.name} - - - {destination.slug} - - - - {destination.destination_kind} - - {destination.description ? ( - - {destination.description} - - ) : ( - - — - - )} - - - - - - - - - {isExpanded ? ( - - - - - - ) : null} - -) - -/** - * What this destination may read, computed server-side from consent rows. - * - * An empty list means default deny — either nobody has consented or the - * destination is retired — and the API does not distinguish those, so neither - * does this. The retired case is called out only because the row already - * knows it. - */ -const PublishedThings = ({ - slug, - active, -}: { - slug: string - active: boolean -}) => { - const published = usePublishedThings(slug) - - if (published.isLoading) { - return ( - - - - Loading what {slug} may read... +const Row = ({ destination }: { destination: Destination }) => ( + + + + + {destination.name} + + + {destination.slug} - ) - } - - if (published.isError) { - return ( - - Failed to load what {slug} may read. - - ) - } - - const rows = published.data ?? [] - - if (rows.length === 0) { - return ( - - {active - ? 'Nothing is published here yet. Consent is what opens this up.' - : 'This destination is retired, so it may read nothing.'} - - ) - } - - return ( - - - {rows.length} thing{rows.length === 1 ? '' : 's'} published to {slug} - - - {rows.slice(0, 25).map((thing) => ( - - - thing {thing.thing_id} - - {thing.data_types.map((dataType) => ( - - ))} - - ))} - - {rows.length > 25 ? ( - - Showing the first 25 of {rows.length}. + + {destination.destination_kind} + + {destination.description ? ( + + {destination.description} - ) : null} - - ) -} + ) : ( + + — + + )} + + + + + +) const DestinationDialog = ({ onClose, diff --git a/src/pages/access/grants/GrantDialog.tsx b/src/pages/access/grants/GrantDialog.tsx index 9650085e..1e5d44ce 100644 --- a/src/pages/access/grants/GrantDialog.tsx +++ b/src/pages/access/grants/GrantDialog.tsx @@ -10,6 +10,7 @@ import { TextField, } from '@mui/material' import { useState } from 'react' +import { useGroups } from '@/hooks' import { ACCESS_DATA_TYPES, CAPABILITIES, @@ -83,6 +84,7 @@ export const GrantDialog = ({ emptyGrantForm(today, defaultPrincipalId) ) const [errors, setErrors] = useState({}) + const groups = useGroups() const set = (field: keyof GrantFormState) => (value: string) => setForm((previous) => ({ ...previous, [field]: value })) @@ -233,19 +235,46 @@ export const GrantDialog = ({ ))}
- set('scope_id')(event.target.value)} - /> + {scopeType === 'group' ? ( + // An admin knows the group by name; the id is what the API + // stores, so the picker carries it and never shows it. + set('scope_id')(event.target.value)} + > + {groups.options.map((option) => ( + + {option.label} + + ))} + + ) : ( + set('scope_id')(event.target.value)} + /> + )}
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 index e9535c58..b385c98e 100644 --- a/src/pages/access/grants/index.tsx +++ b/src/pages/access/grants/index.tsx @@ -1,7 +1,6 @@ -import { Add, FilterAltOff } from '@mui/icons-material' +import { Add, DesktopWindows, FilterAltOff, Storage } from '@mui/icons-material' import { Alert, - Box, Button, Chip, CircularProgress, @@ -10,41 +9,34 @@ import { Paper, Stack, Switch, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, TextField, - Tooltip, Typography, } from '@mui/material' import { useState } from 'react' import { ConfirmDialog } from '@/components/ConfirmDialog' -import { useAccessGrants, useCreateGrant, useRevokeGrant } from '@/hooks' +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, - type GrantStatus, - grantStatusOf, isUnfiltered, matchesFilters, type PermissionGrant, sortGrants, } from '@/utils/accessGrants' -import { - ACCESS_STATUS_COLORS, - ACCESS_STATUS_LABELS, - isRevocable, -} from '@/utils/accessLifecycle' /** * Operations console for ADR5 permission grants. @@ -115,7 +107,15 @@ const GrantsTab = () => { }) } - const rows = grants.data ? sortGrants(grants.data, today) : [] + // Every filter but `subject` is answered by the API; that one narrows the + // rows here, because the route filters on an exact screen rather than on + // whether a grant names one at all. + const rows = grants.data + ? sortGrants( + grants.data.filter((grant) => matchesFilters(grant, filters)), + today + ) + : [] return ( @@ -128,7 +128,7 @@ const GrantsTab = () => { + {isPartialPage(grants.data) ? ( + + Showing the first {GRANT_PAGE_SIZE} of {grants.data?.total} grants. + Narrow by principal or capability to see the rest — sorting and the + Covers filter apply to what is loaded. + + ) : null} + {grantedOutOfView ? ( = {}): PermissionGrant => ...overrides, }) -const listResult = (rows: PermissionGrant[]) => ({ - data: rows, +// 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, @@ -290,6 +291,7 @@ describe('AccessGrantsPage', () => { expect(createMutateMock).toHaveBeenCalledWith( expect.objectContaining({ principal_id: 'ak-subject-9', + capability: 'view', scope_type: 'global', scope_id: null, data_type: null, @@ -373,12 +375,7 @@ describe('AccessGrantsPage', () => { 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({ - data: [grant({ id: 11, reason })], - isLoading: false, - isError: false, - error: null, - }) + useAccessGrantsMock.mockReturnValue(listResult([grant({ id: 11, reason })])) render() const cell = screen.getByText(reason) @@ -393,8 +390,8 @@ describe('AccessGrantsPage', () => { }) it('tints a scoped grant row and leaves a global one plain', () => { - useAccessGrantsMock.mockReturnValue({ - data: [ + useAccessGrantsMock.mockReturnValue( + listResult([ grant({ id: 21, principal_id: 'scoped-one', scope_type: 'thing' }), grant({ id: 22, @@ -402,11 +399,8 @@ describe('AccessGrantsPage', () => { scope_type: 'global', scope_id: null, }), - ], - isLoading: false, - isError: false, - error: null, - }) + ]) + ) render() const scopedRow = screen.getByText('scoped-one').closest('tr') @@ -418,8 +412,8 @@ describe('AccessGrantsPage', () => { it('marks a screen grant apart from a data grant', async () => { const user = userEvent.setup() - useAccessGrantsMock.mockReturnValue({ - data: [ + useAccessGrantsMock.mockReturnValue( + listResult([ grant({ id: 31, principal_id: 'screen-holder', @@ -431,11 +425,8 @@ describe('AccessGrantsPage', () => { principal_id: 'data-holder', data_type: 'water level', }), - ], - isLoading: false, - isError: false, - error: null, - }) + ]) + ) render() const screenRow = screen.getByText('screen-holder').closest('tr') @@ -457,8 +448,8 @@ describe('AccessGrantsPage', () => { it('filters the table down to screen grants', async () => { const user = userEvent.setup() - useAccessGrantsMock.mockReturnValue({ - data: [ + useAccessGrantsMock.mockReturnValue( + listResult([ grant({ id: 41, principal_id: 'screen-holder', @@ -470,11 +461,8 @@ describe('AccessGrantsPage', () => { principal_id: 'data-holder', data_type: 'water level', }), - ], - isLoading: false, - isError: false, - error: null, - }) + ]) + ) render() await user.click(screen.getByLabelText('Covers')) @@ -512,12 +500,9 @@ describe('AccessGrantsPage', () => { }) it('shows a group scope by name', () => { - useAccessGrantsMock.mockReturnValue({ - data: [grant({ id: 51, scope_type: 'group', scope_id: 42 })], - isLoading: false, - isError: false, - error: null, - }) + useAccessGrantsMock.mockReturnValue( + listResult([grant({ id: 51, scope_type: 'group', scope_id: 42 })]) + ) render() expect(screen.getByText('group Roswell Basin')).toBeInTheDocument() @@ -526,18 +511,17 @@ describe('AccessGrantsPage', () => { it('pages the table rather than rendering every grant', async () => { const user = userEvent.setup() - useAccessGrantsMock.mockReturnValue({ - data: 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')}`, - }) - ), - isLoading: false, - isError: false, - error: null, - }) + 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. @@ -549,6 +533,13 @@ describe('AccessGrantsPage', () => { 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() diff --git a/src/test/utils/accessGrants.test.ts b/src/test/utils/accessGrants.test.ts index eaa3cf89..eb58494e 100644 --- a/src/test/utils/accessGrants.test.ts +++ b/src/test/utils/accessGrants.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { describeScope, + GRANT_PAGE_SIZE, describeSubject, grantQueryParams, grantStatusOf, @@ -196,6 +197,7 @@ describe('validateGrantForm', () => { const form = { principal_id: 'ak-subject-1', subject: 'data_type', + capability: 'read', ui_surface: '', scope_type: 'global', scope_id: '', @@ -231,6 +233,12 @@ describe('validateGrantForm', () => { ).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' }) @@ -286,15 +294,17 @@ describe('toCreateGrantInput', () => { }) }) - it('sends a surface grant as global, with no data type', () => { + 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, @@ -302,6 +312,12 @@ describe('toCreateGrantInput', () => { }) }) + 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 @@ -345,7 +361,10 @@ describe('toDateInputValue', () => { describe('grantQueryParams', () => { it('sends only include_revoked when nothing is filtered', () => { - expect(grantQueryParams({})).toEqual({ include_revoked: false }) + expect(grantQueryParams({})).toEqual({ + include_revoked: false, + size: GRANT_PAGE_SIZE, + }) }) it('maps each filter to its query name', () => { @@ -363,12 +382,14 @@ describe('grantQueryParams', () => { 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, }) }) diff --git a/src/test/utils/uiSurfaceGrants.test.ts b/src/test/utils/uiSurfaceGrants.test.ts index b8ea62af..21678cd0 100644 --- a/src/test/utils/uiSurfaceGrants.test.ts +++ b/src/test/utils/uiSurfaceGrants.test.ts @@ -24,7 +24,7 @@ describe('isUiSurfaceGranted', () => { await expect(isUiSurfaceGranted('ocotillo.lexicon')).resolves.toBe(true) expect(fetcherMock).toHaveBeenCalledWith('access/decision', { - params: { capability: 'read', ui_surface: 'ocotillo.lexicon' }, + params: { capability: 'view', ui_surface: 'ocotillo.lexicon' }, }) }) diff --git a/src/utils/accessGrants.ts b/src/utils/accessGrants.ts index ade9460c..2fa045cf 100644 --- a/src/utils/accessGrants.ts +++ b/src/utils/accessGrants.ts @@ -29,7 +29,24 @@ import { const zGrantEnum = z.string() export const PRINCIPAL_TYPES = ['user', 'role', 'api key'] as const -export const CAPABILITIES = ['read', 'enter', 'correct', 'administer'] 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', @@ -91,6 +108,20 @@ export const zPermissionGrant = z.looseObject({ 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 = { @@ -131,8 +162,20 @@ export type GrantQueryParams = { 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 by lifecycle and + * the screen/data filter both run over the whole result here, so asking for one + * page at a time would sort and filter a slice rather than the set. 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 @@ -141,6 +184,7 @@ export type GrantQueryParams = { export const grantQueryParams = (filters: GrantFilters): GrantQueryParams => { const params: GrantQueryParams = { include_revoked: filters.includeRevoked ?? false, + size: GRANT_PAGE_SIZE, } const principalId = filters.principalId?.trim() @@ -177,6 +221,13 @@ export const matchesFilters = ( 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() && @@ -222,7 +273,10 @@ export const describeScope = ( } export type GrantFormErrors = Partial< - Record<'principal_id' | 'scope_id' | 'ends_at' | 'ui_surface', string> + Record< + 'principal_id' | 'scope_id' | 'ends_at' | 'ui_surface' | 'capability', + string + > > /** True for a grant that opens a screen rather than reaching data. */ @@ -244,6 +298,13 @@ export const describeSubject = (grant: PermissionGrant): string => 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 @@ -252,6 +313,7 @@ export const scopeTypeFor = (subject: string, scopeType: string): string => export const validateGrantForm = (form: { principal_id: string subject: string + capability: string ui_surface: string scope_type: string scope_id: string @@ -261,6 +323,10 @@ export const validateGrantForm = (form: { 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.' } @@ -299,7 +365,7 @@ export const toCreateGrantInput = (form: { return { principal_type: form.principal_type, principal_id: form.principal_id.trim(), - capability: form.capability, + 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. diff --git a/src/utils/uiSurfaceGrants.ts b/src/utils/uiSurfaceGrants.ts index a49f5a9e..59210f9c 100644 --- a/src/utils/uiSurfaceGrants.ts +++ b/src/utils/uiSurfaceGrants.ts @@ -1,11 +1,12 @@ 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` answers whether it does. + * `ui_surface` and the `view` capability answers whether it does. * * Two rules this module exists to keep: * @@ -31,7 +32,9 @@ export const resetUiSurfaceGrants = () => cache.clear() const askDecision = async (surface: string): Promise => { try { const response = await fetcher('access/decision', { - params: { capability: 'read', ui_surface: surface }, + // `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 { From 6df71750f9bd9564a3630b0ca596a05bc56f6529 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 30 Aug 2026 12:18:59 -0700 Subject: [PATCH 7/7] feat(access): ask the route for one kind of grant The screen/data filter was applied to the rows after they arrived, because the route could filter on an exact ui_surface but not on whether a grant named one at all. It takes a `subject` filter now, so the question goes with the query and the answer is narrowed before it is paged. The rows are still filtered on arrival. An API without that filter ignores a query parameter it does not recognise rather than refusing it, so a console pointed at one would show every grant as though the filter had been applied. One pass over at most a page of rows is cheaper than that being wrong, and it costs nothing once the route understands the parameter. Co-Authored-By: Claude Opus 5 --- src/pages/access/grants/index.tsx | 10 +++++----- src/test/utils/accessGrants.test.ts | 9 +++++++++ src/utils/accessGrants.ts | 19 +++++++++++-------- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/pages/access/grants/index.tsx b/src/pages/access/grants/index.tsx index 43a77a71..dc3fbdde 100644 --- a/src/pages/access/grants/index.tsx +++ b/src/pages/access/grants/index.tsx @@ -109,9 +109,9 @@ const GrantsTab = () => { }) } - // Every filter but `subject` is answered by the API; that one narrows the - // rows here, because the route filters on an exact screen rather than on - // whether a grant names one at all. + // 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)), @@ -144,8 +144,8 @@ const GrantsTab = () => { {isPartialPage(grants.data) ? ( Showing the first {GRANT_PAGE_SIZE} of {grants.data?.total} grants. - Narrow by principal or capability to see the rest — sorting and the - Covers filter apply to what is loaded. + Narrow by principal or capability to see the rest — sorting applies to + what is loaded. ) : null} diff --git a/src/test/utils/accessGrants.test.ts b/src/test/utils/accessGrants.test.ts index eb58494e..b45d9581 100644 --- a/src/test/utils/accessGrants.test.ts +++ b/src/test/utils/accessGrants.test.ts @@ -372,6 +372,7 @@ describe('grantQueryParams', () => { grantQueryParams({ principalId: 'ak-subject-1', capability: 'read', + subject: 'ui_surface', dataType: 'water level', scopeType: 'thing', includeRevoked: true, @@ -379,6 +380,7 @@ describe('grantQueryParams', () => { ).toEqual({ principal_id: 'ak-subject-1', capability: 'read', + subject: 'ui_surface', data_type: 'water level', scope_type: 'thing', include_revoked: true, @@ -393,6 +395,13 @@ describe('grantQueryParams', () => { }) }) + 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' diff --git a/src/utils/accessGrants.ts b/src/utils/accessGrants.ts index 2fa045cf..0180e0e5 100644 --- a/src/utils/accessGrants.ts +++ b/src/utils/accessGrants.ts @@ -146,9 +146,10 @@ export type GrantFilters = { principalId?: string capability?: string /** - * Which kind of grant to show. The API filters on an exact `ui_surface`, not - * on "any screen", so this one narrows the fetched rows rather than the - * query — see `grantQueryParams`, which deliberately does not send it. + * 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 @@ -159,6 +160,7 @@ export type GrantFilters = { export type GrantQueryParams = { principal_id?: string capability?: string + subject?: string data_type?: string scope_type?: string include_revoked: boolean @@ -168,11 +170,11 @@ export type GrantQueryParams = { /** * How many grants the console asks for at once. * - * The route pages at 25 by default and caps at 10000. Sorting by lifecycle and - * the screen/data filter both run over the whole result here, so asking for one - * page at a time would sort and filter a slice rather than the set. This asks - * for more than any principal will have and says so when the answer is short — - * see `isPartialPage`. + * 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 @@ -190,6 +192,7 @@ export const grantQueryParams = (filters: GrantFilters): GrantQueryParams => { 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