Skip to content

feat(settings): add a user settings page - #360

Open
jirhiker wants to merge 4 commits into
stagingfrom
feat/user-settings-page
Open

feat(settings): add a user settings page#360
jirhiker wants to merge 4 commits into
stagingfrom
feat/user-settings-page

Conversation

@jirhiker

@jirhiker jirhiker commented Aug 24, 2026

Copy link
Copy Markdown
Member

What

Adds /settings, reachable from the user dropdown in the header. Five sections:

Section What it does
Profile Name, email, user id, session expiry — read from the id token, read-only, and it says these live in single sign-on
Access Roles grouped by portal with the primary role marked, in the same vocabulary accessControl uses
Appearance Light / Dark / System
Navigation Whether the sidebar collapses on the map page
API keys Generate, rename, revoke, one-time reveal, expiry — wired to /api_key, gated on OGC.Internal, see the deploy order below

Why

There was nowhere to see who you are signed in as, what roles you hold, or why a page is hidden from you. The only per-user control in the app was the theme toggle buried in the header dropdown.

Two supporting changes

Colour mode gains a real "system" preference. It used to fall back to the OS setting only when nothing was stored — no way back to it once you picked, and no response when the OS switched themes mid-session. The stored value is now the preference (light / dark / system), the rendered mode is derived from it, and a matchMedia listener keeps system honest. Anything unrecognised in localStorage falls back to system, which is what the app did before. The header dropdown gets a System item and ticks the preference rather than the resolved mode.

Sidebar auto-collapse on the map is now a preference, not a rule. On by default, so nothing changes for anyone who does not go looking. Preferences are per-browser, in a small store read through useSyncExternalStore, so the settings page and the shell stay in step without another provider — and a write in one tab moves the others via the storage event.

API keys

This card started as local component state with a disclaimer saying so, because no endpoint existed. /api_key exists now, so the card talks to it. useApiKeys lists, issues, renames and revokes; every mutation invalidates the list rather than patching the cache, since a key's status and its last-used stamp both move on the server's clock rather than on anything this client does.

A key reaches the internal OGC collections and nothing else, and the token appears exactly once, in the create response, because the server stores only its digest. The card says both of those where a person will read them.

src/utils/apiKeys.ts is now zod schemas mirroring schemas/api_key.py, with zNewApiKey the only shape carrying a token. The field names are snake_case and match the route exactly, so nothing translates between the two.

Keys expire. The table has an Expires column that reads differently as the date approaches: a plain date while it is far off, a warning-coloured countdown inside the 14-day window, then "Expired" in the error colour. An expired key is as unusable as a revoked one, so its actions disable and it sorts below the active keys. A revoked row shows no expiry at all, since its own expiry stopped meaning anything.

Connecting from ArcGIS Pro opens a dialog with the steps, because Pro is the reason keys exist: it cannot carry an Authentik bearer token, so it authenticates with Basic and a saved login, or with a token request parameter when an intermediary refuses Basic. The server URL is built from the configured API base rather than hard-coded, so it is correct in whichever environment the app is pointed at.

Gating on OGC.Internal

The card is limited to accounts in the OGC.Internal group — the same group /api_key gates minting on, since a key is a pre-authorized stand-in for it. Gating key creation on anything broader would let any Viewer mint themselves a credential that reaches the internal mount.

OGC.Internal is modelled as a CapabilityGroup rather than added to PortalRole: it carries no viewer/editor/admin hierarchy, it should never win the primary role, and it does not belong in the Access card's portal grouping. Accounts without it keep the card and get an alert naming the group to request, rather than the card vanishing — a silently missing section is exactly what this page exists to explain. No key data renders in that state.

One piece of plumbing worth a reviewer's eye: getPermissions normalised the groups claim down to portal roles, which discarded OGC.Internal before any component could see it. It and getAccessControlGroups now use normalizeAuthGroups, which carries portal roles and capability groups through together. Authorisation is unaffected — canAccessResource re-filters to portal roles internally, so the extra string cannot widen access anywhere.

Deploy order

/api_key ships on OcotilloAPI's feat/api-key-management branch. Until that merges and deploys, the API keys card shows a load error instead of a list — including on this PR's preview, which builds against staging. That failure is rendered rather than swallowed on purpose: a card that silently showed nothing would be indistinguishable from an account with no keys.

Nothing else on the page depends on that branch.

Verification

  • npm run typecheck — clean.
  • 93 tests across the settings work: colour-mode resolution and preference validation, initials and role grouping, session-expiry formatting, preference read/write/fallback/notify, API key parsing and expiry copy at each threshold, capability-group handling in accessControl, and component tests for every card — including that the token is shown once and never again, that revoke needs confirmation, that a revoked or expired key's actions are disabled, that an account without OGC.Internal sees the explanation and no key data, and that the ArcGIS dialog carries the environment's own server URL.
  • Biome: AppShell.tsx has pre-existing format drift on staging; per-file diagnostics are unchanged and the suppression on the auto-collapse effect now sits where Biome wants it. Every new file is clean. biome check --write initially reformatted ~900 unrelated lines in AppShell.tsx — I rebuilt the file from staging and re-applied only the semantic edits, so the diff there is 37 lines.
  • Driven in the browser against a local API with the route: keys load and list, the ArcGIS dialog shows the right URL with its steps numbered, theme switches apply and persist, system resolves against the OS, and the map preference actually changes whether the sidebar collapses. No console errors.

🤖 Generated with Claude Code

There was nowhere to see who you are signed in as, what roles you hold, or why
a page is hidden from you — the only per-user control in the app was the theme
toggle buried in the header dropdown. Adds /settings, reachable from that same
dropdown, with five sections:

- Profile: name, email, user id and session expiry, read from the id token.
  Read-only, and it says so: these live in single sign-on.
- Access: roles grouped by portal, with the primary role marked, in the same
  vocabulary the access control provider uses.
- Appearance: light, dark, or system.
- Navigation: whether the sidebar collapses on the map page.
- API keys: generate, rename, revoke, and a one-time reveal of a new key.

Two supporting changes came with it.

Colour mode gains a real "system" preference. It used to fall back to the OS
setting only when nothing was stored, with no way to return to it and no
response when the OS switched. The stored value is now the preference, the
rendered mode is derived from it, and a media query listener keeps "system"
honest.

The sidebar auto-collapse on the map is now a preference rather than a rule.
It is on by default, so nothing changes for anyone who does not go looking.
Preferences are local to the browser and live in a small store read through
useSyncExternalStore, so the settings page and the shell stay in step without
another provider.

The API keys section is UI only. No endpoints exist yet, so it runs on local
state to settle the interaction first, and the card says on screen that the
keys are not real credentials and do not survive a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jirhiker and others added 2 commits August 28, 2026 13:42
Three changes to the API keys section of the settings page.

Field names are snake_case, matching what the API will serialise, so a real
response can replace the local state without a translation layer:
token_preview, created_at, expires_at, last_used_at, revoked_at.

Keys now carry an expiry. A key is issued with a 90-day lifetime and the
table gains an Expires column: a plain date while expiry is far off, then a
warning-coloured countdown inside the 14-day warning window, then "Expired"
in the error colour. An expired key is as unusable as a revoked one, so its
actions disable and it sorts below the active keys. Revoked rows show no
expiry, which no longer means anything for them. Both thresholds are
exported constants; the API will own the real lifetime.

The card is gated on the OGC.Internal group, modelled as a CapabilityGroup
rather than a PortalRole: it carries no viewer/editor/admin hierarchy, never
wins the primary role, and does not belong in the settings page's portal
grouping. Accounts without it keep the card but see an alert naming the
group to request, since a silently missing card leaves someone guessing.

getPermissions previously normalised the groups claim down to portal roles,
which discarded OGC.Internal before any component saw it; it and
getAccessControlGroups now use normalizeAuthGroups, which carries roles and
capability groups through together. Authorisation is unaffected, since
canAccessResource re-filters to portal roles internally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflict in the access control suite: staging added an assertion for
ocotillo.asset-unassociated inside the same test this branch had reformatted.
Kept both — the new assertion, in the wrapped form Biome produces here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card ran the whole generate / rename / revoke flow against local component
state, because no endpoint existed to run it against. `/api_key` exists now, so
the card talks to it: `useApiKeys` lists, issues, renames and revokes, and every
mutation invalidates the list rather than patching the cache, since a key's
status and its last-used stamp both move on the server's clock.

The disclaimer about keys not being real credentials is gone with the state
that made it true. What replaces it is what a key actually is: it reaches the
internal OGC collections and nothing else, and it is shown once, at creation,
because the server keeps only the digest.

`apiKeys.ts` loses the token generator and the state mutators and gains zod
schemas mirroring `schemas/api_key.py`. `zNewApiKey` is the only shape carrying
a token. The presentation helpers are unchanged; the snake_case field names
they were rewritten for turn out to match the route exactly, so nothing
translates between the two.

A new dialog covers connecting ArcGIS Pro, which is the reason keys exist:
Pro cannot carry an Authentik bearer token, so it authenticates with Basic and
a saved login, or with a `token` request parameter when an intermediary refuses
Basic. The server URL is built from the configured API base rather than
hard-coded, so it is right in every environment.

Note the deploy order: `/api_key` ships on OcotilloAPI's feat/api-key-management
branch. Until that merges and deploys, this card shows a load error rather than
a list, which is why the failure is rendered rather than swallowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant