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 2cc94a83..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'
@@ -23,3 +24,4 @@ export * from './useSearchModalState'
export * from './useSidebarPanelSync'
export * from './useWellDetails'
export * from './useContainerMinWidth'
+export * from './useBooleanPreference'
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/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..7523c428
--- /dev/null
+++ b/src/pages/settings/ApiKeysCard.tsx
@@ -0,0 +1,582 @@
+import {
+ Add,
+ ContentCopy,
+ Delete,
+ Edit,
+ WarningAmber,
+} from '@mui/icons-material'
+import {
+ Alert,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ IconButton,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ TextField,
+ Tooltip,
+ 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,
+ describeExpiry,
+ describeLastUsed,
+ isApiKeyActive,
+ type NewApiKey,
+ 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: NewApiKey | null
+ onClose: () => void
+}) => {
+ const [copied, setCopied] = useState(false)
+
+ const handleCopy = async () => {
+ if (!apiKey?.token) return
+ await navigator.clipboard.writeText(apiKey.token)
+ setCopied(true)
+ }
+
+ return (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+const RevokeDialog = ({
+ apiKey,
+ onCancel,
+ onConfirm,
+}: {
+ apiKey: ApiKey | null
+ onCancel: () => void
+ onConfirm: () => void
+}) => (
+
+)
+
+/** 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 (
+
+ )
+}
+
+/**
+ * Personal API keys.
+ *
+ * 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,
+ now = () => new Date(),
+}: {
+ /** Whether the account holds the group the route requires. */
+ canManageKeys: boolean
+ now?: () => Date
+}) => {
+ 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) => {
+ 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
+ renameKey.mutate(
+ { id: editing.id, name },
+ { onSuccess: () => setEditing(null) }
+ )
+ }
+
+ const handleRevoke = () => {
+ if (!revoking) return
+ revokeKey.mutate(revoking.id, { onSuccess: () => setRevoking(null) })
+ }
+
+ // 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 = keys.data ? sortApiKeys(keys.data, at) : []
+
+ return (
+
+
+
+ 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}
+
+
+ }
+ disabled={createKey.isPending}
+ onClick={() => setIsGenerating(true)}
+ >
+ {createKey.isPending ? 'Generating...' : 'Generate key'}
+
+
+
+
+ {keys.isLoading ? (
+
+
+
+ Loading your keys...
+
+
+ ) : sorted.length === 0 ? (
+
+ No keys yet. Generate one to use the API outside this app.
+
+ ) : (
+
+
+
+ Name
+ Key
+ Created
+ Expires
+ Last used
+ Actions
+
+
+
+ {sorted.map((key) => {
+ const status = apiKeyStatus(key, at)
+ const active = isApiKeyActive(key, at)
+ const expiryColor =
+ status === 'expired'
+ ? 'error.main'
+ : status === 'expiring'
+ ? 'warning.main'
+ : 'text.secondary'
+
+ return (
+
+
+
+
+ {key.name}
+
+ {status === 'revoked' ? (
+
+ ) : null}
+
+
+
+
+ {key.token_preview}
+
+
+
+
+ {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)}
+
+
+
+
+
+
+ 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)} />
+ setIsShowingArcGis(false)}
+ />
+
+ )
+}
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..3052178b
--- /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, canManageApiKeys } = 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/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/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..996950f2
--- /dev/null
+++ b/src/test/pages/apiKeysCard.test.tsx
@@ -0,0 +1,256 @@
+// @vitest-environment jsdom
+import { act, render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ApiKeysCard } from '@/pages/settings/ApiKeysCard'
+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 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', () => {
+ useApiKeysMock.mockReturnValue(listed([key()]))
+ 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 what a key reaches', () => {
+ render()
+
+ expect(
+ screen.getByText(/internal OGC collections and nothing else/)
+ ).toBeInTheDocument()
+ expect(screen.getByText(/No keys yet/)).toBeInTheDocument()
+ })
+
+ 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.getByText('Never used')).toBeInTheDocument()
+ })
+
+ it('issues a key and shows the token once', 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' }))
+
+ 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(created.token)).toBeInTheDocument()
+
+ await user.click(within(dialog).getByRole('button', { name: 'Done' }))
+
+ expect(screen.queryByText(created.token)).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()
+ expect(createMutateMock).not.toHaveBeenCalled()
+ })
+
+ it('renames a key by id', async () => {
+ const user = userEvent.setup()
+ useApiKeysMock.mockReturnValue(listed([key({ id: 7 })]))
+ 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(renameMutateMock).toHaveBeenCalledWith(
+ { id: 7, name: 'Field tablet' },
+ expect.anything()
+ )
+ })
+
+ it('revokes a key only after confirmation', async () => {
+ const user = userEvent.setup()
+ 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' }))
+ await waitFor(() =>
+ expect(screen.queryByRole('dialog')).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(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', () => {
+ 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', () => {
+ useApiKeysMock.mockReturnValue(
+ listed([key({ name: 'Old key', expires_at: '2026-01-31T00:00:00.000Z' })])
+ )
+ 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', () => {
+ useApiKeysMock.mockReturnValue(
+ listed([key({ revoked_at: '2026-08-24T09:00:00.000Z' })])
+ )
+ 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/accessControl.test.ts b/src/test/utils/accessControl.test.ts
index 84657370..7d0976b1 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'
@@ -450,8 +453,46 @@ 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('ocotillo.asset-unassociated')).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
new file mode 100644
index 00000000..67c4ef7a
--- /dev/null
+++ b/src/test/utils/apiKeys.test.ts
@@ -0,0 +1,143 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest'
+import {
+ API_KEY_EXPIRY_WARNING_DAYS,
+ type ApiKey,
+ apiKeyStatus,
+ describeExpiry,
+ describeLastUsed,
+ isApiKeyActive,
+ sortApiKeys,
+ zApiKey,
+ zNewApiKey,
+} from '@/utils/apiKeys'
+
+const now = new Date('2026-08-23T12:00:00Z')
+
+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,
+ })
+
+const daysFromNow = (days: number): Date =>
+ new Date(now.getTime() + days * 24 * 60 * 60 * 1000)
+
+describe('zApiKey', () => {
+ it('defaults the nullable stamps the API may omit', () => {
+ const parsed = key()
+
+ expect(parsed.last_used_at).toBeNull()
+ expect(parsed.revoked_at).toBeNull()
+ })
+
+ it('carries a field the console does not know about', () => {
+ expect(
+ zApiKey.parse({
+ ...key(),
+ owner_name: 'someone@example.org',
+ })
+ ).toHaveProperty('owner_name')
+ })
+
+ it('only the create response carries a token', () => {
+ expect(() => zNewApiKey.parse(key())).toThrow()
+ expect(zNewApiKey.parse({ ...key(), token: 'ocot_secret' }).token).toBe(
+ 'ocot_secret'
+ )
+ })
+})
+
+describe('apiKeyStatus', () => {
+ 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(isApiKeyActive(key(), daysFromNow(120))).toBe(false)
+ })
+
+ it('reports revocation ahead of expiry', () => {
+ const revoked = key({ revoked_at: '2026-08-24T09:00:00.000Z' })
+
+ expect(apiKeyStatus(revoked, daysFromNow(120))).toBe('revoked')
+ })
+})
+
+describe('describeExpiry', () => {
+ 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('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 = key({
+ id: 1,
+ name: 'Short',
+ 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/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/accessControl.ts b/src/utils/accessControl.ts
index 0ae8b3ad..137aa9f9 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,
@@ -225,6 +263,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
new file mode 100644
index 00000000..0192fb1f
--- /dev/null
+++ b/src/utils/apiKeys.ts
@@ -0,0 +1,106 @@
+/**
+ * Client model for personal API keys (`/api_key` on the Ocotillo API).
+ *
+ * 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.
+ *
+ * A key authorizes `/ogcapi-internal` and nothing else, which is why the card
+ * is gated on the group that mount is gated on.
+ */
+
+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),
+})
+
+export const zApiKeyList = z.array(zApiKey)
+
+/**
+ * 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() })
+
+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
+
+/**
+ * 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'
+}
+
+/**
+ * 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))
+ return isApiKeyActive(a, now) ? -1 : 1
+ return b.created_at.localeCompare(a.created_at)
+ })
+
+export const describeLastUsed = (key: ApiKey): string => {
+ 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()
+}
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'
+ }`
+}