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/DEVLOG.md b/DEVLOG.md index 6969e2a..9a6b25d 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -64,6 +64,9 @@ Keine Änderungen ermittelbar. ## 2026-08-27 17:28 – 17:29 (1m) **Beschreibung:** Claude Code Session **Projekt:** code +## 2026-08-28 21:44 – 21:44 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** nexarch ### Commits Keine Commits in dieser Session. @@ -150,6 +153,33 @@ Keine Commits in dieser Session. - migrations/0002_superadmins.up.sql | 14 ++++++++++++++ - migrations/tenant/0001_users.down.sql | 1 + - migrations/tenant/0001_users.up.sql | 16 ++++++++++++++++ +- 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 ++++++++++++++++++ + +--- +## 2026-08-28 21:51 – 21:57 (5m) +**Beschreibung:** Claude Code Session +**Projekt:** nexarch + +### Commits +- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl) + +### Geänderte Dateien +- web/shl/package.json | 2 ++ +- web/shl/vitest.config.ts | 8 ++++++++ +- web/shl/vitest.setup.ts | 1 + --- ## 2026-08-29 00:08 – 00:08 (0m) 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/tenant/registry.go b/internal/tenant/registry.go index 1e96bb2..0af0c2c 100644 --- a/internal/tenant/registry.go +++ b/internal/tenant/registry.go @@ -38,14 +38,13 @@ func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) { // 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. - var t Tenant row := r.pool.QueryRow(ctx, ` 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, - &t.PreviousStatus, &t.DeletionScheduledAt); err != nil { + t, err := scanTenantWithLifecycle(row) + if err != nil { return Tenant{}, fmt.Errorf("tenant laden: %w", err) } return t, nil @@ -63,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 { @@ -73,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/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/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