Build Admin login and sidebar layout with auth guard #8
Reference in New Issue
Block a user
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?
Task 8 — Plan reference:
docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.mdUser story (US-AUTH-003):
As an admin, I log in with my username and password and see the admin interface with sidebar navigation.
Acceptance criteria
GET /admin/*(except/admin/login) redirects to/admin/loginif noadmin_sessioncookieverifyAdminPassword, setsadmin_sessioncookie on successbg-admin-bg, 155px wide) shows 4 nav items: Inventar, Codes, Reservierungen, Übersichtbg-white/10 text-white border-l-[3px] border-accentleft bordermd:flexpattern/admin/logout, clears cookie, redirects to/admin/loginfamily_codecookie grants zero access to/admin/*(NFR-SEC-004)admin_sessioncookie grants zero access to the family gallery — separate auth systemsFiles to create
src/routes/admin/login/+page.sveltesrc/routes/admin/login/+page.server.tssrc/routes/admin/+layout.server.tssrc/routes/admin/+layout.sveltesrc/routes/admin/logout/+page.server.tsDepends on: #3 | Size: S | Spec: system-design §4 (Auth), reservierung-design §5.1, views spec View 04, NFR-SEC-004
👤 Markus Keller — Application Architect
Observations
+layout.server.tsand not in individual routes — this is the right enforcement point per the architecture.hooks.server.tsremains the cookie-to-locals layer; the layout guard is the redirect layer).admin_sessioncookie design (store admin name, whitelist againstADMIN_NAMESderived from env vars) is documented in both the system design and the persona files. The issue implies this but doesn't spell out the implementation shape.155px) differs from the views spec impl-ref table (w-44= 176px). These need to be consistent before coding starts./admin/*) is explicitly listed as an acceptance criterion — this is correctly tested at the+layout.server.tsguard, not duplicated per route.admin_sessioncookie grants zero access to the family gallery" is the inverse check. This is enforced ingalerie/+page.server.tsvialocals.familyCode, which has nothing to do withlocals.admin. These are truly independent — the architecture handles this cleanly without extra code in this issue./admin/logout, clear cookie, redirect) is clean; the logout route needs to clear only theadmin_sessioncookie, notfamily_code.Recommendations
155px, the views spec saysw-44(176px). Pick one and update the other. The impl-ref table in the spec is the binding source of truth; update the issue body to176px/w-44.+layout.server.tsguard must redirect to/admin/login, never return a 403 (Nora's rule: 403 reveals the route exists). Write this as the first line:if (!locals.admin) redirect(303, '/admin/login').+layout.sveltemust use<slot />(SvelteKit 2) not{@render children()}— confirm the SvelteKit version inpackage.jsononce the project is scaffolded. The plan uses Svelte 5 runes, where the snippet pattern applies:{@render children?.()}.page.url.pathname(SvelteKit$pagestore), not a prop passed from each sub-route — this avoids per-page boilerplate.POSTaction in+page.server.ts— no+page.svelteis needed if it immediately redirects.Open Decisions (omit if none)
w-44). The implementation cannot split the difference — pick one. If the spec mockup is the source of truth (which it should be), the issue body needs correction tow-44/ 176px.👤 Felix Brandt — Fullstack Developer
Observations
/home/marcel/Desktop/wannsee-kram/— this is a greenfield implementation of the five listed files.+page.svelte(login form UI),+page.server.ts(bcrypt verify + cookie set),+layout.server.ts(auth guard),+layout.svelte(sidebar),logout/+page.server.ts(cookie clear + redirect). This is a well-bounded S-size task.verifyAdminPasswordfunction is mentioned in the acceptance criteria but not yet inlib/auth.ts(which doesn't exist yet). It belongs there, not inline in+page.server.ts."Ungültige Zugangsdaten."— but the error display in the login+page.sveltemust mapform?.errorto this string, not echo it raw.$pagestore reference to mark the active link — this is a$derivedor computed value in+layout.svelte.+page.server.tshas no UI — it just runs an action. It needs at minimum a default export action function (SvelteKit won't accept a file with onlyexport const actionsand no load if accessed via GET accidentally). Aloadthat redirects to/admin/logincovers the GET case gracefully.md:flexpattern — on mobile the sidebar is hidden and a hamburger toggle is needed. This implies$state(false)forsidebarOpenin+layout.svelte. This reactive state management must use Svelte 5 runes, not legacy$:.Recommendations
verifyAdminPassword(username: string, password: string): Promise<boolean>inlib/auth.tsbefore writing the login action. The action should call it, not re-implement bcrypt inline.+page.sveltefor login, bind the error display to the generic message directly fromform.error, since the action already returns the German string:{#if form?.error}<p class="text-xs text-status-taken mt-2">{form.error}</p>{/if}.{#each}with(link.href)key on the sidebar nav items.path: '/') it was set with, otherwise the browser won't honour the deletion.verifyAdminPasswordreturnsfalsefor unknown username, wrong password, empty string.+layout.server.tsload redirects to/admin/loginwhenlocals.adminis null.👤 Nora "NullX" Steiner — Security Engineer
Observations
User enumeration prevention (CWE-204): The acceptance criterion "Wrong credentials → 'Ungültige Zugangsdaten.' (same message for wrong name and wrong password)" is correctly specified. The implementation must NOT branch on unknown username before the bcrypt call. The correct pattern:
The
!hash ||guard is acceptable here because both branches return the identical error. What is NOT acceptable is returning different error messages for each branch.NFR-SEC-004 test surface: "Valid
family_codecookie grants zero access to/admin/*" is enforced by the+layout.server.tsguard checkinglocals.admin, notlocals.familyCode. Sincehooks.server.tspopulates these independently, the guard works correctly as long as it only checkslocals.admin. No cross-contamination risk in this design.Cookie security for
admin_session: The acceptance criterion doesn't specifyHttpOnly,SameSite, orSecure. These are required. The 8-hourmaxAgefrom the system design doc is correct. The cookie must NOT store any sensitive value beyond the admin username, and it must be validated against the env-var whitelist on every request inhooks.server.ts— the cookie value alone must never be trusted without this check.Redirect vs. 403 for unauthenticated admin access: The guard must use
redirect(303, '/admin/login'), nevererror(403, ...). A 403 discloses that the route exists. This is correctly implied by the issue but should be explicit in the acceptance criteria.Logout POST-only: The logout route must only respond to POST. A GET to
/admin/logoutmust not clear the session (CSRF risk if logout were triggered by a<img src="/admin/logout">). SvelteKit Form Actions are POST-only by default — correct. The logout+page.server.tsshould have aloadthat redirects away so a stray GET doesn't render a blank page.admin_sessioncookie value scope: Storing the plaintext admin username in the cookie is acceptable for this project's threat model because it is validated againstADMIN_NAMESon every request inhooks.server.ts. The cookie cannot be forged to grant access as a non-whitelisted name.Recommendations
GET /admin/logoutredirects to/admin/login(does NOT clear the session cookie).httpOnly: true,sameSite: 'strict',secure: !dev,maxAge: 60 * 60 * 8.hooks.server.tsimplementation (in a later task, or Task 2 which must precede this one) must validate theadmin_sessioncookie value againstADMIN_NAMES— not simply setlocals.admin = cookieValuewithout checking.Open Decisions (omit if none)
👤 Sara Holt — QA Engineer
Observations
family_codecookie grants zero access to/admin/*" criterion is a security boundary test — it must be in the permanent regression suite, not just in a smoke test./admin/logout— this needs an E2E test that verifies the cookie is actually cleared (not just that the redirect happens).verifyAdminPassword(3 cases: unknown user, wrong password, correct credentials):memory:):+layout.server.tsload function redirects whenlocals.adminis null/admin/inventarRecommendations
Define and track these specific test cases in the issue before implementation begins:
Unit —
lib/auth.ts:Integration —
+layout.server.ts:E2E (Playwright):
The user enumeration test (same error text for unknown user and wrong password) belongs permanently in the regression suite. Add it to the Playwright E2E suite, not just as a one-time check.
Use Playwright
page.context().cookies()to assert thatadmin_sessionis absent after logout — don't only check the URL.The mobile hamburger behavior (sidebar collapse at ≤767px) needs a component or Playwright test at 375px viewport width.
Open Decisions (omit if none)
ADMIN_TEST_PASSWORD_HASHin CI/Playwright config, (b) use a fixed known-hash for a test adminTestAdminonly in test runs. The production admin names (Marcel, Renate, Berit) must not appear in committed test files. (Raised by: Sara Holt)👤 Leonie Voss — UI/UX Design Lead
Observations
bg-adminbackground, surface-colored card withrounded-xl,p-[22px],max-w-xs. The "Anmelden" button isbg-admin hover:bg-[#1E2C24]— not the primary green. This distinction matters: the login form is in the admin dark world, not the family green world.155px widebut the views spec impl-ref table specifiesw-44(176px) withhidden md:flex. This is a measurable inconsistency that will create a visual difference from the approved mockup.bg-white/10 text-white border-l-[3px] border-accent. The views spec says:flex items-center gap-2 px-3 py-1.5 text-[10px] font-semibold text-white bg-white/10 border-l-[3px] border-accent. These match — good. The inactive state istext-white/45 border-l-[3px] border-transparent hover:text-white/75— thehoverstate and transparent border placeholder are both needed to prevent layout shift on hover.md:flexpattern (sidebar hidden belowmd:, hamburger visible). The views spec confirms: "Sidebar Mobil: Overlay-Drawer — Toggle via Svelte$state(false)" with a hamburger in a mobile top bar. The+layout.svelteneeds both the desktop sidebar and a mobile top bar with hamburger — two distinct layout regions.mt-autoto push to the bottom of the flex column. Style should match inactive sidebar links (no prominent color — this is a utility action, not navigation).<label for="…">verknüpfen". Both username and password inputs must have<label>elements with matchingfor/idpairs — not placeholder-only labeling.type="password"to prevent browser autocomplete exposing it and to trigger password manager UX.Recommendations
w-44(176px) in the issue body to match the views spec — this is the binding implementation reference.bg-admin-bg(token:#2A3B30). The tailwind config in the plan uses'admin-bg': '#2A3B30'as the token name, so the class isbg-admin-bg. The views spec referencesbg-admin. Confirm the Tailwind config key matches before implementing — it should beadmin(shorter alias) oradmin-bg(descriptive). Pick one and be consistent across the login page and the sidebar.<form method="POST" action="/admin/logout">with a submit button — not a link, to ensure it's a POST action.autocomplete="username"to the username input andautocomplete="current-password"to the password input so password managers work correctly.<span>for the "Admin" kicker in small caps, then the serif title — these are two visually distinct elements.Open Decisions (omit if none)
adminoradmin-bg. The views spec HTML usesbg-adminas the CSS class; the Tailwind config in the plan file defines the key as'admin-bg'. These produce different class names (bg-adminvsbg-admin-bg). One must be chosen before Task 1 (scaffold) creates the Tailwind config, or corrected before this task starts. (Raised by: Leonie Voss)👤 Elicit — Requirements Engineer
Observations
hooks.server.ts(the cookie-to-locals layer). Withouthooks.server.tspopulatinglocals.admin, the+layout.server.tsguard has nothing to check. This dependency should be explicit in the issue: "Depends on: #3 (hooks.server.ts withlocals.adminpopulated fromadmin_sessioncookie)."admin_sessioncookie on success" omitshttpOnly,sameSite,maxAge. These are NFR-SEC attributes, not implementation details — they are testable and should be explicit: "setsadmin_sessioncookie (httpOnly,SameSite=Strict, 8h TTL) on success."md:flexpattern" — this tells the developer what the CSS pattern is, but doesn't specify the hamburger behavior: is the drawer a full overlay, a slide-in panel, or something else? The views spec says "Overlay-Drawer" — that level of precision should be in the acceptance criterion or referenced explicitly./admin/logout, clears cookie, redirects to/admin/login". It doesn't specify what happens if theadmin_sessioncookie is already absent (e.g., expired session, then user clicks logout). This edge case should resolve gracefully — a redirect to/admin/loginis correct, but this should be explicit.Recommendations
hooks.server.ts—locals.adminpopulated fromadmin_sessioncookie validation against env-var whitelist)."admin_sessioncookie on success" with "setsadmin_sessioncookie (httpOnly: true,sameSite: 'strict',maxAge: 8h,path: '/') on success."/admin/logoutwith absent or invalidadmin_sessioncookie still redirects to/admin/loginwithout error."/admin/loginwhenlocals.adminis already set redirect to/admin/inventar(skip the login form for already-authenticated admins)."👤 Tobias Wendt — DevOps & Platform Engineer
Observations
Dockerfile,docker-compose.yml, orCaddyfileare required for this task.admin_session— its value (the admin username) is not sensitive enough to require encryption, but the env vars it derives from (ADMIN_MARCEL_PASSWORD_HASH, etc.) must be present. Thedocker-compose.ymltemplate in the system design already includes these variables.family_codecookie grants zero access to/admin/*" depends on the separation working correctly at runtime. This is a pure application-layer check — no infra involvement.verifyAdminPasswordfunction reads fromprocess.env[ADMIN_${username.toUpperCase()}_PASSWORD_HASH]. If the env var is missing (misconfigured.envon the server), it silently returnsundefined→ bcrypt.compare short-circuits → login always fails. This is the correct secure behavior (fail closed), but the error is silent — the admin sees "Ungültige Zugangsdaten." with no log output indicating the real cause.Recommendations
SESSION_SECRETvalidation: log a warning (not an error — the app should still start) if any of the threeADMIN_*_PASSWORD_HASHenv vars is missing. Example:/admin/logoutPOST action clears theadmin_sessioncookie. Confirm thepath: '/'option is set on thecookies.delete()call — without matchingpath, the browser won't honour the deletion, and the admin will appear to remain logged in./healthendpoint (referenced in thedocker-compose.ymlhealthcheck) is not part of this issue but should exist before deployment. Flag this as a prerequisite for the first deploy, not this task..envfile before the first test. This is an ops concern to prepare before this task's acceptance testing.🗳️ Decision Queue — Action Required
4 decisions need your input before implementation starts.
Spec vs. Issue Consistency
w-44). The two sources disagree on a measurable value. The views spec impl-ref table is the binding UI reference. Update the issue body tow-44/ 176px, or consciously override the spec with a documented reason. (Raised by: Markus Keller, Leonie Voss)Design Tokens
adminoradmin-bg. The views spec HTML usesbg-adminas the CSS class name; the implementation plan'stailwind.config.jsdefines the key as'admin-bg'(producingbg-admin-bg). These are two different class names. This must be resolved before Task 1 (scaffold) locks in the Tailwind config, as it affects every admin page. (Raised by: Leonie Voss)Security Policy
Testing Infrastructure
ADMIN_TEST_PASSWORD_HASHenv var for test runs with a committed known-plaintext password; (b) add aTestAdminadmin account that only exists in the test environment. Real admin names (Marcel, Renate, Berit) must not appear in committed test files. Pick the approach before writing the E2E test setup. (Raised by: Sara Holt)