From 962ef5a27aa5777dcfb996f4c38b7b333af2197e Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 23:49:53 +0200 Subject: [PATCH 1/8] TEN-05: lifecycle-code aus TEN-04 auf ten-03-basis portiert (registry liest previous_status/deletion_scheduled_at) --- internal/tenant/lifecycle.go | 206 ++++++++++++++++++ internal/tenant/lifecycle_test.go | 253 ++++++++++++++++++++++ internal/tenant/registry.go | 15 +- internal/tenant/tenant.go | 9 + migrations/0003_tenant_lifecycle.down.sql | 2 + migrations/0003_tenant_lifecycle.up.sql | 6 + 6 files changed, 485 insertions(+), 6 deletions(-) create mode 100644 internal/tenant/lifecycle.go create mode 100644 internal/tenant/lifecycle_test.go create mode 100644 migrations/0003_tenant_lifecycle.down.sql create mode 100644 migrations/0003_tenant_lifecycle.up.sql diff --git a/internal/tenant/lifecycle.go b/internal/tenant/lifecycle.go new file mode 100644 index 0000000..82793ad --- /dev/null +++ b/internal/tenant/lifecycle.go @@ -0,0 +1,206 @@ +package tenant + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrTenantNotFound = errors.New("tenant: nicht gefunden") + ErrInvalidTransition = errors.New("tenant: ungueltiger zustandsuebergang") + // ErrTenantNotActive wird von Lifecycle.CheckActive verwendet — bewusst + // EIN Fehler fuer suspendiert/zur-Loeschung-vorgemerkt/geloescht, da der + // Aufrufer (z.B. Login) nur wissen muss "kein Zugriff", nicht welcher der + // Nicht-aktiv-Zustaende genau vorliegt. + ErrTenantNotActive = errors.New("tenant: nicht aktiv") +) + +func scanTenantWithLifecycle(row pgx.Row) (Tenant, error) { + var t Tenant + if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, + &t.CreatedAt, &t.PreviousStatus, &t.DeletionScheduledAt); err != nil { + return Tenant{}, err + } + return t, nil +} + +// transition fuehrt einen bewachten Zustandsuebergang aus: das UPDATE greift +// nur, wenn der aktuelle Status einer von allowedFrom ist (atomarer +// Check-and-Set, kein Race zwischen Lesen und Schreiben). Greift es nicht, +// wird zwischen "Tenant existiert nicht" und "Uebergang nicht erlaubt" +// unterschieden, damit AC1 ("ungueltige Uebergaenge werden abgewiesen") einen +// sprechenden Fehler liefert statt eines stillen No-Ops. +func (r *Registry) transition(ctx context.Context, slug string, allowedFrom []Status, to Status, previousStatus *string, deletionAt *time.Time) (Tenant, error) { + from := make([]string, len(allowedFrom)) + for i, s := range allowedFrom { + from[i] = string(s) + } + + row := r.pool.QueryRow(ctx, ` + UPDATE tenants + SET status = $2, previous_status = $3, deletion_scheduled_at = $4 + WHERE slug = $1 AND status = ANY($5) + RETURNING id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at + `, slug, string(to), previousStatus, deletionAt, from) + + t, err := scanTenantWithLifecycle(row) + if err == nil { + return t, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return Tenant{}, fmt.Errorf("zustandsuebergang: %w", err) + } + + existing, getErr := r.GetBySlug(ctx, slug) + if getErr != nil { + return Tenant{}, ErrTenantNotFound + } + return Tenant{}, fmt.Errorf("%w: von %q nach %q (aktuell: %q)", ErrInvalidTransition, allowedFrom, to, existing.Status) +} + +// Suspend haelt die Daten des Mandanten unveraendert, sperrt aber den Zugriff +// (Akzeptanzkriterium 1) — es findet keine Loeschung/Migration statt. +func (r *Registry) Suspend(ctx context.Context, slug string) (Tenant, error) { + return r.transition(ctx, slug, []Status{StatusActive}, StatusSuspended, nil, nil) +} + +// Reactivate stellt den Zustand vor der Suspendierung vollstaendig wieder her +// (Akzeptanzkriterium 2) — da Suspend keine weiteren Daten veraendert, genuegt +// die Rueckkehr nach StatusActive. +func (r *Registry) Reactivate(ctx context.Context, slug string) (Tenant, error) { + return r.transition(ctx, slug, []Status{StatusSuspended}, StatusActive, nil, nil) +} + +// ScheduleDeletion merkt den Mandanten zur Loeschung vor und startet die +// Karenzzeit (Akzeptanzkriterium 3). previous_status wird festgehalten, damit +// CancelDeletion exakt dorthin zurueckkehren kann (aktiv ODER suspendiert). +func (r *Registry) ScheduleDeletion(ctx context.Context, slug string, grace time.Duration) (Tenant, error) { + existing, err := r.GetBySlug(ctx, slug) + if err != nil { + return Tenant{}, ErrTenantNotFound + } + prev := string(existing.Status) + deletionAt := time.Now().Add(grace) + return r.transition(ctx, slug, []Status{StatusActive, StatusSuspended}, StatusPendingDeletion, &prev, &deletionAt) +} + +// CancelDeletion widerruft eine Loeschvormerkung innerhalb der Karenzzeit und +// stellt exakt den zuvor gesicherten Zustand wieder her. +func (r *Registry) CancelDeletion(ctx context.Context, slug string) (Tenant, error) { + existing, err := r.GetBySlug(ctx, slug) + if err != nil { + return Tenant{}, ErrTenantNotFound + } + if existing.Status != StatusPendingDeletion || existing.PreviousStatus == nil { + return Tenant{}, fmt.Errorf("%w: von %q nach aktiv/suspendiert (aktuell: %q)", ErrInvalidTransition, StatusPendingDeletion, existing.Status) + } + restoreTo := Status(*existing.PreviousStatus) + return r.transition(ctx, slug, []Status{StatusPendingDeletion}, restoreTo, nil, nil) +} + +// Lifecycle fuehrt die tatsaechliche, physische Loeschung nach Ablauf der +// Karenzzeit aus (Datenbank-Drop) und stellt die Zugriffsschutz-Pruefung +// bereit. Getrennt von Registry, weil hierfuer zusaetzlich der adminPool +// (fuer DROP DATABASE) noetig ist, siehe internal/tenant.Provisioner. +type Lifecycle struct { + registry *Registry + adminPool *pgxpool.Pool +} + +func NewLifecycle(registry *Registry, adminPool *pgxpool.Pool) *Lifecycle { + return &Lifecycle{registry: registry, adminPool: adminPool} +} + +// CheckActive verweigert Zugriff fuer jeden Nicht-aktiv-Zustand und loggt den +// Vorgang strukturiert (Akzeptanzkriterium 1 / Pruefung 2). +func (l *Lifecycle) CheckActive(ctx context.Context, slug string) error { + t, err := l.registry.GetBySlug(ctx, slug) + if err != nil { + return ErrTenantNotFound + } + if t.Status != StatusActive { + slog.Warn("zugriff auf nicht-aktiven mandanten verweigert", + "tenant_slug", slug, "tenant_status", t.Status) + return ErrTenantNotActive + } + return nil +} + +// ProcessDueDeletions loescht alle Mandanten-Datenbanken, deren Karenzzeit +// abgelaufen ist (Akzeptanzkriterium 3 / Pruefung 3). FOR UPDATE SKIP LOCKED +// folgt der projektweiten Postgres-Jobqueue-Konvention (siehe +// SKALIERUNGSKONZEPT.md) und macht die Funktion sicher fuer mehrere parallel +// laufende Core-Instanzen. +func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) { + tx, err := l.registry.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("sweep-transaktion starten: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + rows, err := tx.Query(ctx, ` + SELECT id, db_name FROM tenants + WHERE status = $1 AND deletion_scheduled_at <= now() + FOR UPDATE SKIP LOCKED + `, string(StatusPendingDeletion)) + if err != nil { + return 0, fmt.Errorf("faellige loeschungen abfragen: %w", err) + } + + type due struct{ id, dbName string } + var candidates []due + for rows.Next() { + var d due + if err := rows.Scan(&d.id, &d.dbName); err != nil { + rows.Close() + return 0, fmt.Errorf("faellige loeschung lesen: %w", err) + } + candidates = append(candidates, d) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, err + } + + processed := 0 + for _, c := range candidates { + if _, err := l.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, c.dbName)); err != nil { + return processed, fmt.Errorf("tenant-datenbank %q loeschen: %w", c.dbName, err) + } + if _, err := tx.Exec(ctx, ` + UPDATE tenants SET status = $2, previous_status = NULL, deletion_scheduled_at = NULL + WHERE id = $1 + `, c.id, string(StatusDeleted)); err != nil { + return processed, fmt.Errorf("tenant %q als geloescht markieren: %w", c.id, err) + } + processed++ + } + + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("sweep-transaktion committen: %w", err) + } + return processed, nil +} + +// RunSweeper triggert ProcessDueDeletions periodisch, bis ctx beendet wird — +// die "In-Prozess-Worker-Goroutine" aus der projektweiten Jobqueue-Konvention. +func (l *Lifecycle) RunSweeper(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if _, err := l.ProcessDueDeletions(ctx); err != nil { + slog.Error("tenant-loeschung-sweep fehlgeschlagen", "error", err) + } + } + } +} diff --git a/internal/tenant/lifecycle_test.go b/internal/tenant/lifecycle_test.go new file mode 100644 index 0000000..262acaa --- /dev/null +++ b/internal/tenant/lifecycle_test.go @@ -0,0 +1,253 @@ +package tenant + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *pgxpool.Pool, func()) { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + adminPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("admin pool: %v", err) + } + registryPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("registry pool: %v", err) + } + if _, err := registryPool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + db_name TEXT NOT NULL UNIQUE, + db_dsn TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + previous_status TEXT, + deletion_scheduled_at TIMESTAMPTZ + )`); err != nil { + t.Fatalf("registry-schema: %v", err) + } + + registry := NewRegistry(registryPool) + dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1) + provisioner := NewProvisioner(adminPool, registry, dsnTemplate) + lifecycle := NewLifecycle(registry, adminPool) + + cleanup := func() { + registryPool.Close() + adminPool.Close() + } + _ = provisioner + return registry, lifecycle, adminPool, cleanup +} + +func provisionTestTenant(t *testing.T, registry *Registry, adminPool *pgxpool.Pool, slug string) { + t.Helper() + dsnTemplate := strings.Replace(os.Getenv("TEST_ADMIN_DSN"), "/postgres?", "/%s?", 1) + provisioner := NewProvisioner(adminPool, registry, dsnTemplate) + if _, err := provisioner.Provision(context.Background(), slug, slug); err != nil { + t.Fatalf("provision %s: %v", slug, err) + } + t.Cleanup(func() { + ctx := context.Background() + _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug))) + _, _ = registry.pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug) + }) +} + +// Akzeptanzkriterium 1 (Suspend) + 2 (Reactivate) + Pruefung 1 (Uebergaenge). +func TestLifecycle_SuspendAndReactivate(t *testing.T) { + registry, _, adminPool, cleanup := newLifecycleTestSetup(t) + defer cleanup() + provisionTestTenant(t, registry, adminPool, "lc_suspend") + ctx := context.Background() + + suspended, err := registry.Suspend(ctx, "lc_suspend") + if err != nil { + t.Fatalf("suspend: %v", err) + } + if suspended.Status != StatusSuspended { + t.Fatalf("status = %q, want suspended", suspended.Status) + } + + reactivated, err := registry.Reactivate(ctx, "lc_suspend") + if err != nil { + t.Fatalf("reactivate: %v", err) + } + if reactivated.Status != StatusActive { + t.Fatalf("status = %q, want active", reactivated.Status) + } +} + +// Pruefung 1: ungueltige Uebergaenge werden abgewiesen. +func TestLifecycle_RejectsInvalidTransitions(t *testing.T) { + registry, _, adminPool, cleanup := newLifecycleTestSetup(t) + defer cleanup() + provisionTestTenant(t, registry, adminPool, "lc_invalid") + ctx := context.Background() + + // Reactivate auf einem bereits aktiven Tenant ist kein gueltiger Uebergang. + if _, err := registry.Reactivate(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("erwartet ErrInvalidTransition, habe %v", err) + } + + if _, err := registry.Suspend(ctx, "lc_invalid"); err != nil { + t.Fatalf("suspend: %v", err) + } + // Suspend auf einem bereits suspendierten Tenant ist ebenfalls ungueltig. + if _, err := registry.Suspend(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("erwartet ErrInvalidTransition, habe %v", err) + } + + // CancelDeletion ohne vorherige Loeschvormerkung ist ungueltig. + if _, err := registry.CancelDeletion(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("erwartet ErrInvalidTransition, habe %v", err) + } + + if _, err := registry.Suspend(ctx, "unbekannter-slug-xyz"); !errors.Is(err, ErrTenantNotFound) { + t.Fatalf("erwartet ErrTenantNotFound, habe %v", err) + } +} + +// Akzeptanzkriterium 3: Loeschung zweistufig mit Karenzzeit, innerhalb der +// Frist widerrufbar — sowohl aus 'active' als auch aus 'suspended' heraus, +// mit exakter Wiederherstellung des jeweiligen Vorzustands. +func TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState(t *testing.T) { + registry, _, adminPool, cleanup := newLifecycleTestSetup(t) + defer cleanup() + provisionTestTenant(t, registry, adminPool, "lc_cancel_active") + provisionTestTenant(t, registry, adminPool, "lc_cancel_suspended") + ctx := context.Background() + + // Fall 1: aus 'active' heraus vorgemerkt und widerrufen. + scheduled, err := registry.ScheduleDeletion(ctx, "lc_cancel_active", time.Hour) + if err != nil { + t.Fatalf("schedule deletion: %v", err) + } + if scheduled.Status != StatusPendingDeletion { + t.Fatalf("status = %q, want pending_deletion", scheduled.Status) + } + if scheduled.DeletionScheduledAt == nil { + t.Fatal("erwartet gesetzte deletion_scheduled_at") + } + + restored, err := registry.CancelDeletion(ctx, "lc_cancel_active") + if err != nil { + t.Fatalf("cancel deletion: %v", err) + } + if restored.Status != StatusActive { + t.Fatalf("status = %q, want active (vorheriger zustand)", restored.Status) + } + + // Fall 2: aus 'suspended' heraus vorgemerkt und widerrufen — muss zu + // 'suspended' zurueckkehren, NICHT zu 'active'. + if _, err := registry.Suspend(ctx, "lc_cancel_suspended"); err != nil { + t.Fatalf("suspend: %v", err) + } + if _, err := registry.ScheduleDeletion(ctx, "lc_cancel_suspended", time.Hour); err != nil { + t.Fatalf("schedule deletion: %v", err) + } + restoredSuspended, err := registry.CancelDeletion(ctx, "lc_cancel_suspended") + if err != nil { + t.Fatalf("cancel deletion: %v", err) + } + if restoredSuspended.Status != StatusSuspended { + t.Fatalf("status = %q, want suspended (vorheriger zustand)", restoredSuspended.Status) + } +} + +// Akzeptanzkriterium 1 + Pruefung 2: suspendierter Tenant erzeugt bei jedem +// Zugriffsversuch einen klaren Fehler. +func TestLifecycle_CheckActive_RejectsNonActive(t *testing.T) { + registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t) + defer cleanup() + provisionTestTenant(t, registry, adminPool, "lc_checkactive") + ctx := context.Background() + + if err := lifecycle.CheckActive(ctx, "lc_checkactive"); err != nil { + t.Fatalf("aktiver tenant sollte durchgehen, habe %v", err) + } + + if _, err := registry.Suspend(ctx, "lc_checkactive"); err != nil { + t.Fatalf("suspend: %v", err) + } + + for i := 0; i < 3; i++ { + if err := lifecycle.CheckActive(ctx, "lc_checkactive"); !errors.Is(err, ErrTenantNotActive) { + t.Fatalf("versuch %d: erwartet ErrTenantNotActive, habe %v", i, err) + } + } + + if err := lifecycle.CheckActive(ctx, "nie-registriert"); !errors.Is(err, ErrTenantNotFound) { + t.Fatalf("erwartet ErrTenantNotFound, habe %v", err) + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: Loeschvorgang nach Ablauf der Karenzzeit +// automatisch ausgeloest (hier durch direkten Aufruf von ProcessDueDeletions, +// das RunSweeper periodisch aufruft). +func TestLifecycle_ProcessDueDeletions(t *testing.T) { + registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t) + defer cleanup() + provisionTestTenant(t, registry, adminPool, "lc_due") + provisionTestTenant(t, registry, adminPool, "lc_not_due") + ctx := context.Background() + + // lc_due: Karenzzeit liegt bereits in der Vergangenheit -> faellig. + if _, err := registry.ScheduleDeletion(ctx, "lc_due", -time.Minute); err != nil { + t.Fatalf("schedule deletion (due): %v", err) + } + // lc_not_due: Karenzzeit liegt weit in der Zukunft -> nicht faellig. + if _, err := registry.ScheduleDeletion(ctx, "lc_not_due", time.Hour); err != nil { + t.Fatalf("schedule deletion (not due): %v", err) + } + + processed, err := lifecycle.ProcessDueDeletions(ctx) + if err != nil { + t.Fatalf("process due deletions: %v", err) + } + if processed != 1 { + t.Fatalf("erwartet genau 1 verarbeitete loeschung, habe %d", processed) + } + + due, err := registry.GetBySlug(ctx, "lc_due") + if err != nil { + t.Fatalf("get lc_due: %v", err) + } + if due.Status != StatusDeleted { + t.Fatalf("lc_due status = %q, want deleted", due.Status) + } + + notDue, err := registry.GetBySlug(ctx, "lc_not_due") + if err != nil { + t.Fatalf("get lc_not_due: %v", err) + } + if notDue.Status != StatusPendingDeletion { + t.Fatalf("lc_not_due status = %q, want pending_deletion (noch nicht faellig)", notDue.Status) + } + + // Datenbank von lc_due wurde tatsaechlich physisch entfernt. + var exists bool + if err := adminPool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`, + dbNameForSlug("lc_due")).Scan(&exists); err != nil { + t.Fatalf("pg_database pruefen: %v", err) + } + if exists { + t.Fatal("erwartet, dass die tenant-datenbank von lc_due geloescht wurde") + } +} diff --git a/internal/tenant/registry.go b/internal/tenant/registry.go index 74440b1..0af0c2c 100644 --- a/internal/tenant/registry.go +++ b/internal/tenant/registry.go @@ -35,13 +35,16 @@ func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, e } func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) { - var t Tenant + // previous_status/deletion_scheduled_at werden mitgelesen, damit TEN-04 + // (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand + // ueber GetBySlug ansehen kann, statt eine eigene Abfrage zu duplizieren. row := r.pool.QueryRow(ctx, ` - SELECT id, slug, name, db_name, db_dsn, status, created_at + SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at FROM tenants WHERE slug = $1 `, slug) - if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil { + t, err := scanTenantWithLifecycle(row) + if err != nil { return Tenant{}, fmt.Errorf("tenant laden: %w", err) } return t, nil @@ -59,7 +62,7 @@ func (r *Registry) Delete(ctx context.Context, id string) error { func (r *Registry) List(ctx context.Context) ([]Tenant, error) { rows, err := r.pool.Query(ctx, ` - SELECT id, slug, name, db_name, db_dsn, status, created_at + SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at FROM tenants ORDER BY created_at `) if err != nil { @@ -69,8 +72,8 @@ func (r *Registry) List(ctx context.Context) ([]Tenant, error) { var out []Tenant for rows.Next() { - var t Tenant - if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil { + t, err := scanTenantWithLifecycle(rows) + if err != nil { return nil, fmt.Errorf("tenant lesen: %w", err) } out = append(out, t) diff --git a/internal/tenant/tenant.go b/internal/tenant/tenant.go index 698566e..9778bc8 100644 --- a/internal/tenant/tenant.go +++ b/internal/tenant/tenant.go @@ -12,6 +12,10 @@ type Status string const ( StatusActive Status = "active" + // Lebenszyklus-Zustaende aus TEN-04 (siehe internal/tenant/lifecycle.go). + StatusSuspended Status = "suspended" + StatusPendingDeletion Status = "pending_deletion" + StatusDeleted Status = "deleted" ) type Tenant struct { @@ -22,6 +26,11 @@ type Tenant struct { DBDSN string Status Status CreatedAt time.Time + // PreviousStatus und DeletionScheduledAt sind nur waehrend + // StatusPendingDeletion gesetzt (TEN-04) — sie halten fest, in welchen + // Zustand CancelDeletion zurueckkehrt und wann die Karenzzeit ablaeuft. + PreviousStatus *string + DeletionScheduledAt *time.Time } // slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit diff --git a/migrations/0003_tenant_lifecycle.down.sql b/migrations/0003_tenant_lifecycle.down.sql new file mode 100644 index 0000000..43c6130 --- /dev/null +++ b/migrations/0003_tenant_lifecycle.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE tenants DROP COLUMN previous_status; +ALTER TABLE tenants DROP COLUMN deletion_scheduled_at; diff --git a/migrations/0003_tenant_lifecycle.up.sql b/migrations/0003_tenant_lifecycle.up.sql new file mode 100644 index 0000000..141c116 --- /dev/null +++ b/migrations/0003_tenant_lifecycle.up.sql @@ -0,0 +1,6 @@ +-- Lebenszyklus-Zustaende fuer Mandanten (TEN-04, siehe core-kanban/tickets/TEN-04.md). +-- previous_status haelt den Zustand VOR einer Loeschvormerkung, damit +-- CancelDeletion "den vorherigen Zustand vollstaendig wiederherstellt" +-- (aktiv ODER suspendiert), statt hart auf 'active' zurueckzusetzen. +ALTER TABLE tenants ADD COLUMN previous_status TEXT; +ALTER TABLE tenants ADD COLUMN deletion_scheduled_at TIMESTAMPTZ; From 11ed3e37900cb89262e62c1c82192662e684f614 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 23:58:04 +0200 Subject: [PATCH 2/8] TEN-05: backend-api + dev-server + next.js tenant-verwaltungsoberflaeche --- cmd/tenantadmin-devserver/main.go | 67 +++++ internal/tenantadmin/tenantadmin.go | 298 +++++++++++++++++++++++ internal/tenantadmin/tenantadmin_test.go | 232 ++++++++++++++++++ web/tenant-admin/app/layout.tsx | 17 ++ web/tenant-admin/app/page.tsx | 225 +++++++++++++++++ web/tenant-admin/lib/api.ts | 106 ++++++++ web/tenant-admin/next.config.mjs | 3 + web/tenant-admin/package.json | 21 ++ web/tenant-admin/tsconfig.json | 21 ++ 9 files changed, 990 insertions(+) create mode 100644 cmd/tenantadmin-devserver/main.go create mode 100644 internal/tenantadmin/tenantadmin.go create mode 100644 internal/tenantadmin/tenantadmin_test.go create mode 100644 web/tenant-admin/app/layout.tsx create mode 100644 web/tenant-admin/app/page.tsx create mode 100644 web/tenant-admin/lib/api.ts create mode 100644 web/tenant-admin/next.config.mjs create mode 100644 web/tenant-admin/package.json create mode 100644 web/tenant-admin/tsconfig.json diff --git a/cmd/tenantadmin-devserver/main.go b/cmd/tenantadmin-devserver/main.go new file mode 100644 index 0000000..b20afc0 --- /dev/null +++ b/cmd/tenantadmin-devserver/main.go @@ -0,0 +1,67 @@ +// tenantadmin-devserver stellt das TEN-05-Backend-API (internal/tenantadmin) +// fuer die Next.js-Tenant-Verwaltungsoberflaeche bereit. Getrennt von +// cmd/core aus demselben Grund wie cmd/licadmin-devserver (siehe LIC-04): +// echte Auth (IAM-01/IAM-02) ist noch nicht in die zentrale Server-Topologie +// verdrahtet, dieser Server dient Entwicklung/Betrieb der Oberflaeche gegen +// eine echte Datenbank, ohne cmd/core anzufassen. +package main + +import ( + "context" + "log" + "net/http" + "os" + + "gitea.perlbach24.de/scripte/nexarch/internal/db" + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantadmin" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +func main() { + dsn := os.Getenv("NEXARCH_REGISTRY_DSN") + if dsn == "" { + log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt") + } + addr := os.Getenv("NEXARCH_TENANTADMIN_LISTEN_ADDR") + if addr == "" { + addr = ":8082" + } + + ctx := context.Background() + pool, err := db.Connect(ctx, dsn) + if err != nil { + log.Fatalf("db: %v", err) + } + defer pool.Close() + + registry := tenant.NewRegistry(pool) + lifecycle := tenant.NewLifecycle(registry, pool) + settingsStore := tenantsettings.NewStore(pool) + superadmins := user.NewSuperadminStore(pool) + handler := tenantadmin.NewHandler(registry, lifecycle, settingsStore, superadmins) + + mux := http.NewServeMux() + mux.HandleFunc("/admin/tenants", withCORS(handler.ListTenantsHandler)) + mux.HandleFunc("/admin/tenants/detail", withCORS(handler.TenantDetailHandler)) + mux.HandleFunc("/admin/tenants/settings", withCORS(handler.UpdateSettingsHandler)) + mux.HandleFunc("/admin/tenants/lifecycle", withCORS(handler.LifecycleActionHandler)) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + log.Printf("tenantadmin-devserver listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +func withCORS(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + next(w, r) + } +} diff --git a/internal/tenantadmin/tenantadmin.go b/internal/tenantadmin/tenantadmin.go new file mode 100644 index 0000000..1aa4af2 --- /dev/null +++ b/internal/tenantadmin/tenantadmin.go @@ -0,0 +1,298 @@ +// Package tenantadmin implementiert Core TEN-05: das Backend-API fuer die +// Tenant-Verwaltungsoberflaeche. Enthaelt KEINE eigene Provisioning-/ +// Lifecycle-/Settings-Logik, sondern ist ein duenner Vermittler ueber +// internal/tenant (TEN-01/TEN-04), internal/tenantsettings (TEN-03) und +// internal/user.SuperadminStore (Berechtigungspruefung) — Ticket-Vorgabe: +// "Verwaltungsoberflaeche im Stil einer schlanken Zitadel-Console". +package tenantadmin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +// ErrNotSuperadmin wird geliefert, wenn der Aufrufer keine aktive +// Superadmin-Identitaet hat (Akzeptanzkriterium 1 / Pruefung 1: die +// Oberflaeche zeigt Mandanten nur berechtigten Superadmins). +var ErrNotSuperadmin = errors.New("tenantadmin: aufrufer ist kein aktiver superadmin") + +// ErrMissingDisplayName wird geliefert, wenn ein Einstellungs-Update ohne +// das Pflichtfeld Anzeigename versucht wird (Akzeptanzkriterium 2 / Pruefung 2). +var ErrMissingDisplayName = errors.New("tenantadmin: anzeigename ist ein pflichtfeld") + +type Handler struct { + registry *tenant.Registry + lifecycle *tenant.Lifecycle + settings *tenantsettings.Store + superadmins *user.SuperadminStore +} + +func NewHandler(registry *tenant.Registry, lifecycle *tenant.Lifecycle, settings *tenantsettings.Store, superadmins *user.SuperadminStore) *Handler { + return &Handler{registry: registry, lifecycle: lifecycle, settings: settings, superadmins: superadmins} +} + +// requireSuperadmin prueft, dass der Aufrufer ein EXISTIERENDER, AKTIVER +// Superadmin ist — es gibt (bewusst, siehe internal/user.SuperadminStore) +// keine Tenant-Scoping-Dimension fuer Superadmins: wer ueberhaupt Zugriff +// hat, sieht alle Mandanten. Alles andere wird abgelehnt, bevor irgendeine +// Mandantendatei gelesen wird (Fail-Safe-Default). +func (h *Handler) requireSuperadmin(ctx context.Context, superadminID string) error { + if superadminID == "" { + return ErrNotSuperadmin + } + admin, err := h.superadmins.Get(ctx, superadminID) + if err != nil { + return ErrNotSuperadmin + } + if admin.Status != user.StatusActive { + return ErrNotSuperadmin + } + return nil +} + +// TenantListItem ist die fuer die Uebersichtsliste relevante Projektion +// (Akzeptanzkriterium 1). +type TenantListItem struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` +} + +// ListTenants liefert alle Mandanten, optional gefiltert nach Suchbegriff +// (Slug/Name, Teilstring, case-insensitive) und Status — beides serverseitig, +// damit die Oberflaeche nicht selbst ueber unautorisierte Datensaetze +// filtern muss (Akzeptanzkriterium 1: Suche und Filter). +func (h *Handler) ListTenants(ctx context.Context, superadminID, search, statusFilter string) ([]TenantListItem, error) { + if err := h.requireSuperadmin(ctx, superadminID); err != nil { + return nil, err + } + + all, err := h.registry.List(ctx) + if err != nil { + return nil, fmt.Errorf("mandanten auflisten: %w", err) + } + + search = strings.ToLower(strings.TrimSpace(search)) + out := make([]TenantListItem, 0, len(all)) + for _, t := range all { + if statusFilter != "" && string(t.Status) != statusFilter { + continue + } + if search != "" && !strings.Contains(strings.ToLower(t.Slug), search) && !strings.Contains(strings.ToLower(t.Name), search) { + continue + } + out = append(out, TenantListItem{ID: t.ID, Slug: t.Slug, Name: t.Name, Status: string(t.Status)}) + } + return out, nil +} + +// TenantDetail buendelt Stammdaten und Einstellungen fuer die Detailansicht. +type TenantDetail struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` + Settings tenantsettings.Settings `json:"settings"` +} + +func (h *Handler) GetTenantDetail(ctx context.Context, superadminID, slug string) (TenantDetail, error) { + if err := h.requireSuperadmin(ctx, superadminID); err != nil { + return TenantDetail{}, err + } + t, err := h.registry.GetBySlug(ctx, slug) + if err != nil { + return TenantDetail{}, fmt.Errorf("mandant laden: %w", err) + } + s, err := h.settings.Get(ctx, t.ID) + if err != nil { + return TenantDetail{}, fmt.Errorf("einstellungen laden: %w", err) + } + return TenantDetail{ID: t.ID, Slug: t.Slug, Name: t.Name, Status: string(t.Status), Settings: s}, nil +} + +// SettingsPatch ist die vom Formular gesendete Aenderung. DisplayName ist +// KEIN Zeiger, weil es Pflichtfeld ist (Akzeptanzkriterium 2) — die anderen +// Felder bleiben optional (Zeiger = "unveraendert lassen", siehe +// tenantsettings.Patch). +type SettingsPatch struct { + DisplayName string + LogoURL *string + ColorScheme *string + Timezone *string + Language *string +} + +// UpdateSettings validiert das Pflichtfeld Anzeigename, BEVOR irgendein +// Schreibzugriff erfolgt (Akzeptanzkriterium 2 / Pruefung 2: unvollstaendige +// Pflichtfelder werden serverseitig verhindert, nicht nur clientseitig). +func (h *Handler) UpdateSettings(ctx context.Context, superadminID, slug string, patch SettingsPatch) (tenantsettings.Settings, error) { + if err := h.requireSuperadmin(ctx, superadminID); err != nil { + return tenantsettings.Settings{}, err + } + if strings.TrimSpace(patch.DisplayName) == "" { + return tenantsettings.Settings{}, ErrMissingDisplayName + } + + t, err := h.registry.GetBySlug(ctx, slug) + if err != nil { + return tenantsettings.Settings{}, fmt.Errorf("mandant laden: %w", err) + } + + displayName := patch.DisplayName + return h.settings.Update(ctx, t.ID, tenantsettings.Patch{ + DisplayName: &displayName, + LogoURL: patch.LogoURL, + ColorScheme: patch.ColorScheme, + Timezone: patch.Timezone, + Language: patch.Language, + }) +} + +// LifecycleAction sind die von der Oberflaeche ausloesbaren Aktionen +// (Akzeptanzkriterium 3) — je EIN Wort pro Aktion, damit ein Bestaetigungs- +// dialog im Frontend darauf verzweigen kann, ohne HTTP-Interna zu kennen. +type LifecycleAction string + +const ( + ActionSuspend LifecycleAction = "suspend" + ActionReactivate LifecycleAction = "reactivate" + ActionScheduleDeletion LifecycleAction = "schedule_deletion" + ActionCancelDeletion LifecycleAction = "cancel_deletion" +) + +var ErrUnknownAction = errors.New("tenantadmin: unbekannte lifecycle-aktion") + +// DefaultDeletionGracePeriod ist die Karenzzeit, die die Oberflaeche beim +// Ausloesen von ActionScheduleDeletion verwendet — Konfiguration dieses +// Pakets, nicht von internal/tenant (das lifecycle.go generisch mit einer +// uebergebenen Dauer arbeitet, siehe TEN-04). +const DefaultDeletionGracePeriod = 30 * 24 * time.Hour + +// PerformLifecycleAction fuehrt EINEN der vier Uebergaenge aus. Ungueltige +// Zustandsuebergaenge (z.B. "suspend" auf einen bereits geloeschten Tenant) +// werden von internal/tenant.Lifecycle selbst mit ErrInvalidTransition +// abgelehnt (siehe TEN-04) — dieses Paket dupliziert diese Pruefung nicht. +func (h *Handler) PerformLifecycleAction(ctx context.Context, superadminID, slug string, action LifecycleAction) (tenant.Tenant, error) { + if err := h.requireSuperadmin(ctx, superadminID); err != nil { + return tenant.Tenant{}, err + } + + switch action { + case ActionSuspend: + return h.registry.Suspend(ctx, slug) + case ActionReactivate: + return h.registry.Reactivate(ctx, slug) + case ActionScheduleDeletion: + return h.registry.ScheduleDeletion(ctx, slug, DefaultDeletionGracePeriod) + case ActionCancelDeletion: + return h.registry.CancelDeletion(ctx, slug) + default: + return tenant.Tenant{}, ErrUnknownAction + } +} + +// --- HTTP-Bindung --- +// +// Der Aufrufer wird bewusst als expliziter Query-/Body-Parameter +// "superadmin" statt aus einem Auth-Header gelesen — Session-/Token-basierte +// Authentifizierung ist Sache von IAM-01/IAM-02 und wird hier NICHT +// dupliziert (Kein Umbau angrenzender Bereiche); dieser Handler ist ein +// duenner Entwicklungs-/Testzugang, der genau die in requireSuperadmin +// beschriebene Berechtigungspruefung durchsetzt. + +func (h *Handler) ListTenantsHandler(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + items, err := h.ListTenants(r.Context(), q.Get("superadmin"), q.Get("search"), q.Get("status")) + writeResult(w, items, err) +} + +func (h *Handler) TenantDetailHandler(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + detail, err := h.GetTenantDetail(r.Context(), q.Get("superadmin"), q.Get("slug")) + writeResult(w, detail, err) +} + +type settingsRequest struct { + Superadmin string `json:"superadmin"` + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + LogoURL *string `json:"logo_url"` + ColorScheme *string `json:"color_scheme"` + Timezone *string `json:"timezone"` + Language *string `json:"language"` +} + +func (h *Handler) UpdateSettingsHandler(w http.ResponseWriter, r *http.Request) { + var req settingsRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper") + return + } + result, err := h.UpdateSettings(r.Context(), req.Superadmin, req.Slug, SettingsPatch{ + DisplayName: req.DisplayName, + LogoURL: req.LogoURL, + ColorScheme: req.ColorScheme, + Timezone: req.Timezone, + Language: req.Language, + }) + writeResult(w, result, err) +} + +type lifecycleRequest struct { + Superadmin string `json:"superadmin"` + Slug string `json:"slug"` + Action LifecycleAction `json:"action"` +} + +func (h *Handler) LifecycleActionHandler(w http.ResponseWriter, r *http.Request) { + var req lifecycleRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper") + return + } + result, err := h.PerformLifecycleAction(r.Context(), req.Superadmin, req.Slug, req.Action) + writeResult(w, result, err) +} + +func decodeJSON(r *http.Request, v any) error { + return json.NewDecoder(r.Body).Decode(v) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func writeResult(w http.ResponseWriter, body any, err error) { + if err == nil { + writeJSON(w, http.StatusOK, body) + return + } + switch { + case errors.Is(err, ErrNotSuperadmin): + writeError(w, http.StatusForbidden, err.Error()) + case errors.Is(err, ErrMissingDisplayName): + writeError(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, ErrUnknownAction), errors.Is(err, tenant.ErrInvalidTransition): + writeError(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, tenant.ErrTenantNotFound): + writeError(w, http.StatusNotFound, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } +} diff --git a/internal/tenantadmin/tenantadmin_test.go b/internal/tenantadmin/tenantadmin_test.go new file mode 100644 index 0000000..65ee50f --- /dev/null +++ b/internal/tenantadmin/tenantadmin_test.go @@ -0,0 +1,232 @@ +package tenantadmin + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +type testEnv struct { + handler *Handler + superadminID string + slug string + adminPool *pgxpool.Pool + registry *tenant.Registry +} + +func setupTest(t *testing.T) (testEnv, func()) { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + registryPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("registry pool: %v", err) + } + adminPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("admin pool: %v", err) + } + + if _, err := registryPool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), previous_status TEXT, deletion_scheduled_at TIMESTAMPTZ + ); + CREATE TABLE IF NOT EXISTS tenant_settings ( + tenant_id UUID PRIMARY KEY REFERENCES tenants(id), display_name TEXT, logo_url TEXT, + color_scheme TEXT, timezone TEXT, language TEXT, version INT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS tenant_settings_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, display_name TEXT, + logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT, version INT NOT NULL, + changed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS superadmins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + registry := tenant.NewRegistry(registryPool) + dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1) + provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate) + lifecycle := tenant.NewLifecycle(registry, adminPool) + settingsStore := tenantsettings.NewStore(registryPool) + superadmins := user.NewSuperadminStore(registryPool) + + slug := fmt.Sprintf("tadm_%d", time.Now().UnixNano()%1_000_000_000) + if _, err := provisioner.Provision(ctx, slug, "Test Mandant "+slug); err != nil { + t.Fatalf("provision: %v", err) + } + + admin, err := superadmins.Create(ctx, fmt.Sprintf("admin-%d@example.com", time.Now().UnixNano()), "Test Superadmin") + if err != nil { + t.Fatalf("superadmin anlegen: %v", err) + } + + handler := NewHandler(registry, lifecycle, settingsStore, superadmins) + + cleanup := func() { + _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug)) + _, _ = registryPool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN (SELECT id FROM tenants WHERE slug = $1)`, slug) + _, _ = registryPool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN (SELECT id FROM tenants WHERE slug = $1)`, slug) + _, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug) + _, _ = registryPool.Exec(ctx, `DELETE FROM superadmins WHERE id = $1`, admin.ID) + registryPool.Close() + adminPool.Close() + } + return testEnv{handler: handler, superadminID: admin.ID, slug: slug, adminPool: adminPool, registry: registry}, cleanup +} + +// Akzeptanzkriterium 1 + Pruefung 1: nur berechtigte (aktive) Superadmins +// sehen die Mandantenliste ueberhaupt. +func TestListTenants_RejectsNonSuperadmin(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + _, err := env.handler.ListTenants(ctx, "irgendeine-nicht-existierende-id", "", "") + if !errors.Is(err, ErrNotSuperadmin) { + t.Fatalf("erwartet ErrNotSuperadmin, habe: %v", err) + } +} + +func TestListTenants_ShowsSearchAndStatusFilterResults(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + all, err := env.handler.ListTenants(ctx, env.superadminID, "", "") + if err != nil { + t.Fatalf("list: %v", err) + } + found := false + for _, item := range all { + if item.Slug == env.slug { + found = true + if item.Status != "active" { + t.Fatalf("status = %q, want active", item.Status) + } + } + } + if !found { + t.Fatal("erwartet test-tenant in ungefilterter liste") + } + + bySearch, err := env.handler.ListTenants(ctx, env.superadminID, env.slug, "") + if err != nil { + t.Fatalf("list mit suche: %v", err) + } + if len(bySearch) != 1 || bySearch[0].Slug != env.slug { + t.Fatalf("suche nach slug lieferte unerwartetes ergebnis: %+v", bySearch) + } + + byWrongStatus, err := env.handler.ListTenants(ctx, env.superadminID, env.slug, "suspended") + if err != nil { + t.Fatalf("list mit statusfilter: %v", err) + } + if len(byWrongStatus) != 0 { + t.Fatalf("statusfilter haette test-tenant (status=active) ausfiltern muessen, habe: %+v", byWrongStatus) + } +} + +// Akzeptanzkriterium 2 + Pruefung 2: fehlender Pflichtwert (Anzeigename) +// wird serverseitig abgelehnt, bevor etwas gespeichert wird. +func TestUpdateSettings_RejectsMissingDisplayName(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + _, err := env.handler.UpdateSettings(ctx, env.superadminID, env.slug, SettingsPatch{DisplayName: " "}) + if !errors.Is(err, ErrMissingDisplayName) { + t.Fatalf("erwartet ErrMissingDisplayName, habe: %v", err) + } +} + +func TestUpdateSettings_AppliesValidPatch(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + updated, err := env.handler.UpdateSettings(ctx, env.superadminID, env.slug, SettingsPatch{DisplayName: "Neuer Name"}) + if err != nil { + t.Fatalf("update: %v", err) + } + if updated.DisplayName != "Neuer Name" { + t.Fatalf("displayname = %q, want 'Neuer Name'", updated.DisplayName) + } + + detail, err := env.handler.GetTenantDetail(ctx, env.superadminID, env.slug) + if err != nil { + t.Fatalf("detail: %v", err) + } + if detail.Settings.DisplayName != "Neuer Name" { + t.Fatalf("detail zeigt nicht den aktualisierten namen: %+v", detail.Settings) + } +} + +// Akzeptanzkriterium 3: Lifecycle-Aktionen sind ausloesbar und wirken sich +// auf den tatsaechlichen Mandantenstatus aus. +func TestPerformLifecycleAction_SuspendAndReactivate(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + suspended, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionSuspend) + if err != nil { + t.Fatalf("suspend: %v", err) + } + if suspended.Status != tenant.StatusSuspended { + t.Fatalf("status = %q, want suspended", suspended.Status) + } + + reactivated, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionReactivate) + if err != nil { + t.Fatalf("reactivate: %v", err) + } + if reactivated.Status != tenant.StatusActive { + t.Fatalf("status = %q, want active", reactivated.Status) + } +} + +func TestPerformLifecycleAction_RejectsInvalidTransition(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + // Reaktivieren eines bereits aktiven Tenants ist kein gueltiger Uebergang. + _, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionReactivate) + if !errors.Is(err, tenant.ErrInvalidTransition) { + t.Fatalf("erwartet ErrInvalidTransition, habe: %v", err) + } +} + +func TestPerformLifecycleAction_RejectsNonSuperadmin(t *testing.T) { + env, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + _, err := env.handler.PerformLifecycleAction(ctx, "keine-berechtigung", env.slug, ActionSuspend) + if !errors.Is(err, ErrNotSuperadmin) { + t.Fatalf("erwartet ErrNotSuperadmin, habe: %v", err) + } +} diff --git a/web/tenant-admin/app/layout.tsx b/web/tenant-admin/app/layout.tsx new file mode 100644 index 0000000..fbcda0b --- /dev/null +++ b/web/tenant-admin/app/layout.tsx @@ -0,0 +1,17 @@ +export const metadata = { + title: "NEXARCH Mandantenverwaltung", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/web/tenant-admin/app/page.tsx b/web/tenant-admin/app/page.tsx new file mode 100644 index 0000000..5a6e57f --- /dev/null +++ b/web/tenant-admin/app/page.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useState } from "react"; +import { + fetchTenants, + fetchTenantDetail, + updateSettings, + performLifecycleAction, + type TenantListItem, + type TenantDetail, + type LifecycleAction, +} from "@/lib/api"; + +const ACTION_LABEL: Record = { + suspend: "Suspendieren", + reactivate: "Reaktivieren", + schedule_deletion: "Löschung vormerken", + cancel_deletion: "Löschung abbrechen", +}; + +const ACTION_CONFIRM: Record = { + suspend: "Mandant wirklich suspendieren? Benutzer können sich danach nicht mehr anmelden.", + reactivate: "Mandant wirklich reaktivieren?", + schedule_deletion: + "Mandant wirklich zur Löschung vormerken? Nach der Karenzzeit wird er unwiderruflich gelöscht.", + cancel_deletion: "Vorgemerkte Löschung wirklich abbrechen?", +}; + +export default function Page() { + const [superadminId, setSuperadminId] = useState(""); + const [search, setSearch] = useState(""); + const [status, setStatus] = useState(""); + const [tenants, setTenants] = useState(null); + const [detail, setDetail] = useState(null); + const [error, setError] = useState(null); + const [formError, setFormError] = useState(null); + + async function loadList() { + setError(null); + try { + const items = await fetchTenants(superadminId.trim(), search.trim(), status); + setTenants(items); + } catch (e: any) { + setError(e.message ?? "Unbekannter Fehler"); + setTenants(null); + } + } + + async function openDetail(slug: string) { + setError(null); + try { + const d = await fetchTenantDetail(superadminId.trim(), slug); + setDetail(d); + } catch (e: any) { + setError(e.message ?? "Unbekannter Fehler"); + } + } + + async function onSubmitSettings(e: React.FormEvent) { + e.preventDefault(); + if (!detail) return; + setFormError(null); + + const form = new FormData(e.currentTarget); + const displayName = String(form.get("displayName") ?? "").trim(); + if (!displayName) { + setFormError("Anzeigename ist ein Pflichtfeld."); + return; + } + + try { + await updateSettings( + superadminId.trim(), + detail.slug, + displayName, + String(form.get("colorScheme") ?? ""), + String(form.get("timezone") ?? ""), + String(form.get("language") ?? "") + ); + await openDetail(detail.slug); + } catch (e: any) { + setFormError(e.message ?? "Speichern fehlgeschlagen"); + } + } + + async function onLifecycleAction(action: LifecycleAction) { + if (!detail) return; + if (!window.confirm(ACTION_CONFIRM[action])) return; + setError(null); + try { + await performLifecycleAction(superadminId.trim(), detail.slug, action); + await openDetail(detail.slug); + await loadList(); + } catch (e: any) { + setError(e.message ?? "Aktion fehlgeschlagen"); + } + } + + return ( +
+

Mandantenverwaltung

+ +
+ setSuperadminId(e.target.value)} + placeholder="Superadmin-ID" + style={{ padding: "0.5rem" }} + /> + setSearch(e.target.value)} + placeholder="Suche (Slug/Name)" + style={{ padding: "0.5rem" }} + /> + + +
+ + {error && ( +

+ Fehler: {error} +

+ )} + + {tenants && ( + + + + + + + + + + + {tenants.map((t) => ( + + + + + + + ))} + {tenants.length === 0 && ( + + + + )} + +
SlugNameStatus
{t.slug}{t.name}{t.status} + +
+ Keine Mandanten gefunden. +
+ )} + + {detail && ( +
+

+ {detail.name} ({detail.slug}) — Status: {detail.status} +

+ +
+ {formError && ( +

+ {formError} +

+ )} + + + + + +
+ +
+ {(Object.keys(ACTION_LABEL) as LifecycleAction[]).map((action) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/web/tenant-admin/lib/api.ts b/web/tenant-admin/lib/api.ts new file mode 100644 index 0000000..b1e0ba6 --- /dev/null +++ b/web/tenant-admin/lib/api.ts @@ -0,0 +1,106 @@ +// Duenner Client fuer das TEN-05-Backend-API (internal/tenantadmin) — keine +// eigene Provisioning-/Lifecycle-/Validierungslogik ausser der Pflichtfeld- +// Vorpruefung im Formular (Akzeptanzkriterium 2), die zusaetzlich serverseitig +// durchgesetzt wird. +export type TenantListItem = { + id: string; + slug: string; + name: string; + status: string; +}; + +export type Settings = { + DisplayName: string; + LogoURL: string; + ColorScheme: string; + Timezone: string; + Language: string; + Version: number; +}; + +export type TenantDetail = { + id: string; + slug: string; + name: string; + status: string; + settings: Settings; +}; + +function apiBase(): string { + const base = process.env.NEXT_PUBLIC_TENANTADMIN_API_URL; + if (!base) { + throw new Error( + "NEXT_PUBLIC_TENANTADMIN_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)" + ); + } + return base; +} + +async function handle(res: Response): Promise { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Anfrage fehlgeschlagen (${res.status})`); + } + return res.json(); +} + +export async function fetchTenants( + superadminId: string, + search: string, + status: string +): Promise { + const params = new URLSearchParams({ superadmin: superadminId, search, status }); + const res = await fetch(`${apiBase()}/admin/tenants?${params}`, { cache: "no-store" }); + return handle(res); +} + +export async function fetchTenantDetail( + superadminId: string, + slug: string +): Promise { + const params = new URLSearchParams({ superadmin: superadminId, slug }); + const res = await fetch(`${apiBase()}/admin/tenants/detail?${params}`, { cache: "no-store" }); + return handle(res); +} + +export async function updateSettings( + superadminId: string, + slug: string, + displayName: string, + colorScheme: string, + timezone: string, + language: string +): Promise { + const res = await fetch(`${apiBase()}/admin/tenants/settings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + superadmin: superadminId, + slug, + display_name: displayName, + color_scheme: colorScheme, + timezone, + language, + }), + }); + return handle(res); +} + +export type LifecycleAction = + | "suspend" + | "reactivate" + | "schedule_deletion" + | "cancel_deletion"; + +export async function performLifecycleAction( + superadminId: string, + slug: string, + action: LifecycleAction +): Promise { + const res = await fetch(`${apiBase()}/admin/tenants/lifecycle`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ superadmin: superadminId, slug, action }), + }); + await handle(res); +} diff --git a/web/tenant-admin/next.config.mjs b/web/tenant-admin/next.config.mjs new file mode 100644 index 0000000..f26ac37 --- /dev/null +++ b/web/tenant-admin/next.config.mjs @@ -0,0 +1,3 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; +export default nextConfig; diff --git a/web/tenant-admin/package.json b/web/tenant-admin/package.json new file mode 100644 index 0000000..c5fb585 --- /dev/null +++ b/web/tenant-admin/package.json @@ -0,0 +1,21 @@ +{ + "name": "nexarch-tenant-admin", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "14.2.35", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.14.9", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "typescript": "5.5.3" + } +} diff --git a/web/tenant-admin/tsconfig.json b/web/tenant-admin/tsconfig.json new file mode 100644 index 0000000..26caf44 --- /dev/null +++ b/web/tenant-admin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From 00665592f6238efcd12d5d71ec0ff355a473aed7 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 21:41:56 +0200 Subject: [PATCH 3/8] SHL-01: ui-shell-design-system-zentral (tokens, theming, i18n-rahmen, basis-komponenten) --- .gitignore | 2 + web/shl/README.md | 45 +++++++++++++ web/shl/__tests__/Dialog.test.tsx | 38 +++++++++++ web/shl/__tests__/tokens.test.ts | 39 ++++++++++++ web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++ web/shl/components/FormElements.tsx | 98 +++++++++++++++++++++++++++++ web/shl/components/Shell.tsx | 62 ++++++++++++++++++ web/shl/components/Table.tsx | 83 ++++++++++++++++++++++++ web/shl/components/Toast.tsx | 67 ++++++++++++++++++++ web/shl/i18n/i18n.tsx | 75 ++++++++++++++++++++++ web/shl/index.ts | 25 ++++++++ web/shl/package.json | 23 +++++++ web/shl/theme/ThemeProvider.tsx | 81 ++++++++++++++++++++++++ web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++ web/shl/tsconfig.json | 18 ++++++ 15 files changed, 840 insertions(+) create mode 100644 web/shl/README.md create mode 100644 web/shl/__tests__/Dialog.test.tsx create mode 100644 web/shl/__tests__/tokens.test.ts create mode 100644 web/shl/components/Dialog.tsx create mode 100644 web/shl/components/FormElements.tsx create mode 100644 web/shl/components/Shell.tsx create mode 100644 web/shl/components/Table.tsx create mode 100644 web/shl/components/Toast.tsx create mode 100644 web/shl/i18n/i18n.tsx create mode 100644 web/shl/index.ts create mode 100644 web/shl/package.json create mode 100644 web/shl/theme/ThemeProvider.tsx create mode 100644 web/shl/tokens/tokens.ts create mode 100644 web/shl/tsconfig.json diff --git a/.gitignore b/.gitignore index ac00d6f..a5e59d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *.log .env +web/*/node_modules/ +web/*/.next/ diff --git a/web/shl/README.md b/web/shl/README.md new file mode 100644 index 0000000..86f7609 --- /dev/null +++ b/web/shl/README.md @@ -0,0 +1,45 @@ +# @nexarch/shl — UI-Shell & Design-System (Core SHL-01) + +Gemeinsames Paket für alle NEXARCH-Modul-Frontends (Core, DMS, Mail, Archive, Workflow, AI, Connect). +Ein Modul-Frontend importiert ausschließlich über `index.ts`, kopiert keine Komponenten oder Tokens lokal. + +## Enthält + +- **Design-Tokens** (`tokens/tokens.ts`) — Farbe (Hell/Dunkel), Abstand, Typografie. Kontrastwerte gegen WCAG 2.1 AA geprüft (siehe `__tests__/tokens.test.ts`). +- **Theming** (`theme/ThemeProvider.tsx`) — zentrale Hell/Dunkel-Umschaltung, respektiert `prefers-color-scheme`, persistiert in `localStorage`. +- **i18n-Rahmen** (`i18n/i18n.tsx`) — Umschaltmechanismus Deutsch/Englisch. Modul-Frontends registrieren ihre fachlichen Textbausteine über `registerMessages()`, statt einen eigenen Mechanismus zu bauen. +- **Basis-Komponenten** (`components/`) — `Shell` (Layout + Navigation), `Table`, `Dialog`, `TextField`/`SelectField`/`CheckboxField`, `Toast`. Alle mit WCAG-2.1-AA-Grundlage (Tastaturbedienung, ARIA-Attribute, Fokus-Management). + +## Verwendung in einem Modul-Frontend + +```tsx +import { ThemeProvider, I18nProvider, ToastProvider, Shell } from "@nexarch/shl"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + {children} + + + + + ); +} +``` + +## Bekannter offener Punkt + +Die vier bereits gebauten Core-Frontends (`TEN-05`, `LIC-04`, `AUD-04`, `OPS-02`) sind vor diesem Paket entstanden und binden es noch nicht ein — Retrofit ist der nächste Schritt, siehe `nexarch-state.json`. + +## Tests + +Ausführung auf dem Test-Host (nicht lokal, siehe Projekt-Testinfrastruktur): + +```bash +npm install +npm test +npm run typecheck +``` diff --git a/web/shl/__tests__/Dialog.test.tsx b/web/shl/__tests__/Dialog.test.tsx new file mode 100644 index 0000000..cde82cb --- /dev/null +++ b/web/shl/__tests__/Dialog.test.tsx @@ -0,0 +1,38 @@ +// Prüfung: Tastaturbedienung der Basis-Komponenten funktioniert (SHL-01 Prüfung 2). +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Dialog } from "../components/Dialog"; +import { I18nProvider } from "../i18n/i18n"; + +function renderDialog(onClose: () => void) { + return render( + + + + + + , + ); +} + +describe("Dialog: Tastaturbedienung", () => { + it("schließt sich bei ESC", () => { + const onClose = vi.fn(); + renderDialog(onClose); + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("setzt den Fokus beim Öffnen auf das erste fokussierbare Element", () => { + renderDialog(vi.fn()); + const closeButton = screen.getByRole("button", { name: /schließen/i }); + expect(document.activeElement).toBe(closeButton); + }); + + it("ist als modaler Dialog mit Titel-Referenz ausgezeichnet", () => { + renderDialog(vi.fn()); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toHaveAttribute("aria-labelledby", "test-title"); + }); +}); diff --git a/web/shl/__tests__/tokens.test.ts b/web/shl/__tests__/tokens.test.ts new file mode 100644 index 0000000..91e186f --- /dev/null +++ b/web/shl/__tests__/tokens.test.ts @@ -0,0 +1,39 @@ +// Prüfung: Kontrastwerte erfüllen mindestens AA (SHL-01 Prüfung 3 / Akzeptanzkriterium 4). +import { describe, expect, it } from "vitest"; +import { colorTokens } from "../tokens/tokens"; + +// WCAG-2.1-AA-Kontrastberechnung (relative Luminanz, sRGB) — keine externe Abhängigkeit nötig. +function relLuminance(hex: string): number { + const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrastRatio(a: string, b: string): number { + const l1 = relLuminance(a); + const l2 = relLuminance(b); + const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1]; + return (lighter + 0.05) / (darker + 0.05); +} + +describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => { + for (const scheme of ["light", "dark"] as const) { + const c = colorTokens[scheme]; + + it(`${scheme}: textPrimary auf background erfüllt AA (>= 4.5:1)`, () => { + expect(contrastRatio(c.textPrimary, c.background)).toBeGreaterThanOrEqual(4.5); + }); + + it(`${scheme}: textSecondary auf surface erfüllt AA (>= 4.5:1)`, () => { + expect(contrastRatio(c.textSecondary, c.surface)).toBeGreaterThanOrEqual(4.5); + }); + + it(`${scheme}: accentContrast auf accent erfüllt AA (>= 4.5:1)`, () => { + expect(contrastRatio(c.accentContrast, c.accent)).toBeGreaterThanOrEqual(4.5); + }); + + it(`${scheme}: dangerContrast auf danger erfüllt AA (>= 4.5:1)`, () => { + expect(contrastRatio(c.dangerContrast, c.danger)).toBeGreaterThanOrEqual(4.5); + }); + } +}); diff --git a/web/shl/components/Dialog.tsx b/web/shl/components/Dialog.tsx new file mode 100644 index 0000000..4815b31 --- /dev/null +++ b/web/shl/components/Dialog.tsx @@ -0,0 +1,88 @@ +"use client"; + +// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig. + +import { useEffect, useRef } from "react"; +import type { ReactNode } from "react"; +import { useI18n } from "../i18n/i18n"; + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export interface DialogProps { + open: boolean; + onClose: () => void; + titleId: string; + title: string; + children: ReactNode; +} + +export function Dialog({ open, onClose, titleId, title, children }: DialogProps) { + const { t } = useI18n(); + const dialogRef = useRef(null); + const previouslyFocused = useRef(null); + + useEffect(() => { + if (!open) return; + previouslyFocused.current = document.activeElement as HTMLElement | null; + + const node = dialogRef.current; + const focusables = node?.querySelectorAll(FOCUSABLE_SELECTOR); + focusables?.[0]?.focus(); + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + onClose(); + return; + } + if (event.key !== "Tab" || !node) return; + + const items = Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR)); + if (items.length === 0) return; + const first = items[0]; + const last = items[items.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + previouslyFocused.current?.focus(); + }; + }, [open, onClose]); + + if (!open) return null; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+

{title}

+ +
+
{children}
+
+
+ ); +} diff --git a/web/shl/components/FormElements.tsx b/web/shl/components/FormElements.tsx new file mode 100644 index 0000000..12c312a --- /dev/null +++ b/web/shl/components/FormElements.tsx @@ -0,0 +1,98 @@ +// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes