(unauthorized) route group: 403 page with sign-out button (consumes #15) #17
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Type
Feature
Lineage
Decomposed from
forgejo_admin/westside-admin#2(Keycloak cookie SSR auth + admin role gate). Sub-task 4 of 4. Depends onforgejo_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 atsrc/routes/(unauthorized)/__unauthorized/+page.svelte. The bare/(unauthorized)route group resolves to/, which collides with the home page — the__unauthorizedsegment inside the group disambiguates.Repo
forgejo_admin/westside-adminUser Story
story-westside-admin-admin-row-crud. When an authenticated user lacks theadminrole, 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'beforeresolve. 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 populatedevent.locals.user(withsub,email,name,rolesper the mergedApp.Localsextension); 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:
+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.sveltecannot remove the parent.$page.data.useris NOT populated by the hook directly — it requires a+page.server.ts(or+layout.server.ts) that exposeslocals.uservia itsloadfunction. Since this page consumesuser.name, it needs its own server-load.+page.server.tsloadreturns —setHeaders({})controls headers, not status. The idiomatic way to ship a non-200 status from a route is tothrow error(403, 'Admin role required')from@sveltejs/kitinside the load function. That throw routes the request to the nearest+error.svelteboundary, 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.userdoesn't auto-populate fromevent.locals; needs an explicit load. Sets HTTP 403 viathrow error(403, 'Admin role required')(handled by the co-located+error.sveltebelow) — the throw renders+error.svelteinstead 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.messageand user from$page.data.user. This file replaces what would have been+page.svelte— theerror()throw routes here.src/routes/(unauthorized)/__unauthorized/+page.svelte— empty/minimal placeholder. SvelteKit requires a+page.sveltefor 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.sveltewould 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.tsloadthrowserror(403), SvelteKit discards the page load's return value before rendering+error.svelte. Layout loads survive the throw and persist$page.data.userinto the error boundary. Without this file,$page.data.userisundefinedin+error.svelteand AC #2 (display user name) cannot be met. Note: the@sigil on+layout@.svelteresets 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.tssrc/hooks.server.tssrc/routes/auth/*src/routes/+layout.svelte(the root layout — leave admin chrome unchanged for the rest of the app).Acceptance Criteria
+page.server.tsloadfunction readslocals.userand throwserror(403, 'Admin role required')from@sveltejs/kit.+error.svelterenders the 403 UI: heading ("Admin access required" or similar), user's display name from$page.data.user.name(populated by sibling+layout.server.tsload — see File Targets), a paragraph explaining that this account doesn't have admin privileges on the basketball-api database, and a "Sign out" button.curl -i -b cookie.txt http://localhost:5173/playerswith a non-admin cookie — first response lineHTTP/1.1 403 Forbidden)./auth/logoutvia<form action="/auth/logout" method="POST">(no JS, works without JS, matches #16's same-origin POST contract)./) 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@.svelte(with the@sigil). A plain+layout.sveltedoes NOT reset layout inheritance.+page.tsneeded since user data is server-loaded)./players) without admin role: URL stays/players, page body is the 403 UI, status is 403.Test Expectations
/players→ expect to see the 403 page (URL stays at/players), with "Sign out" button./.npm run dev+ manual browser flow.Constraints
feedback_no_tailwind). Pure CSS in a<style>block.<form action="/auth/logout" method="POST">— no JS-only fetch (works without JS).static/.Checklist
Related
project-westside-adminforgejo_admin/westside-admin#2— parentforgejo_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_tailwindScope Review: NEEDS_REFINEMENT
Review note:
review-1137-2026-05-03Path coordination (the
(unauthorized)/__unauthorized/+page.sveltelocation) is correct and verified againsthooks.server.ts@b222400line 66. Sibling deps #14/#15/#16 all merged./auth/logoutOrigin-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.tsneeded", "Decision:" says usesetHeaders— which can't set status — and the closing line tells the agent to "pick whichever is simpler"). Pick ONE approach. Recommend+page.server.tswithloadreturning{ user: locals.user }and throwingerror(403, '...'), paired with(unauthorized)/+error.svelte.[BODY]$page.data.user.nameis not available. No+layout.server.tsor+page.server.tsexists in the repo — hook only populatesevent.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.sveltealways wraps any group layout in SvelteKit, so the "no admin chrome" AC cannot be satisfied by adding(unauthorized)/+layout.sveltealone. 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 notearch-westside-admindoes not exist. All four sub-tasks of parent #2 carryarch:westside-adminlabel. File a separate scope-cleanup item to create the arch note.Route to
skill-refine-ticketwith the three[BODY]items above. Re-review after refinement.Scope Review (iter 2): NEEDS_REFINEMENT
Review note:
review-1137-2026-05-03-iter2Iter-1 blockers status: AC #4 contradiction RESOLVED, layout reset via
+layout@.svelteRESOLVED,$page.data.userPARTIALLY resolved — the new design (load throwserror(403)) introduces a new gap.Two new blockers:
src/routes/(unauthorized)/+layout.server.tsto File Targets —load({ locals }) => ({ user: locals.user }). Without this,$page.data.userisundefinedwhen+error.svelterenders, because a thrownerror()discards the page load's return value. Layout loads survive sibling page-load throws and populate$page.datafor the rendered+error.svelte.+layout.server.tsload — see File Targets)".Cleanup:
src/routes/+layout.svelteand could mislead a skim-reader).setHeaders({})+return { status: 403, ... }is not how SvelteKit works") — see review note for suggested rewrite.Standing scope finding (not blocking):
arch-westside-adminnote 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: APPROVED (iter 3)
Review note:
review-1137-2026-05-03-iter3All four iter-2 issues resolved:
+layout.server.tsadded to File Targets with correct spec + rationale+layout.server.tsas the data sourcegrep -creturns 1)Body verified against
forgejo_admin/westside-admin@b222400(post-#18/#20/#21). All five File Targets check out: path matchesUNAUTHORIZED_PATHin hook,App.Locals.user.nameis 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-adminarchitecture note. Affects all parent-#2 sub-tasks.Ready for dev dispatch.