From 03bd81cfecd3cabfffe472e2a9ca306c1b680b2e Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 23 Aug 2026 16:51:49 -0700 Subject: [PATCH 1/3] feat(settings): add a user settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was nowhere to see who you are signed in as, what roles you hold, or why a page is hidden from you — the only per-user control in the app was the theme toggle buried in the header dropdown. Adds /settings, reachable from that same dropdown, with five sections: - Profile: name, email, user id and session expiry, read from the id token. Read-only, and it says so: these live in single sign-on. - Access: roles grouped by portal, with the primary role marked, in the same vocabulary the access control provider uses. - Appearance: light, dark, or system. - Navigation: whether the sidebar collapses on the map page. - API keys: generate, rename, revoke, and a one-time reveal of a new key. Two supporting changes came with it. Colour mode gains a real "system" preference. It used to fall back to the OS setting only when nothing was stored, with no way to return to it and no response when the OS switched. The stored value is now the preference, the rendered mode is derived from it, and a media query listener keeps "system" honest. The sidebar auto-collapse on the map is now a preference rather than a rule. It is on by default, so nothing changes for anyone who does not go looking. Preferences are local to the browser and live in a small store read through useSyncExternalStore, so the settings page and the shell stay in step without another provider. The API keys section is UI only. No endpoints exist yet, so it runs on local state to settle the interaction first, and the card says on screen that the keys are not real credentials and do not survive a reload. Co-Authored-By: Claude Opus 5 --- src/App.tsx | 2 + src/components/AppShell.tsx | 37 ++- src/contexts/ColorModeContext.ts | 11 +- src/contexts/ColorModeContextProvider.tsx | 67 ++-- src/hooks/index.ts | 1 + src/hooks/useBooleanPreference.ts | 30 ++ src/pages/settings/ApiKeysCard.tsx | 365 ++++++++++++++++++++++ src/pages/settings/SettingsCard.tsx | 62 ++++ src/pages/settings/index.tsx | 282 +++++++++++++++++ src/test/components/sider.test.tsx | 2 +- src/test/pages/apiKeysCard.test.tsx | 116 +++++++ src/test/pages/settings.test.tsx | 135 ++++++++ src/test/utils/apiKeys.test.ts | 114 +++++++ src/test/utils/preferences.test.ts | 48 +++ src/test/utils/userProfile.test.ts | 110 +++++++ src/utils/apiKeys.ts | 98 ++++++ src/utils/preferences.ts | 67 ++++ src/utils/userProfile.ts | 108 +++++++ 18 files changed, 1622 insertions(+), 33 deletions(-) create mode 100644 src/hooks/useBooleanPreference.ts create mode 100644 src/pages/settings/ApiKeysCard.tsx create mode 100644 src/pages/settings/SettingsCard.tsx create mode 100644 src/pages/settings/index.tsx create mode 100644 src/test/pages/apiKeysCard.test.tsx create mode 100644 src/test/pages/settings.test.tsx create mode 100644 src/test/utils/apiKeys.test.ts create mode 100644 src/test/utils/preferences.test.ts create mode 100644 src/test/utils/userProfile.test.ts create mode 100644 src/utils/apiKeys.ts create mode 100644 src/utils/preferences.ts create mode 100644 src/utils/userProfile.ts diff --git a/src/App.tsx b/src/App.tsx index b05236da..4ff9a0bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { Callback, Login } from '@/components/Auth' import { ContentPage } from '@/pages/content' import { TypographyPage } from '@/pages/example/TypographyPage' import { Home } from '@/pages/home' +import { SettingsPage } from '@/pages/settings' import { GeothermalRoutes, OcotilloRoutes, ST2Routes } from '@/routes' import { settings } from '@/settings' @@ -61,6 +62,7 @@ const App: React.FC = () => ( path="/ogcapi" element={} /> + } /> {/* TEMPORARY: example specimen pages */} } /> } /> diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index f3a4b7d6..01d5ff80 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -53,8 +53,10 @@ import { Lock, LogOut, Menu, + Monitor, Moon, Search, + Settings as SettingsIcon, Sun, User, X, @@ -63,11 +65,12 @@ import { ColorModeContext } from '@/contexts' import SearchBar from '@/components/SearchBar' import { ReportBugButton } from '@/components/Button' import { AmpRole, PRIMARY_NAV, RESOURCE_NAV, type NavItem } from '@/config/navigation' -import { useAccessCapabilities } from '@/hooks' +import { useAccessCapabilities, useBooleanPreference } from '@/hooks' import { useSearch } from '@/providers/search-provider' import { SupportPanelContext } from '@/components/SupportPanelContext' import { NewVersionBanner } from '@/components/NewVersionBanner' import { trackNavItemClicked } from '@/analytics/posthog' +import { PREFERENCE_KEYS } from '@/utils/preferences' import pkg from '../../package.json' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' @@ -1100,7 +1103,7 @@ function ShellHeader() { const { warnWhen, setWarnWhen } = useWarnAboutChange() const { mutate: logout } = useLogout() const translate = useTranslate() - const { mode, setMode } = useContext(ColorModeContext) + const { preference, setMode } = useContext(ColorModeContext) const { openSearch } = useSearch() const initials = user?.name @@ -1175,6 +1178,13 @@ function ShellHeader() { + + + + Settings + + + {/* Appearance */} Appearance @@ -1182,12 +1192,17 @@ function ShellHeader() { setMode('light')}> Light - {mode === 'light' && } + {preference === 'light' && } setMode('dark')}> Dark - {mode === 'dark' && } + {preference === 'dark' && } + + setMode('system')}> + + System + {preference === 'system' && } {isExistAuthentication && ( @@ -1209,13 +1224,18 @@ const AUTO_COLLAPSE_PATHS = ['/ocotillo/map'] function SidebarAutoCollapse(): null { const location = useLocation() const { setOpen } = useSidebar() + const [autoCollapseEnabled] = useBooleanPreference( + PREFERENCE_KEYS.autoCollapseSidebarOnMap, + true + ) // Track whether the sidebar was collapsed by this component (not by the user) const autoCollapsed = useRef(false) + // biome-ignore lint/correctness/useExhaustiveDependencies: setOpen is stable from sidebar context. useEffect(() => { - const isAutoCollapsePage = AUTO_COLLAPSE_PATHS.some((p) => - location.pathname.startsWith(p) - ) + const isAutoCollapsePage = + autoCollapseEnabled && + AUTO_COLLAPSE_PATHS.some((p) => location.pathname.startsWith(p)) if (isAutoCollapsePage) { autoCollapsed.current = true @@ -1225,8 +1245,7 @@ function SidebarAutoCollapse(): null { autoCollapsed.current = false setOpen(true) } - // biome-ignore lint/correctness/useExhaustiveDependencies: setOpen is stable from sidebar context. - }, [location.pathname]) + }, [location.pathname, autoCollapseEnabled]) return null } diff --git a/src/contexts/ColorModeContext.ts b/src/contexts/ColorModeContext.ts index c84ef1c9..7449dbc2 100644 --- a/src/contexts/ColorModeContext.ts +++ b/src/contexts/ColorModeContext.ts @@ -1,8 +1,15 @@ import { createContext } from 'react' +import type { + ColorModePreference, + ResolvedColorMode, +} from '@/utils/userProfile' export type ColorModeContextType = { - mode: string - setMode: (mode?: string) => void + /** The mode actually rendering right now — never "system". */ + mode: ResolvedColorMode + /** What the user chose, which may be "system". */ + preference: ColorModePreference + setMode: (mode?: ColorModePreference) => void } export const ColorModeContext = createContext( diff --git a/src/contexts/ColorModeContextProvider.tsx b/src/contexts/ColorModeContextProvider.tsx index 67dd6285..6b5f2cdb 100644 --- a/src/contexts/ColorModeContextProvider.tsx +++ b/src/contexts/ColorModeContextProvider.tsx @@ -1,48 +1,73 @@ -import React, { PropsWithChildren, useEffect, useState } from 'react' import { ThemeProvider } from '@mui/material' +import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react' import { getTheme } from '@/theme' +import { + COLOR_MODE_STORAGE_KEY, + type ColorModePreference, + isColorModePreference, + resolveColorMode, +} from '@/utils/userProfile' import { ColorModeContext } from './ColorModeContext' +const DARK_QUERY = '(prefers-color-scheme: dark)' + +const storedPreference = (): ColorModePreference => { + const stored = localStorage.getItem(COLOR_MODE_STORAGE_KEY) + // Anything older or unrecognised falls back to following the OS, which is + // what this app did before "system" was an explicit choice. + return isColorModePreference(stored) ? stored : 'system' +} + export const ColorModeContextProvider: React.FC = ({ children, }) => { - const colorModeFromLocalStorage = localStorage.getItem('colorMode') - const isSystemPreferenceDark = window?.matchMedia( - '(prefers-color-scheme: dark)' - ).matches + const [preference, setPreference] = + useState(storedPreference) + const [systemPrefersDark, setSystemPrefersDark] = useState( + () => window?.matchMedia(DARK_QUERY).matches ?? false + ) - const systemPreference = isSystemPreferenceDark ? 'dark' : 'light' - const initialMode = colorModeFromLocalStorage || systemPreference + const mode = resolveColorMode(preference, systemPrefersDark) - // Apply class immediately so shadcn/Tailwind dark styles don't flash on load - document.documentElement.classList.toggle('dark', initialMode === 'dark') + // Apply the class before paint so Tailwind/shadcn dark styles don't flash + document.documentElement.classList.toggle('dark', mode === 'dark') - const [mode, setMode] = useState(initialMode) + // Following the OS means following it as it changes, not only at load. + useEffect(() => { + const query = window.matchMedia(DARK_QUERY) + const onChange = (event: MediaQueryListEvent) => + setSystemPrefersDark(event.matches) + + query.addEventListener('change', onChange) + return () => query.removeEventListener('change', onChange) + }, []) useEffect(() => { - window.localStorage.setItem('colorMode', mode) - // Sync the .dark class on so Tailwind/shadcn dark variants activate + window.localStorage.setItem(COLOR_MODE_STORAGE_KEY, preference) document.documentElement.classList.toggle('dark', mode === 'dark') - }, [mode]) + }, [preference, mode]) - const setColorMode = (next?: string) => { - if (next === 'light' || next === 'dark') { - setMode(next) + const setColorMode = (next?: ColorModePreference) => { + if (isColorModePreference(next)) { + setPreference(next) } else { - setMode(mode === 'light' ? 'dark' : 'light') + // No argument still means "flip what I'm looking at", which is how the + // header toggle has always called this. + setPreference(mode === 'light' ? 'dark' : 'light') } } + const theme = useMemo(() => getTheme(mode), [mode]) + return ( - - {children} - + {children} ) } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index b39fcf72..7c4722c9 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -22,3 +22,4 @@ export * from './useSearchModalState' export * from './useSidebarPanelSync' export * from './useWellDetails' export * from './useContainerMinWidth' +export * from './useBooleanPreference' diff --git a/src/hooks/useBooleanPreference.ts b/src/hooks/useBooleanPreference.ts new file mode 100644 index 00000000..462dca65 --- /dev/null +++ b/src/hooks/useBooleanPreference.ts @@ -0,0 +1,30 @@ +import { useCallback, useSyncExternalStore } from 'react' +import { + type PreferenceKey, + readBooleanPreference, + subscribeToPreferences, + writeBooleanPreference, +} from '@/utils/preferences' + +/** + * Reads a localStorage-backed preference as React state. Every component using + * the same key re-renders when any of them writes it, so the settings page and + * the shell stay in step without a shared provider. + */ +export const useBooleanPreference = ( + key: PreferenceKey, + fallback: boolean +): [boolean, (value: boolean) => void] => { + const value = useSyncExternalStore( + subscribeToPreferences, + () => readBooleanPreference(key, fallback), + () => fallback + ) + + const setValue = useCallback( + (next: boolean) => writeBooleanPreference(key, next), + [key] + ) + + return [value, setValue] +} diff --git a/src/pages/settings/ApiKeysCard.tsx b/src/pages/settings/ApiKeysCard.tsx new file mode 100644 index 00000000..91fa9d60 --- /dev/null +++ b/src/pages/settings/ApiKeysCard.tsx @@ -0,0 +1,365 @@ +import { Add, ContentCopy, Delete, Edit } from '@mui/icons-material' +import { + Alert, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from '@mui/material' +import { useState } from 'react' +import { SettingsCard } from '@/pages/settings/SettingsCard' +import { + type ApiKey, + createApiKey, + describeLastUsed, + isApiKeyActive, + renameApiKey, + revokeApiKey, + sortApiKeys, +} from '@/utils/apiKeys' + +/** + * Shown once, immediately after generation. A real API returns the token only + * at creation, so the dialog is the single chance to copy it — the card below + * never renders a full token again. + */ +const NewKeyDialog = ({ + apiKey, + onClose, +}: { + apiKey: ApiKey | null + onClose: () => void +}) => { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + if (!apiKey?.token) return + await navigator.clipboard.writeText(apiKey.token) + setCopied(true) + } + + return ( + + Copy your new key + + + + This is the only time the full key is shown. Copy it now and store + it somewhere safe. + + + + {apiKey?.token} + + + + + + + + + + + + + + ) +} + +const NameDialog = ({ + open, + title, + confirmLabel, + initialName, + onCancel, + onConfirm, +}: { + open: boolean + title: string + confirmLabel: string + initialName?: string + onCancel: () => void + onConfirm: (name: string) => void +}) => { + const [name, setName] = useState(initialName ?? '') + + return ( + + {title} + + setName(event.target.value)} + helperText="A name you will recognise later, so you know what to revoke." + /> + + + + + + + ) +} + +const RevokeDialog = ({ + apiKey, + onCancel, + onConfirm, +}: { + apiKey: ApiKey | null + onCancel: () => void + onConfirm: () => void +}) => ( + + Revoke “{apiKey?.name}”? + + + Anything using this key stops working immediately. This cannot be undone + — you would generate a new key instead. + + + + + + + +) + +/** + * Personal API keys. + * + * Deliberately not wired to the backend: the endpoints do not exist yet, so + * this renders the whole flow against local state to settle the interaction + * first. Keys generated here are not credentials and do not survive a reload, + * and the card says as much rather than letting anyone assume otherwise. + */ +export const ApiKeysCard = ({ + initialKeys = [], + now = () => new Date(), +}: { + initialKeys?: ApiKey[] + now?: () => Date +}) => { + const [keys, setKeys] = useState(initialKeys) + const [newKey, setNewKey] = useState(null) + const [isGenerating, setIsGenerating] = useState(false) + const [editing, setEditing] = useState(null) + const [revoking, setRevoking] = useState(null) + + const handleGenerate = (name: string) => { + const created = createApiKey({ name, now: now() }) + setKeys((existing) => [created, ...existing]) + setIsGenerating(false) + setNewKey(created) + } + + const handleRename = (name: string) => { + if (!editing) return + setKeys((existing) => + existing.map((key) => + key.id === editing.id ? renameApiKey(key, name) : key + ) + ) + setEditing(null) + } + + const handleRevoke = () => { + if (!revoking) return + setKeys((existing) => + existing.map((key) => + key.id === revoking.id ? revokeApiKey(key, now()) : key + ) + ) + setRevoking(null) + } + + const sorted = sortApiKeys(keys) + + return ( + + + + Preview only. The API does not issue keys yet, so keys created here + are not real credentials and disappear when you reload the page. + + + + + + + {sorted.length === 0 ? ( + + No keys yet. Generate one to use the API outside this app. + + ) : ( + + + + Name + Key + Created + Last used + Actions + + + + {sorted.map((key) => { + const active = isApiKeyActive(key) + + return ( + + + + + {key.name} + + {active ? null : ( + + )} + + + + + {key.tokenPreview} + + + + + {new Date(key.createdAt).toLocaleDateString()} + + + + + {describeLastUsed(key)} + + + + + + + setEditing(key)} + aria-label={`Rename ${key.name}`} + > + + + + + + + setRevoking(key)} + aria-label={`Revoke ${key.name}`} + > + + + + + + + + ) + })} + +
+ )} +
+ + setIsGenerating(false)} + onConfirm={handleGenerate} + /> + setEditing(null)} + onConfirm={handleRename} + /> + setRevoking(null)} + onConfirm={handleRevoke} + /> + setNewKey(null)} /> +
+ ) +} diff --git a/src/pages/settings/SettingsCard.tsx b/src/pages/settings/SettingsCard.tsx new file mode 100644 index 00000000..26fd9505 --- /dev/null +++ b/src/pages/settings/SettingsCard.tsx @@ -0,0 +1,62 @@ +import { + Box, + Card, + CardContent, + Divider, + Stack, + Typography, +} from '@mui/material' + +/** + * The shared frame every settings section uses: a titled card with an + * optional description, and label/value rows inside it. + */ +export const SettingRow = ({ + label, + children, +}: { + label: string + children: React.ReactNode +}) => ( + + + {label} + + {children} + +) + +export const SettingsCard = ({ + title, + description, + children, +}: { + title: string + description?: string + children: React.ReactNode +}) => ( + + + + + {title} + {description ? ( + + {description} + + ) : null} + + + {children} + + + +) diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx new file mode 100644 index 00000000..47dcb943 --- /dev/null +++ b/src/pages/settings/index.tsx @@ -0,0 +1,282 @@ +import { DarkMode, LightMode, SettingsBrightness } from '@mui/icons-material' +import { + Box, + Chip, + Container, + FormControlLabel, + Stack, + Switch, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material' +import { alpha } from '@mui/material/styles' +import { useGetIdentity } from '@refinedev/core' +import { jwtDecode } from 'jwt-decode' +import { useContext } from 'react' +import { ColorModeContext } from '@/contexts' +import { useAccessCapabilities, useBooleanPreference } from '@/hooks' +import { ApiKeysCard } from '@/pages/settings/ApiKeysCard' +import { SettingRow, SettingsCard } from '@/pages/settings/SettingsCard' +import { tokenStore } from '@/providers/authentik-provider' +import type { PortalRole } from '@/utils/accessControl' +import { PREFERENCE_KEYS } from '@/utils/preferences' +import { + type ColorModePreference, + formatSessionExpiry, + groupRolesByPortal, + initialsFromName, + roleShortLabel, +} from '@/utils/userProfile' + +/** + * Session expiry comes off the id token rather than any app state: the token + * is what actually ends the session, so anything else here would be a guess. + */ +const sessionExpiry = (): string | null => { + const idToken = tokenStore.idToken + if (!idToken) return null + + try { + const { exp } = jwtDecode<{ exp?: number }>(idToken) + return formatSessionExpiry(exp, new Date()) + } catch { + return null + } +} + +/** + * Read-only view of who the signed-in user is. Names, emails and roles all + * come from Authentik, so this page shows them and says where to change them + * rather than pretending to own them. + */ +export const ProfileCard = ({ + name, + email, + userId, + expiry, +}: { + name?: string + email?: string + userId?: string + expiry: string | null +}) => ( + + + ({ + width: 56, + height: 56, + borderRadius: 2, + display: 'grid', + placeItems: 'center', + bgcolor: alpha(theme.palette.primary.main, 0.12), + color: 'primary.main', + fontWeight: 700, + })} + > + {initialsFromName(name)} + + + + {name || 'Unknown user'} + + + {email || 'No email on this account'} + + + + + {userId ? ( + + + {userId} + + + ) : null} + + {expiry ? ( + + {expiry} + + ) : null} + +) + +/** + * What the signed-in user can reach, in the same role vocabulary the access + * control provider uses — so a "why can't I see this page?" question can be + * answered from here instead of from a token dump. + */ +export const AccessCard = ({ + roles, + primaryRole, +}: { + roles: PortalRole[] + primaryRole: PortalRole | null +}) => { + const groups = groupRolesByPortal(roles) + + return ( + + {groups.length === 0 ? ( + + No portal roles are assigned to this account, so most pages will be + hidden. An administrator can grant access. + + ) : ( + groups.map((group) => ( + + + {group.roles.map((role) => ( + + ))} + + + )) + )} + + ) +} + +export const AppearanceCard = ({ + preference, + onChange, +}: { + preference: ColorModePreference + onChange: (next: ColorModePreference) => void +}) => ( + + + + { + if (next) onChange(next) + }} + aria-label="Theme" + > + + + Light + + + + Dark + + + + System + + + + System follows your operating system setting, including when it + switches on its own. + + + + +) + +/** + * Shell behaviour that people reasonably disagree about. The map collapses the + * sidebar on arrival to give the canvas the width; anyone navigating between + * the map and the rest of the app all day would rather it stayed put. + */ +export const NavigationCard = ({ + autoCollapseOnMap, + onAutoCollapseChange, +}: { + autoCollapseOnMap: boolean + onAutoCollapseChange: (next: boolean) => void +}) => ( + + + + onAutoCollapseChange(event.target.checked)} + inputProps={{ 'aria-label': 'Collapse the sidebar on the map' }} + /> + } + label="Collapse the sidebar on the map" + /> + + On by default, so the map gets the full width. Turn it off to keep the + sidebar open when you open the map. + + + + +) + +export const SettingsPage = () => { + const { data: user } = useGetIdentity<{ + id?: string + name?: string + email?: string + }>() + const { roles, primaryRole } = useAccessCapabilities() + const { preference, setMode } = useContext(ColorModeContext) + const [autoCollapseOnMap, setAutoCollapseOnMap] = useBooleanPreference( + PREFERENCE_KEYS.autoCollapseSidebarOnMap, + true + ) + + return ( + + + + Settings + + Your account and how this app looks on this device. + + + + + + + + + + + ) +} diff --git a/src/test/components/sider.test.tsx b/src/test/components/sider.test.tsx index d1ddf686..b50b749e 100644 --- a/src/test/components/sider.test.tsx +++ b/src/test/components/sider.test.tsx @@ -67,7 +67,7 @@ vi.mock('@/components/layout/logout', () => ({ const renderSider = () => render( - + diff --git a/src/test/pages/apiKeysCard.test.tsx b/src/test/pages/apiKeysCard.test.tsx new file mode 100644 index 00000000..6b6c76b9 --- /dev/null +++ b/src/test/pages/apiKeysCard.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import { ApiKeysCard } from '@/pages/settings/ApiKeysCard' +import { createApiKey, revokeApiKey } from '@/utils/apiKeys' + +const now = new Date('2026-08-23T12:00:00Z') + +const laptopKey = createApiKey({ + name: 'Field laptop', + now, + token: 'ocot_abcdefghijklmnop', + id: 'key-1', +}) + +describe('ApiKeysCard', () => { + it('says it is not connected to the API', () => { + render() + + expect(screen.getByText(/Preview only/)).toBeInTheDocument() + expect(screen.getByText(/No keys yet/)).toBeInTheDocument() + }) + + it('lists existing keys by preview, never the full token', () => { + render( now} />) + + expect(screen.getByText('Field laptop')).toBeInTheDocument() + expect(screen.getByText('ocot_abcde…mnop')).toBeInTheDocument() + expect(screen.queryByText('ocot_abcdefghijklmnop')).not.toBeInTheDocument() + expect(screen.getByText('Never used')).toBeInTheDocument() + }) + + it('generates a key and shows it once for copying', async () => { + const user = userEvent.setup() + render( now} />) + + await user.click(screen.getByRole('button', { name: 'Generate key' })) + await user.type(screen.getByLabelText('Key name'), 'QGIS at the office') + await user.click(screen.getByRole('button', { name: 'Generate' })) + + const dialog = screen.getByRole('dialog') + expect( + within(dialog).getByText(/only time the full key/) + ).toBeInTheDocument() + expect(within(dialog).getByText(/^ocot_[a-z0-9]{32}$/)).toBeInTheDocument() + + await user.click(within(dialog).getByRole('button', { name: 'Done' })) + + expect(screen.getByText('QGIS at the office')).toBeInTheDocument() + expect(screen.queryByText(/^ocot_[a-z0-9]{32}$/)).not.toBeInTheDocument() + }) + + it('will not generate a key without a name', async () => { + const user = userEvent.setup() + render( now} />) + + await user.click(screen.getByRole('button', { name: 'Generate key' })) + + expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled() + }) + + it('renames a key', async () => { + const user = userEvent.setup() + render( now} />) + + await user.click( + screen.getByRole('button', { name: 'Rename Field laptop' }) + ) + const field = screen.getByLabelText('Key name') + await user.clear(field) + await user.type(field, 'Field tablet') + await user.click(screen.getByRole('button', { name: 'Save' })) + + expect(screen.getByText('Field tablet')).toBeInTheDocument() + expect(screen.queryByText('Field laptop')).not.toBeInTheDocument() + }) + + it('revokes a key only after confirmation', async () => { + const user = userEvent.setup() + render( now} />) + + await user.click( + screen.getByRole('button', { name: 'Revoke Field laptop' }) + ) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + // The dialog fades out; the table underneath stays aria-hidden until it has. + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + ) + expect(screen.queryByText('Revoked')).not.toBeInTheDocument() + + await user.click( + screen.getByRole('button', { name: 'Revoke Field laptop' }) + ) + await user.click(screen.getByRole('button', { name: 'Revoke key' })) + + expect(screen.getAllByText('Revoked').length).toBeGreaterThan(0) + }) + + it('disables the actions on an already revoked key', () => { + render( + now} + /> + ) + + expect( + screen.getByRole('button', { name: 'Rename Field laptop' }) + ).toBeDisabled() + expect( + screen.getByRole('button', { name: 'Revoke Field laptop' }) + ).toBeDisabled() + }) +}) diff --git a/src/test/pages/settings.test.tsx b/src/test/pages/settings.test.tsx new file mode 100644 index 00000000..879ebd9a --- /dev/null +++ b/src/test/pages/settings.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { + AccessCard, + AppearanceCard, + NavigationCard, + ProfileCard, +} from '@/pages/settings' + +describe('ProfileCard', () => { + it('shows the identity from single sign-on', () => { + render( + + ) + + expect(screen.getByText('Jake Ross')).toBeInTheDocument() + expect(screen.getByText('jake@example.org')).toBeInTheDocument() + expect(screen.getByText('abc-123')).toBeInTheDocument() + expect(screen.getByText('Expires in 42 minutes')).toBeInTheDocument() + expect(screen.getByText('JR')).toBeInTheDocument() + }) + + it('falls back when the token carries no name or email', () => { + render() + + expect(screen.getByText('Unknown user')).toBeInTheDocument() + expect(screen.getByText('No email on this account')).toBeInTheDocument() + expect(screen.getByText('?')).toBeInTheDocument() + }) + + it('omits the session row when the expiry is unknown', () => { + render() + + expect(screen.queryByText('Session')).not.toBeInTheDocument() + }) +}) + +describe('AccessCard', () => { + it('groups roles by portal and marks the primary one', () => { + render( + + ) + + expect(screen.getByText('Aquifer Mapping Program')).toBeInTheDocument() + expect(screen.getByText('Geothermal')).toBeInTheDocument() + expect(screen.getByText('Viewer')).toBeInTheDocument() + expect(screen.getByText('Editor')).toBeInTheDocument() + expect(screen.getByText('Admin')).toBeInTheDocument() + }) + + it('says so when the account has no roles', () => { + render() + + expect( + screen.getByText(/No portal roles are assigned to this account/) + ).toBeInTheDocument() + }) +}) + +describe('AppearanceCard', () => { + it('marks the current preference as selected', () => { + render() + + expect( + screen.getByRole('button', { name: 'System theme' }) + ).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'Dark theme' })).toHaveAttribute( + 'aria-pressed', + 'false' + ) + }) + + it('reports the chosen preference', async () => { + const onChange = vi.fn() + render() + + await userEvent.click(screen.getByRole('button', { name: 'Dark theme' })) + + expect(onChange).toHaveBeenCalledWith('dark') + }) + + it('ignores a click on the already-selected option', async () => { + const onChange = vi.fn() + render() + + await userEvent.click(screen.getByRole('button', { name: 'Light theme' })) + + expect(onChange).not.toHaveBeenCalled() + }) +}) + +describe('NavigationCard', () => { + it('reflects the stored preference', () => { + render( + + ) + + expect( + screen.getByRole('checkbox', { + name: 'Collapse the sidebar on the map', + }) + ).not.toBeChecked() + }) + + it('reports the toggled value', async () => { + const onAutoCollapseChange = vi.fn() + render( + + ) + + await userEvent.click( + screen.getByRole('checkbox', { + name: 'Collapse the sidebar on the map', + }) + ) + + expect(onAutoCollapseChange).toHaveBeenCalledWith(false) + }) +}) diff --git a/src/test/utils/apiKeys.test.ts b/src/test/utils/apiKeys.test.ts new file mode 100644 index 00000000..e076a26d --- /dev/null +++ b/src/test/utils/apiKeys.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { + type ApiKey, + createApiKey, + describeLastUsed, + generateApiKeyToken, + isApiKeyActive, + previewOfToken, + renameApiKey, + revokeApiKey, + sortApiKeys, +} from '@/utils/apiKeys' + +const now = new Date('2026-08-23T12:00:00Z') + +describe('generateApiKeyToken', () => { + it('produces a prefixed token of a fixed shape', () => { + const token = generateApiKeyToken() + + expect(token).toMatch(/^ocot_[a-z0-9]{32}$/) + }) + + it('does not repeat itself', () => { + const tokens = new Set( + Array.from({ length: 50 }, () => generateApiKeyToken()) + ) + + expect(tokens.size).toBe(50) + }) +}) + +describe('previewOfToken', () => { + it('keeps the prefix and the last four characters', () => { + expect(previewOfToken('ocot_abcdefghijklmnop')).toBe('ocot_abcde…mnop') + }) +}) + +describe('createApiKey', () => { + it('records the name, preview and creation time', () => { + const key = createApiKey({ + name: ' Field laptop ', + now, + token: 'ocot_abcdefghijklmnop', + }) + + expect(key.name).toBe('Field laptop') + expect(key.token).toBe('ocot_abcdefghijklmnop') + expect(key.tokenPreview).toBe('ocot_abcde…mnop') + expect(key.createdAt).toBe('2026-08-23T12:00:00.000Z') + expect(key.lastUsedAt).toBeNull() + expect(isApiKeyActive(key)).toBe(true) + }) + + it('names an unnamed key rather than leaving it blank', () => { + expect(createApiKey({ name: ' ', now }).name).toBe('Untitled key') + }) +}) + +describe('revokeApiKey', () => { + it('marks the key revoked and drops the token', () => { + const key = createApiKey({ name: 'Laptop', now }) + const revoked = revokeApiKey(key, new Date('2026-08-24T09:00:00Z')) + + expect(isApiKeyActive(revoked)).toBe(false) + expect(revoked.revokedAt).toBe('2026-08-24T09:00:00.000Z') + expect(revoked.token).toBeUndefined() + expect(revoked.tokenPreview).toBe(key.tokenPreview) + }) +}) + +describe('renameApiKey', () => { + it('trims the new name and keeps the old one when blank', () => { + const key = createApiKey({ name: 'Laptop', now }) + + expect(renameApiKey(key, ' Desktop ').name).toBe('Desktop') + expect(renameApiKey(key, ' ').name).toBe('Laptop') + }) +}) + +describe('sortApiKeys', () => { + it('puts active keys first, newest first within each group', () => { + const older = createApiKey({ + name: 'Older', + now: new Date('2026-08-01T00:00:00Z'), + }) + const newer = createApiKey({ + name: 'Newer', + now: new Date('2026-08-20T00:00:00Z'), + }) + const revoked = revokeApiKey( + createApiKey({ name: 'Revoked', now: new Date('2026-08-22T00:00:00Z') }), + now + ) + + expect(sortApiKeys([older, revoked, newer]).map((key) => key.name)).toEqual( + ['Newer', 'Older', 'Revoked'] + ) + }) +}) + +describe('describeLastUsed', () => { + it('reports never used, revoked, or the date', () => { + const key = createApiKey({ name: 'Laptop', now }) + + expect(describeLastUsed(key)).toBe('Never used') + expect(describeLastUsed(revokeApiKey(key, now))).toBe('Revoked') + + const used: ApiKey = { ...key, lastUsedAt: '2026-08-22T18:00:00Z' } + expect(describeLastUsed(used)).toBe( + new Date('2026-08-22T18:00:00Z').toLocaleDateString() + ) + }) +}) diff --git a/src/test/utils/preferences.test.ts b/src/test/utils/preferences.test.ts new file mode 100644 index 00000000..fdbf05ec --- /dev/null +++ b/src/test/utils/preferences.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + PREFERENCE_KEYS, + readBooleanPreference, + subscribeToPreferences, + writeBooleanPreference, +} from '@/utils/preferences' + +const KEY = PREFERENCE_KEYS.autoCollapseSidebarOnMap + +describe('boolean preferences', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('falls back when nothing is stored', () => { + expect(readBooleanPreference(KEY, true)).toBe(true) + expect(readBooleanPreference(KEY, false)).toBe(false) + }) + + it('round-trips both values', () => { + writeBooleanPreference(KEY, false) + expect(readBooleanPreference(KEY, true)).toBe(false) + + writeBooleanPreference(KEY, true) + expect(readBooleanPreference(KEY, false)).toBe(true) + }) + + it('falls back on a value it did not write', () => { + localStorage.setItem(KEY, 'yes please') + + expect(readBooleanPreference(KEY, true)).toBe(true) + expect(readBooleanPreference(KEY, false)).toBe(false) + }) + + it('notifies subscribers on write, and stops after unsubscribe', () => { + const listener = vi.fn() + const unsubscribe = subscribeToPreferences(listener) + + writeBooleanPreference(KEY, false) + expect(listener).toHaveBeenCalledTimes(1) + + unsubscribe() + writeBooleanPreference(KEY, true) + expect(listener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/test/utils/userProfile.test.ts b/src/test/utils/userProfile.test.ts new file mode 100644 index 00000000..9c640dcc --- /dev/null +++ b/src/test/utils/userProfile.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import type { PortalRole } from '@/utils/accessControl' +import { + formatSessionExpiry, + groupRolesByPortal, + initialsFromName, + isColorModePreference, + resolveColorMode, + roleShortLabel, +} from '@/utils/userProfile' + +describe('resolveColorMode', () => { + it('follows the OS only when the preference is "system"', () => { + expect(resolveColorMode('system', true)).toBe('dark') + expect(resolveColorMode('system', false)).toBe('light') + expect(resolveColorMode('light', true)).toBe('light') + expect(resolveColorMode('dark', false)).toBe('dark') + }) +}) + +describe('isColorModePreference', () => { + it('rejects anything not a stored preference', () => { + expect(isColorModePreference('light')).toBe(true) + expect(isColorModePreference('dark')).toBe(true) + expect(isColorModePreference('system')).toBe(true) + expect(isColorModePreference('sepia')).toBe(false) + expect(isColorModePreference(null)).toBe(false) + expect(isColorModePreference(undefined)).toBe(false) + }) +}) + +describe('initialsFromName', () => { + it('takes the first letter of the first two names', () => { + expect(initialsFromName('Jake Ross')).toBe('JR') + expect(initialsFromName('ada byron lovelace')).toBe('AB') + expect(initialsFromName('Cher')).toBe('C') + }) + + it('falls back when the name is missing or blank', () => { + expect(initialsFromName(undefined)).toBe('?') + expect(initialsFromName(null)).toBe('?') + expect(initialsFromName(' ')).toBe('?') + }) +}) + +describe('groupRolesByPortal', () => { + it('groups by the portal prefix, in the order the roles arrive', () => { + const roles: PortalRole[] = ['AMP.Viewer', 'AMP.Editor', 'Geothermal.Admin'] + + expect(groupRolesByPortal(roles)).toEqual([ + { + portal: 'Aquifer Mapping Program', + roles: ['AMP.Viewer', 'AMP.Editor'], + }, + { portal: 'Geothermal', roles: ['Geothermal.Admin'] }, + ]) + }) + + it('returns nothing for an account with no roles', () => { + expect(groupRolesByPortal([])).toEqual([]) + }) +}) + +describe('roleShortLabel', () => { + it('drops the portal prefix', () => { + expect(roleShortLabel('AMP.Admin')).toBe('Admin') + expect(roleShortLabel('Geothermal.Viewer')).toBe('Viewer') + }) +}) + +describe('formatSessionExpiry', () => { + const now = new Date('2026-08-23T12:00:00Z') + const inMinutes = (minutes: number) => + Math.floor(now.getTime() / 1000) + minutes * 60 + + it('returns null when there is no usable expiry', () => { + expect(formatSessionExpiry(undefined, now)).toBeNull() + expect(formatSessionExpiry(null, now)).toBeNull() + expect(formatSessionExpiry(Number.NaN, now)).toBeNull() + }) + + it('reports an expired token', () => { + expect(formatSessionExpiry(inMinutes(-5), now)).toBe( + 'Expired — you will be signed out shortly' + ) + }) + + it('reports minutes, singular and plural', () => { + expect(formatSessionExpiry(inMinutes(1), now)).toBe('Expires in 1 minute') + expect(formatSessionExpiry(inMinutes(42), now)).toBe( + 'Expires in 42 minutes' + ) + }) + + it('reports hours, with and without leftover minutes', () => { + expect(formatSessionExpiry(inMinutes(60), now)).toBe('Expires in 1 hour') + expect(formatSessionExpiry(inMinutes(150), now)).toBe( + 'Expires in 2 hours 30 minutes' + ) + expect(formatSessionExpiry(inMinutes(61), now)).toBe( + 'Expires in 1 hour 1 minute' + ) + }) + + it('handles the sub-minute case rather than rounding to zero', () => { + expect( + formatSessionExpiry(Math.floor(now.getTime() / 1000) + 20, now) + ).toBe('Expires in under a minute') + }) +}) diff --git a/src/utils/apiKeys.ts b/src/utils/apiKeys.ts new file mode 100644 index 00000000..73f3bc0b --- /dev/null +++ b/src/utils/apiKeys.ts @@ -0,0 +1,98 @@ +/** + * Client-side model for personal API keys. + * + * There is no API behind this yet: the settings page renders the full + * generate / rename / revoke flow against local state so the interaction can + * be reviewed before the endpoints exist. Nothing here talks to a server, and + * nothing here should be treated as a real credential — see `ApiKeysCard`, + * which says so on screen. + * + * When the backend lands, the shapes below are what the page expects; swap the + * local state for the real calls and keep the helpers. + */ + +export type ApiKey = { + id: string + name: string + /** Full token. Only ever held for a freshly generated key, never stored. */ + token?: string + /** The leading characters, which is all a server would return afterwards. */ + tokenPreview: string + createdAt: string + lastUsedAt?: string | null + revokedAt?: string | null +} + +const TOKEN_PREFIX = 'ocot' +const TOKEN_BODY_LENGTH = 32 +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' + +const randomValues = (length: number): number[] => { + const values = new Uint32Array(length) + crypto.getRandomValues(values) + return [...values] +} + +/** + * A token that looks like what the API will issue, so the reveal dialog and + * the copy affordance can be judged at the right length. + */ +export const generateApiKeyToken = (): string => { + const body = randomValues(TOKEN_BODY_LENGTH) + .map((value) => ALPHABET[value % ALPHABET.length]) + .join('') + + return `${TOKEN_PREFIX}_${body}` +} + +/** What a server would show after creation: enough to recognise, not to use. */ +export const previewOfToken = (token: string): string => + // Prefix, separator, five characters — enough to tell two keys apart. + `${token.slice(0, TOKEN_PREFIX.length + 6)}…${token.slice(-4)}` + +export const createApiKey = ({ + name, + now, + token = generateApiKeyToken(), + id, +}: { + name: string + now: Date + token?: string + id?: string +}): ApiKey => ({ + id: id ?? token.slice(-12), + name: name.trim() || 'Untitled key', + token, + tokenPreview: previewOfToken(token), + createdAt: now.toISOString(), + lastUsedAt: null, + revokedAt: null, +}) + +export const isApiKeyActive = (key: ApiKey): boolean => !key.revokedAt + +export const revokeApiKey = (key: ApiKey, now: Date): ApiKey => ({ + ...key, + token: undefined, + revokedAt: now.toISOString(), +}) + +export const renameApiKey = (key: ApiKey, name: string): ApiKey => ({ + ...key, + name: name.trim() || key.name, +}) + +/** Active keys first, newest first within each group. */ +export const sortApiKeys = (keys: ApiKey[]): ApiKey[] => + [...keys].sort((a, b) => { + if (isApiKeyActive(a) !== isApiKeyActive(b)) + return isApiKeyActive(a) ? -1 : 1 + return b.createdAt.localeCompare(a.createdAt) + }) + +export const describeLastUsed = (key: ApiKey): string => { + if (key.revokedAt) return 'Revoked' + if (!key.lastUsedAt) return 'Never used' + return new Date(key.lastUsedAt).toLocaleDateString() +} diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts new file mode 100644 index 00000000..42a49c26 --- /dev/null +++ b/src/utils/preferences.ts @@ -0,0 +1,67 @@ +/** + * Local, per-browser preferences. + * + * These are not account settings: there is no API for user preferences, so + * everything here lives in localStorage and applies to this browser only. The + * store exists so a preference can be changed on the settings page and take + * effect in the shell immediately, without threading a context through every + * component that reads one. + */ + +export const PREFERENCE_KEYS = { + /** Collapse the sidebar on arrival at the map, to maximise the canvas. */ + autoCollapseSidebarOnMap: 'ocotillo.pref.autoCollapseSidebarOnMap', +} as const + +export type PreferenceKey = + (typeof PREFERENCE_KEYS)[keyof typeof PREFERENCE_KEYS] + +const listeners = new Set<() => void>() + +const notify = () => { + for (const listener of listeners) listener() +} + +export const subscribeToPreferences = (listener: () => void): (() => void) => { + listeners.add(listener) + // Another tab writing the same key should move this one too. + const onStorage = (event: StorageEvent) => { + if (!event.key || event.key.startsWith('ocotillo.pref.')) listener() + } + window.addEventListener('storage', onStorage) + + return () => { + listeners.delete(listener) + window.removeEventListener('storage', onStorage) + } +} + +/** + * Anything unparseable falls back rather than throwing: a preference is never + * important enough to break the page that reads it. + */ +export const readBooleanPreference = ( + key: PreferenceKey, + fallback: boolean +): boolean => { + try { + const stored = localStorage.getItem(key) + if (stored === 'true') return true + if (stored === 'false') return false + return fallback + } catch { + return fallback + } +} + +export const writeBooleanPreference = ( + key: PreferenceKey, + value: boolean +): void => { + try { + localStorage.setItem(key, String(value)) + } catch { + // A full or blocked localStorage should not break the toggle that set it. + } + notify() +} diff --git a/src/utils/userProfile.ts b/src/utils/userProfile.ts new file mode 100644 index 00000000..95894422 --- /dev/null +++ b/src/utils/userProfile.ts @@ -0,0 +1,108 @@ +import type { PortalRole } from '@/utils/accessControl' + +/** + * Presentation helpers for the settings page. Everything here is pure so the + * page itself stays a thin arrangement of components — the parts worth testing + * are the fallbacks, which is where identity data actually varies. + */ + +export type ColorModePreference = 'light' | 'dark' | 'system' +export type ResolvedColorMode = 'light' | 'dark' + +export const COLOR_MODE_STORAGE_KEY = 'colorMode' + +export const isColorModePreference = ( + value: unknown +): value is ColorModePreference => + value === 'light' || value === 'dark' || value === 'system' + +/** + * "System" is a preference, not a mode: it resolves against the OS setting at + * render time and has to keep resolving as that setting changes. + */ +export const resolveColorMode = ( + preference: ColorModePreference, + systemPrefersDark: boolean +): ResolvedColorMode => { + if (preference === 'system') return systemPrefersDark ? 'dark' : 'light' + return preference +} + +/** + * Two letters for the avatar block. Names arrive from the id token in whatever + * shape the identity provider has, so anything unusable falls back to "?". + */ +export const initialsFromName = (name: string | undefined | null): string => { + const parts = (name ?? '') + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join('') + + return parts ? parts.toUpperCase() : '?' +} + +export type RoleGroup = { + portal: string + roles: PortalRole[] +} + +const PORTAL_LABELS: Record = { + AMP: 'Aquifer Mapping Program', + Geothermal: 'Geothermal', +} + +/** + * Groups the flat role list by the portal prefix the roles carry, so the page + * can show "Aquifer Mapping Program: Viewer, Editor" rather than six chips + * that all repeat their own prefix. + */ +export const groupRolesByPortal = (roles: PortalRole[]): RoleGroup[] => { + const byPortal = new Map() + + for (const role of roles) { + const [prefix] = role.split('.') + const existing = byPortal.get(prefix) + if (existing) existing.push(role) + else byPortal.set(prefix, [role]) + } + + return [...byPortal.entries()].map(([prefix, portalRoles]) => ({ + portal: PORTAL_LABELS[prefix] ?? prefix, + roles: portalRoles, + })) +} + +/** "AMP.Editor" reads as "Editor" once the portal is the row label. */ +export const roleShortLabel = (role: PortalRole): string => + role.split('.')[1] ?? role + +/** + * How long the current session has left, from the id token's `exp` claim. + * Returns null when there is no usable expiry rather than guessing, so the + * page can omit the row instead of showing a wrong time. + */ +export const formatSessionExpiry = ( + expSeconds: number | undefined | null, + now: Date +): string | null => { + if (!expSeconds || !Number.isFinite(expSeconds)) return null + + const remainingMs = expSeconds * 1000 - now.getTime() + if (remainingMs <= 0) return 'Expired — you will be signed out shortly' + + const minutes = Math.round(remainingMs / 60000) + if (minutes < 1) return 'Expires in under a minute' + if (minutes < 60) + return `Expires in ${minutes} minute${minutes === 1 ? '' : 's'}` + + const hours = Math.floor(minutes / 60) + const leftoverMinutes = minutes % 60 + const hourLabel = `${hours} hour${hours === 1 ? '' : 's'}` + if (leftoverMinutes === 0) return `Expires in ${hourLabel}` + + return `Expires in ${hourLabel} ${leftoverMinutes} minute${ + leftoverMinutes === 1 ? '' : 's' + }` +} From 467a8cf54b957fb0f1d1e593e99c6d4cd955fe55 Mon Sep 17 00:00:00 2001 From: jakeross Date: Fri, 28 Aug 2026 13:42:19 -0700 Subject: [PATCH 2/3] feat(settings): expire API keys and gate the card on OGC.Internal Three changes to the API keys section of the settings page. Field names are snake_case, matching what the API will serialise, so a real response can replace the local state without a translation layer: token_preview, created_at, expires_at, last_used_at, revoked_at. Keys now carry an expiry. A key is issued with a 90-day lifetime and the table gains an Expires column: a plain date while expiry is far off, then a warning-coloured countdown inside the 14-day warning window, then "Expired" in the error colour. An expired key is as unusable as a revoked one, so its actions disable and it sorts below the active keys. Revoked rows show no expiry, which no longer means anything for them. Both thresholds are exported constants; the API will own the real lifetime. The card is gated on the OGC.Internal group, modelled as a CapabilityGroup rather than a PortalRole: it carries no viewer/editor/admin hierarchy, never wins the primary role, and does not belong in the settings page's portal grouping. Accounts without it keep the card but see an alert naming the group to request, since a silently missing card leaves someone guessing. getPermissions previously normalised the groups claim down to portal roles, which discarded OGC.Internal before any component saw it; it and getAccessControlGroups now use normalizeAuthGroups, which carries roles and capability groups through together. Authorisation is unaffected, since canAccessResource re-filters to portal roles internally. Co-Authored-By: Claude Opus 5 --- src/pages/settings/ApiKeysCard.tsx | 84 ++++++++++++++++++++-- src/pages/settings/index.tsx | 4 +- src/providers/authentik-provider.ts | 11 +-- src/test/pages/apiKeysCard.test.tsx | 65 +++++++++++++++-- src/test/utils/accessControl.test.ts | 43 +++++++++++- src/test/utils/apiKeys.test.ts | 101 ++++++++++++++++++++++++--- src/utils/accessControl.ts | 39 +++++++++++ src/utils/apiKeys.ts | 91 +++++++++++++++++++----- 8 files changed, 389 insertions(+), 49 deletions(-) diff --git a/src/pages/settings/ApiKeysCard.tsx b/src/pages/settings/ApiKeysCard.tsx index 91fa9d60..d708a0db 100644 --- a/src/pages/settings/ApiKeysCard.tsx +++ b/src/pages/settings/ApiKeysCard.tsx @@ -1,4 +1,10 @@ -import { Add, ContentCopy, Delete, Edit } from '@mui/icons-material' +import { + Add, + ContentCopy, + Delete, + Edit, + WarningAmber, +} from '@mui/icons-material' import { Alert, Box, @@ -22,9 +28,12 @@ import { } from '@mui/material' import { useState } from 'react' import { SettingsCard } from '@/pages/settings/SettingsCard' +import { OGC_INTERNAL_GROUP } from '@/utils/accessControl' import { type ApiKey, + apiKeyStatus, createApiKey, + describeExpiry, describeLastUsed, isApiKeyActive, renameApiKey, @@ -179,9 +188,12 @@ const RevokeDialog = ({ * and the card says as much rather than letting anyone assume otherwise. */ export const ApiKeysCard = ({ + canManageKeys, initialKeys = [], now = () => new Date(), }: { + /** Whether the account holds the group the API will require. */ + canManageKeys: boolean initialKeys?: ApiKey[] now?: () => Date }) => { @@ -218,7 +230,29 @@ export const ApiKeysCard = ({ setRevoking(null) } - const sorted = sortApiKeys(keys) + // Shown rather than hidden: a missing card leaves someone guessing why, and + // this page exists to answer exactly that kind of question. + if (!canManageKeys) { + return ( + + + API keys are limited to accounts in the{' '} + + {OGC_INTERNAL_GROUP} + {' '} + group, which this account does not hold. Ask an administrator to add + you if you need to reach the API from outside this app. + + + ) + } + + // One reading of the clock per render, so every row agrees on what "now" is. + const at = now() + const sorted = sortApiKeys(keys, at) return ( Name Key Created + Expires Last used Actions {sorted.map((key) => { - const active = isApiKeyActive(key) + const status = apiKeyStatus(key, at) + const active = isApiKeyActive(key, at) + const expiryColor = + status === 'expired' + ? 'error.main' + : status === 'expiring' + ? 'warning.main' + : 'text.secondary' return ( @@ -274,9 +316,9 @@ export const ApiKeysCard = ({ {key.name} - {active ? null : ( + {status === 'revoked' ? ( - )} + ) : null} @@ -285,14 +327,42 @@ export const ApiKeysCard = ({ variant="caption" sx={{ overflowWrap: 'anywhere' }} > - {key.tokenPreview} + {key.token_preview} - {new Date(key.createdAt).toLocaleDateString()} + {new Date(key.created_at).toLocaleDateString()} + + {/* A revoked key's own expiry no longer means anything. */} + {status === 'revoked' ? ( + + — + + ) : ( + + {status === 'expiring' || status === 'expired' ? ( + + ) : null} + + {describeExpiry(key, at)} + + + )} + {describeLastUsed(key)} diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index 47dcb943..3052178b 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -246,7 +246,7 @@ export const SettingsPage = () => { name?: string email?: string }>() - const { roles, primaryRole } = useAccessCapabilities() + const { roles, primaryRole, canManageApiKeys } = useAccessCapabilities() const { preference, setMode } = useContext(ColorModeContext) const [autoCollapseOnMap, setAutoCollapseOnMap] = useBooleanPreference( PREFERENCE_KEYS.autoCollapseSidebarOnMap, @@ -275,7 +275,7 @@ export const SettingsPage = () => { autoCollapseOnMap={autoCollapseOnMap} onAutoCollapseChange={setAutoCollapseOnMap} /> - + ) diff --git a/src/providers/authentik-provider.ts b/src/providers/authentik-provider.ts index d885e02f..ddd32aa3 100644 --- a/src/providers/authentik-provider.ts +++ b/src/providers/authentik-provider.ts @@ -21,7 +21,7 @@ import { STORAGE_KEYS, IS_TESTING_AUTH, } from '@/config' -import { normalizeAccessControlGroups } from '@/utils/accessControl' +import { normalizeAuthGroups } from '@/utils/accessControl' const gravatarUrl = (email: string) => { const hash = email.trim().toLowerCase() @@ -51,6 +51,7 @@ const TEST_AUTH_GROUPS: AuthentikPermissions = [ 'AMP.Admin', 'Geothermal.Viewer', 'Geothermal.Editor', + 'OGC.Internal', ] const PKCE_LOCAL_FALLBACK_TTL_MS = 5 * 60 * 1000 @@ -128,7 +129,9 @@ export const clearPkceFallbacks = (): void => { export const getAccessToken = async ({ refresh, -}: { refresh?: boolean } = {}): Promise => { +}: { + refresh?: boolean +} = {}): Promise => { const currentAccess = localStorage.getItem(STORAGE_KEYS.accessToken) if (!refresh) return currentAccess @@ -175,7 +178,7 @@ export const getAccessControlGroups = (): string[] | null => { try { const decoded = jwtDecode<{ groups?: string[] }>(idToken) - return normalizeAccessControlGroups(decoded.groups) + return normalizeAuthGroups(decoded.groups) } catch { return null } @@ -328,7 +331,7 @@ export const authentikAuthProvider: AuthProvider = { try { const decoded = jwtDecode(idToken) - return normalizeAccessControlGroups(decoded.groups) + return normalizeAuthGroups(decoded.groups) } catch { return null } diff --git a/src/test/pages/apiKeysCard.test.tsx b/src/test/pages/apiKeysCard.test.tsx index 6b6c76b9..c2fb7f3b 100644 --- a/src/test/pages/apiKeysCard.test.tsx +++ b/src/test/pages/apiKeysCard.test.tsx @@ -15,15 +15,29 @@ const laptopKey = createApiKey({ }) describe('ApiKeysCard', () => { + it('explains the missing group instead of hiding the card', () => { + render() + + expect(screen.getByText(/limited to accounts in the/)).toBeInTheDocument() + expect(screen.getByText('OGC.Internal')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Generate key' }) + ).not.toBeInTheDocument() + // No key data leaks into the gated state either. + expect(screen.queryByText('Field laptop')).not.toBeInTheDocument() + }) + it('says it is not connected to the API', () => { - render() + render() expect(screen.getByText(/Preview only/)).toBeInTheDocument() expect(screen.getByText(/No keys yet/)).toBeInTheDocument() }) it('lists existing keys by preview, never the full token', () => { - render( now} />) + render( + now} /> + ) expect(screen.getByText('Field laptop')).toBeInTheDocument() expect(screen.getByText('ocot_abcde…mnop')).toBeInTheDocument() @@ -33,7 +47,7 @@ describe('ApiKeysCard', () => { it('generates a key and shows it once for copying', async () => { const user = userEvent.setup() - render( now} />) + render( now} />) await user.click(screen.getByRole('button', { name: 'Generate key' })) await user.type(screen.getByLabelText('Key name'), 'QGIS at the office') @@ -53,7 +67,7 @@ describe('ApiKeysCard', () => { it('will not generate a key without a name', async () => { const user = userEvent.setup() - render( now} />) + render( now} />) await user.click(screen.getByRole('button', { name: 'Generate key' })) @@ -62,7 +76,9 @@ describe('ApiKeysCard', () => { it('renames a key', async () => { const user = userEvent.setup() - render( now} />) + render( + now} /> + ) await user.click( screen.getByRole('button', { name: 'Rename Field laptop' }) @@ -78,7 +94,9 @@ describe('ApiKeysCard', () => { it('revokes a key only after confirmation', async () => { const user = userEvent.setup() - render( now} />) + render( + now} /> + ) await user.click( screen.getByRole('button', { name: 'Revoke Field laptop' }) @@ -98,9 +116,44 @@ describe('ApiKeysCard', () => { expect(screen.getAllByText('Revoked').length).toBeGreaterThan(0) }) + it('warns on a key that is close to expiring', () => { + const expiring = createApiKey({ + name: 'Expiring soon', + now, + lifetimeDays: 3, + id: 'key-2', + }) + render( + now} /> + ) + + expect(screen.getByText('Expires in 3 days')).toBeInTheDocument() + }) + + it('marks an expired key and disables its actions', () => { + const expired = createApiKey({ + name: 'Old key', + now: new Date('2026-01-01T00:00:00Z'), + lifetimeDays: 30, + id: 'key-3', + }) + render( + now} /> + ) + + expect(screen.getByText('Expired')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Rename Old key' }) + ).toBeDisabled() + expect( + screen.getByRole('button', { name: 'Revoke Old key' }) + ).toBeDisabled() + }) + it('disables the actions on an already revoked key', () => { render( now} /> diff --git a/src/test/utils/accessControl.test.ts b/src/test/utils/accessControl.test.ts index ed5b751e..bd866d12 100644 --- a/src/test/utils/accessControl.test.ts +++ b/src/test/utils/accessControl.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest' import { canAccessResource, getAccessCapabilities, + getPrimaryRole, isResourceListAdminOnly, normalizeAccessControlGroups, + normalizeAuthGroups, + normalizeCapabilityGroups, } from '@/utils/accessControl' import { resources } from '@/resources' @@ -431,7 +434,45 @@ describe('isResourceListAdminOnly', () => { it('returns false for non-admin list resources and unknown resources', () => { expect(isResourceListAdminOnly('ocotillo.thing-well')).toBe(false) - expect(isResourceListAdminOnly('ocotillo.hydrograph-correction')).toBe(false) + expect(isResourceListAdminOnly('ocotillo.hydrograph-correction')).toBe( + false + ) expect(isResourceListAdminOnly('unknown.resource')).toBe(false) }) }) + +describe('capability groups', () => { + const groups = ['AMP.Editor', 'OGC.Internal', 'Something.Else'] + + it('keeps OGC.Internal out of the portal roles', () => { + expect(normalizeAccessControlGroups(groups)).toEqual([ + 'AMP.Viewer', + 'AMP.Editor', + ]) + expect(getPrimaryRole(groups)).toBe('AMP.Editor') + }) + + it('matches capability groups exactly and drops anything unknown', () => { + expect(normalizeCapabilityGroups(groups)).toEqual(['OGC.Internal']) + expect(normalizeCapabilityGroups(['ogc.internal'])).toEqual([]) + expect(normalizeCapabilityGroups(null)).toEqual([]) + }) + + it('carries roles and capability groups through together', () => { + expect(normalizeAuthGroups(groups)).toEqual([ + 'AMP.Viewer', + 'AMP.Editor', + 'OGC.Internal', + ]) + }) + + it('gates API keys on the group, independent of portal role', () => { + expect(getAccessCapabilities(groups).canManageApiKeys).toBe(true) + expect(getAccessCapabilities(['AMP.Admin']).canManageApiKeys).toBe(false) + // A capability group on its own grants no portal access. + const onlyCapability = getAccessCapabilities(['OGC.Internal']) + expect(onlyCapability.canManageApiKeys).toBe(true) + expect(onlyCapability.roles).toEqual([]) + expect(onlyCapability.canViewAmp).toBe(false) + }) +}) diff --git a/src/test/utils/apiKeys.test.ts b/src/test/utils/apiKeys.test.ts index e076a26d..b685a8f9 100644 --- a/src/test/utils/apiKeys.test.ts +++ b/src/test/utils/apiKeys.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest' import { type ApiKey, + API_KEY_EXPIRY_WARNING_DAYS, + apiKeyStatus, createApiKey, + describeExpiry, describeLastUsed, generateApiKeyToken, isApiKeyActive, @@ -46,10 +49,12 @@ describe('createApiKey', () => { expect(key.name).toBe('Field laptop') expect(key.token).toBe('ocot_abcdefghijklmnop') - expect(key.tokenPreview).toBe('ocot_abcde…mnop') - expect(key.createdAt).toBe('2026-08-23T12:00:00.000Z') - expect(key.lastUsedAt).toBeNull() - expect(isApiKeyActive(key)).toBe(true) + expect(key.token_preview).toBe('ocot_abcde…mnop') + expect(key.created_at).toBe('2026-08-23T12:00:00.000Z') + // 90 days after creation, which is the lifetime the API will issue. + expect(key.expires_at).toBe('2026-11-21T12:00:00.000Z') + expect(key.last_used_at).toBeNull() + expect(isApiKeyActive(key, now)).toBe(true) }) it('names an unnamed key rather than leaving it blank', () => { @@ -62,10 +67,10 @@ describe('revokeApiKey', () => { const key = createApiKey({ name: 'Laptop', now }) const revoked = revokeApiKey(key, new Date('2026-08-24T09:00:00Z')) - expect(isApiKeyActive(revoked)).toBe(false) - expect(revoked.revokedAt).toBe('2026-08-24T09:00:00.000Z') + expect(isApiKeyActive(revoked, now)).toBe(false) + expect(revoked.revoked_at).toBe('2026-08-24T09:00:00.000Z') expect(revoked.token).toBeUndefined() - expect(revoked.tokenPreview).toBe(key.tokenPreview) + expect(revoked.token_preview).toBe(key.token_preview) }) }) @@ -93,9 +98,9 @@ describe('sortApiKeys', () => { now ) - expect(sortApiKeys([older, revoked, newer]).map((key) => key.name)).toEqual( - ['Newer', 'Older', 'Revoked'] - ) + expect( + sortApiKeys([older, revoked, newer], now).map((key) => key.name) + ).toEqual(['Newer', 'Older', 'Revoked']) }) }) @@ -106,9 +111,83 @@ describe('describeLastUsed', () => { expect(describeLastUsed(key)).toBe('Never used') expect(describeLastUsed(revokeApiKey(key, now))).toBe('Revoked') - const used: ApiKey = { ...key, lastUsedAt: '2026-08-22T18:00:00Z' } + const used: ApiKey = { ...key, last_used_at: '2026-08-22T18:00:00Z' } expect(describeLastUsed(used)).toBe( new Date('2026-08-22T18:00:00Z').toLocaleDateString() ) }) }) + +const daysFromNow = (days: number): Date => + new Date(now.getTime() + days * 24 * 60 * 60 * 1000) + +describe('apiKeyStatus', () => { + const key = createApiKey({ name: 'Laptop', now }) + + it('is active while expiry is further out than the warning window', () => { + expect(apiKeyStatus(key, daysFromNow(1))).toBe('active') + expect( + apiKeyStatus(key, daysFromNow(90 - API_KEY_EXPIRY_WARNING_DAYS - 1)) + ).toBe('active') + }) + + it('warns once expiry is inside the warning window', () => { + expect( + apiKeyStatus(key, daysFromNow(90 - API_KEY_EXPIRY_WARNING_DAYS)) + ).toBe('expiring') + expect(apiKeyStatus(key, daysFromNow(89.5))).toBe('expiring') + }) + + it('is expired at the expiry instant and after it', () => { + expect(apiKeyStatus(key, daysFromNow(90))).toBe('expired') + expect(apiKeyStatus(key, daysFromNow(120))).toBe('expired') + expect(isApiKeyActive(key, daysFromNow(120))).toBe(false) + }) + + it('reports revocation ahead of expiry', () => { + const revoked = revokeApiKey(key, daysFromNow(1)) + + expect(apiKeyStatus(revoked, daysFromNow(120))).toBe('revoked') + }) +}) + +describe('describeExpiry', () => { + const key = createApiKey({ name: 'Laptop', now }) + + it('counts down in whole days inside the warning window', () => { + expect(describeExpiry(key, daysFromNow(87))).toBe('Expires in 3 days') + expect(describeExpiry(key, daysFromNow(89))).toBe('Expires in 1 day') + // Part of a day left still reads as a day rather than rounding to zero. + expect(describeExpiry(key, daysFromNow(89.5))).toBe('Expires in 1 day') + }) + + it('says expired once the moment has passed', () => { + expect(describeExpiry(key, daysFromNow(90))).toBe('Expired') + }) + + it('shows a plain date while expiry is far off', () => { + expect(describeExpiry(key, daysFromNow(1))).toBe( + new Date(key.expires_at).toLocaleDateString() + ) + }) +}) + +describe('sortApiKeys', () => { + it('drops expired keys below active ones', () => { + const shortLived = createApiKey({ + name: 'Short', + now, + lifetimeDays: 1, + token: 'ocot_aaaaaaaaaaaaaaaa', + }) + const longLived = createApiKey({ + name: 'Long', + now: new Date('2026-08-01T00:00:00Z'), + token: 'ocot_bbbbbbbbbbbbbbbb', + }) + + expect( + sortApiKeys([shortLived, longLived], daysFromNow(5)).map((k) => k.name) + ).toEqual(['Long', 'Short']) + }) +}) diff --git a/src/utils/accessControl.ts b/src/utils/accessControl.ts index b4b1b3a2..ab4c54e7 100644 --- a/src/utils/accessControl.ts +++ b/src/utils/accessControl.ts @@ -12,6 +12,20 @@ export type GeothermalRole = | 'Geothermal.Admin' export type PortalRole = AmpRole | GeothermalRole +/** + * Groups that grant a capability without being a portal role. + * + * These are deliberately outside `PortalRole`: they carry no viewer/editor/admin + * hierarchy, they never win the primary role, and they do not belong in the + * settings page's portal grouping. `OGC.Internal` marks staff allowed to hold + * personal API keys against the internal OGC services. + */ +export type CapabilityGroup = 'OGC.Internal' + +export const OGC_INTERNAL_GROUP: CapabilityGroup = 'OGC.Internal' + +const capabilityGroupOrder: CapabilityGroup[] = [OGC_INTERNAL_GROUP] + const roleOrder: PortalRole[] = [ 'AMP.Viewer', 'AMP.Editor', @@ -186,6 +200,28 @@ export const normalizeAccessControlGroups = ( return roleOrder.filter((role) => expandedRoles.has(role)) } +/** + * Capability groups the account actually holds. Unlike portal roles these are + * matched exactly — nothing expands into anything else. + */ +export const normalizeCapabilityGroups = ( + groups: string[] | null | undefined +): CapabilityGroup[] => { + const held = new Set(groups ?? []) + return capabilityGroupOrder.filter((group) => held.has(group)) +} + +/** + * Everything the app understands from an id token's groups claim: portal roles + * first, then capability groups. Anything unrecognised is still dropped. + */ +export const normalizeAuthGroups = ( + groups: string[] | null | undefined +): string[] => [ + ...normalizeAccessControlGroups(groups), + ...normalizeCapabilityGroups(groups), +] + export const getPrimaryRole = ( groups: string[] | null | undefined ): PortalRole | null => { @@ -195,6 +231,7 @@ export const getPrimaryRole = ( export const getAccessCapabilities = (groups: string[] | null | undefined) => { const roles = normalizeAccessControlGroups(groups) + const capabilityGroups = normalizeCapabilityGroups(groups) const primaryRole = getPrimaryRole(groups) const canViewAmp = roles.includes('AMP.Viewer') || @@ -214,6 +251,7 @@ export const getAccessCapabilities = (groups: string[] | null | undefined) => { return { roles, + capabilityGroups, primaryRole, canViewAmp, canEditAmp, @@ -224,6 +262,7 @@ export const getAccessCapabilities = (groups: string[] | null | undefined) => { canEditGeothermal, canManageGeothermal, canViewLexicon: canEditAmp, + canManageApiKeys: capabilityGroups.includes(OGC_INTERNAL_GROUP), // Change canManageAmp → canEditAmp here when editors should get well editing access. canEditWell: canManageAmp, } diff --git a/src/utils/apiKeys.ts b/src/utils/apiKeys.ts index 73f3bc0b..2a3b0888 100644 --- a/src/utils/apiKeys.ts +++ b/src/utils/apiKeys.ts @@ -8,7 +8,9 @@ * which says so on screen. * * When the backend lands, the shapes below are what the page expects; swap the - * local state for the real calls and keep the helpers. + * local state for the real calls and keep the helpers. Field names are + * snake_case to match what the API serialises, so a real response can be + * dropped in without a translation layer in between. */ export type ApiKey = { @@ -17,16 +19,31 @@ export type ApiKey = { /** Full token. Only ever held for a freshly generated key, never stored. */ token?: string /** The leading characters, which is all a server would return afterwards. */ - tokenPreview: string - createdAt: string - lastUsedAt?: string | null - revokedAt?: string | null + token_preview: string + created_at: string + expires_at: string + last_used_at?: string | null + revoked_at?: string | null } +/** What a key is worth at a glance: usable, nearly stale, or finished. */ +export type ApiKeyStatus = 'active' | 'expiring' | 'expired' | 'revoked' + const TOKEN_PREFIX = 'ocot' const TOKEN_BODY_LENGTH = 32 const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' +/** How long an issued key lasts. The API will own this; the page mirrors it. */ +export const API_KEY_LIFETIME_DAYS = 90 + +/** + * How early the page starts warning. Long enough that someone who only opens + * settings occasionally still sees the warning before the key stops working. + */ +export const API_KEY_EXPIRY_WARNING_DAYS = 14 + +const MS_PER_DAY = 24 * 60 * 60 * 1000 + const randomValues = (length: number): number[] => { const values = new Uint32Array(length) crypto.getRandomValues(values) @@ -55,27 +72,51 @@ export const createApiKey = ({ now, token = generateApiKeyToken(), id, + lifetimeDays = API_KEY_LIFETIME_DAYS, }: { name: string now: Date token?: string id?: string + lifetimeDays?: number }): ApiKey => ({ id: id ?? token.slice(-12), name: name.trim() || 'Untitled key', token, - tokenPreview: previewOfToken(token), - createdAt: now.toISOString(), - lastUsedAt: null, - revokedAt: null, + token_preview: previewOfToken(token), + created_at: now.toISOString(), + expires_at: new Date(now.getTime() + lifetimeDays * MS_PER_DAY).toISOString(), + last_used_at: null, + revoked_at: null, }) -export const isApiKeyActive = (key: ApiKey): boolean => !key.revokedAt +/** + * Whole days left, rounded up, so a key with any part of a day left still + * reads as "1 day" rather than "0". + */ +export const daysUntilExpiry = (key: ApiKey, now: Date): number => + Math.ceil((new Date(key.expires_at).getTime() - now.getTime()) / MS_PER_DAY) + +export const apiKeyStatus = (key: ApiKey, now: Date): ApiKeyStatus => { + // Revocation is deliberate and outranks expiry, which merely happens. + if (key.revoked_at) return 'revoked' + + const remaining = daysUntilExpiry(key, now) + if (remaining <= 0) return 'expired' + if (remaining <= API_KEY_EXPIRY_WARNING_DAYS) return 'expiring' + return 'active' +} + +/** An expired key is as dead as a revoked one — neither can be used again. */ +export const isApiKeyActive = (key: ApiKey, now: Date): boolean => { + const status = apiKeyStatus(key, now) + return status === 'active' || status === 'expiring' +} export const revokeApiKey = (key: ApiKey, now: Date): ApiKey => ({ ...key, token: undefined, - revokedAt: now.toISOString(), + revoked_at: now.toISOString(), }) export const renameApiKey = (key: ApiKey, name: string): ApiKey => ({ @@ -84,15 +125,29 @@ export const renameApiKey = (key: ApiKey, name: string): ApiKey => ({ }) /** Active keys first, newest first within each group. */ -export const sortApiKeys = (keys: ApiKey[]): ApiKey[] => +export const sortApiKeys = (keys: ApiKey[], now: Date): ApiKey[] => [...keys].sort((a, b) => { - if (isApiKeyActive(a) !== isApiKeyActive(b)) - return isApiKeyActive(a) ? -1 : 1 - return b.createdAt.localeCompare(a.createdAt) + if (isApiKeyActive(a, now) !== isApiKeyActive(b, now)) + return isApiKeyActive(a, now) ? -1 : 1 + return b.created_at.localeCompare(a.created_at) }) export const describeLastUsed = (key: ApiKey): string => { - if (key.revokedAt) return 'Revoked' - if (!key.lastUsedAt) return 'Never used' - return new Date(key.lastUsedAt).toLocaleDateString() + if (key.revoked_at) return 'Revoked' + if (!key.last_used_at) return 'Never used' + return new Date(key.last_used_at).toLocaleDateString() +} + +/** + * The expiry column. A key close to its end says how long is left, in the + * units someone would act on; anything further out is just a date. + */ +export const describeExpiry = (key: ApiKey, now: Date): string => { + const status = apiKeyStatus(key, now) + if (status === 'expired') return 'Expired' + if (status === 'expiring') { + const remaining = daysUntilExpiry(key, now) + return remaining === 1 ? 'Expires in 1 day' : `Expires in ${remaining} days` + } + return new Date(key.expires_at).toLocaleDateString() } From 287e155fc4db1edba9cbcbd0beece15b966c94e6 Mon Sep 17 00:00:00 2001 From: jakeross Date: Sun, 30 Aug 2026 16:04:55 -0700 Subject: [PATCH 3/3] feat(settings): issue real API keys, and say how to use one The card ran the whole generate / rename / revoke flow against local component state, because no endpoint existed to run it against. `/api_key` exists now, so the card talks to it: `useApiKeys` lists, issues, renames and revokes, and every mutation invalidates the list rather than patching the cache, since a key's status and its last-used stamp both move on the server's clock. The disclaimer about keys not being real credentials is gone with the state that made it true. What replaces it is what a key actually is: it reaches the internal OGC collections and nothing else, and it is shown once, at creation, because the server keeps only the digest. `apiKeys.ts` loses the token generator and the state mutators and gains zod schemas mirroring `schemas/api_key.py`. `zNewApiKey` is the only shape carrying a token. The presentation helpers are unchanged; the snake_case field names they were rewritten for turn out to match the route exactly, so nothing translates between the two. A new dialog covers connecting ArcGIS Pro, which is the reason keys exist: Pro cannot carry an Authentik bearer token, so it authenticates with Basic and a saved login, or with a `token` request parameter when an intermediary refuses Basic. The server URL is built from the configured API base rather than hard-coded, so it is right in every environment. Note the deploy order: `/api_key` ships on OcotilloAPI's feat/api-key-management branch. Until that merges and deploys, this card shows a load error rather than a list, which is why the failure is rendered rather than swallowed. Co-Authored-By: Claude Opus 5 --- src/hooks/index.ts | 1 + src/hooks/useApiKeys.ts | 82 +++++++++++ src/pages/settings/ApiKeysCard.tsx | 217 +++++++++++++++++++++++----- src/test/pages/apiKeysCard.test.tsx | 197 ++++++++++++++++++------- src/test/utils/apiKeys.test.ts | 198 ++++++++++--------------- src/utils/apiKeys.ts | 127 +++++----------- 6 files changed, 521 insertions(+), 301 deletions(-) create mode 100644 src/hooks/useApiKeys.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 1f5a368a..8957f311 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,6 +4,7 @@ export * from './useAccessCapabilities' export * from './useSearchHistory' export * from './useAll' export * from './useAllNotes' +export * from './useApiKeys' export * from './useDebounce' export * from './useElevation' export * from './useGisArtifacts' diff --git a/src/hooks/useApiKeys.ts b/src/hooks/useApiKeys.ts new file mode 100644 index 00000000..0fe08283 --- /dev/null +++ b/src/hooks/useApiKeys.ts @@ -0,0 +1,82 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { axiosCall, fetcher } from '@/providers/ocotillo-data-provider' +import { + type ApiKey, + type NewApiKey, + zApiKeyList, + zNewApiKey, +} from '@/utils/apiKeys' + +/** + * Personal API keys (`/api_key`), for the settings page. + * + * The route answers with the caller's own keys and nothing else — ownership is + * the `sub` claim on the token, never a parameter — so there is nothing to + * filter and one query key serves the whole card. + */ +export const useApiKeys = () => + useQuery({ + queryKey: ['api-keys'], + queryFn: async () => { + const response = await fetcher('api_key') + return zApiKeyList.parse(response.data) + }, + }) + +/** + * Every mutation invalidates the list rather than patching the cache. A key's + * rendered status depends on the server's clock, and `last_used_at` moves + * without this client doing anything, so the authoritative row is the one the + * next read returns. + */ +const useApiKeyMutation = ( + mutationFn: (variables: TVariables) => Promise +) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['api-keys'] }) + }, + }) +} + +/** + * Issue a key. The response is the only place the token ever appears — the + * server stores a digest — so whatever calls this owns showing it once. + */ +export const useCreateApiKey = () => + useApiKeyMutation<{ name: string; lifetimeDays?: number }, NewApiKey>( + async ({ name, lifetimeDays }) => { + const response = await axiosCall('api_key', { + method: 'POST', + data: { + name, + // Omitted rather than guessed: the API owns the default and clamps + // anything longer than its maximum. + ...(lifetimeDays === undefined + ? {} + : { lifetime_days: lifetimeDays }), + }, + }) + return zNewApiKey.parse(response.data) + } + ) + +export const useRenameApiKey = () => + useApiKeyMutation<{ id: number; name: string }, ApiKey>( + async ({ id, name }) => { + const response = await axiosCall(`api_key/${id}`, { + method: 'PATCH', + data: { name }, + }) + return zApiKeyList.element.parse(response.data) + } + ) + +/** Revocation answers 204, so there is no body to parse or return. */ +export const useRevokeApiKey = () => + useApiKeyMutation(async (id) => { + await axiosCall(`api_key/${id}`, { method: 'DELETE' }) + }) diff --git a/src/pages/settings/ApiKeysCard.tsx b/src/pages/settings/ApiKeysCard.tsx index d708a0db..7523c428 100644 --- a/src/pages/settings/ApiKeysCard.tsx +++ b/src/pages/settings/ApiKeysCard.tsx @@ -10,6 +10,7 @@ import { Box, Button, Chip, + CircularProgress, Dialog, DialogActions, DialogContent, @@ -27,17 +28,22 @@ import { Typography, } from '@mui/material' import { useState } from 'react' +import { + useApiKeys, + useCreateApiKey, + useRenameApiKey, + useRevokeApiKey, +} from '@/hooks' import { SettingsCard } from '@/pages/settings/SettingsCard' +import { settings } from '@/settings' import { OGC_INTERNAL_GROUP } from '@/utils/accessControl' import { type ApiKey, apiKeyStatus, - createApiKey, describeExpiry, describeLastUsed, isApiKeyActive, - renameApiKey, - revokeApiKey, + type NewApiKey, sortApiKeys, } from '@/utils/apiKeys' @@ -50,7 +56,7 @@ const NewKeyDialog = ({ apiKey, onClose, }: { - apiKey: ApiKey | null + apiKey: NewApiKey | null onClose: () => void }) => { const [copied, setCopied] = useState(false) @@ -179,55 +185,165 @@ const RevokeDialog = ({ ) +/** The one URL a desktop client connects to. A key reaches this and nothing else. */ +const INTERNAL_OGC_URL = `${settings.ocotillo_api_url.replace(/\/+$/, '')}/ogcapi-internal` + +/** + * How to use a key from ArcGIS Pro. + * + * Pro cannot carry an Authentik bearer token, which is why keys exist at all: + * Basic auth with a saved login is the only scheme its OGC API connection + * dialog supports, and the query parameter is the fallback for when an + * intermediary refuses Basic. + */ +const ArcGisDialog = ({ + open, + onClose, +}: { + open: boolean + onClose: () => void +}) => { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(INTERNAL_OGC_URL) + setCopied(true) + } + + return ( + + Connecting from ArcGIS Pro + + + + A key connects ArcGIS Pro to the internal OGC collections, which + include draft records and skip the public filters. Generate the key + first. It is shown once, so copy it before you start. + + + + + Server URL + + + + {INTERNAL_OGC_URL} + + + + + + + + + + + + Basic authentication (preferred) + + +
  • + InsertConnections →{' '} + ServerNew OGC API Server. +
  • +
  • Paste the server URL above.
  • +
  • + Authentication: Server Authentication. Any + username works, so use apikey. Password: your key. +
  • +
  • + Check Save Login so Pro keeps the key with the + connection. +
  • +
    +
    + + + + If Basic is refused + + + Leave Authentication as No Authentication. Add a + custom request parameter named token with your key as + the value. Pro re-appends it to every request, including paging. + + +
    +
    + + + +
    + ) +} + /** * Personal API keys. * - * Deliberately not wired to the backend: the endpoints do not exist yet, so - * this renders the whole flow against local state to settle the interaction - * first. Keys generated here are not credentials and do not survive a reload, - * and the card says as much rather than letting anyone assume otherwise. + * A key here is a real credential for `/ogcapi-internal` and nothing else. The + * token is shown once, at creation, because that is the only time the server + * has it — everything after reads the digest. */ export const ApiKeysCard = ({ canManageKeys, - initialKeys = [], now = () => new Date(), }: { - /** Whether the account holds the group the API will require. */ + /** Whether the account holds the group the route requires. */ canManageKeys: boolean - initialKeys?: ApiKey[] now?: () => Date }) => { - const [keys, setKeys] = useState(initialKeys) - const [newKey, setNewKey] = useState(null) + const [newKey, setNewKey] = useState(null) const [isGenerating, setIsGenerating] = useState(false) const [editing, setEditing] = useState(null) const [revoking, setRevoking] = useState(null) + const [isShowingArcGis, setIsShowingArcGis] = useState(false) + + const keys = useApiKeys() + const createKey = useCreateApiKey() + const renameKey = useRenameApiKey() + const revokeKey = useRevokeApiKey() const handleGenerate = (name: string) => { - const created = createApiKey({ name, now: now() }) - setKeys((existing) => [created, ...existing]) - setIsGenerating(false) - setNewKey(created) + createKey.mutate( + { name }, + { + onSuccess: (created) => { + setIsGenerating(false) + // The one moment the token exists outside the server. + setNewKey(created) + }, + } + ) } const handleRename = (name: string) => { if (!editing) return - setKeys((existing) => - existing.map((key) => - key.id === editing.id ? renameApiKey(key, name) : key - ) + renameKey.mutate( + { id: editing.id, name }, + { onSuccess: () => setEditing(null) } ) - setEditing(null) } const handleRevoke = () => { if (!revoking) return - setKeys((existing) => - existing.map((key) => - key.id === revoking.id ? revokeApiKey(key, now()) : key - ) - ) - setRevoking(null) + revokeKey.mutate(revoking.id, { onSuccess: () => setRevoking(null) }) } // Shown rather than hidden: a missing card leaves someone guessing why, and @@ -252,7 +368,7 @@ export const ApiKeysCard = ({ // One reading of the clock per render, so every row agrees on what "now" is. const at = now() - const sorted = sortApiKeys(keys, at) + const sorted = keys.data ? sortApiKeys(keys.data, at) : [] return ( - Preview only. The API does not issue keys yet, so keys created here - are not real credentials and disappear when you reload the page. + A key reaches the internal OGC collections and nothing else. It is + shown once, when it is created. - + {keys.isError ? ( + + Failed to load your keys. + {keys.error instanceof Error ? ` ${keys.error.message}` : null} + + ) : null} + + {createKey.isError ? ( + + Failed to issue a key. + {createKey.error instanceof Error + ? ` ${createKey.error.message}` + : null} + + ) : null} + + + - + - {sorted.length === 0 ? ( + {keys.isLoading ? ( + + + + Loading your keys... + + + ) : sorted.length === 0 ? ( No keys yet. Generate one to use the API outside this app. @@ -430,6 +573,10 @@ export const ApiKeysCard = ({ onConfirm={handleRevoke} /> setNewKey(null)} /> + setIsShowingArcGis(false)} + /> ) } diff --git a/src/test/pages/apiKeysCard.test.tsx b/src/test/pages/apiKeysCard.test.tsx index c2fb7f3b..996950f2 100644 --- a/src/test/pages/apiKeysCard.test.tsx +++ b/src/test/pages/apiKeysCard.test.tsx @@ -1,22 +1,73 @@ // @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 { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { ApiKeysCard } from '@/pages/settings/ApiKeysCard' -import { createApiKey, revokeApiKey } from '@/utils/apiKeys' +import { type ApiKey, zApiKey } from '@/utils/apiKeys' + +const { useApiKeysMock, createMutateMock, renameMutateMock, revokeMutateMock } = + vi.hoisted(() => ({ + useApiKeysMock: vi.fn(), + createMutateMock: vi.fn(), + renameMutateMock: vi.fn(), + revokeMutateMock: vi.fn(), + })) + +vi.mock('@/hooks', () => ({ + useApiKeys: () => useApiKeysMock(), + useCreateApiKey: () => ({ + mutate: createMutateMock, + isPending: false, + isError: false, + error: null, + }), + useRenameApiKey: () => ({ + mutate: renameMutateMock, + isPending: false, + isError: false, + error: null, + }), + useRevokeApiKey: () => ({ + mutate: revokeMutateMock, + isPending: false, + isError: false, + error: null, + }), +})) const now = new Date('2026-08-23T12:00:00Z') -const laptopKey = createApiKey({ - name: 'Field laptop', - now, - token: 'ocot_abcdefghijklmnop', - id: 'key-1', +const key = (overrides: Partial = {}): ApiKey => + zApiKey.parse({ + id: 1, + name: 'Field laptop', + token_preview: 'ocot_abcde…mnop', + scope: 'ogc_internal', + created_at: '2026-08-23T12:00:00.000Z', + expires_at: '2026-11-21T12:00:00.000Z', + last_used_at: null, + revoked_at: null, + ...overrides, + }) + +const listed = (rows: ApiKey[]) => ({ + data: rows, + isLoading: false, + isError: false, + error: null, +}) + +beforeEach(() => { + useApiKeysMock.mockReset().mockReturnValue(listed([])) + createMutateMock.mockReset() + renameMutateMock.mockReset() + revokeMutateMock.mockReset() }) describe('ApiKeysCard', () => { it('explains the missing group instead of hiding the card', () => { - render() + useApiKeysMock.mockReturnValue(listed([key()])) + render() expect(screen.getByText(/limited to accounts in the/)).toBeInTheDocument() expect(screen.getByText('OGC.Internal')).toBeInTheDocument() @@ -27,25 +78,38 @@ describe('ApiKeysCard', () => { expect(screen.queryByText('Field laptop')).not.toBeInTheDocument() }) - it('says it is not connected to the API', () => { + it('says what a key reaches', () => { render() - expect(screen.getByText(/Preview only/)).toBeInTheDocument() + expect( + screen.getByText(/internal OGC collections and nothing else/) + ).toBeInTheDocument() expect(screen.getByText(/No keys yet/)).toBeInTheDocument() }) - it('lists existing keys by preview, never the full token', () => { - render( - now} /> - ) + it('surfaces a failure to load rather than showing an empty table', () => { + useApiKeysMock.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('404 Not Found'), + }) + render() + + expect(screen.getByText(/Failed to load your keys/)).toBeInTheDocument() + expect(screen.getByText(/404 Not Found/)).toBeInTheDocument() + }) + + it('lists existing keys by preview, never a full token', () => { + useApiKeysMock.mockReturnValue(listed([key()])) + render( now} />) expect(screen.getByText('Field laptop')).toBeInTheDocument() expect(screen.getByText('ocot_abcde…mnop')).toBeInTheDocument() - expect(screen.queryByText('ocot_abcdefghijklmnop')).not.toBeInTheDocument() expect(screen.getByText('Never used')).toBeInTheDocument() }) - it('generates a key and shows it once for copying', async () => { + it('issues a key and shows the token once', async () => { const user = userEvent.setup() render( now} />) @@ -53,16 +117,33 @@ describe('ApiKeysCard', () => { await user.type(screen.getByLabelText('Key name'), 'QGIS at the office') await user.click(screen.getByRole('button', { name: 'Generate' })) - const dialog = screen.getByRole('dialog') + expect(createMutateMock).toHaveBeenCalledWith( + { name: 'QGIS at the office' }, + expect.anything() + ) + + // The card shows what the create response carried, which is the only time + // the token exists outside the server. + const [, options] = createMutateMock.mock.calls.at(-1) ?? [] + const created = { + ...key({ id: 2, name: 'QGIS at the office' }), + token: 'ocot_abcdefghijklmnopqrstuvwxyz012345', + } + // The state this drives is React's, so it has to settle before the reveal + // dialog can be queried. + await act(async () => { + options.onSuccess(created) + }) + + const dialog = await screen.findByRole('dialog') expect( within(dialog).getByText(/only time the full key/) ).toBeInTheDocument() - expect(within(dialog).getByText(/^ocot_[a-z0-9]{32}$/)).toBeInTheDocument() + expect(within(dialog).getByText(created.token)).toBeInTheDocument() await user.click(within(dialog).getByRole('button', { name: 'Done' })) - expect(screen.getByText('QGIS at the office')).toBeInTheDocument() - expect(screen.queryByText(/^ocot_[a-z0-9]{32}$/)).not.toBeInTheDocument() + expect(screen.queryByText(created.token)).not.toBeInTheDocument() }) it('will not generate a key without a name', async () => { @@ -72,13 +153,13 @@ describe('ApiKeysCard', () => { await user.click(screen.getByRole('button', { name: 'Generate key' })) expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled() + expect(createMutateMock).not.toHaveBeenCalled() }) - it('renames a key', async () => { + it('renames a key by id', async () => { const user = userEvent.setup() - render( - now} /> - ) + useApiKeysMock.mockReturnValue(listed([key({ id: 7 })])) + render( now} />) await user.click( screen.getByRole('button', { name: 'Rename Field laptop' }) @@ -88,58 +169,67 @@ describe('ApiKeysCard', () => { await user.type(field, 'Field tablet') await user.click(screen.getByRole('button', { name: 'Save' })) - expect(screen.getByText('Field tablet')).toBeInTheDocument() - expect(screen.queryByText('Field laptop')).not.toBeInTheDocument() + expect(renameMutateMock).toHaveBeenCalledWith( + { id: 7, name: 'Field tablet' }, + expect.anything() + ) }) it('revokes a key only after confirmation', async () => { const user = userEvent.setup() - render( - now} /> - ) + useApiKeysMock.mockReturnValue(listed([key({ id: 7 })])) + render( now} />) await user.click( screen.getByRole('button', { name: 'Revoke Field laptop' }) ) await user.click(screen.getByRole('button', { name: 'Cancel' })) - // The dialog fades out; the table underneath stays aria-hidden until it has. await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument() ) - expect(screen.queryByText('Revoked')).not.toBeInTheDocument() + expect(revokeMutateMock).not.toHaveBeenCalled() await user.click( screen.getByRole('button', { name: 'Revoke Field laptop' }) ) await user.click(screen.getByRole('button', { name: 'Revoke key' })) - expect(screen.getAllByText('Revoked').length).toBeGreaterThan(0) + expect(revokeMutateMock).toHaveBeenCalledWith(7, expect.anything()) + }) + + it('explains the ArcGIS Pro connection, URL and all', async () => { + const user = userEvent.setup() + render( now} />) + + await user.click( + screen.getByRole('button', { name: 'Connecting from ArcGIS Pro' }) + ) + + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText(/\/ogcapi-internal$/)).toBeInTheDocument() + expect( + within(dialog).getByText(/Server Authentication/) + ).toBeInTheDocument() + // The fallback, and why it is the fallback. + expect( + within(dialog).getByText(/custom request parameter/) + ).toBeInTheDocument() }) it('warns on a key that is close to expiring', () => { - const expiring = createApiKey({ - name: 'Expiring soon', - now, - lifetimeDays: 3, - id: 'key-2', - }) - render( - now} /> + useApiKeysMock.mockReturnValue( + listed([key({ expires_at: '2026-08-26T12:00:00.000Z' })]) ) + render( now} />) expect(screen.getByText('Expires in 3 days')).toBeInTheDocument() }) it('marks an expired key and disables its actions', () => { - const expired = createApiKey({ - name: 'Old key', - now: new Date('2026-01-01T00:00:00Z'), - lifetimeDays: 30, - id: 'key-3', - }) - render( - now} /> + useApiKeysMock.mockReturnValue( + listed([key({ name: 'Old key', expires_at: '2026-01-31T00:00:00.000Z' })]) ) + render( now} />) expect(screen.getByText('Expired')).toBeInTheDocument() expect( @@ -151,13 +241,10 @@ describe('ApiKeysCard', () => { }) it('disables the actions on an already revoked key', () => { - render( - now} - /> + useApiKeysMock.mockReturnValue( + listed([key({ revoked_at: '2026-08-24T09:00:00.000Z' })]) ) + render( now} />) expect( screen.getByRole('button', { name: 'Rename Field laptop' }) diff --git a/src/test/utils/apiKeys.test.ts b/src/test/utils/apiKeys.test.ts index b685a8f9..67c4ef7a 100644 --- a/src/test/utils/apiKeys.test.ts +++ b/src/test/utils/apiKeys.test.ts @@ -1,193 +1,143 @@ // @vitest-environment jsdom import { describe, expect, it } from 'vitest' import { - type ApiKey, API_KEY_EXPIRY_WARNING_DAYS, + type ApiKey, apiKeyStatus, - createApiKey, describeExpiry, describeLastUsed, - generateApiKeyToken, isApiKeyActive, - previewOfToken, - renameApiKey, - revokeApiKey, sortApiKeys, + zApiKey, + zNewApiKey, } from '@/utils/apiKeys' const now = new Date('2026-08-23T12:00:00Z') -describe('generateApiKeyToken', () => { - it('produces a prefixed token of a fixed shape', () => { - const token = generateApiKeyToken() - - expect(token).toMatch(/^ocot_[a-z0-9]{32}$/) - }) - - it('does not repeat itself', () => { - const tokens = new Set( - Array.from({ length: 50 }, () => generateApiKeyToken()) - ) - - expect(tokens.size).toBe(50) - }) -}) - -describe('previewOfToken', () => { - it('keeps the prefix and the last four characters', () => { - expect(previewOfToken('ocot_abcdefghijklmnop')).toBe('ocot_abcde…mnop') - }) -}) - -describe('createApiKey', () => { - it('records the name, preview and creation time', () => { - const key = createApiKey({ - name: ' Field laptop ', - now, - token: 'ocot_abcdefghijklmnop', - }) - - expect(key.name).toBe('Field laptop') - expect(key.token).toBe('ocot_abcdefghijklmnop') - expect(key.token_preview).toBe('ocot_abcde…mnop') - expect(key.created_at).toBe('2026-08-23T12:00:00.000Z') - // 90 days after creation, which is the lifetime the API will issue. - expect(key.expires_at).toBe('2026-11-21T12:00:00.000Z') - expect(key.last_used_at).toBeNull() - expect(isApiKeyActive(key, now)).toBe(true) - }) - - it('names an unnamed key rather than leaving it blank', () => { - expect(createApiKey({ name: ' ', now }).name).toBe('Untitled key') +const key = (overrides: Partial = {}): ApiKey => + zApiKey.parse({ + id: 1, + name: 'Field laptop', + token_preview: 'ocot_abcde…mnop', + scope: 'ogc_internal', + created_at: '2026-08-23T12:00:00.000Z', + // 90 days out, which is what the API issues by default. + expires_at: '2026-11-21T12:00:00.000Z', + ...overrides, }) -}) -describe('revokeApiKey', () => { - it('marks the key revoked and drops the token', () => { - const key = createApiKey({ name: 'Laptop', now }) - const revoked = revokeApiKey(key, new Date('2026-08-24T09:00:00Z')) - - expect(isApiKeyActive(revoked, now)).toBe(false) - expect(revoked.revoked_at).toBe('2026-08-24T09:00:00.000Z') - expect(revoked.token).toBeUndefined() - expect(revoked.token_preview).toBe(key.token_preview) - }) -}) +const daysFromNow = (days: number): Date => + new Date(now.getTime() + days * 24 * 60 * 60 * 1000) -describe('renameApiKey', () => { - it('trims the new name and keeps the old one when blank', () => { - const key = createApiKey({ name: 'Laptop', now }) +describe('zApiKey', () => { + it('defaults the nullable stamps the API may omit', () => { + const parsed = key() - expect(renameApiKey(key, ' Desktop ').name).toBe('Desktop') - expect(renameApiKey(key, ' ').name).toBe('Laptop') + expect(parsed.last_used_at).toBeNull() + expect(parsed.revoked_at).toBeNull() }) -}) - -describe('sortApiKeys', () => { - it('puts active keys first, newest first within each group', () => { - const older = createApiKey({ - name: 'Older', - now: new Date('2026-08-01T00:00:00Z'), - }) - const newer = createApiKey({ - name: 'Newer', - now: new Date('2026-08-20T00:00:00Z'), - }) - const revoked = revokeApiKey( - createApiKey({ name: 'Revoked', now: new Date('2026-08-22T00:00:00Z') }), - now - ) + it('carries a field the console does not know about', () => { expect( - sortApiKeys([older, revoked, newer], now).map((key) => key.name) - ).toEqual(['Newer', 'Older', 'Revoked']) + zApiKey.parse({ + ...key(), + owner_name: 'someone@example.org', + }) + ).toHaveProperty('owner_name') }) -}) - -describe('describeLastUsed', () => { - it('reports never used, revoked, or the date', () => { - const key = createApiKey({ name: 'Laptop', now }) - expect(describeLastUsed(key)).toBe('Never used') - expect(describeLastUsed(revokeApiKey(key, now))).toBe('Revoked') - - const used: ApiKey = { ...key, last_used_at: '2026-08-22T18:00:00Z' } - expect(describeLastUsed(used)).toBe( - new Date('2026-08-22T18:00:00Z').toLocaleDateString() + it('only the create response carries a token', () => { + expect(() => zNewApiKey.parse(key())).toThrow() + expect(zNewApiKey.parse({ ...key(), token: 'ocot_secret' }).token).toBe( + 'ocot_secret' ) }) }) -const daysFromNow = (days: number): Date => - new Date(now.getTime() + days * 24 * 60 * 60 * 1000) - describe('apiKeyStatus', () => { - const key = createApiKey({ name: 'Laptop', now }) - it('is active while expiry is further out than the warning window', () => { - expect(apiKeyStatus(key, daysFromNow(1))).toBe('active') + expect(apiKeyStatus(key(), daysFromNow(1))).toBe('active') expect( - apiKeyStatus(key, daysFromNow(90 - API_KEY_EXPIRY_WARNING_DAYS - 1)) + apiKeyStatus(key(), daysFromNow(90 - API_KEY_EXPIRY_WARNING_DAYS - 1)) ).toBe('active') }) it('warns once expiry is inside the warning window', () => { expect( - apiKeyStatus(key, daysFromNow(90 - API_KEY_EXPIRY_WARNING_DAYS)) + apiKeyStatus(key(), daysFromNow(90 - API_KEY_EXPIRY_WARNING_DAYS)) ).toBe('expiring') - expect(apiKeyStatus(key, daysFromNow(89.5))).toBe('expiring') + expect(apiKeyStatus(key(), daysFromNow(89.5))).toBe('expiring') }) it('is expired at the expiry instant and after it', () => { - expect(apiKeyStatus(key, daysFromNow(90))).toBe('expired') - expect(apiKeyStatus(key, daysFromNow(120))).toBe('expired') - expect(isApiKeyActive(key, daysFromNow(120))).toBe(false) + expect(apiKeyStatus(key(), daysFromNow(90))).toBe('expired') + expect(isApiKeyActive(key(), daysFromNow(120))).toBe(false) }) it('reports revocation ahead of expiry', () => { - const revoked = revokeApiKey(key, daysFromNow(1)) + const revoked = key({ revoked_at: '2026-08-24T09:00:00.000Z' }) expect(apiKeyStatus(revoked, daysFromNow(120))).toBe('revoked') }) }) describe('describeExpiry', () => { - const key = createApiKey({ name: 'Laptop', now }) - it('counts down in whole days inside the warning window', () => { - expect(describeExpiry(key, daysFromNow(87))).toBe('Expires in 3 days') - expect(describeExpiry(key, daysFromNow(89))).toBe('Expires in 1 day') + expect(describeExpiry(key(), daysFromNow(87))).toBe('Expires in 3 days') + expect(describeExpiry(key(), daysFromNow(89))).toBe('Expires in 1 day') // Part of a day left still reads as a day rather than rounding to zero. - expect(describeExpiry(key, daysFromNow(89.5))).toBe('Expires in 1 day') + expect(describeExpiry(key(), daysFromNow(89.5))).toBe('Expires in 1 day') }) it('says expired once the moment has passed', () => { - expect(describeExpiry(key, daysFromNow(90))).toBe('Expired') + expect(describeExpiry(key(), daysFromNow(90))).toBe('Expired') }) it('shows a plain date while expiry is far off', () => { - expect(describeExpiry(key, daysFromNow(1))).toBe( - new Date(key.expires_at).toLocaleDateString() + expect(describeExpiry(key(), daysFromNow(1))).toBe( + new Date(key().expires_at).toLocaleDateString() ) }) }) describe('sortApiKeys', () => { + it('puts active keys first, newest first within each group', () => { + const older = key({ id: 1, name: 'Older', created_at: '2026-08-01' }) + const newer = key({ id: 2, name: 'Newer', created_at: '2026-08-20' }) + const revoked = key({ + id: 3, + name: 'Revoked', + created_at: '2026-08-22', + revoked_at: '2026-08-23', + }) + + expect( + sortApiKeys([older, revoked, newer], now).map((k) => k.name) + ).toEqual(['Newer', 'Older', 'Revoked']) + }) + it('drops expired keys below active ones', () => { - const shortLived = createApiKey({ + const shortLived = key({ + id: 1, name: 'Short', - now, - lifetimeDays: 1, - token: 'ocot_aaaaaaaaaaaaaaaa', - }) - const longLived = createApiKey({ - name: 'Long', - now: new Date('2026-08-01T00:00:00Z'), - token: 'ocot_bbbbbbbbbbbbbbbb', + expires_at: '2026-08-24T12:00:00.000Z', }) + const longLived = key({ id: 2, name: 'Long', created_at: '2026-08-01' }) expect( sortApiKeys([shortLived, longLived], daysFromNow(5)).map((k) => k.name) ).toEqual(['Long', 'Short']) }) }) + +describe('describeLastUsed', () => { + it('reports never used, revoked, or the date', () => { + expect(describeLastUsed(key())).toBe('Never used') + expect( + describeLastUsed(key({ revoked_at: '2026-08-24T09:00:00.000Z' })) + ).toBe('Revoked') + expect( + describeLastUsed(key({ last_used_at: '2026-08-22T18:00:00Z' })) + ).toBe(new Date('2026-08-22T18:00:00Z').toLocaleDateString()) + }) +}) diff --git a/src/utils/apiKeys.ts b/src/utils/apiKeys.ts index 2a3b0888..0192fb1f 100644 --- a/src/utils/apiKeys.ts +++ b/src/utils/apiKeys.ts @@ -1,95 +1,53 @@ /** - * Client-side model for personal API keys. + * Client model for personal API keys (`/api_key` on the Ocotillo API). * - * There is no API behind this yet: the settings page renders the full - * generate / rename / revoke flow against local state so the interaction can - * be reviewed before the endpoints exist. Nothing here talks to a server, and - * nothing here should be treated as a real credential — see `ApiKeysCard`, - * which says so on screen. + * Hand-written, like `accessGrants.ts`: the committed `openapi-auth.json` + * snapshot predates the route, so `src/generated` cannot describe it. The + * shapes mirror `schemas/api_key.py`; refresh the spec and regenerate once + * `/api_key` is in it. * - * When the backend lands, the shapes below are what the page expects; swap the - * local state for the real calls and keep the helpers. Field names are - * snake_case to match what the API serialises, so a real response can be - * dropped in without a translation layer in between. + * A key authorizes `/ogcapi-internal` and nothing else, which is why the card + * is gated on the group that mount is gated on. */ -export type ApiKey = { - id: string - name: string - /** Full token. Only ever held for a freshly generated key, never stored. */ - token?: string - /** The leading characters, which is all a server would return afterwards. */ - token_preview: string - created_at: string - expires_at: string - last_used_at?: string | null - revoked_at?: string | null -} +import { z } from 'zod' + +export const zApiKey = z.looseObject({ + id: z.number(), + name: z.string(), + /** The leading characters, which is all the server returns after creation. */ + token_preview: z.string(), + scope: z.string(), + created_at: z.string(), + expires_at: z.string(), + last_used_at: z.string().nullable().default(null), + revoked_at: z.string().nullable().default(null), +}) -/** What a key is worth at a glance: usable, nearly stale, or finished. */ -export type ApiKeyStatus = 'active' | 'expiring' | 'expired' | 'revoked' +export const zApiKeyList = z.array(zApiKey) -const TOKEN_PREFIX = 'ocot' -const TOKEN_BODY_LENGTH = 32 -const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' +/** + * The create response, and the only one that ever carries the token. Nothing + * re-reads it: only the digest is stored, so a client that loses this response + * has to issue another key. + */ +export const zNewApiKey = zApiKey.extend({ token: z.string() }) -/** How long an issued key lasts. The API will own this; the page mirrors it. */ -export const API_KEY_LIFETIME_DAYS = 90 +export type ApiKey = z.infer +export type NewApiKey = z.infer + +/** What a key is worth at a glance: usable, nearly stale, or finished. */ +export type ApiKeyStatus = 'active' | 'expiring' | 'expired' | 'revoked' /** * How early the page starts warning. Long enough that someone who only opens * settings occasionally still sees the warning before the key stops working. + * The lifetime itself is the API's to decide — it clamps what it is asked for. */ export const API_KEY_EXPIRY_WARNING_DAYS = 14 const MS_PER_DAY = 24 * 60 * 60 * 1000 -const randomValues = (length: number): number[] => { - const values = new Uint32Array(length) - crypto.getRandomValues(values) - return [...values] -} - -/** - * A token that looks like what the API will issue, so the reveal dialog and - * the copy affordance can be judged at the right length. - */ -export const generateApiKeyToken = (): string => { - const body = randomValues(TOKEN_BODY_LENGTH) - .map((value) => ALPHABET[value % ALPHABET.length]) - .join('') - - return `${TOKEN_PREFIX}_${body}` -} - -/** What a server would show after creation: enough to recognise, not to use. */ -export const previewOfToken = (token: string): string => - // Prefix, separator, five characters — enough to tell two keys apart. - `${token.slice(0, TOKEN_PREFIX.length + 6)}…${token.slice(-4)}` - -export const createApiKey = ({ - name, - now, - token = generateApiKeyToken(), - id, - lifetimeDays = API_KEY_LIFETIME_DAYS, -}: { - name: string - now: Date - token?: string - id?: string - lifetimeDays?: number -}): ApiKey => ({ - id: id ?? token.slice(-12), - name: name.trim() || 'Untitled key', - token, - token_preview: previewOfToken(token), - created_at: now.toISOString(), - expires_at: new Date(now.getTime() + lifetimeDays * MS_PER_DAY).toISOString(), - last_used_at: null, - revoked_at: null, -}) - /** * Whole days left, rounded up, so a key with any part of a day left still * reads as "1 day" rather than "0". @@ -113,18 +71,13 @@ export const isApiKeyActive = (key: ApiKey, now: Date): boolean => { return status === 'active' || status === 'expiring' } -export const revokeApiKey = (key: ApiKey, now: Date): ApiKey => ({ - ...key, - token: undefined, - revoked_at: now.toISOString(), -}) - -export const renameApiKey = (key: ApiKey, name: string): ApiKey => ({ - ...key, - name: name.trim() || key.name, -}) - -/** Active keys first, newest first within each group. */ +/** + * Active keys first, newest first within each group. + * + * The route already returns this order. Sorting again costs nothing and keeps + * the table right when a mutation puts a fresh row in the cache before the + * refetch lands. + */ export const sortApiKeys = (keys: ApiKey[], now: Date): ApiKey[] => [...keys].sort((a, b) => { if (isApiKeyActive(a, now) !== isApiKeyActive(b, now))