Frontend: A4 — Join household (accept invite) #21
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?
Summary
Invited member opens a link, sees the household identity, creates an account, and is automatically joined with the Member role.
Journey: J6 — Household setup
Role: New user (invited, unauthenticated)
Layout
Mobile (< 768px)
Desktop (> 1024px)
--green-tintbg (logo, household name, inviter name, permissions list)--color-pagebgIdentity Panel Content
Form Fields
user_account+household_member(role=member) + updateshousehold_invite→ redirect to C1Acceptance Criteria
Spec file:
specs/frontend/j6-household-setup.html— screen A4 with mobile + desktop previews, agent table, and LLM implementation guide.👨💻 Kai — Frontend Engineer
A4 is a pre-auth page, which means a different layout shell than anything else in the app — no nav, no session context, fully public route. Let me think through the implementation concerns.
Routing:
/join/[token]— a dynamic route. The+page.server.tsload function must read the invite token fromparams.token, call the backend to validate it (is the invite valid? not expired?), and return the household name + inviter name. If the token is invalid or expired, the load function should redirect to an error page or return a 404/410 — not render a broken invite page.hooks.server.tsmust explicitly allow it through without a session redirect.Form handling:
+page.server.tsform action, not a client-side fetch. The invite token must be included as a hidden field or re-read from params in the action.Component split I'd propose:
JoinHouseholdPage.svelte— layout wrapper (no-chrome, mobile/desktop split)HouseholdIdentityPanel.svelte— logo, household name, inviter text, permissions list (left panel desktop / banner mobile)JoinForm.svelte— name, email, password fields + submitQuestions:
< 768pxmobile,> 1024pxdesktop with a tablet state in between?🔧 Backend Engineer — Join Household (A4)
The accept-invite flow touches three tables in one transaction:
user_account,household_member, andhousehold_invite. Let me walk through the backend concerns.Transaction boundary:
user_account, create thehousehold_member(role=member), and markhousehold_inviteas used — all within a single@Transactionalblock. If any step fails, the whole thing rolls back. A partial success (user created but not joined, or invite not consumed) would leave the system in an inconsistent state.Invite token lifecycle:
GET /invites/{token}(for the load function) — validates the token and returns household name + inviter name. Must check: token exists, not expired, not already used. Returning a 404 vs 410 (Gone) for expired vs used tokens would give the frontend a useful distinction.POST /invites/{token}/accept(for the form submit) — performs the three-step transaction above. The token must be re-validated inside this endpoint too, not trusted because the load step passed (TOCTOU window between page load and form submission).Email uniqueness:
user_accountinsert will hit a UNIQUE constraint. The service must catch this and return a meaningful 409 response — not let a raw SQL constraint violation bubble up as a 500.user_account" — should we also handle the case where the person already has an account and just wants to join? Or is that out of scope for v1?Questions:
household_invitea token column or a UUID primary key? How is it generated? The issue says "invite code/link from URL parameter" but doesn't specify format. UUIDv4 is the minimum bar for unguessability (see also issue #14 on invite authorization).🧪 QA Engineer — Join Household (A4)
The invite flow is a high-stakes path — it's the only way a new member enters the system. I want comprehensive coverage before this ships.
Happy path:
Bad paths I need tested:
Edge cases:
Pre-auth layout:
/join/[token]while already logged in — what happens? Redirect to C1? Show the form anyway? This needs a defined behavior and a test.Component tests I'll write:
HouseholdIdentityPanel: renders household name, inviter name, and all three permissions from propsJoinForm: shows validation errors on empty submit; disables submit button while submitting; renders success state / redirect after valid submission🔐 Sable — Security Engineer
A4 is the invite acceptance flow, and it's a concentrated security risk surface. This is exactly the kind of screen that needs a threat model before a single line is written.
Invite token security (critical):
used_attimestamp or markstatus = accepted). The TOCTOU window betweenGET /invites/{token}(load) andPOST /invites/{token}/accept(submit) is a real race condition — the accept endpoint must re-validate atomically./join/{token}), it will appear in server access logs by default. Consider whether this is acceptable or whether a POST-first flow (token submitted in request body) would be safer.Session fixation on account creation:
Open redirect prevention:
?next=/c1). A?next=https://evil.comparameter is an open redirect vulnerability. We've addressed this in the login flow — apply the same rule here.Role enforcement:
memberrole, regardless of what's in the request body. The role must be set by the server from the invite record, not accepted from the client. A client sendingrole=plannerin the form submit must be ignored.Questions:
🎨 Atlas — UI/UX Designer
A4 is the first thing an invited member sees of this app — it's both a trust signal and an onboarding moment. The design needs to do real work here.
Identity panel — mobile vs desktop:
--green-tintbg. The green-tint color is correct for this — it's the app's primary identity color and it communicates "you're joining something real."--color-text-mutedor similar. It should be clearly subordinate to the household name.Permissions info box:
--green-tintbg with a slightly darker border or subtle inset. Three bullet points with checkmark icons (not list bullets) would reinforce the positive framing.Form design:
Missing states to design:
Questions:
🔐 Sable — Security Engineer (follow-up discussion)
Picking up from my April 3 threat model comment. Worked through the open items with the team.
Resolved
1. Invite token format (8-char alphanumeric)
Intentional. Time-limited exposure reduces risk. Mitigated by requiring enumeration-resistant responses: all negative token cases (never existed, expired, already used) must return 404 with no distinguishing message. Frontend shows a single generic "invite link invalid or expired" screen.
2. Token TTL and single-use — deferred
Raised the gap: a single-use, single-active-link model makes it hard to invite multiple members simultaneously. This is a product decision, not a security decision. Opened issue #60 for resolution.
3. Token in access logs
Token appears in URL path → server access logs. Accepted as a known risk given the self-hosted deployment context and low-value target profile. No mitigation required.
4. Session fixation protection — IN SCOPE
Current
SecurityFilterChainhasmaximumSessions(1)but no explicit session fixation config. Spring's default (IF_REQUIRED) does not guarantee session ID rotation on login. Must add.sessionFixation().changeSessionId()to thesessionManagementblock as part of this issue. This also closes the gap for the existing login flow.5. Role pinning
HouseholdService.acceptInvite()already hardcodes"member"— role is never read from the request body. Correct as-is, no change needed.6. Authenticated user on
/join/[token]Defined behavior:
hooks.server.tsdetects an existing session on/join/[token]routes and redirects to C1 immediately. Invite is not consumed. User must be unauthenticated to accept an invite.Unresolved
Overall read
The flow is sound with the mitigations above. The two concrete additions to the acceptance criteria are: (1) enumeration-resistant 404s on all invalid-token paths, and (2) session fixation protection in
SecurityFilterChain. Both are straightforward to implement and verify.👨💻 Kai — Frontend Engineer (follow-up discussion)
Picking up from my April 3 implementation comment. Resolved all open questions.
Resolved
1. Email-already-registered UX
409 from backend → inline form error: "Diese E-Mail-Adresse ist bereits registriert." + "Anmelden →" link to
/login?next=/join/{token}. Existing login redirect logic handles the return flow. Note: login'snextparam must validate same-origin (Sable's open redirect rule applies here too).2. Tablet breakpoint
Mobile layout (banner + form stacked) for everything
< 1024px. Desktop split (400px identity panel + flex:1 form) kicks in at>= 1024pxonly.3. Permissions list
Static, hardcoded in
HouseholdIdentityPanel.svelte. No prop, no fetch.4. Authenticated-user redirect in
hooks.server.ts/jointoPUBLIC_ROUTESso unauthenticated users can reach the pageisPublicRoutecheck passes, redirect authenticated users (cookie present) to//v1/auth/me— cookie presence is sufficient signal5. Invalid token error screen
Load function returns
invalid: trueon 404 (covers never-existed, expired, already-used — all the same from Sable's enumeration-resistant 404 rule). Page renders a "Einladung ungültig oder abgelaufen" state within the no-chrome layout. No SvelteKit+error.svelteboundary — that would re-introduce app chrome.6. Form submit pattern
+page.server.tsform action, not client-side fetch. Token re-read fromparams.tokeninside the action (not from a hidden field). On success:redirect(302, '/'). Progressive enhancement works out of the box.Overall read
All implementation decisions are settled. This is ready to build.
🔧 Backend Engineer — follow-up discussion
Picking up from my April 3 comment. All open backend questions resolved.
Resolved
1. Enumeration-resistant 404s
Service throws
ResourceNotFoundExceptionfor all negative token cases (never existed, expired, already used). No 404 vs 410 distinction. Controller stays thin — unified error lives in the service.2.
GET /v1/invites/{token}— new endpoint requiredThis endpoint does not exist yet and must be created. Unauthenticated. Response:
{ householdName, inviterName }— nothing more. 404 for all negative cases per item 1.3. Session creation after accept
After
householdService.acceptInvite()completes: invalidate old session if present, then call theauthenticateInSession()pattern fromAuthController(manually buildUsernamePasswordAuthenticationToken, store inSecurityContext+ HTTP session). Returns 200; SvelteKit action handles the redirect to/.4. Transaction boundary +
PasswordEncoderacceptInvite()is already@Transactional— boundary is correct. Two things need adding:PasswordEncodermust be injected intoHouseholdService(not currently present)acceptInvite()signature must expand to(token, name, email, rawPassword)— currently assumes the user account already exists viafindUser(email)5.
sessionFixation().changeSessionId()inSecurityFilterChainAdd to the existing
sessionManagementblock:Login controller already manually invalidates old sessions — this is defence in depth and covers any future auth path.
6.
permitAll()for invite endpointsAdd to
SecurityFilterChain:Covers both
GETandPOSTon the invite path. Unauthenticated access required since the user has no session at this point.Overall read
The accept flow is more substantial than it looks — it's effectively a signup + login + household join in one transaction. The pieces are all there in the codebase; it's a matter of wiring them together correctly.
✅ Implementation complete — branch
feat/issue-21-join-householdAll acceptance criteria met. Two commits:
Commits
92f25e5— Backendinvited_byFK column onhousehold_inviteHouseholdInviteentity:invitedByfield, set oncreateInviteInviteInfoResponse,AcceptInviteRequestHouseholdService.getInviteInfo(code): validates token, returns{householdName, inviterName}— 404 for all invalid cases (enumeration-resistant)HouseholdService.acceptInvite(code, name, email, rawPassword): createsUserAccount(409 if email taken), validates invite, marks used, createsHouseholdMember— all in one@TransactionalHouseholdController:GET /v1/invites/{code}(unauthenticated), updatedPOST /v1/invites/{code}/accept(no Principal, takes request body, creates session after service call)SecurityConfig:.requestMatchers("/v1/invites/*").permitAll()+.sessionFixation().changeSessionId()6950b3d— Frontendschema.d.ts:GET /v1/invites/{code},InviteInfoResponse,AcceptInviteRequest, updatedacceptInviteoperationhooks.server.ts:/joinadded toPUBLIC_ROUTES; authenticated users on/join/*redirected to/+page.server.ts(/join/[token]): load validates token (returnsinvalid: trueon 404, no SvelteKit error boundary); action POSTs accept, handles 409 (email taken), setsJSESSIONIDcookie, redirects to/HouseholdIdentityPanel.svelte: logo, household name (Fraunces), inviter text, static permissions listJoinForm.svelte: name/email/password + show/hide toggle, "Haushalt beitreten" CTA, field errors, server error replay, pre-fill+page.svelte: no-chrome layout; invalid-token state inline; mobile< 1024px(banner + stacked form) / desktop≥ 1024px(400px identity panel + flex:1 form)Test results
Next step
/review-pron the branch, then merge.