feat(members): implement /members page — Kachel-Ansicht (E2) #48
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?
Beschreibung
Implementierung der
/members-Seite als Kachel-Ansicht (Card Grid). Jedes Haushaltsmitglied erscheint als Kachel. Der Planer kann Rollen ändern und Mitglieder entfernen. Eine Einladekachel ermöglicht das Generieren und Kopieren des Einlade-Links.Spec:
specs/frontend/e2-members-kachel.htmlZustände (aus Spec)
Layout
grid-template-columns: repeat(4, 1fr), Gap 16pxgrid-template-columns: repeat(2, 1fr), Gap 12pxjoinedAtASC → Einladekachel zuletztKomponenten / Verhalten
--green-dark, Mitglied =--blueborder-color: --green-light, "Du"-Badge, kein Kebab-Button⋯):opacity: 0→1on hover/focus; auf Touch-Geräten immer sichtbar. Click-away schließt, ESC schließt.navigator.clipboard.writeText→ Button zeigt "Kopiert ✓" für 2s. Regenerieren invalidiert alten Code.⚠️ Backend-Lücken — müssen vor Implementierung geschlossen werden
Diese Endpoints existieren noch nicht im API-Schema (
schema.d.ts):Bestehend:
GET /v1/households/mine/members,POST /v1/households/mine/invitesAkzeptanzkriterien
⚛️ Kai — Frontend Engineer
Questions & Observations
Backend-first dependency is a hard blocker. DELETE + PATCH member + GET invites müssen alle drei fertig und im
schema.d.tsdrin sein, bevor ich anfangen kann. Ich würde vorschlagen, die Frontendarbeit in einen separaten Branch zu verschieben, der auf einen Backend-Branch rebased, statt auf master zu warten. Haben wir schon einen Backend-Issue dafür?Komponenten-Split. Ich denke an:
MemberGrid.svelte(Grid-Layout + Karten-Orchestration),MemberCard.svelte(einzelne Kachel, inklusive Kebab und Role-Control),InviteCard.svelte,InvitePanel.svelte,RemoveDialog.svelte. Fühlt sich richtig an — jede Datei bleibt fokussiert. Wäre das okay oder soll alles in einer Seite bleiben?Click-away Listener. Ich werde das mit einem
$effectlösen, der einendocument.addEventListener('click', ...)registriert und in der Cleanup-Funktion wieder entfernt. SSR-Problem:documentist server-seitig nicht verfügbar. Brauche ich einbrowser-Guard. Ist das euer Standard-Pattern oder gibt es eine App-Util dafür?Touch-Detection für immer-sichtbaren Kebab.
@media (hover: none)CSS-Media-Query ist zuverlässiger als JSnavigator.maxTouchPoints. Ich würde das rein über CSS machen:navigator.clipboard.writeTextist nur im HTTPS-Kontext verfügbar. Lokale Dev-Umgebung läuft auf HTTP — brauchen wir einen Fallback (execCommand('copy')oder eine Fehlermeldung)?Optimistisches Update bei Rolle ändern. Ich setze die neue Rolle sofort in
$state, dispatch dann das PATCH. Bei 4xx rollback ich zurück und zeige einen Toast. Gibt es eine globale Toast-Komponente oder muss ich eine bauen?Suggestions
+page.server.tslädtGET /v1/households/mine/members— das gibt bereits die Members zurück. Ich brauchelocals.benutzer.idaus dem Layout-Server-Load, um die eigene Kachel zu identifizieren. Das ist schon in+layout.server.tsverfügbar — kein extra API-Call nötig.show: boolean-$state-prop, nicht als inlined HTML-Block. Das macht es testbar.MemberCardan (einfachste Unit), dannMemberGrid, dann die Seite selbst. Für den Dialog gibt es typische Render-Tests + Click-Tests.🏗️ Backend Engineer — Spring Boot / PostgreSQL
Questions & Observations
Drei neue Endpoints — wo sitzen die Business Rules? Die Guards "Planer kann sich nicht selbst entfernen" und "letzter Planer kann nicht degradiert werden" gehören in den Service, nicht in den Controller. Der Controller validiert nur den Request, der Service entscheidet ob die Aktion erlaubt ist.
Aggregate-Grenze: Haushalt ist der Root. Beide Endpoints operieren auf
households/mine/members/{userId}— das deutet darauf hin, dass die Validierung auf demHousehold-Aggregate läuft: "Darf ich dieses Mitglied in meinem Haushalt entfernen?". Ich würdeHouseholdService.removeMember(householdId, requesterId, targetUserId)als Signatur vorschlagen — so ist die Household-Isolation im Service erzwingbar.GET /v1/households/mine/invites fehlt im Schema. Die bestehende
POST-Route erstellt eine Einladung — aberGETzum Auflisten aktiver Codes ist noch nicht implementiert. Wichtige Frage: Soll es immer nur einen aktiven Code geben (einfacher) oder mehrere? Für einen einzelnen Haushalt im Familienkontext reicht ein aktiver Code pro Haushalt.Audit-Log. Rollenänderung und Mitglied-Entfernen sind sicherheitsrelevante Aktionen. Beide sollten in
admin_audit_loggeschrieben werden — analog zu anderen privilegierten Aktionen im System.HTTP-Semantik:
DELETE /v1/households/mine/members/{userId}→204 No Contentbei ErfolgPATCH /v1/households/mine/members/{userId}→200 OKmit dem aktualisiertenMemberResponse409 Conflictoder422 Unprocessable Entitymit klarer FehlermeldungIdempotenz bei PATCH. Was passiert, wenn ich
PATCHmitrole: "mitglied"aufrufe und das Mitglied ist bereitsmitglied? Das sollte ein200 OKohne Seiteneffekte zurückgeben — kein Fehler, kein unnötiger Audit-Log-Eintrag.Suggestions
household_invites-Konzept prüfen: Braucht dieinvites-Tabelle eineinvalidated_at-Spalte, um alte Codes zu markieren ohne zu löschen (Audit-Trail)?activeInvite pro Haushalt. Das kann über einen Partial Unique Index abgesichert werden:UNIQUE (household_id) WHERE invalidated_at IS NULL.🔒 Sable — Security Engineer
Questions & Observations
IDOR auf DELETE und PATCH — kritisch.
DELETE /v1/households/mine/members/{userId}muss serverseitig verifizieren, dass{userId}tatsächlich Mitglied des eigenen Haushalts ist. Ein Planer aus Haushalt A darf keinen User aus Haushalt B entfernen. Das klingt offensichtlich, aber ich habe schon gesehen wiefindById(userId)ohne Haushalt-Join aufgerufen wird. Die Repository-Query muss lauten:findByUserIdAndHouseholdId(userId, currentHouseholdId).Broken Access Control auf PATCH. Ein
mitglieddarfPATCH /v1/households/mine/members/{userId}nicht aufrufen — auch nicht für sich selbst. Derrolle === 'planer'-Check muss imSecurityFilterChainoder im Service stattfinden, nicht nur im Frontend. Das Frontend-Guard (kein Kebab-Menü für Mitglieder) ist kein Sicherheitskontrollpunkt.Invite-Code Entropie. Die Spec sagt "Regenerieren invalidiert alten Code" — gut. Aber wie wird der Code generiert?
UUIDv4(122 Bit Entropie) ist ausreichend. Kürzere Codes (z.B.X4K9-RZMQwie in der Spec) haben drastisch weniger Entropie und sind über Brute-Force angreifbar, wenn kein Rate-Limiting existiert. Wichtige Frage: Gibt es Rate-Limiting aufPOST /v1/invites/{code}/accept?Invite-Link im Einlade-Panel. Der
shareUrlwird clientseitig im DOM gerendert — kein XSS-Risiko solange kein{@html}verwendet wird (Svelte escaped automatisch). Aber: Der Link landet möglicherweise in Browserlogs, Proxy-Logs oder Referrer-Headers.Referrer-Policy: no-referrerauf der App-Ebene prüfen.navigator.clipboard ist HTTPS-only. Kai hat das bereits erwähnt. Aus Security-Perspektive: kein Fallback auf
execCommand— das ist deprecated und hat eigene Security-Probleme. Lieber eine klare Fehlermeldung "Kopieren nur über HTTPS verfügbar" zeigen.Audit-Log-Pflicht. Rollenänderung (
planer → mitgliedoder umgekehrt) und Mitglied-Entfernen sind privilegierte Aktionen. Beide müssen inadmin_audit_logstehen:actor_id,action,target_id,timestamp. Das ist kein Nice-to-have — ohne Audit-Trail gibt es keine Nachvollziehbarkeit bei Disputes.Session-Invalidierung nach Entfernen. Was passiert mit der aktiven Session des entfernten Mitglieds? Idealerweise wird die Session sofort invalidiert — sonst hat das entfernte Mitglied bis zum Session-Timeout noch Zugang. Spring Security hat hierfür
SessionRegistry+expireNow().Suggestions
householdIdaus dem Session-Principal mitgeben, nie aus dem Request-Body oder -Pfad allein vertrauen.403zurückgeben, nicht404(404 würde Existenz bestätigen, 403 tut das auch, aber konsistenter).🧪 QA Engineer
Questions & Observations
Fehlende Akzeptanzkriterien für wichtige Edge Cases:
404oder409zurückgeben, nicht500.expiresAt— aber was passiert wenn ein User einen abgelaufenen Link benutzt? Wird das Frontend informiert? Gibt es eine Fehlermeldung imPOST /v1/invites/{code}/acceptResponse?Test-Coverage, die ich aufbauen würde:
Fehlende
data-testid-Attribute in der Spec. Ich brauche testbare Selektoren für:member-grid,member-card,invite-card,kebab-btn,role-control,remove-dialog,confirm-remove-btn. Die Spec sollte diese benennen.Suggestions
j7-manage-members.spec.ts— mindestens Happy Path: Einladen + Rolle ändern + Entfernen.🎨 Atlas — UI/UX Design
Questions & Observations
Typografie-Konsistenz. Button-Text im Dropdown und im Dialog muss exakt sein:
font-size: 13px,font-weight: 500,letter-spacing: 0.04em,font-family: DM Sans. Das gilt für "Rolle ändern", "Entfernen" im Dropdown und "Abbrechen"/"Entfernen" im Dialog-Footer. Bitte nicht Tailwind'stext-sm(14px) verwenden — hier ist 13px spezifiziert.Touch-Target auf dem Kebab-Button. Der Kebab-Button ist mit
top: 12px; right: 12pxpositioniert und muss mindestens 44×44px Tap-Fläche haben (WCAG 2.2 AA). Die visuelle Größe kann kleiner sein, aber das::after-Pseudo-Element oder ein transparentes Padding muss den Tap-Bereich auf 44px ausdehnen.Focus-Indicator. Alle interaktiven Elemente (Kacheln, Kebab, Dropdown-Items, Segmented-Control-Buttons, Dialog-Buttons) brauchen sichtbare Fokus-Indikatoren:
outline: 2px solid var(--green-dark); outline-offset: 2px. Bitte nichtoutline: nonesetzen. Keyboard-Navigation durch das Grid muss funktionieren (Tab → Kacheln, Enter → Kebab, Pfeiltasten im Dropdown).Role-Badge Kontrastverhältnis. Die "Du"-Badge (
--green-tintbg,--green-darktext):#E8F5EAauf#2E6E39— das ist ~7:1, WCAG AA ✓. Das Mitglied-Badge (--blue-tintbg,--blue-darktext):#E6F1FBauf#0C447C— ca. 6.5:1, ✓. Beides passt.Mobile Bottom Sheet Höhe. Der Dialog als Bottom Sheet auf Mobile: die Spezifikation sagt
border-radiusnur oben, keinmax-width. Ich würde ergänzen:padding-bottom: env(safe-area-inset-bottom)damit der Inhalt auf Geräten mit Home-Indicator (iPhone) nicht abgeschnitten wird.Einladekachel im leeren Grid. Was passiert wenn der Haushalt nur 1 Mitglied hat (der eingeloggte Planer) und sonst niemanden? Dann ist das Grid
[eigene Kachel] [Einladekachel]— 2 von 4 Spalten gefüllt. Sollte die Einladekachel dann prominenter hervorgehoben werden (z.B.grid-column: span 2)?Animation/Transition beim Entfernen. Die Spec sagt "fade-out" beim Entfernen einer Kachel. Ich empfehle
opacity 200ms ease+transform: scale(0.95) 200ms easekombiniert — reiner Opacity-Fade ohne Scale wirkt flach. Der Grid-Reflow danach soll ohne Transition passieren (CSSgridreflow ist nicht animierbar ohne Hacks).Suggestions
--shadow-raisedfür das offene Dropdown ist richtig. Sicherstellen, dassz-indexauf der Dropdown-Karte höher liegt als auf den benachbarten Kacheln — sonst wird das Dropdown von der nächsten Kachel überschnitten.border-radius: --radius-md(6px) für den Container, die inneren Buttons habenborder-radius: 0(overflow: hidden on container macht das). Nicht--radius-fullverwenden — das wäre eine Pill-Form, die nicht in das System passt.border-radius: --radius-xl(16px) passt zu den Kacheln. Der Abstand zwischen Grid und Panel:margin-top: 16pxwie in der Spec. ✓🎨 Atlas — UI/UX Design · Designentscheidungen aus der Diskussion
Resolved
Einladekachel span — Bleibt
1frwie alle anderen Kacheln. Keingrid-column: span 2, auch wenn das Grid nur dünn besetzt ist.Avatar-Farbe der eigenen Kachel — Immer
--green-dark, unabhängig von der Rolle des eingeloggten Users. Der grüne Rahmen (--green-lightborder) und das „Du"-Badge definieren die eigene Kachel — das Avatar-Grün gehört dazu, auch wenn die Rollemitgliedist.BottomSheet — Wird als neue, wiederverwendbare
BottomSheet.svelte-Systemkomponente gebaut. Nicht inline in den Remove-Dialog. Spezifikation sollte sie als eigenständiges Design-System-Element dokumentieren.Transition-Token für Card-Exit —
--transition-card-exitwird als offizieller Token im Design System angelegt (empfohlener Wert:opacity 200ms ease, transform 200ms ease). Kein ad-hoc CSS in der Komponente.SegmentedControl — Wird neu gebaut als
SegmentedControl.svelte. Erstmalige Systemkomponente, entstanden in diesem Feature.Insgesamt ist das Issue designseitig gut spezifiziert. Die fünf Punkte oben sind die einzigen Lücken, die vor der Implementierung entschieden werden mussten.
⚛️ Kai — Frontend Engineer · Implementierungsentscheidungen aus der Diskussion
Resolved
Backend-Strategie — Backend (DELETE, PATCH, GET invites) und Frontend werden im selben Ticket implementiert. Kein separater Branch, kein Warten.
Toast-Komponente — Generisches
Toast.sveltewird neu gebaut insrc/lib/components/. API-Design berücksichtigt, dassUndoBar.sveltespäter einfach darauf aufsetzen kann (message + optionaler action-Prop via Snippet).Click-away
browser-Guard — Kein App-Util vorhanden, kein Precedent im Codebase. Standard-Pattern:import { browser } from '$app/environment'+if (browser)inline im$effectder Kebab-Komponente.BottomSheet Props-API — Neue
BottomSheet.svelteSystemkomponente (Atlas-Entscheidung) bekommt folgende API:PATCH/DELETE routing — Optimistische API-Calls (Rolle ändern, Mitglied entfernen) laufen über SvelteKit
+server.tsals Proxy. Kein direkter Client-zu-Backend Call.Technisch ist das Issue gut durchdacht. Die offenen Punkte oben sind die Vorentscheidungen, die ich vor dem ersten Commit brauchte.
🏗️ Backend Engineer — Spring Boot / PostgreSQL · Implementierungsentscheidungen aus der Diskussion
Resolved
Einziger aktiver Invite-Code — Ein aktiver Code pro Haushalt. Abgesichert via Partial Unique Index:
UNIQUE (household_id) WHERE invalidated_at IS NULL. Regenerieren setztinvalidated_atauf den alten Code und erstellt einen neuen.Guard-Response-Code —
409 Conflictfür alle Business-Rule-Verletzungen: Planer versucht sich selbst zu entfernen, letzter Planer versucht sich zu degradieren. Klare Fehlermeldung im Response-Body.Session-Invalidierung nach Entfernen — Out of scope für dieses Ticket. Separates Issue angelegt: #56.
Audit-Log — Out of scope für dieses Ticket. Separates Issue angelegt: #57. Beide Aktionen (Rollenänderung + Entfernen) werden dort nachgezogen.
schema.d.tsUpdate — Backend schreibt die TypeScript-Typen für die drei neuen Endpoints (DELETE, PATCH, GET invites) als Teil der Implementierung in diesem Ticket. Kai bekommt die fertigen Typen, bevor er mit der Frontend-Arbeit beginnt.Das Issue ist backend-seitig gut durchdacht. Die offenen Punkte oben sind die Vorentscheidungen für eine saubere Implementierung ohne Scope-Creep.
✅ Implementation complete — branch
feat/issue-48-members-kachelAll 21 tasks done. Red/green/refactor TDD throughout.
Backend (commits
d1e4b6c,b04f2c5)invalidated_at timestamptz+ partial unique indexUNIQUE (household_id) WHERE invalidated_at IS NULLonhousehold_inviteHouseholdInvite.invalidatedAt,findByHouseholdIdAndInvalidatedAtIsNull,findByHouseholdIdAndUserId,countByHouseholdIdAndRoleChangeRoleRequestDTOrolefield with@Pattern(planner|member)validationgetActiveInvite()createInvite()removeMember()changeMemberRole()GET /v1/households/mine/invites,DELETE /v1/households/mine/members/{userId},PATCH /v1/households/mine/members/{userId}schema.d.tsBackend: 328 tests ✅
Frontend (commit
1b5704c)Toast.svelteSegmentedControl.sveltemembers/+page.server.tscurrentUserIdmembers/[userId]/+server.tsmembers/invites/+server.tsMemberCard.svelteSegmentedControlfor role edit, click-away + ESC closeRemoveDialog.svelteBottomSheet— backdrop does NOT closeInviteCard.svelteInvitePanel.svelteMemberGrid.sveltemembers/+page.svelteFrontend: 78 test files, 768 tests ✅ | Type check: 0 new errors ✅
Acceptance criteria
Next step:
/review-pror open PR fromfeat/issue-48-members-kachel→master