Merge branch 'feature/ten-05-tenant-verwaltungsoberflaeche' into feature/qa-02-pruefgate-identitaet-mandanten

# Conflicts:
#	internal/tenant/registry.go
This commit is contained in:
sysops
2026-08-29 00:09:28 +02:00
28 changed files with 1902 additions and 6 deletions
+2
View File
@@ -1,2 +1,4 @@
*.log *.log
.env .env
web/*/node_modules/
web/*/.next/
+30
View File
@@ -64,6 +64,9 @@ Keine Änderungen ermittelbar.
## 2026-08-27 17:28 17:29 (1m) ## 2026-08-27 17:28 17:29 (1m)
**Beschreibung:** Claude Code Session **Beschreibung:** Claude Code Session
**Projekt:** code **Projekt:** code
## 2026-08-28 21:44 21:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits ### Commits
Keine Commits in dieser Session. Keine Commits in dieser Session.
@@ -150,6 +153,33 @@ Keine Commits in dieser Session.
- migrations/0002_superadmins.up.sql | 14 ++++++++++++++ - migrations/0002_superadmins.up.sql | 14 ++++++++++++++
- migrations/tenant/0001_users.down.sql | 1 + - migrations/tenant/0001_users.down.sql | 1 +
- migrations/tenant/0001_users.up.sql | 16 ++++++++++++++++ - 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) ## 2026-08-29 00:08 00:08 (0m)
+67
View File
@@ -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)
}
}
+5 -6
View File
@@ -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 // previous_status/deletion_scheduled_at werden mitgelesen, damit TEN-04
// (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand // (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand
// ueber GetBySlug ansehen kann, statt eine eigene Abfrage zu duplizieren. // ueber GetBySlug ansehen kann, statt eine eigene Abfrage zu duplizieren.
var t Tenant
row := r.pool.QueryRow(ctx, ` row := r.pool.QueryRow(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
FROM tenants WHERE slug = $1 FROM tenants WHERE slug = $1
`, slug) `, slug)
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt, t, err := scanTenantWithLifecycle(row)
&t.PreviousStatus, &t.DeletionScheduledAt); err != nil { if err != nil {
return Tenant{}, fmt.Errorf("tenant laden: %w", err) return Tenant{}, fmt.Errorf("tenant laden: %w", err)
} }
return t, nil 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) { func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
rows, err := r.pool.Query(ctx, ` 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 FROM tenants ORDER BY created_at
`) `)
if err != nil { if err != nil {
@@ -73,8 +72,8 @@ func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
var out []Tenant var out []Tenant
for rows.Next() { for rows.Next() {
var t Tenant t, err := scanTenantWithLifecycle(rows)
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil { if err != nil {
return nil, fmt.Errorf("tenant lesen: %w", err) return nil, fmt.Errorf("tenant lesen: %w", err)
} }
out = append(out, t) out = append(out, t)
+298
View File
@@ -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())
}
}
+232
View File
@@ -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)
}
}
+45
View File
@@ -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 (
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>
<Shell modules={[]} tenantLabel="Mandant XY">
{children}
</Shell>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
);
}
```
## 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
```
+38
View File
@@ -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(
<I18nProvider>
<Dialog open titleId="test-title" title="Test-Dialog" onClose={onClose}>
<button type="button">Erste Aktion</button>
<button type="button">Zweite Aktion</button>
</Dialog>
</I18nProvider>,
);
}
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");
});
});
+39
View File
@@ -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);
});
}
});
+88
View File
@@ -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<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const node = dialogRef.current;
const focusables = node?.querySelectorAll<HTMLElement>(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<HTMLElement>(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 (
<div
className="shl-dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
className="shl-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="shl-dialog-header">
<h2 id={titleId}>{title}</h2>
<button type="button" onClick={onClose} aria-label={t("shl.dialog.close")}>
×
</button>
</div>
<div className="shl-dialog-body">{children}</div>
</div>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes <label>,
// Fehler werden per aria-describedby + aria-invalid angebunden, nicht nur farblich markiert.
import { useId } from "react";
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
interface FieldWrapperProps {
label: string;
error?: string;
hint?: string;
children: (ids: { inputId: string; describedBy: string | undefined }) => ReactNode;
}
function FieldWrapper({ label, error, hint, children }: FieldWrapperProps) {
const inputId = useId();
const hintId = hint ? `${inputId}-hint` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className="shl-field">
<label htmlFor={inputId}>{label}</label>
{children({ inputId, describedBy })}
{hint && (
<p id={hintId} className="shl-field-hint">
{hint}
</p>
)}
{error && (
<p id={errorId} className="shl-field-error" role="alert">
{error}
</p>
)}
</div>
);
}
export interface TextFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "aria-describedby"> {
label: string;
error?: string;
hint?: string;
}
export function TextField({ label, error, hint, ...inputProps }: TextFieldProps) {
return (
<FieldWrapper label={label} error={error} hint={hint}>
{({ inputId, describedBy }) => (
<input
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...inputProps}
/>
)}
</FieldWrapper>
);
}
export interface SelectFieldProps
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "id" | "aria-describedby"> {
label: string;
error?: string;
hint?: string;
children: ReactNode;
}
export function SelectField({ label, error, hint, children, ...selectProps }: SelectFieldProps) {
return (
<FieldWrapper label={label} error={error} hint={hint}>
{({ inputId, describedBy }) => (
<select
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...selectProps}
>
{children}
</select>
)}
</FieldWrapper>
);
}
export interface CheckboxFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "type"> {
label: string;
}
export function CheckboxField({ label, ...inputProps }: CheckboxFieldProps) {
const inputId = useId();
return (
<div className="shl-field shl-field-checkbox">
<input id={inputId} type="checkbox" {...inputProps} />
<label htmlFor={inputId}>{label}</label>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
// Layout-Shell mit Navigation — SHL-01 Akzeptanzkriterium 1.
// Globale Navigation zeigt nur Module, die Core für Tenant/Benutzer freigibt (Backend entscheidet, UI blendet nur aus).
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
import { useTheme } from "../theme/ThemeProvider";
export interface ModuleLink {
key: string;
label: string;
href: string;
active?: boolean;
}
export interface ShellProps {
modules: ModuleLink[];
tenantLabel: string;
children: ReactNode;
}
export function Shell({ modules, tenantLabel, children }: ShellProps) {
const { scheme, toggle } = useTheme();
const { t } = useI18n();
return (
<div className="shl-shell">
<a className="shl-skip-link" href="#shl-main-content">
{t("shl.shell.skipToContent", "Zum Inhalt springen")}
</a>
<header className="shl-shell-header">
<nav aria-label={t("shl.shell.moduleNav", "Modul-Navigation")}>
<ul>
{modules.map((mod) => (
<li key={mod.key}>
<a href={mod.href} aria-current={mod.active ? "page" : undefined}>
{mod.label}
</a>
</li>
))}
</ul>
</nav>
<div className="shl-shell-context">
<span className="shl-tenant-context">{tenantLabel}</span>
<button
type="button"
onClick={toggle}
aria-label={
scheme === "light" ? t("shl.theme.toggleToDark") : t("shl.theme.toggleToLight")
}
>
{scheme === "light" ? "🌙" : "☀️"}
</button>
</div>
</header>
<main id="shl-main-content" className="shl-shell-content" tabIndex={-1}>
{children}
</main>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
// Table-Basis-Komponente — SHL-01. WCAG: semantische <table>, scope auf Kopfzellen, sortierbare Spalten per Tastatur.
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
export interface TableColumn<Row> {
key: string;
header: string;
render: (row: Row) => ReactNode;
sortable?: boolean;
}
export interface TableProps<Row> {
columns: TableColumn<Row>[];
rows: Row[];
rowKey: (row: Row) => string;
sortKey?: string;
sortDirection?: "asc" | "desc";
onSort?: (key: string) => void;
caption?: string;
}
export function Table<Row>({
columns,
rows,
rowKey,
sortKey,
sortDirection,
onSort,
caption,
}: TableProps<Row>) {
const { t } = useI18n();
return (
<table className="shl-table">
{caption && <caption>{caption}</caption>}
<thead>
<tr>
{columns.map((column) => {
const isSorted = column.key === sortKey;
const ariaSort = column.sortable
? isSorted
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
: undefined;
return (
<th key={column.key} scope="col" aria-sort={ariaSort}>
{column.sortable ? (
<button
type="button"
onClick={() => onSort?.(column.key)}
className="shl-table-sort-button"
>
{column.header}
</button>
) : (
column.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={columns.length}>{t("shl.table.noRows")}</td>
</tr>
) : (
rows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((column) => (
<td key={column.key}>{column.render(row)}</td>
))}
</tr>
))
)}
</tbody>
</table>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
// Toast-Basis-Komponente — SHL-01. WCAG: aria-live sorgt dafür, dass Screenreader Meldungen ansagen.
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
export type ToastVariant = "info" | "success" | "danger" | "warning";
export interface ToastMessage {
id: string;
text: string;
variant: ToastVariant;
}
interface ToastContextValue {
toasts: ToastMessage[];
push: (text: string, variant?: ToastVariant) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const { t } = useI18n();
const dismiss = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const push = useCallback((text: string, variant: ToastVariant = "info") => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((current) => [...current, { id, text, variant }]);
}, []);
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="shl-toast-region" role="status" aria-live="polite" aria-atomic="false">
{toasts.map((toast) => (
<div key={toast.id} className={`shl-toast shl-toast-${toast.variant}`}>
<span>{toast.text}</span>
<button
type="button"
onClick={() => dismiss(toast.id)}
aria-label={t("shl.toast.dismiss")}
>
×
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error("useToast muss innerhalb von <ToastProvider> aufgerufen werden");
}
return ctx;
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
// i18n-Rahmen (mind. Deutsch/Englisch) — SHL-01 Akzeptanzkriterium 5.
// Liefert nur den Umschaltmechanismus + Basis-Komponenten-Texte.
// Modul-Frontends liefern ihre eigenen fachlichen Textbausteine über registerMessages(),
// statt einen eigenen i18n-Mechanismus zu bauen (siehe UI-UX-KONZEPT.md Abschnitt 4).
import { createContext, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
export type Locale = "de" | "en";
type MessageDict = Record<string, string>;
type MessageBundle = Record<Locale, MessageDict>;
const baseMessages: MessageBundle = {
de: {
"shl.dialog.close": "Schließen",
"shl.toast.dismiss": "Meldung schließen",
"shl.table.noRows": "Keine Einträge vorhanden",
"shl.theme.toggleToLight": "Helles Erscheinungsbild",
"shl.theme.toggleToDark": "Dunkles Erscheinungsbild",
},
en: {
"shl.dialog.close": "Close",
"shl.toast.dismiss": "Dismiss message",
"shl.table.noRows": "No entries",
"shl.theme.toggleToLight": "Switch to light theme",
"shl.theme.toggleToDark": "Switch to dark theme",
},
};
// Registry, in die Modul-Frontends ihre eigenen Textbausteine einhängen.
const registry: MessageBundle = { de: { ...baseMessages.de }, en: { ...baseMessages.en } };
export function registerMessages(locale: Locale, messages: MessageDict): void {
registry[locale] = { ...registry[locale], ...messages };
}
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string, fallback?: string) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({
initialLocale = "de",
children,
}: {
initialLocale?: Locale;
children: ReactNode;
}) {
const [locale, setLocale] = useState<Locale>(initialLocale);
const value = useMemo<I18nContextValue>(
() => ({
locale,
setLocale,
t: (key: string, fallback?: string) => registry[locale][key] ?? fallback ?? key,
}),
[locale],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error("useI18n muss innerhalb von <I18nProvider> aufgerufen werden");
}
return ctx;
}
+25
View File
@@ -0,0 +1,25 @@
// Öffentliche Schnittstelle des Pakets @nexarch/shl — Modul-Frontends importieren ausschließlich hierüber,
// nicht aus internen Unterpfaden (SHL-01 Akzeptanzkriterium 2: dokumentiert, versioniert, importierbar statt kopiert).
export { colorTokens, spacing, breakpoints, typography, cssVariables } from "./tokens/tokens";
export type { ColorScheme, ColorTokens } from "./tokens/tokens";
export { ThemeProvider, useTheme, currentColors } from "./theme/ThemeProvider";
export { I18nProvider, useI18n, registerMessages } from "./i18n/i18n";
export type { Locale } from "./i18n/i18n";
export { Shell } from "./components/Shell";
export type { ShellProps, ModuleLink } from "./components/Shell";
export { Dialog } from "./components/Dialog";
export type { DialogProps } from "./components/Dialog";
export { Table } from "./components/Table";
export type { TableProps, TableColumn } from "./components/Table";
export { TextField, SelectField, CheckboxField } from "./components/FormElements";
export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements";
export { ToastProvider, useToast } from "./components/Toast";
export type { ToastMessage, ToastVariant } from "./components/Toast";
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@nexarch/shl",
"version": "0.1.0",
"private": true,
"description": "NEXARCH UI-Shell & Design-System (Core SHL-01) — gemeinsames Paket für alle Modul-Frontends.",
"main": "index.ts",
"types": "index.ts",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"jsdom": "^24.1.1",
"typescript": "5.5.3",
"vitest": "^2.0.0"
}
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
// Zentrales Theming (Hell/Dunkel) — SHL-01 Akzeptanzkriterium 6.
// Einzige Quelle für Hell/Dunkel-Werte; Modul-Frontends schalten nur um, bauen kein eigenes Theming.
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { colorTokens, cssVariables, type ColorScheme } from "../tokens/tokens";
const STORAGE_KEY = "nexarch-shl-theme";
interface ThemeContextValue {
scheme: ColorScheme;
setScheme: (scheme: ColorScheme) => void;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function readStoredScheme(): ColorScheme | null {
if (typeof window === "undefined") return null;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === "light" || stored === "dark" ? stored : null;
} catch {
// localStorage kann in privaten Fenstern/eingeschränkten Kontexten fehlschlagen — kein Absturz, nur kein persistierter Zustand.
return null;
}
}
function systemPrefersDark(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [scheme, setSchemeState] = useState<ColorScheme>("light");
useEffect(() => {
const stored = readStoredScheme();
setSchemeState(stored ?? (systemPrefersDark() ? "dark" : "light"));
}, []);
const setScheme = useCallback((next: ColorScheme) => {
setSchemeState(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Speichern optional — Umschaltung funktioniert auch ohne Persistenz.
}
}, []);
const toggle = useCallback(() => {
setScheme(scheme === "light" ? "dark" : "light");
}, [scheme, setScheme]);
useEffect(() => {
const vars = cssVariables(scheme);
const root = document.documentElement;
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value);
}
root.dataset.shlTheme = scheme;
}, [scheme]);
const value = useMemo(() => ({ scheme, setScheme, toggle }), [scheme, setScheme, toggle]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme muss innerhalb von <ThemeProvider> aufgerufen werden");
}
return ctx;
}
export function currentColors(scheme: ColorScheme) {
return colorTokens[scheme];
}
+96
View File
@@ -0,0 +1,96 @@
// Design-Tokens: einzige Quelle für Farbe, Abstand, Typografie im gesamten Frontend-Verbund.
// Modul-Frontends importieren diese Tokens, überschreiben sie nicht lokal (SHL-01 Akzeptanzkriterium 3).
// Kontrastwerte sind gegen WCAG 2.1 AA geprüft (Akzeptanzkriterium 1/4): mindestens 4.5:1 für Fließtext.
export type ColorScheme = "light" | "dark";
export interface ColorTokens {
background: string;
surface: string;
surfaceRaised: string;
border: string;
textPrimary: string;
textSecondary: string;
accent: string;
accentContrast: string;
danger: string;
dangerContrast: string;
success: string;
warning: string;
focusRing: string;
}
// Kontrastwerte geprüft: textPrimary auf background/surface >= 7:1, textSecondary >= 4.5:1,
// accentContrast auf accent >= 4.5:1 (WCAG AA, siehe SHL-01 Prüfung 3).
export const colorTokens: Record<ColorScheme, ColorTokens> = {
light: {
background: "#FFFFFF",
surface: "#F5F6F8",
surfaceRaised: "#FFFFFF",
border: "#D7DBE0",
textPrimary: "#14181F",
textSecondary: "#4B5563",
accent: "#1D4ED8",
accentContrast: "#FFFFFF",
danger: "#B91C1C",
dangerContrast: "#FFFFFF",
success: "#15803D",
warning: "#B45309",
focusRing: "#1D4ED8",
},
dark: {
background: "#0F1115",
surface: "#181B21",
surfaceRaised: "#20242C",
border: "#333944",
textPrimary: "#F2F4F7",
textSecondary: "#B4BAC4",
accent: "#5B8DEF",
accentContrast: "#0F1115",
danger: "#F87171",
dangerContrast: "#0F1115",
success: "#4ADE80",
warning: "#FBBF24",
focusRing: "#5B8DEF",
},
};
export const spacing = {
xs: "4px",
sm: "8px",
md: "16px",
lg: "24px",
xl: "32px",
xxl: "48px",
} as const;
export const breakpoints = {
mobile: "0px",
tablet: "768px",
desktop: "1200px",
} as const;
export const typography = {
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
fontFamilyMono: "'JetBrains Mono', ui-monospace, monospace",
sizeSm: "13px",
sizeMd: "15px",
sizeLg: "18px",
sizeXl: "24px",
lineHeight: 1.5,
weightRegular: 400,
weightMedium: 500,
weightBold: 600,
} as const;
export function cssVariables(scheme: ColorScheme): Record<string, string> {
const c = colorTokens[scheme];
const vars: Record<string, string> = {};
for (const [key, value] of Object.entries(c)) {
vars[`--shl-color-${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`] = value;
}
for (const [key, value] of Object.entries(spacing)) {
vars[`--shl-spacing-${key}`] = value;
}
return vars;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
});
+9
View File
@@ -0,0 +1,9 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Ohne explizites Cleanup bleiben zwischen den it()-Blöcken gerenderte Dialoge im DOM stehen
// (mehrere <html>/<body>-Bäume stapeln sich), wodurch getByRole() mehrere Treffer statt einen findet.
afterEach(() => {
cleanup();
});
+30
View File
@@ -0,0 +1,30 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Mandantenverwaltung",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<body
style={{
fontFamily: typography.fontFamily,
margin: 0,
background: "var(--shl-color-background, #ffffff)",
color: "var(--shl-color-text-primary, #14181f)",
}}
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
</html>
);
}
+225
View File
@@ -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<LifecycleAction, string> = {
suspend: "Suspendieren",
reactivate: "Reaktivieren",
schedule_deletion: "Löschung vormerken",
cancel_deletion: "Löschung abbrechen",
};
const ACTION_CONFIRM: Record<LifecycleAction, string> = {
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<TenantListItem[] | null>(null);
const [detail, setDetail] = useState<TenantDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(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<HTMLFormElement>) {
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 (
<main style={{ maxWidth: 900, margin: "0 auto", padding: "2rem 1rem" }}>
<h1>Mandantenverwaltung</h1>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem", flexWrap: "wrap" }}>
<input
value={superadminId}
onChange={(e) => setSuperadminId(e.target.value)}
placeholder="Superadmin-ID"
style={{ padding: "0.5rem" }}
/>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Suche (Slug/Name)"
style={{ padding: "0.5rem" }}
/>
<select value={status} onChange={(e) => setStatus(e.target.value)} style={{ padding: "0.5rem" }}>
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="suspended">Suspendiert</option>
<option value="pending_deletion">Löschung vorgemerkt</option>
<option value="deleted">Gelöscht</option>
</select>
<button onClick={loadList} style={{ padding: "0.5rem 1rem" }}>
Anzeigen
</button>
</div>
{error && (
<p style={{ color: "#c62828" }} role="alert">
Fehler: {error}
</p>
)}
{tenants && (
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
<thead>
<tr style={{ textAlign: "left", borderBottom: "2px solid #ddd" }}>
<th style={{ padding: "0.5rem" }}>Slug</th>
<th style={{ padding: "0.5rem" }}>Name</th>
<th style={{ padding: "0.5rem" }}>Status</th>
<th style={{ padding: "0.5rem" }}></th>
</tr>
</thead>
<tbody>
{tenants.map((t) => (
<tr key={t.id} style={{ borderBottom: "1px solid #eee" }}>
<td style={{ padding: "0.5rem" }}>{t.slug}</td>
<td style={{ padding: "0.5rem" }}>{t.name}</td>
<td style={{ padding: "0.5rem" }}>{t.status}</td>
<td style={{ padding: "0.5rem" }}>
<button onClick={() => openDetail(t.slug)}>Details</button>
</td>
</tr>
))}
{tenants.length === 0 && (
<tr>
<td colSpan={4} style={{ padding: "0.5rem" }}>
Keine Mandanten gefunden.
</td>
</tr>
)}
</tbody>
</table>
)}
{detail && (
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
<h2>
{detail.name} ({detail.slug}) Status: {detail.status}
</h2>
<form onSubmit={onSubmitSettings} style={{ display: "grid", gap: "0.75rem", maxWidth: 400 }}>
{formError && (
<p style={{ color: "#c62828" }} role="alert">
{formError}
</p>
)}
<label>
Anzeigename (Pflichtfeld)
<input
name="displayName"
required
defaultValue={detail.settings.DisplayName}
style={{ width: "100%", padding: "0.4rem" }}
/>
</label>
<label>
Farbschema
<input
name="colorScheme"
defaultValue={detail.settings.ColorScheme}
style={{ width: "100%", padding: "0.4rem" }}
/>
</label>
<label>
Zeitzone
<input
name="timezone"
defaultValue={detail.settings.Timezone}
style={{ width: "100%", padding: "0.4rem" }}
/>
</label>
<label>
Sprache
<input
name="language"
defaultValue={detail.settings.Language}
style={{ width: "100%", padding: "0.4rem" }}
/>
</label>
<button type="submit">Einstellungen speichern</button>
</form>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1.5rem", flexWrap: "wrap" }}>
{(Object.keys(ACTION_LABEL) as LifecycleAction[]).map((action) => (
<button key={action} onClick={() => onLifecycleAction(action)}>
{ACTION_LABEL[action]}
</button>
))}
</div>
</section>
)}
</main>
);
}
+106
View File
@@ -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<T>(res: Response): Promise<T> {
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<TenantListItem[]> {
const params = new URLSearchParams({ superadmin: superadminId, search, status });
const res = await fetch(`${apiBase()}/admin/tenants?${params}`, { cache: "no-store" });
return handle<TenantListItem[]>(res);
}
export async function fetchTenantDetail(
superadminId: string,
slug: string
): Promise<TenantDetail> {
const params = new URLSearchParams({ superadmin: superadminId, slug });
const res = await fetch(`${apiBase()}/admin/tenants/detail?${params}`, { cache: "no-store" });
return handle<TenantDetail>(res);
}
export async function updateSettings(
superadminId: string,
slug: string,
displayName: string,
colorScheme: string,
timezone: string,
language: string
): Promise<Settings> {
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<Settings>(res);
}
export type LifecycleAction =
| "suspend"
| "reactivate"
| "schedule_deletion"
| "cancel_deletion";
export async function performLifecycleAction(
superadminId: string,
slug: string,
action: LifecycleAction
): Promise<void> {
const res = await fetch(`${apiBase()}/admin/tenants/lifecycle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ superadmin: superadminId, slug, action }),
});
await handle<unknown>(res);
}
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules —
// Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md.
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
+22
View File
@@ -0,0 +1,22 @@
{
"name": "nexarch-tenant-admin",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@nexarch/shl": "file:../shl",
"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"
}
}
+21
View File
@@ -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"]
}