Feature: Dummy login picker — log in as each PostgreSQL/JWT role
IMPLEMENTATION RULES: Before implementing this plan, read and follow:
- WORKFLOW.md - The implementation process
- PLANS.md - Plan structure and best practices
Status: Backlog
Goal: Replace the JWT-paste login on /admin/login with a role picker that shows the four PostgreSQL roles documented in postgres-roles.md for context and lets the user log in as anon or as authenticated with any of five capability profiles (Full admin, Registrations admin, Content editor, App-log viewer, Users admin). This is dummy scaffolding for the eventual Okta/Authentik integration; it never replaces an IdP but it lets every contributor exercise the capability gates end-to-end today.
Last Updated: 2026-05-19
Overview
postgres-roles.md documents four PostgreSQL roles for PostgREST: railway_owner, anon, authenticated, authenticator. Only two of those are session-level — railway_owner owns DDL and authenticator is the PostgREST connection role. PostgREST SET ROLEs to anon or authenticated per request based on the JWT.
Today the only ways to log into /admin are:
- Paste an HS256 staff JWT (verifies against
JWT_SECRET). - Type
ADMIN_PASSWORDin dev to mint a broad 6-cap "bootstrap" JWT (mintBootstrapStaffJwt). - Auto-login from
POSTGREST_ADMIN_JWT/POSTGREST_STAFF_JWT_UISin env (gated byNODE_ENV === 'development'orADMIN_BOOTSTRAP_SESSION_FROM_ENV=1).
None of those let you exercise different capability profiles or see what the public-anon experience looks like without rebuilding/restarting.
The dummy login adds a UI in front of /admin/login that presents:
- All four PG roles as a context table —
railway_ownerandauthenticatorare listed with a short "DDL only" / "PostgREST connection role" note and a disabled button (so the doc and the UI stay aligned). - Five session profiles that mint a real HS256 staff JWT with a specific
capabilitiessubset:- Full admin →
["admin"](treated as all-caps bystaffEffectiveCapabilitySet) - Registrations admin →
["registrations:read","registrations:write"] - Content editor →
["content:read","content:write"] - App-log viewer →
["app_log:read"] - Users admin →
["users:read","users:write"]
- Full admin →
- anon → clears the session cookie and redirects to
/. The public surfaces work withPOSTGREST_ANON_JWTand don't need a session.
The existing JWT-paste form remains available as a "manual mode" link for power users who want to verify a UIS-issued token by hand.
Why this design
- Future migration is concrete: Okta/Authentik will issue an ID token whose group claims map to a capability set. The server already mints HS256 PostgREST staff JWTs from a known capability list (
mintHs256Jwt); the dummy picker simulates that exact mapping today. Migration becomes "replace the picker with an IdP redirect and a claims→caps mapper." - Capability gates already exist in the UI:
src/app/admin/(dashboard)/layout.tsxreadsstaffEffectiveCapabilitySet()and passes it toAdminSidebarNav. Once you log in as Registrations admin, the sidebar already hides Content/App-log/Users links. The dummy picker just feeds different inputs into a pipeline that already works. - PostgREST stays authoritative: minted JWTs are real HS256 tokens signed with
JWT_SECRET. PostgREST verifies them server-side; RLS enforces capability gates at the row level. The picker doesn't bypass anything — it just picks which token gets minted.
Gating decision
No env gate. The page is part of the app surface and ships in every environment, per request. The PLAN includes a doc note flagging this so it's a deliberate choice that gets revisited when Okta/Authentik lands. Risk owner is the maintainer; this PLAN records the choice but doesn't relitigate it.
Phase 1: Role-profile model — DONE
Tasks
- 1.1 Create
src/lib/dummy-login-roles.tsexporting:type RoleKind = "pg-role" | "session-profile"type RoleProfile = { id: string; label: string; description: string; kind: RoleKind; sessionRole: "anon" | "authenticated" | null; capabilities: readonly string[] | null; disabled: boolean; disabledReason?: string }DUMMY_LOGIN_ROLES: readonly RoleProfile[]with these entries, in this order:railway_owner(kind=pg-role, disabled, reason "Owns DDL / SECURITY DEFINER functions — not a session role")anon(kind=session-profile, sessionRole='anon', capabilities=null, NOT disabled — clicking clears cookie)authenticated_full_admin(sessionRole='authenticated', capabilities=['admin'])authenticated_registrations(sessionRole='authenticated', capabilities=['registrations:read','registrations:write'])authenticated_content(sessionRole='authenticated', capabilities=['content:read','content:write'])authenticated_applog(sessionRole='authenticated', capabilities=['app_log:read'])authenticated_users(sessionRole='authenticated', capabilities=['users:read','users:write'])authenticator(kind=pg-role, disabled, reason "PostgREST runtime connection role — never appears in a user session")
findRoleProfile(id: string): RoleProfile | undefinedhelper.
- 1.2 Capabilities reference
KNOWN_CAPABILITY_GROUPSfromstaff-jwt-caps.tsso a future cap addition shows up via type/unit checks if needed.
Validation
User confirms the profile list matches their intent for the picker UI.
Phase 2: Server route to mint + set cookie — DONE
Tasks
- 2.1 Add
src/app/api/admin/login/dummy/route.tsexportingPOST:- Body:
{ profileId: string }. Reject other shapes with 400. - Require
JWT_SECRETfrom env; 503 with the same Norwegian message shape as/api/admin/loginif missing. - Look up
findRoleProfile(profileId). 404 if unknown; 409 ifdisabled. - For
sessionRole === 'anon': clearADMIN_SESSION_COOKIE(set to empty withmaxAge: 0); return{ ok: true, redirect: '/' }. - For
sessionRole === 'authenticated': mint HS256 viamintHs256Jwt(secret, { role: 'authenticated', capabilities, aud: 'railway', exp: now + 7d }); set cookie viaadminSessionCookieOptsForToken; return{ ok: true, redirect: '/admin' }.
- Body:
- 2.2 Verify the minted token round-trips through
verifyAdminSessionCookieValuebefore setting the cookie (defence in depth — same pattern as the existing login route). - 2.3 Add minimal request logging (no JWT values in logs). ✓ Deferred — Next dev server already logs the route hit; no JWT material in the response body or logs.
Validation
# With dev server on :3010 and JWT_SECRET in .env:
curl -i -X POST http://localhost:3010/api/admin/login/dummy \
-H "Content-Type: application/json" \
-d '{"profileId":"authenticated_registrations"}'
Expect HTTP 200, a Set-Cookie: railway_admin_session=… header, and {"ok":true,"redirect":"/admin"}.
Phase 3: Login page UI — DONE
Tasks
- 3.1 Add
src/components/admin/dummy-login-picker.tsx— a client component that:- Renders a section header "Dummy login (development scaffolding for Okta/Authentik)"
- Renders one card / button per
DUMMY_LOGIN_ROLESentry. Disabled entries render as muted cards with thedisabledReasonshown below the label. - On click of an enabled entry: POSTs to
/api/admin/login/dummy, then usesrouter.push(redirect)from the response. (Used Next.js router instead ofwindow.location.assignso client-side nav works.) - Capability profiles display the cap list as small badges below the label.
- 3.2 Modify
src/app/admin/login/page.tsxto render<DummyLoginPicker />above the existing<AdminLoginForm />. The manual JWT-paste form is hidden behind a?manual=1link rather than an inline toggle — same effect, simpler markup. - 3.3 Deferred — picker references "PostgreSQL roles" in copy but doesn't link to the doc directly. Will revisit in Phase 4 when adding the postgres-roles.md update; cross-link can go either way.
- 3.4 Changed during implementation — per user direction, auto-bootstrap is now opt-in via
?auto=1so the picker is the default surface. CI / smoke tests still use/api/admin/logindirectly (not the page), so this is non-breaking.
Validation
Manual click-through with npm run dev:
- Visit
http://localhost:3010/admin/login. The picker is visible with all 7 entries. - Click "Full admin" → redirected to
/admin; sidebar shows all sections. - Logout. Click "Registrations admin" → redirected to
/admin; sidebar shows only Registrations. - Logout. Click "anon" → redirected to
/; revisiting/adminreturns to the login picker. - Click "railway_owner" — disabled, no action.
User confirms each works.
Phase 4: Docs + smoke — DONE
Tasks
- 4.1 Add a short "Dummy login (development)" subsection at the bottom of
postgres-roles.mdexplaining what the picker does and that it's temporary scaffolding for Okta/Authentik. - 4.2 Add a row to
project-conventions.mdunder "Code hygiene" (new "Temporary scaffolding" subsection) noting the dummy login is currently always-available and must be replaced with IdP-driven login before production use. - 4.3 Added scope (user request): Write
testing-dummy-login.md— a per-role test checklist. Linked fromcontributors/index.md. - 4.4 Run
npm run smoke:adminagainst the now-green PostgREST. →[smoke] OK — PostgREST staff JWT, /admin count, /admin/registrations, logout gate. - 4.5 Run
npm run buildto confirm Next.js compiles cleanly. → Required a smalltsconfig.jsonchange (exclude: ["node_modules", "website"]) because Next.js was reaching into Docusaurus types. After that, build succeeded with/api/admin/login/dummylisted in the route table. - 4.6 Run
npm run buildinwebsite/to confirm Docusaurus still compiles. → Clean.
Validation
npm run smoke:admin exited 0; both builds succeeded; browser click-through confirmed by Tailway Cowork in talk.md Messages 2 and 4.
Status: Completed
Completed: 2026-05-19
Acceptance Criteria
-
/admin/loginrenders the role picker with all four PG roles (two disabled) and five authenticated-capability profiles. - Clicking each enabled profile logs the user in with the correct capability set; the admin sidebar reflects the set. — Tailway PASS on all 6 capability profiles.
- Clicking "anon" clears the session cookie and redirects to
/. — Tailway Test 7 PASS. - The existing JWT-paste form is still reachable behind a toggle (no regression for the existing flow). — Tailway Test 9 PASS.
-
npm run smoke:adminpasses. — exit 0, "OK" line printed. -
npm run build(both the Next.js app and the Docusaurus site) passes. -
postgres-roles.mdhas a "Dummy login (development)" subsection. -
project-conventions.mdrecords the always-available gating as a temporary choice.
Out-of-scope follow-ups surfaced during testing
INVESTIGATE-app-log-alert-count-permission.md—app_log_alert_countRPC denies EXECUTE for theauthenticatedrole (Tailway S1). Filed in backlog with four options. Affects the Oversikt dashboard for every staff session; needs a product/spec decision before fixing.- Tailway S2 — Oversikt cards silently show
0for roles without the required cap. Tied to the RPC fix above; tracked in the same INVESTIGATE. - Tailway S3 — Logg ut button overlaps Next dev overlay. Dev-only artifact, not filed.
- Tailway Sugg 6 — expose
audclaim on/admin/staff. Nice-to-have; not filed.
Files to Modify
src/lib/dummy-login-roles.ts(new) — typed role-profile listsrc/app/api/admin/login/dummy/route.ts(new) — POST endpoint that mints + sets cookiesrc/components/admin/dummy-login-picker.tsx(new) — client component for the picker UIsrc/app/admin/login/page.tsx(modify) — render the picker, collapse the existing JWT formwebsite/docs/contributors/postgres-roles.md(modify) — append "Dummy login (development)" subsectionwebsite/docs/contributors/project-conventions.md(modify) — note the always-available gating decision
Implementation Notes
- All JWTs minted from this picker are real HS256 tokens signed with
JWT_SECRET. PostgREST verifies and enforces RLS exactly as it does for UIS-issued tokens. The picker is dummy in the sense of user identity (no real user backs it), not in the sense of bypassing security. - Token expiry: 7 days, matching
SESSION_TTL_SECinadmin-session.ts. Long enough for development sessions, short enough that an accidentally-leaked token expires. - The picker must not be the only login path. Until Okta/Authentik replaces it, the JWT-paste form remains the official way to use a UIS-issued staff token.
- Future Okta/Authentik integration: replace the picker with an IdP-redirect button; the
/api/admin/login/dummyroute can be deleted or repurposed as a development-only escape hatch (with an env gate added at that time).