Merge branch 'feature/ten-05-tenant-verwaltungsoberflaeche' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts: # internal/tenant/registry.go
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
*.log
|
||||
.env
|
||||
web/*/node_modules/
|
||||
web/*/.next/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user