(unauthorized) route group: 403 page with sign-out button (consumes #15) #17

Closed
opened 2026-05-03 14:57:29 +00:00 by forgejo_admin · 3 comments

Type

Feature

Lineage

Decomposed from forgejo_admin/westside-admin#2 (Keycloak cookie SSR auth + admin role gate). Sub-task 4 of 4. Depends on forgejo_admin/westside-admin#15 (handle hook routes here on missing-admin) — must land first. Path coordination (added 2026-05-03 per QA review of PR #20): the hook rewrites missing-admin requests to the path /__unauthorized, so the SvelteKit page must live at src/routes/(unauthorized)/__unauthorized/+page.svelte. The bare /(unauthorized) route group resolves to /, which collides with the home page — the __unauthorized segment inside the group disambiguates.

Repo

forgejo_admin/westside-admin

User Story

story-westside-admin-admin-row-crud. When an authenticated user lacks the admin role, they need a clear, non-confusing landing page explaining the gate, with a way out (sign out and try a different account).

Context

The handle hook (#15, merged via PR #20) rewrites authenticated-but-not-admin requests by setting event.url.pathname = '/__unauthorized' before resolve. The browser-visible URL stays at whatever the user hit (e.g. /players) — only the internal path used for route resolution is rewritten — so the user sees this page rendered "in place" of the route they tried to access. The hook has already populated event.locals.user (with sub, email, name, roles per the merged App.Locals extension); the page reaches it via a server load function.

A "Sign out" button POSTs to /auth/logout (#16), which clears the cookie and redirects to Keycloak SLO so they can try a different account.

Three SvelteKit specifics the page must get right:

  1. Layout reset. SvelteKit's root +layout.svelte (admin nav + brand chrome) wraps every nested layout by default, including route-group layouts. To drop the admin chrome, the file must be named +layout@.svelte (the @ sigil resets layout inheritance) — a plain +layout.svelte cannot remove the parent.
  2. Locals → page data. $page.data.user is NOT populated by the hook directly — it requires a +page.server.ts (or +layout.server.ts) that exposes locals.user via its load function. Since this page consumes user.name, it needs its own server-load.
  3. HTTP 403 status. SvelteKit doesn't expose a "set status code" API on +page.server.ts load returns — setHeaders({}) controls headers, not status. The idiomatic way to ship a non-200 status from a route is to throw error(403, 'Admin role required') from @sveltejs/kit inside the load function. That throw routes the request to the nearest +error.svelte boundary, which becomes the actual rendered UI. Pinned approach below.

File Targets

Create:

  • src/routes/(unauthorized)/__unauthorized/+page.server.tsload({ locals }) => ({ user: locals.user }). Required because $page.data.user doesn't auto-populate from event.locals; needs an explicit load. Sets HTTP 403 via throw error(403, 'Admin role required') (handled by the co-located +error.svelte below) — the throw renders +error.svelte instead of +page.svelte, which is the SvelteKit-idiomatic way to ship a non-200 status from a route group.
  • src/routes/(unauthorized)/__unauthorized/+error.svelte — the actual 403 UI (heading, user.name, sign-out form). Reads error message via $page.error.message and user from $page.data.user. This file replaces what would have been +page.svelte — the error() throw routes here.
  • src/routes/(unauthorized)/__unauthorized/+page.svelte — empty/minimal placeholder. SvelteKit requires a +page.svelte for the route to resolve, but it never renders because the load throws before rendering. One line: <!-- routed via +error.svelte after load throws --> is acceptable.
  • src/routes/(unauthorized)/+layout@.sveltelayout reset (note the @ sigil). Without @, the root +layout.svelte would still wrap this page with admin chrome. The @ makes this a top-level layout that does NOT inherit from the root. Minimal contents: <slot /> plus a <style> block with the visual baseline (header colors, fonts matching the rest of the app).
  • src/routes/(unauthorized)/+layout.server.tsload({ locals }) => ({ user: locals.user }). Required: when the page-level +page.server.ts load throws error(403), SvelteKit discards the page load's return value before rendering +error.svelte. Layout loads survive the throw and persist $page.data.user into the error boundary. Without this file, $page.data.user is undefined in +error.svelte and AC #2 (display user name) cannot be met. Note: the @ sigil on +layout@.svelte resets layout component inheritance only — server loads always execute through the full chain, so this layout's load runs even though its component is reset.

Do NOT touch:

  • src/lib/server/keycloak.ts
  • src/hooks.server.ts
  • src/routes/auth/*
  • src/routes/+layout.svelte (the root layout — leave admin chrome unchanged for the rest of the app).
  • Any other route.

Acceptance Criteria

  • +page.server.ts load function reads locals.user and throws error(403, 'Admin role required') from @sveltejs/kit.
  • +error.svelte renders the 403 UI: heading ("Admin access required" or similar), user's display name from $page.data.user.name (populated by sibling +layout.server.ts load — see File Targets), a paragraph explaining that this account doesn't have admin privileges on the basketball-api database, and a "Sign out" button.
  • HTTP response status is 403 (verifiable via curl -i -b cookie.txt http://localhost:5173/players with a non-admin cookie — first response line HTTP/1.1 403 Forbidden).
  • "Sign out" button submits a POST to /auth/logout via <form action="/auth/logout" method="POST"> (no JS, works without JS, matches #16's same-origin POST contract).
  • Layout reset: root admin chrome (top nav, brand link to /) does NOT appear on the 403 page. Verified by inspecting the rendered HTML — the page should NOT contain the root layout's nav markup.
  • Layout file is named +layout@.svelte (with the @ sigil). A plain +layout.svelte does NOT reset layout inheritance.
  • Page is fully static-renderable (no client-side fetches on mount; no +page.ts needed since user data is server-loaded).
  • User who hits a deep route (e.g. /players) without admin role: URL stays /players, page body is the 403 UI, status is 403.

Test Expectations

  • Manual: log in with a non-admin Keycloak user (or strip the admin role from a test user), hit /players → expect to see the 403 page (URL stays at /players), with "Sign out" button.
  • Click "Sign out" → expect the cookie cleared and redirect through Keycloak SLO → land on /.
  • Run command: npm run dev + manual browser flow.

Constraints

  • No CSS frameworks (no Tailwind per feedback_no_tailwind). Pure CSS in a <style> block.
  • Match the visual baseline of the existing scaffold from #6 (header colors, fonts) so the page feels native, not a 404.
  • The "Sign out" button must be a real <form action="/auth/logout" method="POST"> — no JS-only fetch (works without JS).
  • No images / icons that aren't already in static/.

Checklist

  • PR opened
  • Manual ACs verified
  • No new dependencies added
  • Visual baseline preserved (no Tailwind, no framework imports)
  • project-westside-admin
  • forgejo_admin/westside-admin#2 — parent
  • forgejo_admin/westside-admin#15 — hook that routes here (DEPENDS ON)
  • forgejo_admin/westside-admin#16 — logout endpoint that the button hits (DEPENDS ON)
  • feedback_no_tailwind
### Type Feature ### Lineage Decomposed from `forgejo_admin/westside-admin#2` (Keycloak cookie SSR auth + admin role gate). Sub-task 4 of 4. Depends on `forgejo_admin/westside-admin#15` (handle hook routes here on missing-admin) — must land first. **Path coordination (added 2026-05-03 per QA review of PR #20):** the hook rewrites missing-admin requests to the path `/__unauthorized`, so the SvelteKit page must live at `src/routes/(unauthorized)/__unauthorized/+page.svelte`. The bare `/(unauthorized)` route group resolves to `/`, which collides with the home page — the `__unauthorized` segment inside the group disambiguates. ### Repo `forgejo_admin/westside-admin` ### User Story `story-westside-admin-admin-row-crud`. When an authenticated user lacks the `admin` role, they need a clear, non-confusing landing page explaining the gate, with a way out (sign out and try a different account). ### Context The handle hook (#15, merged via PR #20) rewrites authenticated-but-not-admin requests by setting `event.url.pathname = '/__unauthorized'` before `resolve`. The browser-visible URL stays at whatever the user hit (e.g. `/players`) — only the internal path used for route resolution is rewritten — so the user sees this page rendered "in place" of the route they tried to access. The hook has already populated `event.locals.user` (with `sub`, `email`, `name`, `roles` per the merged `App.Locals` extension); the page reaches it via a server load function. A "Sign out" button POSTs to `/auth/logout` (#16), which clears the cookie and redirects to Keycloak SLO so they can try a different account. **Three SvelteKit specifics** the page must get right: 1. **Layout reset.** SvelteKit's root `+layout.svelte` (admin nav + brand chrome) wraps every nested layout by default, including route-group layouts. To drop the admin chrome, the file must be named `+layout@.svelte` (the `@` sigil resets layout inheritance) — a plain `+layout.svelte` cannot remove the parent. 2. **Locals → page data.** `$page.data.user` is NOT populated by the hook directly — it requires a `+page.server.ts` (or `+layout.server.ts`) that exposes `locals.user` via its `load` function. Since this page consumes `user.name`, it needs its own server-load. 3. **HTTP 403 status.** SvelteKit doesn't expose a "set status code" API on `+page.server.ts` `load` returns — `setHeaders({})` controls headers, not status. The idiomatic way to ship a non-200 status from a route is to `throw error(403, 'Admin role required')` from `@sveltejs/kit` inside the load function. That throw routes the request to the nearest `+error.svelte` boundary, which becomes the actual rendered UI. **Pinned approach below.** ### File Targets Create: - `src/routes/(unauthorized)/__unauthorized/+page.server.ts` — `load({ locals }) => ({ user: locals.user })`. Required because `$page.data.user` doesn't auto-populate from `event.locals`; needs an explicit load. Sets HTTP 403 via `throw error(403, 'Admin role required')` (handled by the co-located `+error.svelte` below) — the throw renders `+error.svelte` instead of `+page.svelte`, which is the SvelteKit-idiomatic way to ship a non-200 status from a route group. - `src/routes/(unauthorized)/__unauthorized/+error.svelte` — the actual 403 UI (heading, user.name, sign-out form). Reads error message via `$page.error.message` and user from `$page.data.user`. **This file replaces what would have been `+page.svelte`** — the `error()` throw routes here. - `src/routes/(unauthorized)/__unauthorized/+page.svelte` — empty/minimal placeholder. SvelteKit requires a `+page.svelte` for the route to resolve, but it never renders because the load throws before rendering. One line: `<!-- routed via +error.svelte after load throws -->` is acceptable. - `src/routes/(unauthorized)/+layout@.svelte` — **layout reset** (note the `@` sigil). Without `@`, the root `+layout.svelte` would still wrap this page with admin chrome. The `@` makes this a top-level layout that does NOT inherit from the root. Minimal contents: `<slot />` plus a `<style>` block with the visual baseline (header colors, fonts matching the rest of the app). - `src/routes/(unauthorized)/+layout.server.ts` — `load({ locals }) => ({ user: locals.user })`. **Required:** when the page-level `+page.server.ts` `load` throws `error(403)`, SvelteKit discards the *page* load's return value before rendering `+error.svelte`. Layout loads survive the throw and persist `$page.data.user` into the error boundary. Without this file, `$page.data.user` is `undefined` in `+error.svelte` and AC #2 (display user name) cannot be met. Note: the `@` sigil on `+layout@.svelte` resets layout *component* inheritance only — server loads always execute through the full chain, so this layout's load runs even though its component is reset. Do NOT touch: - `src/lib/server/keycloak.ts` - `src/hooks.server.ts` - `src/routes/auth/*` - `src/routes/+layout.svelte` (the root layout — leave admin chrome unchanged for the rest of the app). - Any other route. ### Acceptance Criteria - [ ] `+page.server.ts` `load` function reads `locals.user` and throws `error(403, 'Admin role required')` from `@sveltejs/kit`. - [ ] `+error.svelte` renders the 403 UI: heading ("Admin access required" or similar), user's display name from `$page.data.user.name` (populated by sibling `+layout.server.ts` load — see File Targets), a paragraph explaining that this account doesn't have admin privileges on the basketball-api database, and a "Sign out" button. - [ ] HTTP response status is 403 (verifiable via `curl -i -b cookie.txt http://localhost:5173/players` with a non-admin cookie — first response line `HTTP/1.1 403 Forbidden`). - [ ] "Sign out" button submits a POST to `/auth/logout` via `<form action="/auth/logout" method="POST">` (no JS, works without JS, matches #16's same-origin POST contract). - [ ] Layout reset: root admin chrome (top nav, brand link to `/`) does NOT appear on the 403 page. Verified by inspecting the rendered HTML — the page should NOT contain the root layout's nav markup. - [ ] Layout file is named `+layout@.svelte` (with the `@` sigil). A plain `+layout.svelte` does NOT reset layout inheritance. - [ ] Page is fully static-renderable (no client-side fetches on mount; no `+page.ts` needed since user data is server-loaded). - [ ] User who hits a deep route (e.g. `/players`) without admin role: URL stays `/players`, page body is the 403 UI, status is 403. ### Test Expectations - Manual: log in with a non-admin Keycloak user (or strip the admin role from a test user), hit `/players` → expect to see the 403 page (URL stays at `/players`), with "Sign out" button. - Click "Sign out" → expect the cookie cleared and redirect through Keycloak SLO → land on `/`. - Run command: `npm run dev` + manual browser flow. ### Constraints - No CSS frameworks (no Tailwind per `feedback_no_tailwind`). Pure CSS in a `<style>` block. - Match the visual baseline of the existing scaffold from #6 (header colors, fonts) so the page feels native, not a 404. - The "Sign out" button must be a real `<form action="/auth/logout" method="POST">` — no JS-only fetch (works without JS). - No images / icons that aren't already in `static/`. ### Checklist - [ ] PR opened - [ ] Manual ACs verified - [ ] No new dependencies added - [ ] Visual baseline preserved (no Tailwind, no framework imports) ### Related - `project-westside-admin` - `forgejo_admin/westside-admin#2` — parent - `forgejo_admin/westside-admin#15` — hook that routes here (DEPENDS ON) - `forgejo_admin/westside-admin#16` — logout endpoint that the button hits (DEPENDS ON) - `feedback_no_tailwind`
Author
Owner

Scope Review: NEEDS_REFINEMENT

Review note: review-1137-2026-05-03

Path coordination (the (unauthorized)/__unauthorized/+page.svelte location) is correct and verified against hooks.server.ts@b222400 line 66. Sibling deps #14/#15/#16 all merged. /auth/logout Origin-CSRF check accepts same-origin form POST, verified.

Three issues block dispatch:

  • [BODY] AC #4 is self-contradictory. Three different approaches in one bullet (one says "no +page.server.ts needed", "Decision:" says use setHeaders — which can't set status — and the closing line tells the agent to "pick whichever is simpler"). Pick ONE approach. Recommend +page.server.ts with load returning { user: locals.user } and throwing error(403, '...'), paired with (unauthorized)/+error.svelte.
  • [BODY] $page.data.user.name is not available. No +layout.server.ts or +page.server.ts exists in the repo — hook only populates event.locals.user. The page must load user via a +page.server.ts (or a group +layout.server.ts). Add this file to the File Targets list.
  • [BODY] Layout inheritance not addressed. Root +layout.svelte always wraps any group layout in SvelteKit, so the "no admin chrome" AC cannot be satisfied by adding (unauthorized)/+layout.svelte alone. Need either a layout reset via (unauthorized)/+layout@.svelte (the @ sigil) or conditional chrome in the root layout.

Also flagged (not blocking this ticket):

  • [SCOPE] Architecture note arch-westside-admin does not exist. All four sub-tasks of parent #2 carry arch:westside-admin label. File a separate scope-cleanup item to create the arch note.

Route to skill-refine-ticket with the three [BODY] items above. Re-review after refinement.

## Scope Review: NEEDS_REFINEMENT Review note: `review-1137-2026-05-03` Path coordination (the `(unauthorized)/__unauthorized/+page.svelte` location) is correct and verified against `hooks.server.ts@b222400` line 66. Sibling deps #14/#15/#16 all merged. `/auth/logout` Origin-CSRF check accepts same-origin form POST, verified. Three issues block dispatch: - `[BODY]` **AC #4 is self-contradictory.** Three different approaches in one bullet (one says "no `+page.server.ts` needed", "Decision:" says use `setHeaders` — which can't set status — and the closing line tells the agent to "pick whichever is simpler"). Pick ONE approach. Recommend `+page.server.ts` with `load` returning `{ user: locals.user }` and throwing `error(403, '...')`, paired with `(unauthorized)/+error.svelte`. - `[BODY]` **`$page.data.user.name` is not available.** No `+layout.server.ts` or `+page.server.ts` exists in the repo — hook only populates `event.locals.user`. The page must load user via a `+page.server.ts` (or a group `+layout.server.ts`). Add this file to the File Targets list. - `[BODY]` **Layout inheritance not addressed.** Root `+layout.svelte` always wraps any group layout in SvelteKit, so the "no admin chrome" AC cannot be satisfied by adding `(unauthorized)/+layout.svelte` alone. Need either a layout reset via `(unauthorized)/+layout@.svelte` (the `@` sigil) or conditional chrome in the root layout. Also flagged (not blocking this ticket): - `[SCOPE]` Architecture note `arch-westside-admin` does not exist. All four sub-tasks of parent #2 carry `arch:westside-admin` label. File a separate scope-cleanup item to create the arch note. Route to `skill-refine-ticket` with the three `[BODY]` items above. Re-review after refinement.
Author
Owner

Scope Review (iter 2): NEEDS_REFINEMENT

Review note: review-1137-2026-05-03-iter2

Iter-1 blockers status: AC #4 contradiction RESOLVED, layout reset via +layout@.svelte RESOLVED, $page.data.user PARTIALLY resolved — the new design (load throws error(403)) introduces a new gap.

Two new blockers:

  • [BODY] Add src/routes/(unauthorized)/+layout.server.ts to File Targets — load({ locals }) => ({ user: locals.user }). Without this, $page.data.user is undefined when +error.svelte renders, because a thrown error() discards the page load's return value. Layout loads survive sibling page-load throws and populate $page.data for the rendered +error.svelte.
  • [BODY] Rewrite AC #2: replace "(passed through by load)" with "(populated by sibling +layout.server.ts load — see File Targets)".

Cleanup:

  • [BODY] Delete the duplicate "Do NOT touch:" block (the second one drops src/routes/+layout.svelte and could mislead a skim-reader).
  • [BODY] Fix the garbled sentence in Three-SvelteKit-specifics #3 ("the simpler approach is setHeaders({}) + return { status: 403, ... } is not how SvelteKit works") — see review note for suggested rewrite.

Standing scope finding (not blocking):

  • [SCOPE] arch-westside-admin note still missing in pal-e-docs. Affects all four #2 sub-tasks; track separately.

Path coordination, layout-reset sigil, error-boundary wiring, and dependency claims are all verified against main@b222400. Re-review after the four BODY fixes.

## Scope Review (iter 2): NEEDS_REFINEMENT Review note: `review-1137-2026-05-03-iter2` Iter-1 blockers status: AC #4 contradiction RESOLVED, layout reset via `+layout@.svelte` RESOLVED, `$page.data.user` PARTIALLY resolved — the new design (load throws `error(403)`) introduces a new gap. **Two new blockers:** - **[BODY]** Add `src/routes/(unauthorized)/+layout.server.ts` to File Targets — `load({ locals }) => ({ user: locals.user })`. Without this, `$page.data.user` is `undefined` when `+error.svelte` renders, because a thrown `error()` discards the page load's return value. Layout loads survive sibling page-load throws and populate `$page.data` for the rendered `+error.svelte`. - **[BODY]** Rewrite AC #2: replace "*(passed through by load)*" with "*(populated by sibling `+layout.server.ts` load — see File Targets)*". **Cleanup:** - **[BODY]** Delete the duplicate "Do NOT touch:" block (the second one drops `src/routes/+layout.svelte` and could mislead a skim-reader). - **[BODY]** Fix the garbled sentence in Three-SvelteKit-specifics #3 ("the simpler approach is `setHeaders({})` + `return { status: 403, ... }` is **not** how SvelteKit works") — see review note for suggested rewrite. **Standing scope finding (not blocking):** - **[SCOPE]** `arch-westside-admin` note still missing in pal-e-docs. Affects all four #2 sub-tasks; track separately. Path coordination, layout-reset sigil, error-boundary wiring, and dependency claims are all verified against `main@b222400`. Re-review after the four BODY fixes.
Author
Owner

Scope Review: APPROVED (iter 3)

Review note: review-1137-2026-05-03-iter3

All four iter-2 issues resolved:

  • Blocker A — +layout.server.ts added to File Targets with correct spec + rationale
  • Blocker B — AC #2 now points at sibling +layout.server.ts as the data source
  • Cosmetic #1 — duplicate "Do NOT touch:" block removed (verified grep -c returns 1)
  • Minor #1 — Three-SvelteKit-specifics #3 sentence rewritten cleanly

Body verified against forgejo_admin/westside-admin@b222400 (post-#18/#20/#21). All five File Targets check out: path matches UNAUTHORIZED_PATH in hook, App.Locals.user.name is always-string, adapter is adapter-node. 5 files / 8 ACs / co-located in one route group — fits one agent pass.

Standing [SCOPE] (not blocking #17): create arch-westside-admin architecture note. Affects all parent-#2 sub-tasks.

Ready for dev dispatch.

## Scope Review: APPROVED (iter 3) Review note: `review-1137-2026-05-03-iter3` All four iter-2 issues resolved: - Blocker A — `+layout.server.ts` added to File Targets with correct spec + rationale - Blocker B — AC #2 now points at sibling `+layout.server.ts` as the data source - Cosmetic #1 — duplicate "Do NOT touch:" block removed (verified `grep -c` returns 1) - Minor #1 — Three-SvelteKit-specifics #3 sentence rewritten cleanly Body verified against `forgejo_admin/westside-admin@b222400` (post-#18/#20/#21). All five File Targets check out: path matches `UNAUTHORIZED_PATH` in hook, `App.Locals.user.name` is always-string, adapter is adapter-node. 5 files / 8 ACs / co-located in one route group — fits one agent pass. Standing [SCOPE] (not blocking #17): create `arch-westside-admin` architecture note. Affects all parent-#2 sub-tasks. Ready for dev dispatch.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
forgejo_admin/westside-admin#17
No description provided.