Build Admin Code management — create, copy link, delete #11
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 11 — Plan reference:
docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.mdUser story (US-ADM-004 + US-ADM-005 + US-ADM-006):
Acceptance criteria
tracking-widest,text-primary), number of reservationsgenerateCode()from auth.ts)navigator.clipboard.writeText(origin + '/?code=' + code)— client-side JSwindow.confirm()dialog warns that all reservations for this person will also be deletedDELETE FROM codes WHERE id = ?— CASCADE removes all reservations for this codeFiles to create
src/routes/admin/codes/+page.sveltesrc/routes/admin/codes/+page.server.tsDepends on: #8 | Size: S | Spec: reservierung-design §5.3, views spec View 07, NFR-SEC-001
🏗️ Markus Keller (@mkeller) — Application Architect
Observations
generateCode()fromauth.ts— good, that function is already spec'd withcrypto.randomBytesand the confusable-char-free charset. No duplication.codes.codecolumn has aUNIQUEconstraint per the schema insystem-design.md, so the retry catches the SQLiteUNIQUE constraint failederror rather than doing a pre-check SELECT — this is correct.DELETE FROM codes WHERE id = ?with CASCADE: the schema specifiescode_id INTEGER NOT NULL REFERENCES codes(id) ON DELETE CASCADEonreservierungen— this will atomically remove all reservations for the deleted code. No application-level cleanup needed. Good.navigator.clipboard.writeTextis client-side JS, as noted. The spec acknowledges this. This is fine — the admin interface already requires JavaScript for the camera/upload flow; clipboard is not a regression.+page.server.tsactions shape for this page: oneloadreturning all codes with reservation counts (a single SQL query with aCOUNTjoin), plus two actions:createCodeanddeleteCode. Both should use prepared statements defined at module load indb.ts— not inlinedb.prepare()calls inside the action body.Recommendations
db.ts, not inside action handlers. DefinestmtCodesAll,stmtInsertCode,stmtDeleteCode, andstmtCodeExistsat module load. The+page.server.tsimports and calls them. This matches the pattern for every other route.db.tsasstmtCodesWithCount.SQLITE_CONSTRAINT_UNIQUE, rethrow everything else. Thecatchin the retry loop should checke instanceof Error && e.message.includes('UNIQUE constraint failed: codes.code')before retrying. An unrelated error (disk full, schema error) should not be silently swallowed by the retry loop.fail(500, { error: 'code_generation_failed' })with a German user-facing message. The issue mentions "returning error" but doesn't specify the HTTP status. 500 is appropriate here since this is an internal entropy failure.display_nameinput must be validated server-side. Trim whitespace. Reject empty string. Max length 100 chars.formData.get('display_name')?.toString().trim()— if falsy,return fail(400, { error: 'name_required' }).👨💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer
Observations
+page.svelte++page.server.ts) with three Form Actions: create, copy-link, delete. The copy-link is client-side only and doesn't need a Form Action — it's a<button onclick>callingnavigator.clipboard.writeText(). The other two (create, delete) are standard Form Actions.generateCode()from auth.ts" — confirmed in the plan'sauth.tsimplementation. The function is exported and unit-tested. No duplication needed.window.confirm()for delete is explicitly specified. This is the simplest correct approach for an admin-only tool. The spec's impl-ref table (View 06) also confirms: "Nativeconfirm()oder Svelte Dialog-Komponente."ActionDatareturned from thecreateCodeaction. In SvelteKit, after a Form Action returns data (not a redirect), the page re-renders withformprop set. The Svelte component reads{#if form?.success}and shows the flash inline — no toast library needed.Recommendations
src/routes/admin/codes/+page.server.test.ts(or equivalent). Tests to write first:load()returns codes with reservation countscreateCodeaction inserts a row and returns{ success: true, code: '...' }createCodeaction retries on collision and returns error after 5 failurescreateCodeaction rejects empty display_name withfail(400)deleteCodeaction removes the code and cascades reservationsdeleteCodeaction returnsfail(400)for non-integer id{#each codes as code (code.id)}— the key expression is mandatory. Without it, Svelte's reconciler uses position, which can corrupt local button state (e.g., a "Kopiert ✓" flash on the wrong row after a delete).$state. Afternavigator.clipboard.writeText(), set a$statevariable totruefor ~2 seconds, then reset. UsesetTimeoutinside the click handler. Do NOT use$effectfor this — it's a one-shot side effect triggered by the click, not a reactive derived value.form?.errorkeys to German user messages in the template. Never render raw error keys like'name_required'or'code_generation_failed'to the user. Add a lookup object or{#if form?.error === 'name_required'}branches with proper German strings.display_nameinput shoulduse:enhanceon the create form. After submission, the page re-renders. Withoutuse:enhance, the browser does a full page reload and loses scroll position. With it, only the affected part updates.+layout.server.tsguards the admin layout, per-action guards are a defense-in-depth measure consistent with how other admin actions in this project should be structured.🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
origin + '/?code=' + code— this is by design (it's how the app works), but worth flagging: this link, if forwarded accidentally, grants full gallery access under that person's name. Thewindow.confirm()delete dialog should mention this when relevant. Low risk, by-design.window.confirm()for delete: adequate for this admin-only tool. The confirm dialog text must include the person's name and the word "Reservierungen" so the admin understands the cascade. A generic "Are you sure?" is not sufficient. The issue AC specifies "warns that all reservations for this person will also be deleted" — enforce this wording in German.display_namefield is user-controlled text that will appear in the gallery next to "● [Name]". It must be validated (trimmed, max length, non-empty) before insertion. If an admin enters<script>alert(1)</script>, SvelteKit's SSR escaping will neutralize it in the template — but enforce the constraint at the server layer anyway: max 100 chars, strip leading/trailing whitespace, reject empty.navigator.clipboardrequires a secure context (HTTPS). Since Caddy handles TLS, production is always HTTPS. In local dev without HTTPS,navigator.clipboard.writeText()will throw. Handle this gracefully:try { await navigator.clipboard.writeText(url); } catch { /* fallback: show the URL in a prompt() */ }. This is dev-only friction but worth noting.idparameter for delete must be validated as a positive integer before use in SQL. Even though the delete button submits a hidden field from the server-rendered table (not user-editable), Form Action inputs can be tampered with via curl or DevTools. Always:const id = Number(formData.get('code_id')); if (!Number.isInteger(id) || id <= 0) return fail(400, ...).sameSite: 'lax'in the plan'sauth.tsshould be'strict'. The Nora persona spec and the system design spec both mandateSameSite=Strict. The plan's Step 3setFamilyCodeCookieuses'lax'— this is a deviation from the security spec. The admin session cookie especially must be'strict'.Recommendations
display_nameon the server: trim, non-empty, max 100 chars. Returnfail(400, { error: 'name_required' })for empty after trim.code_idas a positive integer in the delete action. Don't trust hidden form fields."Code für ${name} löschen? Alle ${anzahl} Reservierungen dieser Person werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden."Usewindow.confirm()with this full string.family_codecookie holder cannot reach the codes admin page. The+layout.server.tsguard covers this, but test your configuration of it explicitly — framework defaults can change.sameSite: 'lax'→'strict'inauth.tsbefore Task 11 runs (this is a pre-existing issue from Task 3 of the plan, not specific to this issue, but it affects the admin session cookie set during admin login).🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist
Observations
:memory:integration test, not just assumed from the schema.Test Plan
Unit tests (
src/lib/auth.test.ts— already planned in Task 3 of the plan):generateCode()returns 8-char alphanumeric — ✅ already specified in plangenerateCode()returns different values on consecutive calls — ✅ already in planIntegration tests (
src/routes/admin/codes/+page.server.test.ts):load()returns empty array when no codes exist (empty state)load()returns codes with correctreservierungen_anzahl = 0for new codesload()returns correctreservierungen_anzahlwhen reservations exist (JOIN correctness)createCodeaction with valid name inserts a code and returns{ success: true, code: /^[A-Z0-9]{8}$/ }createCodeaction with emptydisplay_namereturnsfail(400, { error: 'name_required' })createCodeaction with whitespace-onlydisplay_namereturnsfail(400)after trimcreateCodeaction retries on UNIQUE collision and eventually succeeds (mockgenerateCodeto collide N-1 times)createCodeaction returnsfail(500)after 5 consecutive collisionsdeleteCodeaction removes the code rowdeleteCodeaction cascades: associated reservations are also deleted (FK constraint test)deleteCodeaction with non-integercode_idreturnsfail(400)deleteCodeaction with unknowncode_idreturns gracefully (no row → no-op or 404)E2E tests (Playwright — addition to the critical journey list):
/?code=URL (usepage.evaluate(() => navigator.clipboard.readText())— requires clipboard permissions in Playwright config)Recommendations
generateCodeinjectable or mockable. The cleanest approach: accept an optionalcodeGeneratorparameter in thecreateCodeaction helper, defaulting togenerateCodefromauth.ts. Tests pass a generator that returns a known-colliding value N times then a unique one.:memory:DB, not just by trusting the schema: insert a code, insert a reservation for that code, delete the code, then verifySELECT * FROM reservierungen WHERE code_id = ?returns no rows.playwright.config.ts:page.evaluate(() => navigator.clipboard.readText())equals the expected URL format.🎨 Leonie Voss (@leonievoss) — UX Design Lead & Accessibility Strategist
Observations
docs/superpowers/specs/2026-05-05-erbstuecke-wannsee-views.html) is the ground truth for this screen. I'm reviewing against it exactly.letter-spacing: 2px) | Res. (reservation count) | Actions (🔗 Link + ✕ Delete buttons). The issue AC matches this.tracking-widestandtext-primary— the spec's.code-monoclass confirms:font-family: monospace; font-size: 11px; font-weight: 600; color: var(--pr); letter-spacing: 2px. In Tailwind:font-mono text-[11px] font-semibold text-primary tracking-[2px].+ Neuer Codebutton in the spec is styled withbackground: var(--ac)(amber#C4874A), not green. This matches the admin-area convention where amber = primary admin action. The issue doesn't specify this — the implementer should follow the spec, not usebg-primary(green) for this button..act-linkin the spec:color: var(--pr); background: #DFF0E6; border: 1px solid #A8D5B8. In Tailwind:text-primary bg-[#DFF0E6] border border-[#A8D5B8] text-[9px] font-bold rounded px-1.5 py-0.5..act-del:color: var(--tk); background: #FBF0F0; border: 1px solid #E0C0C0. The ✕ icon-only button needs anaria-label.Recommendations
bg-accent(amber), notbg-primary(green). See the spec's View 06 mock — this is consistent with how the admin inventar's "+ Hinzufügen" button is styled.aria-label="Link kopieren für [Name]". The icon emoji alone is not accessible. Screen readers need to know what link is being copied and for whom.aria-label="Code löschen für [Name]". Without it, screen readers announce "button, ✕" which is meaningless.$statevariable. This approach is visible, non-dismissable, and doesn't require a toast library. The text change should also announce toaria-live="polite"so screen reader users are informed.<code class="font-mono tracking-[2px] text-primary">inline within the flash message.text-ink-muted text-smreading "Noch keine Zugangscodes erstellt." Below it, nudge toward the "+ Neuer Code" button.<label>(not just placeholder). Even if the label is visually small (using the admin form label style:text-[9px] font-extrabold uppercase tracking-wide text-ink-muted), it must be a real<label for="...">element — not a placeholder..act-btnhaspadding: 3px 7pxwhich is below 44px on touch devices. Since this is an admin-only desktop-first view, this is acceptable per the design spec. However, addmin-h-[32px](as shown in the spec's "+ Neuer Code" button) to ensure basic tap affordance on mobile admin use.Open Decisions
window.confirm()vs. native<dialog>for the delete confirmation. The spec's impl-ref table says "Nativeconfirm()oder Svelte Dialog-Komponente."window.confirm()is simpler but blocks the main thread, cannot be styled, and has no focus trap. A native<dialog>element would be accessible (built-in focus trap, escapeable) and consistently styled. For an admin-only tool with 3 users this is low stakes — but if the project uses<dialog>elsewhere (e.g., article modal), consistency favors<dialog>here too.🚀 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer
Observations
src/routes/admin/codes/. No new environment variables, no new volumes, no infrastructure changes. From a DevOps standpoint, this task is clean — no deployment impact.navigator.clipboard.writeText()call constructs the link asorigin + '/?code=' + code. In production,window.location.originwill behttps://erbstuecke.raddatz.cloud. This is correct as long as the Caddyfile reverse-proxies correctly. No action needed here./health) referenced in the devops persona spec is not part of this issue — but it should be in place before the app goes live. If it isn't implemented by the time this feature is done, flag it.Recommendations
docker-compose.yml(with named volumesdb:anduploads:) andDockerfilecover this feature completely.https://erbstuecke.raddatz.cloud/(nothttp://localhost:3000/). This is a one-time sanity check —window.location.originwill be correct if Caddy is properly forwarding theHostheader (it does by default).X-Forwarded-Protois not needed. SvelteKit'surl.originin server-side code uses the actual request headers. Since the clipboard URL is built client-side withwindow.location.origin, this is unambiguously correct regardless of proxy config. No action needed.📋 Elicit — Requirements Engineer
Observations
navigator.clipboard.writeText()can fail silently if the browser denies clipboard permission (e.g., focus lost, permissions API blocked). The issue doesn't specify what happens in this case. This is a gap.Recommendations
navigator.clipboard.writeText()fails, show the URL in aprompt()dialog or display it as selectable text so the admin can copy manually." This is a completeness gap in US-ADM-005.window.confirm()text: "Code für [Name] löschen? Alle [N] Reservierungen dieser Person werden ebenfalls unwiderruflich gelöscht."generateCode()export fromauth.tsas a completed deliverable before this task starts.Open Decisions
🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
UX / Interaction Pattern
window.confirm()vs. native<dialog>—window.confirm()is simpler (3 lines of code), unblockable by popup blockers in most browsers, and explicitly permitted by the spec ("Nativeconfirm()oder Svelte Dialog-Komponente"). A native<dialog>is properly styled, has a built-in focus trap, and is consistent if the article modal uses<dialog>too. Cost ofwindow.confirm(): cannot be styled, blocks the main thread, looks out of place on modern browsers. Cost of<dialog>: ~20 extra lines of Svelte, a per-row$statebinding. (Raised by: Leonie)Requirements / Copy