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 ( + + 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. + + + + + + + +) + +/** 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. + * + * 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} + + + + + + + {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' + }` +}