Account-Verwaltung Implementation Plan
Status: Umgesetzt (E-Mail-Handler ausstehend)
Goal: Implement account self-service (profile, password, sessions), admin team management (create/deactivate/reactivate accounts, password reset), and the supporting DB migration, email notifications, and frontend pages.
Architecture: Two new feature packages (account for self-service, team for admin operations) following the existing 3-layer pattern (HTTP handlers in cmd/http/ → UseCases in feature packages → Jet repositories). New migration adds password_reset table and account.locale column. Frontend adds account/team pages with server functions proxying to the Go backend. Email notifications use the existing 3-stage async system.
Tech Stack: Go 1.24+, Chi v5, Jet code-gen, pgx v5, golang-migrate, bcrypt, crypto/rand | React 19, TanStack Start, TanStack Router, Mantine 8, Tailwind CSS 4, Zod 4, i18next, React Hook Form
File Map
Abschnitt betitelt „File Map“cms/backend/├── migrations/│ ├── 000004_account_management.up.sql # NEW: password_reset table, account.locale, email templates│ └── 000004_account_management.down.sql # NEW: reverse migration├── gen/ # REGENERATE after migration├── internal/│ ├── feature/│ │ ├── auth/│ │ │ ├── usecase.go # MODIFY: SessionCheck returns locale│ │ │ ├── request.go # MODIFY: SessionCheckResponse gets locale│ │ │ ├── repository.go # MODIFY: AccountRepository gets UpdateProfile, UpdatePasswordHash│ │ │ └── jet_repository.go # MODIFY: implement new repo methods│ │ ├── account/ # NEW: self-service feature│ │ │ ├── errors.go│ │ │ ├── repository.go│ │ │ ├── jet_repository.go│ │ │ ├── usecase.go│ │ │ └── request.go│ │ ├── team/ # NEW: admin feature│ │ │ ├── errors.go│ │ │ ├── repository.go│ │ │ ├── jet_repository.go│ │ │ ├── usecase.go│ │ │ └── request.go│ │ └── email/│ │ └── types.go # MODIFY: new message/template types│ └── usecase/│ └── usecase.go # MODIFY: wire Account + Team├── cmd/http/│ ├── routes.go # MODIFY: register new routes│ ├── handlers_account.go # NEW: account HTTP handlers│ ├── handlers_team.go # NEW: team HTTP handlers│ └── main.go # MODIFY: wire email handlers
cms/frontend/├── src/│ ├── api/│ │ ├── account.ts # NEW: account server functions│ │ └── team.ts # NEW: team server functions│ ├── lib/│ │ └── schemas.ts # MODIFY: new schemas│ ├── i18n/│ │ ├── de.ts # MODIFY: account/team keys│ │ ├── en.ts # NEW: English translations│ │ └── index.ts # MODIFY: add English resource│ ├── components/│ │ ├── account/ # NEW│ │ │ ├── ProfileForm.tsx│ │ │ ├── ChangePasswordForm.tsx│ │ │ └── SessionList.tsx│ │ ├── team/ # NEW│ │ │ ├── TeamTable.tsx│ │ │ └── CreateAccountForm.tsx│ │ ├── auth/│ │ │ └── ResetPasswordForm.tsx # NEW│ │ └── layout/│ │ └── AppShell.tsx # MODIFY: navigation│ └── routes/│ ├── _app.account.profile.tsx # NEW│ ├── _app.account.password.tsx # NEW│ ├── _app.account.sessions.tsx # NEW│ ├── _app.team.index.tsx # NEW│ ├── _app.team.create.tsx # NEW│ ├── _app.tsx # MODIFY: pass locale to i18n│ └── _auth.reset-password.tsx # NEWTask 1: Database migration — password_reset table, account.locale, email seeds
Abschnitt betitelt „Task 1: Database migration — password_reset table, account.locale, email seeds“Files:
-
Create:
cms/backend/migrations/000004_account_management.up.sql -
Create:
cms/backend/migrations/000004_account_management.down.sql -
Step 1: Create up migration
-- ============================================================-- Migration 004: Account Management (password_reset, locale, email templates)-- ============================================================
-- ============================================================-- 1. Account locale column-- ============================================================
ALTER TABLE traffino.account ADD COLUMN locale VARCHAR(5) NOT NULL DEFAULT 'de';
-- ============================================================-- 2. Password reset table-- ============================================================
CREATE TABLE traffino.password_reset ( id UUID PRIMARY KEY, created_at TIMESTAMPTZ, created_by TEXT, updated_at TIMESTAMPTZ, updated_by TEXT, row_version INT NOT NULL DEFAULT 0, row_period TSTZRANGE, -- Business account_id UUID NOT NULL REFERENCES traffino.account(id), code VARCHAR(6) NOT NULL, initiated_by UUID NOT NULL REFERENCES traffino.account(id), expires_at TIMESTAMPTZ NOT NULL, verified_at TIMESTAMPTZ, used_at TIMESTAMPTZ);CREATE INDEX idx_password_reset_account_id ON traffino.password_reset (account_id);CREATE INDEX idx_password_reset_expires ON traffino.password_reset (expires_at) WHERE used_at IS NULL AND verified_at IS NULL;SELECT create_base_triggers('traffino.password_reset');
-- ============================================================-- 3. New system message types-- ============================================================
ALTER TYPE traffino.system_message_type ADD VALUE 'password_reset_code';ALTER TYPE traffino.system_message_type ADD VALUE 'password_changed';ALTER TYPE traffino.system_message_type ADD VALUE 'account_deactivated';ALTER TYPE traffino.system_message_type ADD VALUE 'new_device_login';
-- ============================================================-- 4. New email templates-- ============================================================
-- Password reset code template (admin-initiated, code-based)INSERT INTO traffino.email_template (id, template_type, tenant_id, site_id, subject_key, html_body, mjml_source)VALUES ( gen_random_uuid(), 'password_reset_code', NULL, NULL, 'email.password_reset_code.subject', '<p>{{ email.common.salutation }},</p><p>{{ email.password_reset_code.intro }}</p><div style="background: #f5f5f5; padding: 20px; text-align: center; margin: 20px 0; border-radius: 8px;"> <p style="margin: 0 0 5px 0; font-size: 14px; color: #666;">{{ email.password_reset_code.code_label }}</p> <p style="margin: 0; font-size: 32px; font-weight: bold; letter-spacing: 8px;">{{ custom.code }}</p></div><p style="color: #666;">{{ email.password_reset_code.initiated_by }}: {{ custom.initiated_by }}</p><p style="color: #666;">{{ email.password_reset_code.expiry }}</p>', NULL);
-- Password changed notification templateINSERT INTO traffino.email_template (id, template_type, tenant_id, site_id, subject_key, html_body, mjml_source)VALUES ( gen_random_uuid(), 'password_changed', NULL, NULL, 'email.password_changed.subject', '<p>{{ email.common.salutation }},</p><p>{{ email.password_changed.intro }}</p><table style="width: 100%; margin: 20px 0;"> <tr><td style="padding: 4px 0; color: #666;">{{ email.password_changed.time_label }}:</td><td style="padding: 4px 0;">{{ custom.timestamp }}</td></tr> <tr><td style="padding: 4px 0; color: #666;">{{ email.password_changed.ip_label }}:</td><td style="padding: 4px 0;">{{ custom.ip }}</td></tr> <tr><td style="padding: 4px 0; color: #666;">{{ email.password_changed.device_label }}:</td><td style="padding: 4px 0;">{{ custom.device }}</td></tr></table><p style="color: #c00;">{{ email.password_changed.not_you }}</p>', NULL);
-- Account deactivated notification templateINSERT INTO traffino.email_template (id, template_type, tenant_id, site_id, subject_key, html_body, mjml_source)VALUES ( gen_random_uuid(), 'account_deactivated', NULL, NULL, 'email.account_deactivated.subject', '<p>{{ email.common.salutation }},</p><p>{{ email.account_deactivated.intro }}</p><p style="color: #666;">{{ email.account_deactivated.initiated_by }}: {{ custom.initiated_by }}</p><p>{{ email.account_deactivated.contact }}</p>', NULL);
-- New device login notification templateINSERT INTO traffino.email_template (id, template_type, tenant_id, site_id, subject_key, html_body, mjml_source)VALUES ( gen_random_uuid(), 'new_device_login', NULL, NULL, 'email.new_device_login.subject', '<p>{{ email.common.salutation }},</p><p>{{ email.new_device_login.intro }}</p><table style="width: 100%; margin: 20px 0;"> <tr><td style="padding: 4px 0; color: #666;">{{ email.new_device_login.location_label }}:</td><td style="padding: 4px 0;">{{ custom.location }}</td></tr> <tr><td style="padding: 4px 0; color: #666;">{{ email.new_device_login.ip_label }}:</td><td style="padding: 4px 0;">{{ custom.ip }}</td></tr> <tr><td style="padding: 4px 0; color: #666;">{{ email.new_device_login.device_label }}:</td><td style="padding: 4px 0;">{{ custom.device }}</td></tr> <tr><td style="padding: 4px 0; color: #666;">{{ email.new_device_login.time_label }}:</td><td style="padding: 4px 0;">{{ custom.timestamp }}</td></tr></table><p style="color: #c00;">{{ email.new_device_login.not_you }}</p>', NULL);
-- ============================================================-- 5. New translation keys (DE + EN)-- ============================================================
INSERT INTO traffino.email_translation_key (id, key, locale, value, tenant_id, site_id) VALUES-- password_reset_code(gen_random_uuid(), 'email.password_reset_code.subject', 'de', 'Passwort-Reset Code', NULL, NULL),(gen_random_uuid(), 'email.password_reset_code.subject', 'en', 'Password Reset Code', NULL, NULL),(gen_random_uuid(), 'email.password_reset_code.intro', 'de', 'Ein Administrator hat ein Zuruecksetzen deines Passworts veranlasst.', NULL, NULL),(gen_random_uuid(), 'email.password_reset_code.intro', 'en', 'An administrator has initiated a password reset for your account.', NULL, NULL),(gen_random_uuid(), 'email.password_reset_code.code_label', 'de', 'Dein Reset-Code', NULL, NULL),(gen_random_uuid(), 'email.password_reset_code.code_label', 'en', 'Your reset code', NULL, NULL),(gen_random_uuid(), 'email.password_reset_code.initiated_by', 'de', 'Ausgeloest von', NULL, NULL),(gen_random_uuid(), 'email.password_reset_code.initiated_by', 'en', 'Initiated by', NULL, NULL),(gen_random_uuid(), 'email.password_reset_code.expiry', 'de', 'Der Code ist 30 Minuten gueltig.', NULL, NULL),(gen_random_uuid(), 'email.password_reset_code.expiry', 'en', 'The code is valid for 30 minutes.', NULL, NULL),-- password_changed(gen_random_uuid(), 'email.password_changed.subject', 'de', 'Passwort geaendert', NULL, NULL),(gen_random_uuid(), 'email.password_changed.subject', 'en', 'Password Changed', NULL, NULL),(gen_random_uuid(), 'email.password_changed.intro', 'de', 'Dein Passwort wurde soeben geaendert.', NULL, NULL),(gen_random_uuid(), 'email.password_changed.intro', 'en', 'Your password has just been changed.', NULL, NULL),(gen_random_uuid(), 'email.password_changed.time_label', 'de', 'Zeitpunkt', NULL, NULL),(gen_random_uuid(), 'email.password_changed.time_label', 'en', 'Time', NULL, NULL),(gen_random_uuid(), 'email.password_changed.ip_label', 'de', 'IP-Adresse', NULL, NULL),(gen_random_uuid(), 'email.password_changed.ip_label', 'en', 'IP address', NULL, NULL),(gen_random_uuid(), 'email.password_changed.device_label', 'de', 'Geraet', NULL, NULL),(gen_random_uuid(), 'email.password_changed.device_label', 'en', 'Device', NULL, NULL),(gen_random_uuid(), 'email.password_changed.not_you', 'de', 'Falls du dein Passwort nicht geaendert hast, kontaktiere umgehend deinen Administrator.', NULL, NULL),(gen_random_uuid(), 'email.password_changed.not_you', 'en', 'If you did not change your password, contact your administrator immediately.', NULL, NULL),-- account_deactivated(gen_random_uuid(), 'email.account_deactivated.subject', 'de', 'Konto deaktiviert', NULL, NULL),(gen_random_uuid(), 'email.account_deactivated.subject', 'en', 'Account Deactivated', NULL, NULL),(gen_random_uuid(), 'email.account_deactivated.intro', 'de', 'Dein Konto wurde deaktiviert.', NULL, NULL),(gen_random_uuid(), 'email.account_deactivated.intro', 'en', 'Your account has been deactivated.', NULL, NULL),(gen_random_uuid(), 'email.account_deactivated.initiated_by', 'de', 'Deaktiviert von', NULL, NULL),(gen_random_uuid(), 'email.account_deactivated.initiated_by', 'en', 'Deactivated by', NULL, NULL),(gen_random_uuid(), 'email.account_deactivated.contact', 'de', 'Bei Fragen wende dich an deinen Administrator.', NULL, NULL),(gen_random_uuid(), 'email.account_deactivated.contact', 'en', 'If you have questions, please contact your administrator.', NULL, NULL),-- new_device_login(gen_random_uuid(), 'email.new_device_login.subject', 'de', 'Neuer Login von unbekanntem Geraet', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.subject', 'en', 'New Login from Unknown Device', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.intro', 'de', 'Es wurde ein erfolgreicher Login von einem neuen Geraet erkannt.', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.intro', 'en', 'A successful login from a new device was detected.', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.location_label', 'de', 'Standort', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.location_label', 'en', 'Location', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.ip_label', 'de', 'IP-Adresse', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.ip_label', 'en', 'IP address', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.device_label', 'de', 'Geraet', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.device_label', 'en', 'Device', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.time_label', 'de', 'Zeitpunkt', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.time_label', 'en', 'Time', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.not_you', 'de', 'Falls du diesen Login nicht ausgeloest hast, aendere bitte sofort dein Passwort und kontaktiere deinen Administrator.', NULL, NULL),(gen_random_uuid(), 'email.new_device_login.not_you', 'en', 'If you did not initiate this login, please change your password immediately and contact your administrator.', NULL, NULL);- Step 2: Create down migration
-- ============================================================-- Migration 004 DOWN: Reverse account management changes-- ============================================================
-- 1. Password resetDROP TABLE IF EXISTS traffino.password_reset_history;DROP TABLE IF EXISTS traffino.password_reset;
-- 2. Remove locale columnALTER TABLE traffino.account DROP COLUMN IF EXISTS locale;
-- 3. Remove seeded templates and translation keys-- (enum values cannot be removed in PostgreSQL — they remain harmless)DELETE FROM traffino.email_translation_key WHERE key LIKE 'email.password_reset_code.%';DELETE FROM traffino.email_translation_key WHERE key LIKE 'email.password_changed.%';DELETE FROM traffino.email_translation_key WHERE key LIKE 'email.account_deactivated.%';DELETE FROM traffino.email_translation_key WHERE key LIKE 'email.new_device_login.%';DELETE FROM traffino.email_template WHERE template_type IN ('password_reset_code', 'password_changed', 'account_deactivated', 'new_device_login');- Step 3: Run migration and regenerate Jet code
cd cms/backend && make migrate-up && make generate- Step 4: Verify generated models include new fields
Check that gen/traffino/traffino/model/accounts.go now has Locale string and that gen/traffino/traffino/model/password_resets.go exists.
- Step 5: Commit
git add cms/backend/migrations/000004_account_management.up.sql cms/backend/migrations/000004_account_management.down.sql cms/backend/gen/git commit -m "feat: add migration 000004 — password_reset table, account.locale, email templates"Task 2: Email types — add new message and template types
Abschnitt betitelt „Task 2: Email types — add new message and template types“Files:
-
Modify:
cms/backend/internal/feature/email/types.go -
Step 1: Add new types
Add to MessageType constants:
MessageTypePasswordResetCode MessageType = "password_reset_code"MessageTypePasswordChanged MessageType = "password_changed"MessageTypeAccountDeactivated MessageType = "account_deactivated"MessageTypeNewDeviceLogin MessageType = "new_device_login"Add to TemplateType constants:
TemplatePasswordResetCode TemplateType = "password_reset_code"TemplatePasswordChanged TemplateType = "password_changed"TemplateAccountDeactivated TemplateType = "account_deactivated"TemplateNewDeviceLogin TemplateType = "new_device_login"- Step 2: Build to verify
cd cms/backend && make build- Step 3: Commit
git add cms/backend/internal/feature/email/types.gogit commit -m "feat: add email message and template types for account management"Task 3: Auth feature — extend SessionCheck to return locale
Abschnitt betitelt „Task 3: Auth feature — extend SessionCheck to return locale“Files:
-
Modify:
cms/backend/internal/feature/auth/request.go -
Modify:
cms/backend/internal/feature/auth/usecase.go -
Step 1: Add Locale to SessionCheckResponse
In request.go, add Locale field:
type SessionCheckResponse struct { AccountID uuid.UUID `json:"account_id"` DisplayName string `json:"display_name"` Role model.AccountRole `json:"role"` TenantID *uuid.UUID `json:"tenant_id,omitempty"` Locale string `json:"locale"`}- Step 2: Set Locale in SessionCheck usecase
In usecase.go, update the SessionCheck method to include locale:
return SessionCheckResponse{ AccountID: account.ID, DisplayName: account.DisplayName, Role: account.Role, TenantID: account.TenantID, Locale: account.Locale,}, nil- Step 3: Build to verify
cd cms/backend && make build- Step 4: Commit
git add cms/backend/internal/feature/auth/request.go cms/backend/internal/feature/auth/usecase.gogit commit -m "feat: SessionCheck returns account locale"Task 4: Auth feature — extend repositories for profile updates
Abschnitt betitelt „Task 4: Auth feature — extend repositories for profile updates“Files:
-
Modify:
cms/backend/internal/feature/auth/repository.go -
Modify:
cms/backend/internal/feature/auth/jet_repository.go -
Step 1: Add methods to AccountRepository interface
In repository.go, extend AccountRepository:
type AccountRepository interface { FindByEmail(ctx context.Context, email string) (*model.Account, error) FindByID(ctx context.Context, id uuid.UUID) (*model.Account, error) UpdateProfile(ctx context.Context, id uuid.UUID, displayName string, locale string) error UpdatePasswordHash(ctx context.Context, id uuid.UUID, passwordHash string) error}Add methods to SessionRepository:
type SessionRepository interface { Create(ctx context.Context, accountID uuid.UUID, expiresAt time.Time) (*model.Session, error) FindByID(ctx context.Context, id uuid.UUID) (*model.Session, error) Terminate(ctx context.Context, id uuid.UUID) error ListActiveByAccount(ctx context.Context, accountID uuid.UUID) ([]model.Session, error) TerminateAllExcept(ctx context.Context, accountID uuid.UUID, exceptSessionID uuid.UUID) (int64, error)}- Step 2: Implement UpdateProfile in jet_repository.go
func (r *jetAccountRepository) UpdateProfile(ctx context.Context, id uuid.UUID, displayName string, locale string) error { stmt := table.Account.UPDATE(table.Account.DisplayName, table.Account.Locale). SET(displayName, locale). WHERE(table.Account.ID.EQ(postgres.UUID(id)))
res, err := stmt.ExecContext(ctx, r.db) if err != nil { return fmt.Errorf("update profile: %w", err) } rows, _ := res.RowsAffected() if rows == 0 { return ErrAccountNotFound } return nil}- Step 3: Implement UpdatePasswordHash in jet_repository.go
func (r *jetAccountRepository) UpdatePasswordHash(ctx context.Context, id uuid.UUID, passwordHash string) error { stmt := table.Account.UPDATE(table.Account.PasswordHash). SET(passwordHash). WHERE(table.Account.ID.EQ(postgres.UUID(id)))
res, err := stmt.ExecContext(ctx, r.db) if err != nil { return fmt.Errorf("update password hash: %w", err) } rows, _ := res.RowsAffected() if rows == 0 { return ErrAccountNotFound } return nil}- Step 4: Implement ListActiveByAccount in jet_repository.go
func (r *jetSessionRepository) ListActiveByAccount(ctx context.Context, accountID uuid.UUID) ([]model.Session, error) { stmt := postgres.SELECT(table.Session.AllColumns). FROM(table.Session). WHERE( table.Session.AccountID.EQ(postgres.UUID(accountID)). AND(table.Session.TerminatedAt.IS_NULL()). AND(table.Session.ExpiresAt.GT(postgres.TimestampzT(time.Now()))), ). ORDER_BY(table.Session.CreatedAt.DESC())
var dest []model.Session err := stmt.QueryContext(ctx, r.db, &dest) if err != nil { return nil, fmt.Errorf("list active sessions: %w", err) } return dest, nil}- Step 5: Implement TerminateAllExcept in jet_repository.go
func (r *jetSessionRepository) TerminateAllExcept(ctx context.Context, accountID uuid.UUID, exceptSessionID uuid.UUID) (int64, error) { now := time.Now() stmt := table.Session.UPDATE(table.Session.TerminatedAt). SET(now). WHERE( table.Session.AccountID.EQ(postgres.UUID(accountID)). AND(table.Session.ID.NOT_EQ(postgres.UUID(exceptSessionID))). AND(table.Session.TerminatedAt.IS_NULL()), )
res, err := stmt.ExecContext(ctx, r.db) if err != nil { return 0, fmt.Errorf("terminate all sessions except: %w", err) } rows, _ := res.RowsAffected() return rows, nil}- Step 6: Build to verify
cd cms/backend && make build- Step 7: Commit
git add cms/backend/internal/feature/auth/repository.go cms/backend/internal/feature/auth/jet_repository.gogit commit -m "feat: extend auth repositories — UpdateProfile, UpdatePasswordHash, ListActiveByAccount, TerminateAllExcept"Task 5: Account feature — self-service use cases
Abschnitt betitelt „Task 5: Account feature — self-service use cases“Files:
-
Create:
cms/backend/internal/feature/account/errors.go -
Create:
cms/backend/internal/feature/account/request.go -
Create:
cms/backend/internal/feature/account/usecase.go -
Step 1: Create errors.go
package account
import "errors"
var ( ErrInvalidCurrentPassword = errors.New("invalid current password") ErrSessionNotFound = errors.New("session not found or does not belong to user") ErrCannotTerminateCurrentSession = errors.New("cannot terminate current session"))- Step 2: Create request.go
package account
import "github.com/google/uuid"
type UpdateProfileRequest struct { DisplayName string `json:"display_name" validate:"required,min=1,max=255"` Locale string `json:"locale" validate:"required,oneof=de en"`}
type ChangePasswordRequest struct { OldPassword string `json:"old_password" validate:"required"` NewPassword string `json:"new_password" validate:"required,min=8"`}
type SessionItem struct { ID uuid.UUID `json:"id"` CreatedAt string `json:"created_at"` IP string `json:"ip"` Device string `json:"device"` LocationDe string `json:"location_de"` LocationEn string `json:"location_en"` IsCurrent bool `json:"is_current"`}
type ListSessionsResponse struct { Sessions []SessionItem `json:"sessions"`}- Step 3: Create usecase.go
package account
import ( "context" "fmt"
"github.com/google/uuid"
"github.com/traffino/cms/internal/feature/auth" "github.com/traffino/cms/internal/shared/requestctx")
// Account groups all account self-service use cases.type Account struct { accounts auth.AccountRepository sessions auth.SessionRepository}
func NewAccount(accounts auth.AccountRepository, sessions auth.SessionRepository) *Account { return &Account{ accounts: accounts, sessions: sessions, }}
func (a *Account) UpdateProfile(ctx context.Context, accountID uuid.UUID, req UpdateProfileRequest) error { if err := a.accounts.UpdateProfile(ctx, accountID, req.DisplayName, req.Locale); err != nil { return fmt.Errorf("update profile: %w", err) } return nil}
func (a *Account) ChangePassword(ctx context.Context, rc requestctx.RequestContext, accountID uuid.UUID, req ChangePasswordRequest) error { account, err := a.accounts.FindByID(ctx, accountID) if err != nil { return fmt.Errorf("find account: %w", err) }
if !auth.VerifyPassword(account.PasswordHash, req.OldPassword) { return ErrInvalidCurrentPassword }
hash, err := auth.HashPassword(req.NewPassword) if err != nil { return fmt.Errorf("hash password: %w", err) }
if err := a.accounts.UpdatePasswordHash(ctx, accountID, hash); err != nil { return fmt.Errorf("update password: %w", err) }
return nil}
func (a *Account) ListSessions(ctx context.Context, accountID uuid.UUID, currentSessionID *uuid.UUID) (ListSessionsResponse, error) { sessions, err := a.sessions.ListActiveByAccount(ctx, accountID) if err != nil { return ListSessionsResponse{}, fmt.Errorf("list sessions: %w", err) }
items := make([]SessionItem, 0, len(sessions)) for _, s := range sessions { item := SessionItem{ ID: s.ID, IsCurrent: currentSessionID != nil && s.ID == *currentSessionID, } if s.CreatedAt != nil { item.CreatedAt = s.CreatedAt.Format("2006-01-02T15:04:05Z07:00") } items = append(items, item) }
return ListSessionsResponse{Sessions: items}, nil}
func (a *Account) TerminateSession(ctx context.Context, accountID uuid.UUID, currentSessionID *uuid.UUID, targetSessionID uuid.UUID) error { if currentSessionID != nil && *currentSessionID == targetSessionID { return ErrCannotTerminateCurrentSession }
// Verify session belongs to this account sessions, err := a.sessions.ListActiveByAccount(ctx, accountID) if err != nil { return fmt.Errorf("list sessions: %w", err) }
found := false for _, s := range sessions { if s.ID == targetSessionID { found = true break } } if !found { return ErrSessionNotFound }
if err := a.sessions.Terminate(ctx, targetSessionID); err != nil { return fmt.Errorf("terminate session: %w", err) } return nil}
func (a *Account) TerminateOtherSessions(ctx context.Context, accountID uuid.UUID, currentSessionID uuid.UUID) (int64, error) { count, err := a.sessions.TerminateAllExcept(ctx, accountID, currentSessionID) if err != nil { return 0, fmt.Errorf("terminate other sessions: %w", err) } return count, nil}- Step 4: Build to verify
cd cms/backend && make build- Step 5: Commit
git add cms/backend/internal/feature/account/git commit -m "feat: account self-service use cases — UpdateProfile, ChangePassword, ListSessions, TerminateSession"Task 6: Team feature — repository interfaces and implementation
Abschnitt betitelt „Task 6: Team feature — repository interfaces and implementation“Files:
-
Create:
cms/backend/internal/feature/team/errors.go -
Create:
cms/backend/internal/feature/team/repository.go -
Create:
cms/backend/internal/feature/team/jet_repository.go -
Step 1: Create errors.go
package team
import "errors"
var ( ErrInsufficientPermission = errors.New("insufficient permission for target account") ErrCannotModifySelf = errors.New("cannot modify own account") ErrAccountAlreadyActive = errors.New("account is already active") ErrAccountAlreadyInactive = errors.New("account is already inactive") ErrResetCodeInvalid = errors.New("reset code is invalid") ErrResetCodeExpired = errors.New("reset code has expired") ErrResetCodeUsed = errors.New("reset code has already been used") ErrEmailAlreadyExists = errors.New("email address already exists") ErrAccountNotFound = errors.New("account not found") ErrResetNotFound = errors.New("password reset not found"))- Step 2: Create repository.go
package team
import ( "context" "time"
"github.com/google/uuid"
"github.com/traffino/cms/gen/traffino/traffino/model")
type AccountRepository interface { Create(ctx context.Context, account model.Account) (*model.Account, error) FindByID(ctx context.Context, id uuid.UUID) (*model.Account, error) ListByScope(ctx context.Context, role model.AccountRole, tenantID *uuid.UUID) ([]model.Account, error) SetActive(ctx context.Context, id uuid.UUID, active bool) error UpdatePasswordHash(ctx context.Context, id uuid.UUID, hash string) error}
type PasswordResetRepository interface { Create(ctx context.Context, reset model.PasswordReset) (*model.PasswordReset, error) FindLatestByAccountID(ctx context.Context, accountID uuid.UUID) (*model.PasswordReset, error) MarkVerified(ctx context.Context, id uuid.UUID) error MarkUsed(ctx context.Context, id uuid.UUID) error}
type EmailRepository interface { FindEmailByAccountID(ctx context.Context, accountID uuid.UUID) (string, error) EmailExists(ctx context.Context, email string) (bool, error) CreateAccountEmail(ctx context.Context, accountID uuid.UUID, email string) error}
type SessionRepository interface { TerminateAllByAccount(ctx context.Context, accountID uuid.UUID) error}
// AccountWithEmail is returned by list queries.type AccountWithEmail struct { model.Account Email string `json:"email"`}
// AccountListRepository extends AccountRepository for list queries with email.type AccountListRepository interface { ListWithEmailByScope(ctx context.Context, role model.AccountRole, tenantID *uuid.UUID) ([]AccountWithEmail, error)}
// Ensure both list interfaces are available.type Repositories struct { Accounts AccountRepository AccountList AccountListRepository PasswordResets PasswordResetRepository Emails EmailRepository Sessions SessionRepository}- Step 3: Create jet_repository.go
package team
import ( "context" "database/sql" "fmt" "time"
postgres "github.com/go-jet/jet/v2/postgres" "github.com/google/uuid"
"github.com/traffino/cms/gen/traffino/traffino/model" "github.com/traffino/cms/gen/traffino/traffino/table")
// --- Account ---
type jetAccountRepository struct{ db *sql.DB }
func NewAccountRepository(db *sql.DB) AccountRepository { return &jetAccountRepository{db: db}}
func (r *jetAccountRepository) Create(ctx context.Context, account model.Account) (*model.Account, error) { stmt := table.Account.INSERT( table.Account.ID, table.Account.PasswordHash, table.Account.DisplayName, table.Account.Role, table.Account.TenantID, table.Account.Active, table.Account.TwofactorEmail, table.Account.Locale, ).MODEL(account).RETURNING(table.Account.AllColumns)
var dest model.Account err := stmt.QueryContext(ctx, r.db, &dest) if err != nil { return nil, fmt.Errorf("create account: %w", err) } return &dest, nil}
func (r *jetAccountRepository) FindByID(ctx context.Context, id uuid.UUID) (*model.Account, error) { stmt := postgres.SELECT(table.Account.AllColumns). FROM(table.Account). WHERE(table.Account.ID.EQ(postgres.UUID(id)))
var dest model.Account err := stmt.QueryContext(ctx, r.db, &dest) if err != nil { if err == sql.ErrNoRows { return nil, ErrAccountNotFound } return nil, fmt.Errorf("find account: %w", err) } return &dest, nil}
func (r *jetAccountRepository) ListByScope(ctx context.Context, role model.AccountRole, tenantID *uuid.UUID) ([]model.Account, error) { var condition postgres.BoolExpression
switch role { case model.AccountRole_AgencyOwner: condition = postgres.Bool(true) // all accounts case model.AccountRole_TenantAdmin: if tenantID == nil { return nil, fmt.Errorf("tenant_id required for tenant_admin") } condition = table.Account.TenantID.EQ(postgres.UUID(*tenantID)). AND(table.Account.Role.EQ(postgres.NewEnumValue("tenant_member"))) default: return nil, ErrInsufficientPermission }
stmt := postgres.SELECT(table.Account.AllColumns). FROM(table.Account). WHERE(condition). ORDER_BY(table.Account.DisplayName.ASC())
var dest []model.Account err := stmt.QueryContext(ctx, r.db, &dest) if err != nil { return nil, fmt.Errorf("list accounts: %w", err) } return dest, nil}
func (r *jetAccountRepository) SetActive(ctx context.Context, id uuid.UUID, active bool) error { stmt := table.Account.UPDATE(table.Account.Active). SET(active). WHERE(table.Account.ID.EQ(postgres.UUID(id)))
res, err := stmt.ExecContext(ctx, r.db) if err != nil { return fmt.Errorf("set active: %w", err) } rows, _ := res.RowsAffected() if rows == 0 { return ErrAccountNotFound } return nil}
func (r *jetAccountRepository) UpdatePasswordHash(ctx context.Context, id uuid.UUID, hash string) error { stmt := table.Account.UPDATE(table.Account.PasswordHash). SET(hash). WHERE(table.Account.ID.EQ(postgres.UUID(id)))
res, err := stmt.ExecContext(ctx, r.db) if err != nil { return fmt.Errorf("update password hash: %w", err) } rows, _ := res.RowsAffected() if rows == 0 { return ErrAccountNotFound } return nil}
// --- Account List (with email) ---
type jetAccountListRepository struct{ db *sql.DB }
func NewAccountListRepository(db *sql.DB) AccountListRepository { return &jetAccountListRepository{db: db}}
func (r *jetAccountListRepository) ListWithEmailByScope(ctx context.Context, role model.AccountRole, tenantID *uuid.UUID) ([]AccountWithEmail, error) { var roleFilter string var args []any
switch role { case model.AccountRole_AgencyOwner: roleFilter = "TRUE" case model.AccountRole_TenantAdmin: if tenantID == nil { return nil, fmt.Errorf("tenant_id required for tenant_admin") } roleFilter = "a.tenant_id = $1 AND a.role = 'tenant_member'" args = append(args, *tenantID) default: return nil, ErrInsufficientPermission }
query := fmt.Sprintf(` SELECT a.id, a.display_name, a.role, a.tenant_id, a.active, a.locale, a.created_at, e.email FROM traffino.account a LEFT JOIN traffino.email_relation er ON er.relation_id = a.id AND er.relation_type = 'account_primary' LEFT JOIN traffino.email e ON e.id = er.email_id WHERE %s ORDER BY a.display_name ASC`, roleFilter)
rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("list accounts with email: %w", err) } defer rows.Close()
var result []AccountWithEmail for rows.Next() { var a AccountWithEmail if err := rows.Scan( &a.ID, &a.DisplayName, &a.Role, &a.TenantID, &a.Active, &a.Locale, &a.CreatedAt, &a.Email, ); err != nil { return nil, fmt.Errorf("scan account: %w", err) } result = append(result, a) } return result, rows.Err()}
// --- Password Reset ---
type jetPasswordResetRepository struct{ db *sql.DB }
func NewPasswordResetRepository(db *sql.DB) PasswordResetRepository { return &jetPasswordResetRepository{db: db}}
func (r *jetPasswordResetRepository) Create(ctx context.Context, reset model.PasswordReset) (*model.PasswordReset, error) { stmt := table.PasswordReset.INSERT( table.PasswordReset.ID, table.PasswordReset.AccountID, table.PasswordReset.Code, table.PasswordReset.InitiatedBy, table.PasswordReset.ExpiresAt, ).MODEL(reset).RETURNING(table.PasswordReset.AllColumns)
var dest model.PasswordReset err := stmt.QueryContext(ctx, r.db, &dest) if err != nil { return nil, fmt.Errorf("create password reset: %w", err) } return &dest, nil}
func (r *jetPasswordResetRepository) FindLatestByAccountID(ctx context.Context, accountID uuid.UUID) (*model.PasswordReset, error) { stmt := postgres.SELECT(table.PasswordReset.AllColumns). FROM(table.PasswordReset). WHERE( table.PasswordReset.AccountID.EQ(postgres.UUID(accountID)). AND(table.PasswordReset.UsedAt.IS_NULL()), ). ORDER_BY(table.PasswordReset.CreatedAt.DESC()). LIMIT(1)
var dest model.PasswordReset err := stmt.QueryContext(ctx, r.db, &dest) if err != nil { if err == sql.ErrNoRows { return nil, ErrResetNotFound } return nil, fmt.Errorf("find password reset: %w", err) } return &dest, nil}
func (r *jetPasswordResetRepository) MarkVerified(ctx context.Context, id uuid.UUID) error { now := time.Now() stmt := table.PasswordReset.UPDATE(table.PasswordReset.VerifiedAt). SET(now). WHERE(table.PasswordReset.ID.EQ(postgres.UUID(id)))
_, err := stmt.ExecContext(ctx, r.db) if err != nil { return fmt.Errorf("mark verified: %w", err) } return nil}
func (r *jetPasswordResetRepository) MarkUsed(ctx context.Context, id uuid.UUID) error { now := time.Now() stmt := table.PasswordReset.UPDATE(table.PasswordReset.UsedAt). SET(now). WHERE(table.PasswordReset.ID.EQ(postgres.UUID(id)))
_, err := stmt.ExecContext(ctx, r.db) if err != nil { return fmt.Errorf("mark used: %w", err) } return nil}
// --- Email ---
type jetEmailRepository struct{ db *sql.DB }
func NewEmailRepository(db *sql.DB) EmailRepository { return &jetEmailRepository{db: db}}
func (r *jetEmailRepository) FindEmailByAccountID(ctx context.Context, accountID uuid.UUID) (string, error) { query := ` SELECT e.email FROM traffino.email e JOIN traffino.email_relation er ON er.email_id = e.id WHERE er.relation_id = $1 AND er.relation_type = 'account_primary' LIMIT 1`
var email string err := r.db.QueryRowContext(ctx, query, accountID).Scan(&email) if err != nil { return "", fmt.Errorf("find email by account: %w", err) } return email, nil}
func (r *jetEmailRepository) EmailExists(ctx context.Context, email string) (bool, error) { query := ` SELECT EXISTS( SELECT 1 FROM traffino.email WHERE hash = sha256($1::bytea) )`
var exists bool err := r.db.QueryRowContext(ctx, query, email).Scan(&exists) if err != nil { return false, fmt.Errorf("check email exists: %w", err) } return exists, nil}
func (r *jetEmailRepository) CreateAccountEmail(ctx context.Context, accountID uuid.UUID, email string) error { query := ` WITH new_email AS ( INSERT INTO traffino.email (id, email, hash) VALUES (gen_random_uuid(), $1, sha256($1::bytea)) RETURNING id ) INSERT INTO traffino.email_relation (id, email_id, relation_id, relation_type) SELECT gen_random_uuid(), new_email.id, $2, 'account_primary' FROM new_email`
_, err := r.db.ExecContext(ctx, query, email, accountID) if err != nil { return fmt.Errorf("create account email: %w", err) } return nil}
// --- Session ---
type jetSessionRepository struct{ db *sql.DB }
func NewSessionRepository(db *sql.DB) SessionRepository { return &jetSessionRepository{db: db}}
func (r *jetSessionRepository) TerminateAllByAccount(ctx context.Context, accountID uuid.UUID) error { now := time.Now() stmt := table.Session.UPDATE(table.Session.TerminatedAt). SET(now). WHERE( table.Session.AccountID.EQ(postgres.UUID(accountID)). AND(table.Session.TerminatedAt.IS_NULL()), )
_, err := stmt.ExecContext(ctx, r.db) if err != nil { return fmt.Errorf("terminate all sessions: %w", err) } return nil}- Step 4: Build to verify
cd cms/backend && make build- Step 5: Commit
git add cms/backend/internal/feature/team/git commit -m "feat: team feature — repository interfaces and Jet implementations"Task 7: Team feature — use cases
Abschnitt betitelt „Task 7: Team feature — use cases“Files:
-
Create:
cms/backend/internal/feature/team/request.go -
Create:
cms/backend/internal/feature/team/usecase.go -
Step 1: Create request.go
package team
import "github.com/google/uuid"
type CreateAccountRequest struct { Email string `json:"email" validate:"required,email"` DisplayName string `json:"display_name" validate:"required,min=1,max=255"` Role string `json:"role" validate:"required,oneof=agency_employee tenant_admin tenant_member"` TenantID *uuid.UUID `json:"tenant_id"` Locale string `json:"locale" validate:"omitempty,oneof=de en"`}
type CreateAccountResponse struct { ID uuid.UUID `json:"id"` DisplayName string `json:"display_name"` Email string `json:"email"` Role string `json:"role"`}
type AccountListItem struct { ID uuid.UUID `json:"id"` DisplayName string `json:"display_name"` Email string `json:"email"` Role string `json:"role"` Active bool `json:"active"` Locale string `json:"locale"` TenantID *uuid.UUID `json:"tenant_id,omitempty"` CreatedAt *string `json:"created_at,omitempty"`}
type ListAccountsResponse struct { Accounts []AccountListItem `json:"accounts"`}
type InitiateResetResponse struct { Message string `json:"message"`}
type VerifyResetCodeRequest struct { Code string `json:"code" validate:"required,len=6"`}
type CompleteResetRequest struct { Code string `json:"code" validate:"required,len=6"` NewPassword string `json:"new_password" validate:"required,min=8"`}- Step 2: Create usecase.go
package team
import ( "context" "crypto/rand" "fmt" "math/big" "time"
"github.com/google/uuid"
"github.com/traffino/cms/gen/traffino/traffino/model" "github.com/traffino/cms/internal/feature/auth" "github.com/traffino/cms/internal/shared/requestctx")
const resetCodeExpiry = 30 * time.Minute
// Team groups all admin team management use cases.type Team struct { repos Repositories}
func NewTeam(repos Repositories) *Team { return &Team{repos: repos}}
func (t *Team) CreateAccount(ctx context.Context, admin *requestctx.AccountInfo, req CreateAccountRequest) (CreateAccountResponse, error) { if err := t.checkCreatePermission(admin, req.Role); err != nil { return CreateAccountResponse{}, err }
exists, err := t.repos.Emails.EmailExists(ctx, req.Email) if err != nil { return CreateAccountResponse{}, fmt.Errorf("check email: %w", err) } if exists { return CreateAccountResponse{}, ErrEmailAlreadyExists }
locale := req.Locale if locale == "" { locale = "de" }
id, err := uuid.NewV7() if err != nil { return CreateAccountResponse{}, fmt.Errorf("generate uuid: %w", err) }
// Account created without password — admin must initiate password reset account, err := t.repos.Accounts.Create(ctx, model.Account{ ID: id, PasswordHash: "", // no password — must use reset flow DisplayName: req.DisplayName, Role: model.AccountRole(req.Role), TenantID: t.resolveTenantID(admin, req.TenantID), Active: true, TwofactorEmail: true, Locale: locale, }) if err != nil { return CreateAccountResponse{}, fmt.Errorf("create account: %w", err) }
if err := t.repos.Emails.CreateAccountEmail(ctx, account.ID, req.Email); err != nil { return CreateAccountResponse{}, fmt.Errorf("create email: %w", err) }
return CreateAccountResponse{ ID: account.ID, DisplayName: account.DisplayName, Email: req.Email, Role: string(account.Role), }, nil}
func (t *Team) ListAccounts(ctx context.Context, admin *requestctx.AccountInfo) (ListAccountsResponse, error) { accounts, err := t.repos.AccountList.ListWithEmailByScope(ctx, model.AccountRole(admin.Role), admin.TenantID) if err != nil { return ListAccountsResponse{}, fmt.Errorf("list accounts: %w", err) }
items := make([]AccountListItem, 0, len(accounts)) for _, a := range accounts { item := AccountListItem{ ID: a.ID, DisplayName: a.DisplayName, Email: a.Email, Role: string(a.Role), Active: a.Active, Locale: a.Locale, TenantID: a.TenantID, } if a.CreatedAt != nil { ts := a.CreatedAt.Format("2006-01-02T15:04:05Z07:00") item.CreatedAt = &ts } items = append(items, item) }
return ListAccountsResponse{Accounts: items}, nil}
func (t *Team) DeactivateAccount(ctx context.Context, admin *requestctx.AccountInfo, targetID uuid.UUID) error { if admin.ID == targetID { return ErrCannotModifySelf }
target, err := t.repos.Accounts.FindByID(ctx, targetID) if err != nil { return err }
if err := t.checkManagePermission(admin, target); err != nil { return err }
if !target.Active { return ErrAccountAlreadyInactive }
if err := t.repos.Accounts.SetActive(ctx, targetID, false); err != nil { return fmt.Errorf("deactivate account: %w", err) }
// Terminate all sessions if err := t.repos.Sessions.TerminateAllByAccount(ctx, targetID); err != nil { return fmt.Errorf("terminate sessions: %w", err) }
return nil}
func (t *Team) ReactivateAccount(ctx context.Context, admin *requestctx.AccountInfo, targetID uuid.UUID) error { if admin.ID == targetID { return ErrCannotModifySelf }
target, err := t.repos.Accounts.FindByID(ctx, targetID) if err != nil { return err }
if err := t.checkManagePermission(admin, target); err != nil { return err }
if target.Active { return ErrAccountAlreadyActive }
if err := t.repos.Accounts.SetActive(ctx, targetID, true); err != nil { return fmt.Errorf("reactivate account: %w", err) }
return nil}
func (t *Team) InitiatePasswordReset(ctx context.Context, admin *requestctx.AccountInfo, targetID uuid.UUID) (InitiateResetResponse, error) { if admin.ID == targetID { return InitiateResetResponse{}, ErrCannotModifySelf }
target, err := t.repos.Accounts.FindByID(ctx, targetID) if err != nil { return InitiateResetResponse{}, err }
if err := t.checkManagePermission(admin, target); err != nil { return InitiateResetResponse{}, err }
code, err := generateResetCode() if err != nil { return InitiateResetResponse{}, fmt.Errorf("generate code: %w", err) }
id, err := uuid.NewV7() if err != nil { return InitiateResetResponse{}, fmt.Errorf("generate uuid: %w", err) }
_, err = t.repos.PasswordResets.Create(ctx, model.PasswordReset{ ID: id, AccountID: targetID, Code: code, InitiatedBy: admin.ID, ExpiresAt: time.Now().Add(resetCodeExpiry), }) if err != nil { return InitiateResetResponse{}, fmt.Errorf("create reset: %w", err) }
return InitiateResetResponse{Message: "reset code sent"}, nil}
func (t *Team) VerifyResetCode(ctx context.Context, accountID uuid.UUID, code string) error { reset, err := t.repos.PasswordResets.FindLatestByAccountID(ctx, accountID) if err != nil { return ErrResetCodeInvalid }
if reset.UsedAt != nil { return ErrResetCodeUsed }
if time.Now().After(reset.ExpiresAt) { return ErrResetCodeExpired }
if reset.Code != code { return ErrResetCodeInvalid }
if err := t.repos.PasswordResets.MarkVerified(ctx, reset.ID); err != nil { return fmt.Errorf("mark verified: %w", err) }
return nil}
func (t *Team) CompletePasswordReset(ctx context.Context, accountID uuid.UUID, code string, newPassword string) error { reset, err := t.repos.PasswordResets.FindLatestByAccountID(ctx, accountID) if err != nil { return ErrResetCodeInvalid }
if reset.UsedAt != nil { return ErrResetCodeUsed }
if time.Now().After(reset.ExpiresAt) { return ErrResetCodeExpired }
if reset.Code != code { return ErrResetCodeInvalid }
if reset.VerifiedAt == nil { return ErrResetCodeInvalid }
hash, err := auth.HashPassword(newPassword) if err != nil { return fmt.Errorf("hash password: %w", err) }
if err := t.repos.Accounts.UpdatePasswordHash(ctx, accountID, hash); err != nil { return fmt.Errorf("update password: %w", err) }
if err := t.repos.PasswordResets.MarkUsed(ctx, reset.ID); err != nil { return fmt.Errorf("mark used: %w", err) }
// Terminate all existing sessions if err := t.repos.Sessions.TerminateAllByAccount(ctx, accountID); err != nil { return fmt.Errorf("terminate sessions: %w", err) }
return nil}
// --- Permission helpers ---
func (t *Team) checkCreatePermission(admin *requestctx.AccountInfo, targetRole string) error { switch requestctx.AccountRole(admin.Role) { case requestctx.AccountRoleAgencyOwner: // Can create agency_employee, tenant_admin, tenant_member return nil case requestctx.AccountRoleTenantAdmin: if targetRole != "tenant_member" { return ErrInsufficientPermission } return nil default: return ErrInsufficientPermission }}
func (t *Team) checkManagePermission(admin *requestctx.AccountInfo, target *model.Account) error { switch requestctx.AccountRole(admin.Role) { case requestctx.AccountRoleAgencyOwner: return nil // can manage everyone case requestctx.AccountRoleTenantAdmin: if target.TenantID == nil || admin.TenantID == nil || *target.TenantID != *admin.TenantID { return ErrInsufficientPermission } if model.AccountRole(target.Role) != model.AccountRole_TenantMember { return ErrInsufficientPermission } return nil default: return ErrInsufficientPermission }}
func (t *Team) resolveTenantID(admin *requestctx.AccountInfo, requestTenantID *uuid.UUID) *uuid.UUID { if requestctx.AccountRole(admin.Role) == requestctx.AccountRoleTenantAdmin { return admin.TenantID // always own tenant } return requestTenantID // agency_owner can specify}
func generateResetCode() (string, error) { n, err := rand.Int(rand.Reader, big.NewInt(1000000)) if err != nil { return "", err } return fmt.Sprintf("%06d", n.Int64()), nil}- Step 3: Build to verify
cd cms/backend && make build- Step 4: Commit
git add cms/backend/internal/feature/team/request.go cms/backend/internal/feature/team/usecase.gogit commit -m "feat: team use cases — CreateAccount, ListAccounts, Deactivate, Reactivate, PasswordReset"Task 8: Wire account + team into UseCases
Abschnitt betitelt „Task 8: Wire account + team into UseCases“Files:
-
Modify:
cms/backend/internal/usecase/usecase.go -
Step 1: Add Account and Team to UseCases struct
package usecase
import ( "context" "database/sql" "fmt"
"github.com/google/uuid"
"github.com/traffino/cms/internal/feature/account" "github.com/traffino/cms/internal/feature/auth" "github.com/traffino/cms/internal/feature/geolocation" "github.com/traffino/cms/internal/feature/team" "github.com/traffino/cms/internal/shared/requestctx")
type UseCases struct { Auth *auth.Auth Account *account.Account Team *team.Team}
func New(db *sql.DB, geo *geolocation.Geolocation) *UseCases { accountRepo := auth.NewAccountRepository(db) sessionRepo := auth.NewSessionRepository(db) loginAttemptRepo := auth.NewLoginAttemptRepository(db) knownDeviceRepo := auth.NewKnownDeviceRepository(db)
teamRepos := team.Repositories{ Accounts: team.NewAccountRepository(db), AccountList: team.NewAccountListRepository(db), PasswordResets: team.NewPasswordResetRepository(db), Emails: team.NewEmailRepository(db), Sessions: team.NewSessionRepository(db), }
return &UseCases{ Auth: auth.NewAuth(accountRepo, sessionRepo, loginAttemptRepo, knownDeviceRepo, geo, db), Account: account.NewAccount(accountRepo, sessionRepo), Team: team.NewTeam(teamRepos), }}Keep the existing ResolveAgencyContext, ResolveClientContext, ResolveAnyContext, and resolveAccount methods unchanged.
- Step 2: Build to verify
cd cms/backend && make build- Step 3: Commit
git add cms/backend/internal/usecase/usecase.gogit commit -m "feat: wire Account and Team use cases into UseCases aggregate"Task 9: HTTP handlers — account self-service
Abschnitt betitelt „Task 9: HTTP handlers — account self-service“Files:
-
Create:
cms/backend/cmd/http/handlers_account.go -
Step 1: Create handlers_account.go
package main
import ( "encoding/json" "errors" "net/http"
"github.com/go-chi/chi/v5" "github.com/google/uuid"
"github.com/traffino/cms/internal/feature/account" "github.com/traffino/cms/internal/shared/httputil" "github.com/traffino/cms/internal/shared/logging")
func handleUpdateProfile(uc *account.Account) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req account.UpdateProfileRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httputil.Error(w, http.StatusBadRequest, "invalid json") return }
rc := getRequestContext(r) if rc.Account == nil { httputil.Error(w, http.StatusUnauthorized, "not authenticated") return }
if err := uc.UpdateProfile(r.Context(), rc.Account.ID, req); err != nil { logging.FromContext(r.Context()).Error("update profile", "error", err) httputil.Error(w, http.StatusInternalServerError, "internal error") return }
httputil.JSON(w, http.StatusOK, map[string]string{"message": "profile updated"}) }}
func handleChangePassword(uc *account.Account) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req account.ChangePasswordRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httputil.Error(w, http.StatusBadRequest, "invalid json") return }
rc := getRequestContext(r) if rc.Account == nil { httputil.Error(w, http.StatusUnauthorized, "not authenticated") return }
if err := uc.ChangePassword(r.Context(), rc, rc.Account.ID, req); err != nil { if errors.Is(err, account.ErrInvalidCurrentPassword) { httputil.Error(w, http.StatusBadRequest, "invalid current password") return } logging.FromContext(r.Context()).Error("change password", "error", err) httputil.Error(w, http.StatusInternalServerError, "internal error") return }
httputil.JSON(w, http.StatusOK, map[string]string{"message": "password changed"}) }}
func handleListSessions(uc *account.Account) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rc := getRequestContext(r) if rc.Account == nil { httputil.Error(w, http.StatusUnauthorized, "not authenticated") return }
resp, err := uc.ListSessions(r.Context(), rc.Account.ID, rc.SessionID) if err != nil { logging.FromContext(r.Context()).Error("list sessions", "error", err) httputil.Error(w, http.StatusInternalServerError, "internal error") return }
httputil.JSON(w, http.StatusOK, resp) }}
func handleTerminateSession(uc *account.Account) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rc := getRequestContext(r) if rc.Account == nil { httputil.Error(w, http.StatusUnauthorized, "not authenticated") return }
sessionID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { httputil.Error(w, http.StatusBadRequest, "invalid session id") return }
if err := uc.TerminateSession(r.Context(), rc.Account.ID, rc.SessionID, sessionID); err != nil { switch { case errors.Is(err, account.ErrCannotTerminateCurrentSession): httputil.Error(w, http.StatusBadRequest, "cannot terminate current session") case errors.Is(err, account.ErrSessionNotFound): httputil.Error(w, http.StatusNotFound, "session not found") default: logging.FromContext(r.Context()).Error("terminate session", "error", err) httputil.Error(w, http.StatusInternalServerError, "internal error") } return }
httputil.JSON(w, http.StatusOK, map[string]string{"message": "session terminated"}) }}
func handleTerminateOtherSessions(uc *account.Account) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rc := getRequestContext(r) if rc.Account == nil || rc.SessionID == nil { httputil.Error(w, http.StatusUnauthorized, "not authenticated") return }
count, err := uc.TerminateOtherSessions(r.Context(), rc.Account.ID, *rc.SessionID) if err != nil { logging.FromContext(r.Context()).Error("terminate other sessions", "error", err) httputil.Error(w, http.StatusInternalServerError, "internal error") return }
httputil.JSON(w, http.StatusOK, map[string]any{"message": "sessions terminated", "count": count}) }}- Step 2: Build to verify
cd cms/backend && make build- Step 3: Commit
git add cms/backend/cmd/http/handlers_account.gogit commit -m "feat: account HTTP handlers — profile, password, sessions"Task 10: HTTP handlers — team management
Abschnitt betitelt „Task 10: HTTP handlers — team management“Files:
-
Create:
cms/backend/cmd/http/handlers_team.go -
Step 1: Create handlers_team.go
package main
import ( "encoding/json" "errors" "net/http"
"github.com/go-chi/chi/v5" "github.com/google/uuid"
"github.com/traffino/cms/internal/feature/team" "github.com/traffino/cms/internal/shared/httputil" "github.com/traffino/cms/internal/shared/logging")
func handleCreateAccount(uc *team.Team) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req team.CreateAccountRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httputil.Error(w, http.StatusBadRequest, "invalid json") return }
rc := getRequestContext(r) resp, err := uc.CreateAccount(r.Context(), rc.Account, req) if err != nil { mapTeamError(r, w, err) return }
httputil.JSON(w, http.StatusCreated, resp) }}
func handleListAccounts(uc *team.Team) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rc := getRequestContext(r) resp, err := uc.ListAccounts(r.Context(), rc.Account) if err != nil { logging.FromContext(r.Context()).Error("list accounts", "error", err) httputil.Error(w, http.StatusInternalServerError, "internal error") return }
httputil.JSON(w, http.StatusOK, resp) }}
func handleDeactivateAccount(uc *team.Team) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rc := getRequestContext(r) targetID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { httputil.Error(w, http.StatusBadRequest, "invalid account id") return }
if err := uc.DeactivateAccount(r.Context(), rc.Account, targetID); err != nil { mapTeamError(r, w, err) return }
httputil.JSON(w, http.StatusOK, map[string]string{"message": "account deactivated"}) }}
func handleReactivateAccount(uc *team.Team) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rc := getRequestContext(r) targetID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { httputil.Error(w, http.StatusBadRequest, "invalid account id") return }
if err := uc.ReactivateAccount(r.Context(), rc.Account, targetID); err != nil { mapTeamError(r, w, err) return }
httputil.JSON(w, http.StatusOK, map[string]string{"message": "account reactivated"}) }}
func handleInitiatePasswordReset(uc *team.Team) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rc := getRequestContext(r) targetID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { httputil.Error(w, http.StatusBadRequest, "invalid account id") return }
resp, err := uc.InitiatePasswordReset(r.Context(), rc.Account, targetID) if err != nil { mapTeamError(r, w, err) return }
httputil.JSON(w, http.StatusOK, resp) }}
func handleVerifyResetCode(uc *team.Team) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { AccountID uuid.UUID `json:"account_id"` Code string `json:"code"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httputil.Error(w, http.StatusBadRequest, "invalid json") return }
if err := uc.VerifyResetCode(r.Context(), req.AccountID, req.Code); err != nil { mapResetError(r, w, err) return }
httputil.JSON(w, http.StatusOK, map[string]string{"message": "code verified"}) }}
func handleCompleteReset(uc *team.Team) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { AccountID uuid.UUID `json:"account_id"` Code string `json:"code"` NewPassword string `json:"new_password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httputil.Error(w, http.StatusBadRequest, "invalid json") return }
if err := uc.CompletePasswordReset(r.Context(), req.AccountID, req.Code, req.NewPassword); err != nil { mapResetError(r, w, err) return }
httputil.JSON(w, http.StatusOK, map[string]string{"message": "password reset complete"}) }}
func mapTeamError(r *http.Request, w http.ResponseWriter, err error) { switch { case errors.Is(err, team.ErrInsufficientPermission): httputil.Error(w, http.StatusForbidden, "insufficient permission") case errors.Is(err, team.ErrCannotModifySelf): httputil.Error(w, http.StatusBadRequest, "cannot modify own account") case errors.Is(err, team.ErrAccountAlreadyActive): httputil.Error(w, http.StatusConflict, "account already active") case errors.Is(err, team.ErrAccountAlreadyInactive): httputil.Error(w, http.StatusConflict, "account already inactive") case errors.Is(err, team.ErrEmailAlreadyExists): httputil.Error(w, http.StatusConflict, "email already exists") case errors.Is(err, team.ErrAccountNotFound): httputil.Error(w, http.StatusNotFound, "account not found") default: logging.FromContext(r.Context()).Error("team error", "error", err) httputil.Error(w, http.StatusInternalServerError, "internal error") }}
func mapResetError(r *http.Request, w http.ResponseWriter, err error) { switch { case errors.Is(err, team.ErrResetCodeInvalid): httputil.Error(w, http.StatusBadRequest, "invalid reset code") case errors.Is(err, team.ErrResetCodeExpired): httputil.Error(w, http.StatusGone, "reset code expired") case errors.Is(err, team.ErrResetCodeUsed): httputil.Error(w, http.StatusGone, "reset code already used") default: logging.FromContext(r.Context()).Error("reset error", "error", err) httputil.Error(w, http.StatusInternalServerError, "internal error") }}- Step 2: Build to verify
cd cms/backend && make build- Step 3: Commit
git add cms/backend/cmd/http/handlers_team.gogit commit -m "feat: team HTTP handlers — create, list, deactivate, reactivate, password reset"Task 11: Register all new routes
Abschnitt betitelt „Task 11: Register all new routes“Files:
-
Modify:
cms/backend/cmd/http/routes.go -
Step 1: Add account and team routes
Extend registerRoutes — add to the existing anyAuth protected group and uncomment/populate the agency and client groups:
func registerRoutes(r chi.Router, uc *usecase.UseCases) { // Public — no authentication required r.Get("/api/public/health", handleHealth()) r.Post("/api/public/auth/login", handleLogin(uc.Auth)) r.Post("/api/public/auth/verify-2fa", handleVerify2FA(uc.Auth)) r.Get("/api/public/auth/session", handleSessionCheck(uc.Auth))
// Public — password reset (user may not be logged in) r.Post("/api/public/auth/verify-reset", handleVerifyResetCode(uc.Team)) r.Post("/api/public/auth/complete-reset", handleCompleteReset(uc.Team))
// Protected — any authenticated user r.Group(func(r chi.Router) { r.Use(anyAuth(uc)) r.Post("/api/protected/auth/logout", handleLogout(uc.Auth))
// Account self-service r.Put("/api/protected/account/profile", handleUpdateProfile(uc.Account)) r.Put("/api/protected/account/password", handleChangePassword(uc.Account)) r.Get("/api/protected/account/sessions", handleListSessions(uc.Account)) r.Delete("/api/protected/account/sessions/{id}", handleTerminateSession(uc.Account)) r.Delete("/api/protected/account/sessions", handleTerminateOtherSessions(uc.Account)) })
// Agency — agency_owner, agency_employee r.Group(func(r chi.Router) { r.Use(agencyAuth(uc)) r.Post("/api/agency/team", handleCreateAccount(uc.Team)) r.Get("/api/agency/team", handleListAccounts(uc.Team)) r.Put("/api/agency/team/{id}/deactivate", handleDeactivateAccount(uc.Team)) r.Put("/api/agency/team/{id}/reactivate", handleReactivateAccount(uc.Team)) r.Post("/api/agency/team/{id}/reset-password", handleInitiatePasswordReset(uc.Team)) })
// Client — tenant_admin, tenant_member r.Group(func(r chi.Router) { r.Use(clientAuth(uc)) r.Post("/api/client/team", handleCreateAccount(uc.Team)) r.Get("/api/client/team", handleListAccounts(uc.Team)) r.Put("/api/client/team/{id}/deactivate", handleDeactivateAccount(uc.Team)) r.Put("/api/client/team/{id}/reactivate", handleReactivateAccount(uc.Team)) r.Post("/api/client/team/{id}/reset-password", handleInitiatePasswordReset(uc.Team)) })}- Step 2: Build to verify
cd cms/backend && make build- Step 3: Commit
git add cms/backend/cmd/http/routes.gogit commit -m "feat: register account and team routes"Task 12: Backend tests and lint
Abschnitt betitelt „Task 12: Backend tests and lint“- Step 1: Run tests
cd cms/backend && make test- Step 2: Run lint
cd cms/backend && make lint-
Step 3: Fix any issues found
-
Step 4: Commit fixes if any
git add -A cms/backend/git commit -m "fix: resolve lint and test issues"Task 13: Frontend — Zod schemas for account, team, and reset
Abschnitt betitelt „Task 13: Frontend — Zod schemas for account, team, and reset“Files:
-
Modify:
cms/frontend/src/lib/schemas.ts -
Step 1: Add locale to sessionResponseSchema
export const sessionResponseSchema = z.object({ account_id: z.string().uuid(), display_name: z.string(), role: z.string(), tenant_id: z.string().uuid().optional().nullable(), locale: z.string(),});- Step 2: Add account schemas
// Account — Profileexport const updateProfileRequestSchema = z.object({ display_name: z.string().min(1).max(255), locale: z.enum(['de', 'en']),});export type UpdateProfileRequest = z.infer<typeof updateProfileRequestSchema>;
// Account — Passwordexport const changePasswordRequestSchema = z.object({ old_password: z.string().min(1), new_password: z.string().min(8),});export type ChangePasswordRequest = z.infer<typeof changePasswordRequestSchema>;
// Account — Sessionsexport const sessionItemSchema = z.object({ id: z.string().uuid(), created_at: z.string().optional(), ip: z.string().optional(), device: z.string().optional(), location_de: z.string().optional(), location_en: z.string().optional(), is_current: z.boolean(),});export type SessionItem = z.infer<typeof sessionItemSchema>;
export const listSessionsResponseSchema = z.object({ sessions: z.array(sessionItemSchema),});- Step 3: Add team schemas
// Team — Create Accountexport const createAccountRequestSchema = z.object({ email: z.email(), display_name: z.string().min(1).max(255), role: z.enum(['agency_employee', 'tenant_admin', 'tenant_member']), tenant_id: z.string().uuid().optional(), locale: z.enum(['de', 'en']).optional(),});export type CreateAccountRequest = z.infer<typeof createAccountRequestSchema>;
export const createAccountResponseSchema = z.object({ id: z.string().uuid(), display_name: z.string(), email: z.string(), role: z.string(),});
// Team — List Accountsexport const accountListItemSchema = z.object({ id: z.string().uuid(), display_name: z.string(), email: z.string(), role: z.string(), active: z.boolean(), locale: z.string(), tenant_id: z.string().uuid().optional().nullable(), created_at: z.string().optional().nullable(),});export type AccountListItem = z.infer<typeof accountListItemSchema>;
export const listAccountsResponseSchema = z.object({ accounts: z.array(accountListItemSchema),});
// Password Reset (public)export const verifyResetCodeRequestSchema = z.object({ account_id: z.string().uuid(), code: z.string().length(6),});export type VerifyResetCodeRequest = z.infer<typeof verifyResetCodeRequestSchema>;
export const completeResetRequestSchema = z.object({ account_id: z.string().uuid(), code: z.string().length(6), new_password: z.string().min(8),});export type CompleteResetRequest = z.infer<typeof completeResetRequestSchema>;- Step 4: Commit
git add cms/frontend/src/lib/schemas.tsgit commit -m "feat: Zod schemas for account, team, and password reset"Task 14: Frontend — server functions for account and team
Abschnitt betitelt „Task 14: Frontend — server functions for account and team“Files:
-
Create:
cms/frontend/src/api/account.ts -
Create:
cms/frontend/src/api/team.ts -
Step 1: Create account.ts
import { createServerFn } from '@tanstack/react-start';import { getRequest, setResponseHeader } from '@tanstack/react-start/server';import { backendFetch } from '@/lib/server/backend';import { listSessionsResponseSchema, type UpdateProfileRequest, type ChangePasswordRequest,} from '@/lib/schemas';
function getSessionCookie(): string | undefined { const request = getRequest(); return request.headers.get('cookie') ?? undefined;}
export const updateProfile = createServerFn({ method: 'POST' }) .inputValidator((data: UpdateProfileRequest) => data) .handler(async ({ data }) => { try { const cookie = getSessionCookie(); const res = await backendFetch('/api/protected/account/profile', { method: 'PUT', body: data, cookie, });
if (res.status !== 200) { const error = (res.data as { error?: string }).error ?? 'update failed'; return { success: false as const, error, status: res.status }; }
return { success: true as const }; } catch { return { success: false as const, error: 'backend unavailable', status: 503 }; } });
export const changePassword = createServerFn({ method: 'POST' }) .inputValidator((data: ChangePasswordRequest) => data) .handler(async ({ data }) => { try { const cookie = getSessionCookie(); const res = await backendFetch('/api/protected/account/password', { method: 'PUT', body: data, cookie, });
if (res.status !== 200) { const error = (res.data as { error?: string }).error ?? 'change failed'; return { success: false as const, error, status: res.status }; }
return { success: true as const }; } catch { return { success: false as const, error: 'backend unavailable', status: 503 }; } });
export const listSessions = createServerFn({ method: 'GET' }).handler(async () => { try { const cookie = getSessionCookie(); const res = await backendFetch('/api/protected/account/sessions', { cookie });
if (res.status !== 200) { return { success: false as const, error: 'fetch failed', status: res.status }; }
const parsed = listSessionsResponseSchema.parse(res.data); return { success: true as const, data: parsed }; } catch { return { success: false as const, error: 'backend unavailable', status: 503 }; }});
export const terminateSession = createServerFn({ method: 'POST' }) .inputValidator((data: { sessionId: string }) => data) .handler(async ({ data }) => { try { const cookie = getSessionCookie(); const res = await backendFetch(`/api/protected/account/sessions/${data.sessionId}`, { method: 'DELETE', cookie, });
if (res.status !== 200) { const error = (res.data as { error?: string }).error ?? 'terminate failed'; return { success: false as const, error, status: res.status }; }
return { success: true as const }; } catch { return { success: false as const, error: 'backend unavailable', status: 503 }; } });
export const terminateOtherSessions = createServerFn({ method: 'POST' }).handler(async () => { try { const cookie = getSessionCookie(); const res = await backendFetch('/api/protected/account/sessions', { method: 'DELETE', cookie, });
if (res.status !== 200) { const error = (res.data as { error?: string }).error ?? 'terminate failed'; return { success: false as const, error, status: res.status }; }
return { success: true as const }; } catch { return { success: false as const, error: 'backend unavailable', status: 503 }; }});- Step 2: Create team.ts
import { createServerFn } from '@tanstack/react-start';import { getRequest } from '@tanstack/react-start/server';import { backendFetch } from '@/lib/server/backend';import { createAccountResponseSchema, listAccountsResponseSchema, type CreateAccountRequest, type VerifyResetCodeRequest, type CompleteResetRequest,} from '@/lib/schemas';
function getSessionCookie(): string | undefined { const request = getRequest(); return request.headers.get('cookie') ?? undefined;}
function teamBasePath(role: string): string { if (role === 'agency_owner' || role === 'agency_employee') return '/api/agency/team'; return '/api/client/team';}
export const createAccount = createServerFn({ method: 'POST' }) .inputValidator((data: CreateAccountRequest & { callerRole: string }) => data) .handler(async ({ data }) => { try { const cookie = getSessionCookie(); const { callerRole, ...body } = data; const res = await backendFetch(teamBasePath(callerRole), { method: 'POST', body, cookie, });
if (res.status !== 201) { const error = (res.data as { error?: string }).error ?? 'create failed'; return { success: false as const, error, status: res.status }; }
const parsed = createAccountResponseSchema.parse(res.data); return { success: true as const, data: parsed }; } catch { return { success: false as const, error: 'backend unavailable', status: 503 }; } });
export const listAccounts = createServerFn({ method: 'GET' }) .inputValidator((data: { callerRole: string }) => data) .handler(async ({ data }) => { try { const cookie = getSessionCookie(); const res = await backendFetch(teamBasePath(data.callerRole), { cookie });
if (res.status !== 200) { return { success: false as const, error: 'fetch failed', status: res.status }; }
const parsed = listAccountsResponseSchema.parse(res.data); return { success: true as const, data: parsed }; } catch { return { success: false as const, error: 'backend unavailable', status: 503 }; } });
export const deactivateAccount = createServerFn({ method: 'POST' }) .inputValidator((data: { id: string; callerRole: string }) => data) .handler(async ({ data }) => { try { const cookie = getSessionCookie(); const res = await backendFetch(`${teamBasePath(data.callerRole)}/${data.id}/deactivate`, { method: 'PUT', cookie, });
if (res.status !== 200) { const error = (res.data as { error?: string }).error ?? 'deactivate failed'; return { success: false as const, error, status: res.status }; }
return { success: true as const }; } catch { return { success: false as const, error: 'backend unavailable', status: 503 }; } });
export const reactivateAccount = createServerFn({ method: 'POST' }) .inputValidator((data: { id: string; callerRole: string }) => data) .handler(async ({ data }) => { try { const cookie = getSessionCookie(); const res = await backendFetch(`${teamBasePath(data.callerRole)}/${data.id}/reactivate`, { method: 'PUT', cookie, });
if (res.status !== 200) { const error = (res.data as { error?: string }).error ?? 'reactivate failed'; return { success: false as const, error, status: res.status }; }
return { success: true as const }; } catch { return { success: false as const, error: 'backend unavailable', status: 503 }; } });
export const initiatePasswordReset = createServerFn({ method: 'POST' }) .inputValidator((data: { id: string; callerRole: string }) => data) .handler(async ({ data }) => { try { const cookie = getSessionCookie(); const res = await backendFetch(`${teamBasePath(data.callerRole)}/${data.id}/reset-password`, { method: 'POST', cookie, });
if (res.status !== 200) { const error = (res.data as { error?: string }).error ?? 'reset failed'; return { success: false as const, error, status: res.status }; }
return { success: true as const }; } catch { return { success: false as const, error: 'backend unavailable', status: 503 }; } });
// Public — password reset completion (user may not be logged in)export const verifyResetCode = createServerFn({ method: 'POST' }) .inputValidator((data: VerifyResetCodeRequest) => data) .handler(async ({ data }) => { try { const res = await backendFetch('/api/public/auth/verify-reset', { method: 'POST', body: data, });
if (res.status !== 200) { const error = (res.data as { error?: string }).error ?? 'verification failed'; return { success: false as const, error, status: res.status }; }
return { success: true as const }; } catch { return { success: false as const, error: 'backend unavailable', status: 503 }; } });
export const completeReset = createServerFn({ method: 'POST' }) .inputValidator((data: CompleteResetRequest) => data) .handler(async ({ data }) => { try { const res = await backendFetch('/api/public/auth/complete-reset', { method: 'POST', body: data, });
if (res.status !== 200) { const error = (res.data as { error?: string }).error ?? 'reset failed'; return { success: false as const, error, status: res.status }; }
return { success: true as const }; } catch { return { success: false as const, error: 'backend unavailable', status: 503 }; } });- Step 3: Commit
git add cms/frontend/src/api/account.ts cms/frontend/src/api/team.tsgit commit -m "feat: server functions for account self-service and team management"Task 15: Frontend — i18n (English + account/team translations)
Abschnitt betitelt „Task 15: Frontend — i18n (English + account/team translations)“Files:
-
Create:
cms/frontend/src/i18n/en.ts -
Modify:
cms/frontend/src/i18n/de.ts -
Modify:
cms/frontend/src/i18n/index.ts -
Step 1: Add account/team keys to de.ts
Add these keys to the existing de object:
account: { profileTitle: 'Profil bearbeiten', displayName: 'Anzeigename', locale: 'Sprache', localeDe: 'Deutsch', localeEn: 'English', saveProfile: 'Profil speichern', profileSaved: 'Profil gespeichert', passwordTitle: 'Passwort aendern', oldPassword: 'Aktuelles Passwort', newPassword: 'Neues Passwort', confirmPassword: 'Passwort bestaetigen', changePassword: 'Passwort aendern', passwordChanged: 'Passwort geaendert', invalidCurrentPassword: 'Aktuelles Passwort ist falsch', sessionsTitle: 'Aktive Sessions', currentSession: 'Aktuelle Session', terminateSession: 'Abmelden', terminateOthers: 'Alle anderen abmelden', sessionsTerminated: '{{count}} Sessions beendet', sessionTerminated: 'Session beendet', cannotTerminateCurrent: 'Die aktuelle Session kann hier nicht beendet werden',},team: { title: 'Team', createTitle: 'Neuen Account anlegen', email: 'E-Mail-Adresse', displayName: 'Anzeigename', role: 'Rolle', status: 'Status', actions: 'Aktionen', active: 'Aktiv', inactive: 'Inaktiv', create: 'Account anlegen', created: 'Account angelegt', deactivate: 'Deaktivieren', reactivate: 'Aktivieren', resetPassword: 'Passwort zuruecksetzen', deactivated: 'Account deaktiviert', reactivated: 'Account aktiviert', resetSent: 'Reset-Code gesendet', emailExists: 'E-Mail-Adresse bereits vergeben', cannotModifySelf: 'Eigenen Account kann man nicht bearbeiten', insufficientPermission: 'Keine Berechtigung', roleAgencyOwner: 'Agency Owner', roleAgencyEmployee: 'Agency Mitarbeiter', roleTenantAdmin: 'Mandant Admin', roleTenantMember: 'Mandant Mitarbeiter',},resetPassword: { title: 'Passwort zuruecksetzen', subtitle: 'Geben Sie den 6-stelligen Code ein, den Sie per E-Mail erhalten haben', code: 'Reset-Code', newPassword: 'Neues Passwort', confirmPassword: 'Passwort bestaetigen', verify: 'Code pruefen', complete: 'Passwort setzen', codeVerified: 'Code bestaetigt — bitte neues Passwort eingeben', resetComplete: 'Passwort erfolgreich geaendert', invalidCode: 'Ungueltiger Code', codeExpired: 'Der Code ist abgelaufen', codeUsed: 'Der Code wurde bereits verwendet',},nav: { dashboard: 'Dashboard', account: 'Konto', profile: 'Profil', password: 'Passwort', sessions: 'Sessions', team: 'Team',},Note: Replace the existing nav section entirely.
- Step 2: Create en.ts
export const en = { common: { loading: 'Loading...', error: 'An error occurred', retry: 'Try again', save: 'Save', cancel: 'Cancel', logout: 'Log out', }, auth: { loginTitle: 'Sign In', loginSubtitle: 'Sign in to your CMS', email: 'Email address', emailPlaceholder: 'your@email.com', password: 'Password', passwordPlaceholder: 'Your password', loginButton: 'Sign In', twoFactorTitle: 'Two-Factor Authentication', twoFactorSubtitle: 'Enter the 6-digit code we sent to your email', twoFactorCode: 'Verification code', verifyButton: 'Verify', invalidCredentials: 'Email or password is incorrect', accountInactive: 'Your account is deactivated', tooManyAttempts: 'Too many login attempts. Please wait 15 minutes.', invalidCode: 'The code entered is invalid', codeExpired: 'The code has expired. Please sign in again.', unknownError: 'An unknown error occurred', backendUnavailable: 'The server is currently unavailable. Please try again shortly.', }, dashboard: { title: 'Dashboard', welcome: 'Hello, {{name}}', description: 'Manage your websites and content.', gettingStartedTitle: 'All set', gettingStartedDescription: 'Your CMS is set up. More features coming soon.', }, account: { profileTitle: 'Edit Profile', displayName: 'Display Name', locale: 'Language', localeDe: 'Deutsch', localeEn: 'English', saveProfile: 'Save Profile', profileSaved: 'Profile saved', passwordTitle: 'Change Password', oldPassword: 'Current Password', newPassword: 'New Password', confirmPassword: 'Confirm Password', changePassword: 'Change Password', passwordChanged: 'Password changed', invalidCurrentPassword: 'Current password is incorrect', sessionsTitle: 'Active Sessions', currentSession: 'Current session', terminateSession: 'Sign out', terminateOthers: 'Sign out all others', sessionsTerminated: '{{count}} sessions terminated', sessionTerminated: 'Session terminated', cannotTerminateCurrent: 'Cannot terminate the current session here', }, team: { title: 'Team', createTitle: 'Create Account', email: 'Email Address', displayName: 'Display Name', role: 'Role', status: 'Status', actions: 'Actions', active: 'Active', inactive: 'Inactive', create: 'Create Account', created: 'Account created', deactivate: 'Deactivate', reactivate: 'Activate', resetPassword: 'Reset Password', deactivated: 'Account deactivated', reactivated: 'Account activated', resetSent: 'Reset code sent', emailExists: 'Email address already taken', cannotModifySelf: 'Cannot modify own account', insufficientPermission: 'Insufficient permission', roleAgencyOwner: 'Agency Owner', roleAgencyEmployee: 'Agency Employee', roleTenantAdmin: 'Tenant Admin', roleTenantMember: 'Tenant Member', }, resetPassword: { title: 'Reset Password', subtitle: 'Enter the 6-digit code you received by email', code: 'Reset Code', newPassword: 'New Password', confirmPassword: 'Confirm Password', verify: 'Verify Code', complete: 'Set Password', codeVerified: 'Code verified — please enter your new password', resetComplete: 'Password changed successfully', invalidCode: 'Invalid code', codeExpired: 'The code has expired', codeUsed: 'The code has already been used', }, nav: { dashboard: 'Dashboard', account: 'Account', profile: 'Profile', password: 'Password', sessions: 'Sessions', team: 'Team', },};- Step 3: Update i18n/index.ts to include English
import i18n from 'i18next';import { initReactI18next } from 'react-i18next';import { de } from './de';import { en } from './en';
i18n.use(initReactI18next).init({ resources: { de: { translation: de }, en: { translation: en }, }, lng: 'de', fallbackLng: 'de', interpolation: { escapeValue: false, },});
export default i18n;- Step 4: Commit
git add cms/frontend/src/i18n/git commit -m "feat: i18n — English translations + account/team keys"Task 16: Frontend — AppShell navigation + locale switching
Abschnitt betitelt „Task 16: Frontend — AppShell navigation + locale switching“Files:
-
Modify:
cms/frontend/src/components/layout/AppShell.tsx -
Modify:
cms/frontend/src/routes/_app.tsx -
Step 1: Update _app.tsx to set locale on app load
import { createFileRoute, redirect } from '@tanstack/react-router';import { getSession } from '@/api/auth';import { AppShell } from '@/components/layout/AppShell';import i18n from '@/i18n';
export const Route = createFileRoute('/_app')({ beforeLoad: async () => { const session = await getSession();
if (!session.authenticated) { throw redirect({ to: '/login' }); }
// Set UI language based on user preference if (session.user.locale && i18n.language !== session.user.locale) { i18n.changeLanguage(session.user.locale); }
return { user: session.user }; }, component: AppLayout,});
function AppLayout() { const { user } = Route.useRouteContext(); return <AppShell user={user} />;}- Step 2: Update AppShell with account/team navigation
import { AppShell as MantineAppShell, Burger, Group, Text, NavLink, Stack, UnstyledButton,} from '@mantine/core';import { useDisclosure } from '@mantine/hooks';import { IconDashboard, IconLogout, IconUser, IconLock, IconDevices, IconUsers,} from '@tabler/icons-react';import { Link, Outlet, useRouter, useMatchRoute } from '@tanstack/react-router';import { useTranslation } from 'react-i18next';import { logout } from '@/api/auth';import type { SessionResponse } from '@/lib/schemas';
type AppShellProps = { user: SessionResponse;};
export function AppShell({ user }: AppShellProps) { const { t } = useTranslation(); const router = useRouter(); const matchRoute = useMatchRoute(); const [opened, { toggle }] = useDisclosure();
const canManageTeam = user.role === 'agency_owner' || user.role === 'tenant_admin';
async function handleLogout() { await logout(); router.navigate({ to: '/login' }); }
return ( <MantineAppShell header={{ height: 48 }} navbar={{ width: 220, breakpoint: 'sm', collapsed: { mobile: !opened } }} padding="md" > <MantineAppShell.Header className="border-b border-neutral-200"> <Group h="100%" px="md" justify="space-between"> <Group gap="sm"> <Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" /> <Text fw={700} size="sm"> traffino </Text> </Group> <Group gap="xs"> <Text size="xs" c="dimmed"> {user.display_name} </Text> <UnstyledButton onClick={handleLogout} className="text-neutral-400 hover:text-neutral-600"> <IconLogout size={16} /> </UnstyledButton> </Group> </Group> </MantineAppShell.Header>
<MantineAppShell.Navbar p="xs" className="border-r border-neutral-200"> <Stack gap={2}> <NavLink component={Link} to="/" label={t('nav.dashboard')} leftSection={<IconDashboard size={16} />} active={!!matchRoute({ to: '/' })} />
<Text size="xs" fw={500} c="dimmed" mt="md" mb={4} px="xs"> {t('nav.account')} </Text> <NavLink component={Link} to="/account/profile" label={t('nav.profile')} leftSection={<IconUser size={16} />} active={!!matchRoute({ to: '/account/profile' })} /> <NavLink component={Link} to="/account/password" label={t('nav.password')} leftSection={<IconLock size={16} />} active={!!matchRoute({ to: '/account/password' })} /> <NavLink component={Link} to="/account/sessions" label={t('nav.sessions')} leftSection={<IconDevices size={16} />} active={!!matchRoute({ to: '/account/sessions' })} />
{canManageTeam && ( <> <Text size="xs" fw={500} c="dimmed" mt="md" mb={4} px="xs"> {t('nav.team')} </Text> <NavLink component={Link} to="/team" label={t('nav.team')} leftSection={<IconUsers size={16} />} active={!!matchRoute({ to: '/team' })} /> </> )} </Stack> </MantineAppShell.Navbar>
<MantineAppShell.Main> <Outlet /> </MantineAppShell.Main> </MantineAppShell> );}- Step 3: Commit
git add cms/frontend/src/components/layout/AppShell.tsx cms/frontend/src/routes/_app.tsxgit commit -m "feat: AppShell navigation — account/team sections + locale switching"Task 17: Frontend — account pages (profile, password, sessions)
Abschnitt betitelt „Task 17: Frontend — account pages (profile, password, sessions)“Files:
-
Create:
cms/frontend/src/components/account/ProfileForm.tsx -
Create:
cms/frontend/src/components/account/ChangePasswordForm.tsx -
Create:
cms/frontend/src/components/account/SessionList.tsx -
Create:
cms/frontend/src/routes/_app.account.profile.tsx -
Create:
cms/frontend/src/routes/_app.account.password.tsx -
Create:
cms/frontend/src/routes/_app.account.sessions.tsx -
Step 1: Create ProfileForm.tsx
import { Button, Select, Stack, TextInput, Title } from '@mantine/core';import { notifications } from '@mantine/notifications';import { useForm } from 'react-hook-form';import { zodResolver } from '@hookform/resolvers/zod';import { useTranslation } from 'react-i18next';import i18n from '@/i18n';import { updateProfile } from '@/api/account';import { updateProfileRequestSchema, type UpdateProfileRequest, type SessionResponse } from '@/lib/schemas';import { useState } from 'react';
type ProfileFormProps = { user: SessionResponse;};
export function ProfileForm({ user }: ProfileFormProps) { const { t } = useTranslation(); const [loading, setLoading] = useState(false);
const { register, handleSubmit, setValue, watch, formState: { errors }, } = useForm<UpdateProfileRequest>({ resolver: zodResolver(updateProfileRequestSchema), defaultValues: { display_name: user.display_name, locale: (user.locale as 'de' | 'en') || 'de', }, });
const currentLocale = watch('locale');
async function onSubmit(data: UpdateProfileRequest) { setLoading(true); const result = await updateProfile({ data }); setLoading(false);
if (result.success) { notifications.show({ message: t('account.profileSaved'), color: 'green' }); if (data.locale !== i18n.language) { i18n.changeLanguage(data.locale); } } else { notifications.show({ message: t('common.error'), color: 'red' }); } }
return ( <form onSubmit={handleSubmit(onSubmit)}> <Stack gap="md" maw={400}> <Title order={3}>{t('account.profileTitle')}</Title> <TextInput label={t('account.displayName')} error={errors.display_name?.message} {...register('display_name')} /> <Select label={t('account.locale')} data={[ { value: 'de', label: t('account.localeDe') }, { value: 'en', label: t('account.localeEn') }, ]} value={currentLocale} onChange={(val) => val && setValue('locale', val as 'de' | 'en')} /> <Button type="submit" loading={loading}> {t('account.saveProfile')} </Button> </Stack> </form> );}- Step 2: Create ChangePasswordForm.tsx
import { Button, PasswordInput, Stack, Title } from '@mantine/core';import { notifications } from '@mantine/notifications';import { useForm } from 'react-hook-form';import { zodResolver } from '@hookform/resolvers/zod';import { useTranslation } from 'react-i18next';import { changePassword } from '@/api/account';import { changePasswordRequestSchema, type ChangePasswordRequest } from '@/lib/schemas';import { useState } from 'react';
export function ChangePasswordForm() { const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [serverError, setServerError] = useState<string | null>(null);
const { register, handleSubmit, reset, formState: { errors }, } = useForm<ChangePasswordRequest>({ resolver: zodResolver(changePasswordRequestSchema), });
async function onSubmit(data: ChangePasswordRequest) { setLoading(true); setServerError(null); const result = await changePassword({ data }); setLoading(false);
if (result.success) { notifications.show({ message: t('account.passwordChanged'), color: 'green' }); reset(); } else if (result.error === 'invalid current password') { setServerError(t('account.invalidCurrentPassword')); } else { setServerError(t('common.error')); } }
return ( <form onSubmit={handleSubmit(onSubmit)}> <Stack gap="md" maw={400}> <Title order={3}>{t('account.passwordTitle')}</Title> <PasswordInput label={t('account.oldPassword')} error={serverError || errors.old_password?.message} {...register('old_password')} /> <PasswordInput label={t('account.newPassword')} error={errors.new_password?.message} {...register('new_password')} /> <Button type="submit" loading={loading}> {t('account.changePassword')} </Button> </Stack> </form> );}- Step 3: Create SessionList.tsx
import { ActionIcon, Badge, Button, Group, Stack, Table, Text, Title } from '@mantine/core';import { notifications } from '@mantine/notifications';import { IconTrash } from '@tabler/icons-react';import { useTranslation } from 'react-i18next';import { useState, useEffect } from 'react';import { listSessions, terminateSession, terminateOtherSessions } from '@/api/account';import type { SessionItem } from '@/lib/schemas';
export function SessionList() { const { t } = useTranslation(); const [sessions, setSessions] = useState<SessionItem[]>([]); const [loading, setLoading] = useState(true);
async function load() { setLoading(true); const result = await listSessions(); if (result.success) { setSessions(result.data.sessions); } setLoading(false); }
useEffect(() => { load(); }, []);
async function handleTerminate(sessionId: string) { const result = await terminateSession({ data: { sessionId } }); if (result.success) { notifications.show({ message: t('account.sessionTerminated'), color: 'green' }); load(); } else if (result.error === 'cannot terminate current session') { notifications.show({ message: t('account.cannotTerminateCurrent'), color: 'orange' }); } }
async function handleTerminateOthers() { const result = await terminateOtherSessions(); if (result.success) { notifications.show({ message: t('account.sessionsTerminated', { count: 0 }), color: 'green' }); load(); } }
const hasOtherSessions = sessions.filter((s) => !s.is_current).length > 0;
return ( <Stack gap="md"> <Group justify="space-between"> <Title order={3}>{t('account.sessionsTitle')}</Title> {hasOtherSessions && ( <Button variant="light" color="red" size="xs" onClick={handleTerminateOthers}> {t('account.terminateOthers')} </Button> )} </Group>
{loading ? ( <Text c="dimmed">{t('common.loading')}</Text> ) : ( <Table striped highlightOnHover> <Table.Thead> <Table.Tr> <Table.Th>{t('account.sessionsTitle')}</Table.Th> <Table.Th /> </Table.Tr> </Table.Thead> <Table.Tbody> {sessions.map((session) => ( <Table.Tr key={session.id}> <Table.Td> <Group gap="xs"> <Text size="sm">{session.created_at ?? '—'}</Text> {session.is_current && ( <Badge size="xs" color="green"> {t('account.currentSession')} </Badge> )} </Group> </Table.Td> <Table.Td> {!session.is_current && ( <ActionIcon variant="subtle" color="red" size="sm" onClick={() => handleTerminate(session.id)} > <IconTrash size={14} /> </ActionIcon> )} </Table.Td> </Table.Tr> ))} </Table.Tbody> </Table> )} </Stack> );}- Step 4: Create route files
_app.account.profile.tsx:
import { createFileRoute } from '@tanstack/react-router';import { ProfileForm } from '@/components/account/ProfileForm';
export const Route = createFileRoute('/_app/account/profile')({ component: ProfilePage,});
function ProfilePage() { const { user } = Route.useRouteContext(); return <ProfileForm user={user} />;}_app.account.password.tsx:
import { createFileRoute } from '@tanstack/react-router';import { ChangePasswordForm } from '@/components/account/ChangePasswordForm';
export const Route = createFileRoute('/_app/account/password')({ component: PasswordPage,});
function PasswordPage() { return <ChangePasswordForm />;}_app.account.sessions.tsx:
import { createFileRoute } from '@tanstack/react-router';import { SessionList } from '@/components/account/SessionList';
export const Route = createFileRoute('/_app/account/sessions')({ component: SessionsPage,});
function SessionsPage() { return <SessionList />;}- Step 5: Commit
git add cms/frontend/src/components/account/ cms/frontend/src/routes/_app.account.*.tsxgit commit -m "feat: account pages — profile, password, sessions"Task 18: Frontend — team pages (list, create)
Abschnitt betitelt „Task 18: Frontend — team pages (list, create)“Files:
-
Create:
cms/frontend/src/components/team/TeamTable.tsx -
Create:
cms/frontend/src/components/team/CreateAccountForm.tsx -
Create:
cms/frontend/src/routes/_app.team.index.tsx -
Create:
cms/frontend/src/routes/_app.team.create.tsx -
Step 1: Create TeamTable.tsx
import { ActionIcon, Badge, Button, Group, Menu, Stack, Table, Text, Title } from '@mantine/core';import { notifications } from '@mantine/notifications';import { IconDots, IconPlayerStop, IconPlayerPlay, IconKey } from '@tabler/icons-react';import { Link } from '@tanstack/react-router';import { useTranslation } from 'react-i18next';import { useState, useEffect } from 'react';import { listAccounts, deactivateAccount, reactivateAccount, initiatePasswordReset } from '@/api/team';import type { AccountListItem, SessionResponse } from '@/lib/schemas';
type TeamTableProps = { user: SessionResponse;};
const ROLE_LABELS: Record<string, string> = { agency_owner: 'team.roleAgencyOwner', agency_employee: 'team.roleAgencyEmployee', tenant_admin: 'team.roleTenantAdmin', tenant_member: 'team.roleTenantMember',};
export function TeamTable({ user }: TeamTableProps) { const { t } = useTranslation(); const [accounts, setAccounts] = useState<AccountListItem[]>([]); const [loading, setLoading] = useState(true);
async function load() { setLoading(true); const result = await listAccounts({ data: { callerRole: user.role } }); if (result.success) { setAccounts(result.data.accounts); } setLoading(false); }
useEffect(() => { load(); }, []);
async function handleDeactivate(id: string) { const result = await deactivateAccount({ data: { id, callerRole: user.role } }); if (result.success) { notifications.show({ message: t('team.deactivated'), color: 'green' }); load(); } else { notifications.show({ message: t(`team.${result.error === 'cannot modify own account' ? 'cannotModifySelf' : 'insufficientPermission'}`), color: 'red' }); } }
async function handleReactivate(id: string) { const result = await reactivateAccount({ data: { id, callerRole: user.role } }); if (result.success) { notifications.show({ message: t('team.reactivated'), color: 'green' }); load(); } }
async function handleResetPassword(id: string) { const result = await initiatePasswordReset({ data: { id, callerRole: user.role } }); if (result.success) { notifications.show({ message: t('team.resetSent'), color: 'green' }); } }
return ( <Stack gap="md"> <Group justify="space-between"> <Title order={3}>{t('team.title')}</Title> <Button component={Link} to="/team/create" size="xs"> {t('team.create')} </Button> </Group>
{loading ? ( <Text c="dimmed">{t('common.loading')}</Text> ) : ( <Table striped highlightOnHover> <Table.Thead> <Table.Tr> <Table.Th>{t('team.displayName')}</Table.Th> <Table.Th>{t('team.email')}</Table.Th> <Table.Th>{t('team.role')}</Table.Th> <Table.Th>{t('team.status')}</Table.Th> <Table.Th>{t('team.actions')}</Table.Th> </Table.Tr> </Table.Thead> <Table.Tbody> {accounts.map((account) => { const isSelf = account.id === user.account_id; return ( <Table.Tr key={account.id}> <Table.Td>{account.display_name}</Table.Td> <Table.Td> <Text size="sm" c="dimmed">{account.email}</Text> </Table.Td> <Table.Td> <Text size="sm">{t(ROLE_LABELS[account.role] ?? account.role)}</Text> </Table.Td> <Table.Td> <Badge color={account.active ? 'green' : 'red'} size="sm"> {account.active ? t('team.active') : t('team.inactive')} </Badge> </Table.Td> <Table.Td> {!isSelf && ( <Menu shadow="md" width={200}> <Menu.Target> <ActionIcon variant="subtle" size="sm"> <IconDots size={14} /> </ActionIcon> </Menu.Target> <Menu.Dropdown> {account.active ? ( <Menu.Item leftSection={<IconPlayerStop size={14} />} color="red" onClick={() => handleDeactivate(account.id)} > {t('team.deactivate')} </Menu.Item> ) : ( <Menu.Item leftSection={<IconPlayerPlay size={14} />} onClick={() => handleReactivate(account.id)} > {t('team.reactivate')} </Menu.Item> )} <Menu.Item leftSection={<IconKey size={14} />} onClick={() => handleResetPassword(account.id)} > {t('team.resetPassword')} </Menu.Item> </Menu.Dropdown> </Menu> )} </Table.Td> </Table.Tr> ); })} </Table.Tbody> </Table> )} </Stack> );}- Step 2: Create CreateAccountForm.tsx
import { Button, Select, Stack, TextInput, Title } from '@mantine/core';import { notifications } from '@mantine/notifications';import { useForm } from 'react-hook-form';import { zodResolver } from '@hookform/resolvers/zod';import { useRouter } from '@tanstack/react-router';import { useTranslation } from 'react-i18next';import { createAccount } from '@/api/team';import { createAccountRequestSchema, type CreateAccountRequest, type SessionResponse } from '@/lib/schemas';import { useState } from 'react';
type CreateAccountFormProps = { user: SessionResponse;};
export function CreateAccountForm({ user }: CreateAccountFormProps) { const { t } = useTranslation(); const router = useRouter(); const [loading, setLoading] = useState(false); const [serverError, setServerError] = useState<string | null>(null);
const isAgencyOwner = user.role === 'agency_owner';
const roleOptions = isAgencyOwner ? [ { value: 'agency_employee', label: t('team.roleAgencyEmployee') }, { value: 'tenant_admin', label: t('team.roleTenantAdmin') }, { value: 'tenant_member', label: t('team.roleTenantMember') }, ] : [{ value: 'tenant_member', label: t('team.roleTenantMember') }];
const { register, handleSubmit, setValue, watch, formState: { errors }, } = useForm<CreateAccountRequest>({ resolver: zodResolver(createAccountRequestSchema), defaultValues: { role: roleOptions[0].value as CreateAccountRequest['role'], locale: 'de', }, });
const currentRole = watch('role');
async function onSubmit(data: CreateAccountRequest) { setLoading(true); setServerError(null); const result = await createAccount({ data: { ...data, callerRole: user.role } }); setLoading(false);
if (result.success) { notifications.show({ message: t('team.created'), color: 'green' }); router.navigate({ to: '/team' }); } else if (result.error === 'email already exists') { setServerError(t('team.emailExists')); } else { setServerError(t('common.error')); } }
return ( <form onSubmit={handleSubmit(onSubmit)}> <Stack gap="md" maw={400}> <Title order={3}>{t('team.createTitle')}</Title> <TextInput label={t('team.email')} type="email" error={serverError || errors.email?.message} {...register('email')} /> <TextInput label={t('team.displayName')} error={errors.display_name?.message} {...register('display_name')} /> <Select label={t('team.role')} data={roleOptions} value={currentRole} onChange={(val) => val && setValue('role', val as CreateAccountRequest['role'])} /> <Button type="submit" loading={loading}> {t('team.create')} </Button> </Stack> </form> );}- Step 3: Create route files
_app.team.index.tsx:
import { createFileRoute } from '@tanstack/react-router';import { TeamTable } from '@/components/team/TeamTable';
export const Route = createFileRoute('/_app/team/')({ component: TeamPage,});
function TeamPage() { const { user } = Route.useRouteContext(); return <TeamTable user={user} />;}_app.team.create.tsx:
import { createFileRoute } from '@tanstack/react-router';import { CreateAccountForm } from '@/components/team/CreateAccountForm';
export const Route = createFileRoute('/_app/team/create')({ component: CreateAccountPage,});
function CreateAccountPage() { const { user } = Route.useRouteContext(); return <CreateAccountForm user={user} />;}- Step 4: Commit
git add cms/frontend/src/components/team/ cms/frontend/src/routes/_app.team.*.tsxgit commit -m "feat: team pages — account list and create form"Task 19: Frontend — password reset page (public)
Abschnitt betitelt „Task 19: Frontend — password reset page (public)“Files:
-
Create:
cms/frontend/src/components/auth/ResetPasswordForm.tsx -
Create:
cms/frontend/src/routes/_auth.reset-password.tsx -
Step 1: Create ResetPasswordForm.tsx
import { Button, PasswordInput, PinInput, Stack, Text, Title } from '@mantine/core';import { notifications } from '@mantine/notifications';import { useTranslation } from 'react-i18next';import { useState } from 'react';import { verifyResetCode, completeReset } from '@/api/team';
type ResetPasswordFormProps = { accountId: string;};
export function ResetPasswordForm({ accountId }: ResetPasswordFormProps) { const { t } = useTranslation(); const [step, setStep] = useState<'code' | 'password'>('code'); const [code, setCode] = useState(''); const [newPassword, setNewPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null);
const ERROR_MAP: Record<string, string> = { 'invalid reset code': t('resetPassword.invalidCode'), 'reset code expired': t('resetPassword.codeExpired'), 'reset code already used': t('resetPassword.codeUsed'), };
async function handleVerify(submittedCode?: string) { const codeToUse = submittedCode || code; if (codeToUse.length !== 6) return;
setLoading(true); setError(null); const result = await verifyResetCode({ data: { account_id: accountId, code: codeToUse } }); setLoading(false);
if (result.success) { setCode(codeToUse); setStep('password'); notifications.show({ message: t('resetPassword.codeVerified'), color: 'green' }); } else { setError(ERROR_MAP[result.error] ?? t('common.error')); } }
async function handleComplete() { if (newPassword.length < 8) return;
setLoading(true); setError(null); const result = await completeReset({ data: { account_id: accountId, code, new_password: newPassword } }); setLoading(false);
if (result.success) { notifications.show({ message: t('resetPassword.resetComplete'), color: 'green' }); window.location.href = '/login'; } else { setError(ERROR_MAP[result.error] ?? t('common.error')); } }
return ( <Stack gap="md" maw={400} mx="auto"> <Title order={3}>{t('resetPassword.title')}</Title> <Text c="dimmed" size="sm">{t('resetPassword.subtitle')}</Text>
{step === 'code' && ( <> <PinInput length={6} type="number" value={code} onChange={setCode} onComplete={handleVerify} autoFocus /> {error && <Text c="red" size="sm">{error}</Text>} <Button onClick={() => handleVerify()} loading={loading} disabled={code.length !== 6}> {t('resetPassword.verify')} </Button> </> )}
{step === 'password' && ( <> <PasswordInput label={t('resetPassword.newPassword')} value={newPassword} onChange={(e) => setNewPassword(e.currentTarget.value)} autoFocus /> {error && <Text c="red" size="sm">{error}</Text>} <Button onClick={handleComplete} loading={loading} disabled={newPassword.length < 8}> {t('resetPassword.complete')} </Button> </> )} </Stack> );}- Step 2: Create route file
_auth.reset-password.tsx:
import { createFileRoute } from '@tanstack/react-router';import { z } from 'zod';import { ResetPasswordForm } from '@/components/auth/ResetPasswordForm';
const searchSchema = z.object({ account: z.string().uuid(),});
export const Route = createFileRoute('/_auth/reset-password')({ validateSearch: searchSchema, component: ResetPasswordPage,});
function ResetPasswordPage() { const { account } = Route.useSearch(); return <ResetPasswordForm accountId={account} />;}- Step 3: Commit
git add cms/frontend/src/components/auth/ResetPasswordForm.tsx cms/frontend/src/routes/_auth.reset-password.tsxgit commit -m "feat: password reset page (public) — code verification + new password"Task 20: Frontend build and lint
Abschnitt betitelt „Task 20: Frontend build and lint“- Step 1: Run lint
cd cms/frontend && npm run lint- Step 2: Run build
cd cms/frontend && npm run build-
Step 3: Fix any issues found
-
Step 4: Commit fixes if any
git add -A cms/frontend/git commit -m "fix: resolve frontend lint and build issues"Task 21: Update system documentation
Abschnitt betitelt „Task 21: Update system documentation“Files:
-
Modify:
docs/src/content/docs/implementierung/systemdokumentation.md -
Step 1: Add account management section
Add sections documenting the new features:
-
Account Self-Service (profile, password, sessions)
-
Team Management (create, list, deactivate, reactivate)
-
Password Reset (admin-initiated, code-based)
-
New API endpoints table
-
New frontend routes table
-
New email templates
-
Step 2: Update migration table to include 000004
-
Step 3: Commit
git add docs/src/content/docs/implementierung/systemdokumentation.mdgit commit -m "docs: update Systemdokumentation with account management features"Task 22: Final verification and push
Abschnitt betitelt „Task 22: Final verification and push“- Step 1: Run full backend check
cd cms/backend && make build && make test && make lint- Step 2: Run full frontend check
cd cms/frontend && npm run build && npm run lint- Step 3: Push
git push