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"] +}