Zum Inhalt springen

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


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 # NEW

Task 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 template
INSERT 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 template
INSERT 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 template
INSERT 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 reset
DROP TABLE IF EXISTS traffino.password_reset_history;
DROP TABLE IF EXISTS traffino.password_reset;
-- 2. Remove locale column
ALTER 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
Terminal-Fenster
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
Terminal-Fenster
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
Terminal-Fenster
cd cms/backend && make build
  • Step 3: Commit
Terminal-Fenster
git add cms/backend/internal/feature/email/types.go
git 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
Terminal-Fenster
cd cms/backend && make build
  • Step 4: Commit
Terminal-Fenster
git add cms/backend/internal/feature/auth/request.go cms/backend/internal/feature/auth/usecase.go
git 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
Terminal-Fenster
cd cms/backend && make build
  • Step 7: Commit
Terminal-Fenster
git add cms/backend/internal/feature/auth/repository.go cms/backend/internal/feature/auth/jet_repository.go
git 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
Terminal-Fenster
cd cms/backend && make build
  • Step 5: Commit
Terminal-Fenster
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
Terminal-Fenster
cd cms/backend && make build
  • Step 5: Commit
Terminal-Fenster
git add cms/backend/internal/feature/team/
git commit -m "feat: team feature — repository interfaces and Jet implementations"

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
Terminal-Fenster
cd cms/backend && make build
  • Step 4: Commit
Terminal-Fenster
git add cms/backend/internal/feature/team/request.go cms/backend/internal/feature/team/usecase.go
git commit -m "feat: team use cases — CreateAccount, ListAccounts, Deactivate, Reactivate, PasswordReset"

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
Terminal-Fenster
cd cms/backend && make build
  • Step 3: Commit
Terminal-Fenster
git add cms/backend/internal/usecase/usecase.go
git commit -m "feat: wire Account and Team use cases into UseCases aggregate"

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
Terminal-Fenster
cd cms/backend && make build
  • Step 3: Commit
Terminal-Fenster
git add cms/backend/cmd/http/handlers_account.go
git commit -m "feat: account HTTP handlers — profile, password, sessions"

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
Terminal-Fenster
cd cms/backend && make build
  • Step 3: Commit
Terminal-Fenster
git add cms/backend/cmd/http/handlers_team.go
git commit -m "feat: team HTTP handlers — create, list, deactivate, reactivate, password reset"

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
Terminal-Fenster
cd cms/backend && make build
  • Step 3: Commit
Terminal-Fenster
git add cms/backend/cmd/http/routes.go
git commit -m "feat: register account and team routes"

  • Step 1: Run tests
Terminal-Fenster
cd cms/backend && make test
  • Step 2: Run lint
Terminal-Fenster
cd cms/backend && make lint
  • Step 3: Fix any issues found

  • Step 4: Commit fixes if any

Terminal-Fenster
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 — Profile
export 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 — Password
export const changePasswordRequestSchema = z.object({
old_password: z.string().min(1),
new_password: z.string().min(8),
});
export type ChangePasswordRequest = z.infer<typeof changePasswordRequestSchema>;
// Account — Sessions
export 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 Account
export 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 Accounts
export 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
Terminal-Fenster
git add cms/frontend/src/lib/schemas.ts
git 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
Terminal-Fenster
git add cms/frontend/src/api/account.ts cms/frontend/src/api/team.ts
git 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
Terminal-Fenster
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
Terminal-Fenster
git add cms/frontend/src/components/layout/AppShell.tsx cms/frontend/src/routes/_app.tsx
git 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
Terminal-Fenster
git add cms/frontend/src/components/account/ cms/frontend/src/routes/_app.account.*.tsx
git commit -m "feat: account pages — profile, password, sessions"

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
Terminal-Fenster
git add cms/frontend/src/components/team/ cms/frontend/src/routes/_app.team.*.tsx
git 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
Terminal-Fenster
git add cms/frontend/src/components/auth/ResetPasswordForm.tsx cms/frontend/src/routes/_auth.reset-password.tsx
git commit -m "feat: password reset page (public) — code verification + new password"

  • Step 1: Run lint
Terminal-Fenster
cd cms/frontend && npm run lint
  • Step 2: Run build
Terminal-Fenster
cd cms/frontend && npm run build
  • Step 3: Fix any issues found

  • Step 4: Commit fixes if any

Terminal-Fenster
git add -A cms/frontend/
git commit -m "fix: resolve frontend lint and build issues"

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

Terminal-Fenster
git add docs/src/content/docs/implementierung/systemdokumentation.md
git commit -m "docs: update Systemdokumentation with account management features"

  • Step 1: Run full backend check
Terminal-Fenster
cd cms/backend && make build && make test && make lint
  • Step 2: Run full frontend check
Terminal-Fenster
cd cms/frontend && npm run build && npm run lint
  • Step 3: Push
Terminal-Fenster
git push