Compare commits

..
Author SHA1 Message Date
sysops 12b1978ae1 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ten-05-tenant-verwaltungsoberflaeche 2026-08-28 22:02:02 +02:00
sysops bfa5c61db5 SHL-01: fix — Test-Cleanup zwischen Dialog-Tests (afterEach(cleanup), sonst stapeln sich gerenderte DOM-Bäume) 2026-08-28 22:01:56 +02:00
sysops 9911499c1e TEN-05: fix — transpilePackages fuer @nexarch/shl (Next.js transpiliert node_modules sonst nicht, Build brach ab) 2026-08-28 21:56:02 +02:00
sysops 74e1f07379 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ten-05-tenant-verwaltungsoberflaeche 2026-08-28 21:55:43 +02:00
sysops 3c226dab12 SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl) 2026-08-28 21:55:34 +02:00
sysops 8edae6141b TEN-05: Retrofit auf SHL-01 (ThemeProvider/I18nProvider/ToastProvider, Design-Tokens statt hartkodierter Werte) 2026-08-28 21:46:34 +02:00
sysops 49404c5fa4 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ten-05-tenant-verwaltungsoberflaeche
# Conflicts:
#	DEVLOG.md
2026-08-28 21:46:07 +02:00
sysops 584cefa388 DEVLOG: Sessionlog-Eintrag (Auto-Hook) 2026-08-28 21:45:55 +02:00
sysops 00665592f6 SHL-01: ui-shell-design-system-zentral (tokens, theming, i18n-rahmen, basis-komponenten) 2026-08-28 21:41:56 +02:00
sysops 11ed3e3790 TEN-05: backend-api + dev-server + next.js tenant-verwaltungsoberflaeche 2026-08-27 23:58:04 +02:00
sysops 962ef5a27a TEN-05: lifecycle-code aus TEN-04 auf ten-03-basis portiert (registry liest previous_status/deletion_scheduled_at) 2026-08-27 23:49:53 +02:00
sysopsandClaude Sonnet 5 383da92ca8 TEN-03: tenant-einstellungen-branding
internal/tenantsettings: pro-Tenant-Einstellungen (Anzeigename, Logo,
Farbschema, Zeitzone, Sprache) in der Registry-DB, alle Spalten nullable —
fehlender Wert bedeutet immer "Systemvoreinstellung verwenden"
(Defaults(): color_scheme=system, timezone=UTC, language=de), niemals ein
Fehler (Akzeptanzkriterium 2). Store.Update schreibt aktuellen Stand +
Historieneintrag atomar in einer Transaktion mit FOR-UPDATE-Lock auf der
aktuellen Zeile (Akzeptanzkriterium 3, Race-sicher bei nebenlaeufigen
Updates desselben Tenants). Patch-Typ mit *string-Feldern erlaubt
Teil-Updates ohne unbeteiligte Felder zu beruehren.

Schlanker Handler (Get/Update ueber tenant_id-Query-Parameter) als
Vorbereitung der REST-Schnittstelle — echte Auth/Versionierung kommt erst
mit API-01/IAM-02/RBAC-01.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Aenderung eines Tenants wirkt sich nicht auf einen anderen aus —
   TestUpdate_IsolatedBetweenTenants: Tenant A geaendert, Tenant B bleibt
   nachweislich bei Defaults(). PASS.
2. Fehlende Werte liefern Defaults statt Fehler —
   TestGet_UnsetTenantReturnsDefaults (Tenant ganz ohne Datensatz) und
   TestUpdate_PartialPatchKeepsOtherFieldsAtDefault (nur ein Feld gesetzt,
   Rest bleibt Default). PASS.
3. API-Schema von zweiter Person gegen Dokumentation geprueft — NICHT
   durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen.

Zusaetzlich automatisiert getestet (Akzeptanzkriterium 3):
TestUpdate_HistoryTracksVersions — 3 aufeinanderfolgende Aenderungen,
Historie liefert alle 3 in korrekter Reihenfolge, nicht angefasste Felder
bleiben aus dem vorherigen Update erhalten. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:34:09 +02:00
sysopsandClaude Sonnet 5 5915a4e7b1 TEN-02: tenant-onboarding-api-self-service
OnboardingService.Onboard: ein API-Aufruf legt Tenant (via bestehendem
Provisioner) UND ersten Administrator-Account (via IAM-01 TenantUserStore)
an. CREATE DATABASE erlaubt keine echte cross-database Transaktion, daher
Saga-Kompensation: schlaegt die Admin-Anlage nach erfolgreichem Provisioning
fehl, wird der Tenant per neuem Provisioner.Deprovision wieder vollstaendig
entfernt (Registry.Delete + DB-Drop).

Provisioner.Provision mappt Duplikat-Faelle (42P04 duplicate_database und
den bei echt parallelen CREATE DATABASE moeglichen 23505-Unique-Konflikt auf
pg_database) jetzt auf ErrTenantExists statt einer rohen Postgres-Fehlermeldung.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Onboarding-Vorgang zweimal parallel mit gleichem Slug ausgeloest —
   TestOnboarding_RejectsDuplicateSlugConcurrently: genau ein Erfolg, kein
   Doppel-Tenant, zweiter Aufruf bekommt ErrTenantExists. PASS.
2. Fehleingaben (leere Pflichtfelder, ungueltige E-Mail, ungueltiger Slug) —
   TestOnboarding_ValidationErrors deckt alle vier Faelle mit klaren Fehlern
   ab, bevor irgendein DB-Zugriff stattfindet. PASS.
3. Erfolgreicher Durchlauf von zweiter Person end-to-end nachvollzogen —
   NICHT durchgefuehrt (keine zweite Person in dieser Session verfuegbar).
   Offen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:16:54 +02:00
68 changed files with 3222 additions and 1218 deletions
+2
View File
@@ -1,2 +1,4 @@
*.log
.env
web/*/node_modules/
web/*/.next/
+53
View File
@@ -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.
@@ -129,3 +132,53 @@ Keine Commits in dieser Session.
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:53 17:57 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
- e479330 IAM-01: benutzer-datenmodell-crud
### Geänderte Dateien
- cmd/core/main.go | 11 ++++++++++-
- go.mod | 9 +++++++++
- go.sum | 28 ++++++++++++++++++++++++++++
- internal/user/handler.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/store_test.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/superadmin_store.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/tenant_store.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/user.go | 42 ++++++++++++++++++++++++++++++++++++++++++
- internal/user/user_test.go | 24 ++++++++++++++++++++++++
- migrations/0002_superadmins.down.sql | 1 +
- 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 +
---
+3
View File
@@ -34,6 +34,8 @@ func main() {
registry := tenant.NewRegistry(registryPool)
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
tenantHandler := tenant.NewHandler(provisioner)
onboardingService := tenant.NewOnboardingService(provisioner, cfg.TenantSchemaPath)
onboardingHandler := tenant.NewOnboardingHandler(onboardingService)
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
@@ -48,6 +50,7 @@ func main() {
})
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
mux.HandleFunc("/internal/tenants/onboard", onboardingHandler.Onboard)
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
-40
View File
@@ -1,40 +0,0 @@
// secheaders-devserver dient der manuellen/e2e-Verifikation von API-08
// (Pruefung 2: CSP blockiert Inline-Script) mit einem echten Browser.
// Zwei Pfade: /protected (mit Middleware) und /unprotected (ohne) — beide
// liefern dieselbe Seite mit einem Inline-Script, das ein DOM-Element
// aendert. Ein echter Browser zeigt den Unterschied.
package main
import (
"log"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/secheaders"
)
const page = `<!doctype html>
<html><body>
<div id="marker">BLOCKED</div>
<script>document.getElementById('marker').textContent = 'INLINE_RAN';</script>
</body></html>`
func servePage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(page))
}
func main() {
cfg := secheaders.DefaultConfig()
mux := http.NewServeMux()
// SetHeaders statt Middleware: dieser lokale Demo-Server laeuft ohne
// TLS-Terminierung, der HTTPS-Redirect-Teil von Middleware wuerde hier
// in eine Endlosschleife fuehren (siehe secheaders.go-Dokumentation).
mux.HandleFunc("/protected", func(w http.ResponseWriter, r *http.Request) {
secheaders.SetHeaders(cfg, w)
servePage(w, r)
})
mux.HandleFunc("/unprotected", servePage)
log.Println("secheaders-devserver listening on :8086")
log.Fatal(http.ListenAndServe(":8086", mux))
}
+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)
}
}
+2 -5
View File
@@ -2,16 +2,13 @@ module gitea.perlbach24.de/scripte/nexarch
go 1.22
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/crypto v0.17.0
)
require github.com/jackc/pgx/v5 v5.6.0
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
-2
View File
@@ -1,8 +1,6 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
-20
View File
@@ -1,20 +0,0 @@
package apiserver
import "context"
type contextKey int
const requestContextKey contextKey = iota
// RequestContext ist der Tenant-/Benutzerkontext, den die Middleware-Kette
// fuer nachgelagerte Handler bereitstellt (Akzeptanzkriterium 3).
type RequestContext struct {
UserID string
TenantSlug string
}
// FromContext liest den von der Middleware gesetzten Kontext.
func FromContext(ctx context.Context) (RequestContext, bool) {
rc, ok := ctx.Value(requestContextKey).(RequestContext)
return rc, ok
}
-31
View File
@@ -1,31 +0,0 @@
// Package apiserver implementiert Core API-01: das REST-Grundgerüst mit
// URL-Versionierung, einheitlichem Fehlerformat und Middleware-Kette
// (Auth, Tenant-/Benutzerkontext, Logging).
package apiserver
import (
"encoding/json"
"net/http"
)
// errorBody ist das EINE Fehlerschema fuer alle Endpunkte unter /api/{version}/
// (Akzeptanzkriterium 2).
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// WriteError schreibt einen Fehler im einheitlichen Schema. code ist ein
// stabiler, maschinenlesbarer Bezeichner (z.B. "unauthenticated"), message
// ein fuer Menschen lesbarer deutscher Text.
func WriteError(w http.ResponseWriter, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
-56
View File
@@ -1,56 +0,0 @@
package apiserver
import (
"context"
"log/slog"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// authAndTenantContext prueft die Session (wiederverwendet auth.TokenIssuer.Verify
// aus IAM-02 — keine zweite JWT-Implementierung) und setzt bei Erfolg
// RequestContext fuer nachgelagerte Handler (Akzeptanzkriterium 3). Anders
// als auth.RequireAuth (Klartext-Fehler) antwortet diese Middleware im
// einheitlichen API-01-Fehlerschema (Akzeptanzkriterium 2), damit ALLE
// Endpunkte unter /api/{version}/ dasselbe Format liefern, auch bei
// Auth-Fehlern.
func authAndTenantContext(issuer *auth.TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(auth.CookieName)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
return
}
rc := RequestContext{UserID: claims.UserID, TenantSlug: claims.TenantSlug}
next(w, r.WithContext(context.WithValue(r.Context(), requestContextKey, rc)))
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// loggingMiddleware protokolliert jede Anfrage strukturiert.
func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next(rec, r)
slog.Info("api-anfrage", "method", r.Method, "path", r.URL.Path, "status", rec.status, "dauer", time.Since(start))
}
}
-37
View File
@@ -1,37 +0,0 @@
package apiserver
import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// Server registriert versionierte API-Routen (Akzeptanzkriterium 1: unter
// /api/{version}/...) und verdrahtet fuer jede Route dieselbe Middleware-
// Kette (Logging -> Auth+Tenantkontext -> Handler).
type Server struct {
mux *http.ServeMux
issuer *auth.TokenIssuer
}
func NewServer(issuer *auth.TokenIssuer) *Server {
return &Server{mux: http.NewServeMux(), issuer: issuer}
}
// Handle registriert pattern unter der angegebenen Version, z.B.
// Handle("v1", "/things", h) -> erreichbar unter /api/v1/things. Verschiedene
// Versionen sind unabhaengige Pfade — eine neue Version beeintraechtigt
// bestehende nicht (Akzeptanzkriterium 1 / Pruefung 3).
func (s *Server) Handle(version, pattern string, h http.HandlerFunc) {
full := "/api/" + version + pattern
s.mux.HandleFunc(full, loggingMiddleware(authAndTenantContext(s.issuer, h)))
}
// HandleV1 ist die Kurzform fuer die aktuelle Hauptversion.
func (s *Server) HandleV1(pattern string, h http.HandlerFunc) {
s.Handle("v1", pattern, h)
}
func (s *Server) Handler() http.Handler {
return s.mux
}
-149
View File
@@ -1,149 +0,0 @@
package apiserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
func newTestServer() (*Server, *auth.TokenIssuer) {
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
return NewServer(issuer), issuer
}
func withAuthCookie(req *http.Request, token string) *http.Request {
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
return req
}
// Akzeptanzkriterium 1: API unter versioniertem Pfad erreichbar.
func TestHandleV1_RegistersUnderVersionedPath(t *testing.T) {
srv, issuer := newTestServer()
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 1 (Stichprobe): mehrere Endpunkte liefern
// bei fehlerhafter Anfrage dasselbe Fehlerschema.
func TestErrorFormat_ConsistentAcrossEndpoints(t *testing.T) {
srv, _ := newTestServer()
srv.HandleV1("/endpunkt-a", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
srv.HandleV1("/endpunkt-b", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
for _, path := range []string{"/api/v1/endpunkt-a", "/api/v1/endpunkt-b"} {
req := httptest.NewRequest(http.MethodGet, path, nil) // ohne cookie -> 401
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s: status = %d, want 401", path, rec.Code)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("%s: fehlerantwort nicht im erwarteten json-schema: %v (body: %s)", path, err, rec.Body.String())
}
if body.Error.Code == "" || body.Error.Message == "" {
t.Fatalf("%s: erwartet nicht-leeren code/message, habe %+v", path, body)
}
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Middleware-Kette setzt Tenant-/
// Benutzerkontext zuverlaessig, nachweislich fuer mehrere Endpunkte.
func TestMiddleware_SetsRequestContextForEveryEndpoint(t *testing.T) {
srv, issuer := newTestServer()
var gotA, gotB RequestContext
srv.HandleV1("/kontext-a", func(w http.ResponseWriter, r *http.Request) {
gotA, _ = FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
srv.HandleV1("/kontext-b", func(w http.ResponseWriter, r *http.Request) {
gotB, _ = FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
token, err := issuer.Issue("user-42", "tenant-x")
if err != nil {
t.Fatalf("issue: %v", err)
}
for path, got := range map[string]*RequestContext{"/api/v1/kontext-a": &gotA, "/api/v1/kontext-b": &gotB} {
req := withAuthCookie(httptest.NewRequest(http.MethodGet, path, nil), token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status = %d, want 200", path, rec.Code)
}
if got.UserID != "user-42" || got.TenantSlug != "tenant-x" {
t.Fatalf("%s: request-context unerwartet: %+v", path, *got)
}
}
}
// Akzeptanzkriterium 1 + Pruefung 3: eine neue v2-Route laesst sich anlegen,
// ohne v1 zu beeintraechtigen.
func TestVersioning_V2DoesNotAffectV1(t *testing.T) {
srv, issuer := newTestServer()
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("v1-antwort"))
})
srv.Handle("v2", "/things", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("v2-antwort"))
})
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
reqV1 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
recV1 := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV1, reqV1)
if recV1.Body.String() != "v1-antwort" {
t.Fatalf("v1 antwort = %q, want v1-antwort", recV1.Body.String())
}
reqV2 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v2/things", nil), token)
recV2 := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV2, reqV2)
if recV2.Body.String() != "v2-antwort" {
t.Fatalf("v2 antwort = %q, want v2-antwort", recV2.Body.String())
}
// v1 nach dem Anlegen von v2 erneut pruefen — unveraendert.
reqV1Again := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
recV1Again := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV1Again, reqV1Again)
if recV1Again.Body.String() != "v1-antwort" {
t.Fatalf("v1 antwort nach v2-anlage = %q, want weiterhin v1-antwort", recV1Again.Body.String())
}
}
func TestAuthAndTenantContext_RejectsInvalidToken(t *testing.T) {
srv, _ := newTestServer()
srv.HandleV1("/geschuetzt", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/geschuetzt", nil), "kaputtes.token.hier")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
-67
View File
@@ -1,67 +0,0 @@
package auth
import (
"encoding/json"
"net/http"
"time"
)
// Handler stellt Login/Logout als HTTP-Endpunkte bereit. Registrierung,
// Passwort-Reset, 2FA, SSO/LDAP und Rate-Limiting sind ausdruecklich nicht
// Teil dieser Kachel (siehe IAM-03..07).
type Handler struct {
login *LoginService
}
func NewHandler(login *LoginService) *Handler {
return &Handler{login: login}
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
token, err := h.login.Login(r.Context(), req.Email, req.Password)
if err != nil {
http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized)
return
}
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(AccessTokenTTL.Seconds()),
})
w.WriteHeader(http.StatusOK)
}
// Logout loescht das Session-Cookie. Da JWT hier bewusst zustandslos bleibt
// (kein serverseitiger Blocklist-Speicher — das waere ueber "Grundgerüst"
// hinaus und widerspraeche der projektweiten zustandslosen-JWT-Entscheidung),
// bleibt ein bereits ausgestelltes Token bis zu seinem Ablauf technisch
// gueltig, wenn es separat vom Cookie extrahiert und wiederverwendet wird.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
w.WriteHeader(http.StatusOK)
}
-56
View File
@@ -1,56 +0,0 @@
package auth
import (
"context"
"errors"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrInvalidCredentials = errors.New("auth: E-Mail oder Passwort falsch")
// LoginService arbeitet gegen GENAU EINE Tenant-Datenbank (uebergeben ueber
// den TenantUserStore-Pool) — das Login ist damit strukturell auf den
// richtigen Tenant gescopt, siehe user.TenantUserStore.GetByEmailForAuth.
type LoginService struct {
users *user.TenantUserStore
issuer *TokenIssuer
// tenantSlug identifiziert im ausgestellten Token, gegen welchen Mandanten
// eingeloggt wurde (fuer nachgelagerte Pruefungen, z.B. Middleware-Logs).
tenantSlug string
}
func NewLoginService(users *user.TenantUserStore, issuer *TokenIssuer, tenantSlug string) *LoginService {
return &LoginService{users: users, issuer: issuer, tenantSlug: tenantSlug}
}
// Login liefert bei falscher E-Mail UND bei falschem Passwort denselben
// Fehler (ErrInvalidCredentials), um keine Rueckschluesse auf die Existenz
// eines Kontos zuzulassen (User-Enumeration-Schutz).
func (s *LoginService) Login(ctx context.Context, email, password string) (string, error) {
creds, err := s.users.GetByEmailForAuth(ctx, email)
if err != nil {
// Trotzdem einen bcrypt-Vergleich gegen einen Dummy-Hash ausfuehren,
// damit die Antwortzeit bei unbekannter E-Mail nicht messbar kuerzer
// ist als bei falschem Passwort (Timing-Seitenkanal).
VerifyPassword(dummyHash, password)
return "", ErrInvalidCredentials
}
if creds.User.Status != user.StatusActive {
return "", ErrInvalidCredentials
}
if !VerifyPassword(creds.PasswordHash, password) {
return "", ErrInvalidCredentials
}
return s.issuer.Issue(creds.User.ID, s.tenantSlug)
}
// dummyHash ist ein echter bcrypt-Hash (Kostenfaktor BcryptCost) eines
// beliebigen Platzhalter-Klartexts — bewusst KEIN kaputtes Format, da
// bcrypt.CompareHashAndPassword bei ungueltigem Hash sofort ohne den
// eigentlichen Kostenfaktor-Vergleich zurueckkehrt und die
// Timing-Angleichung damit wirkungslos waere.
const dummyHash = "$2a$12$cmwiETrG9DK5/uTM2fg4uetngYUspKjME5P8fNpk0QYTaO64N0r3C"
-177
View File
@@ -1,177 +0,0 @@
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const usersSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
func setupTenantDB(t *testing.T, dbName string) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, usersSchema); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
func createUserWithPassword(t *testing.T, store *user.TenantUserStore, email, password string) user.User {
t.Helper()
ctx := context.Background()
u, err := store.Create(ctx, email, "Test User")
if err != nil {
t.Fatalf("create user: %v", err)
}
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
return u
}
func TestLoginService_SuccessAndWrongPassword(t *testing.T) {
pool := setupTenantDB(t, "test_iam02_login")
store := user.NewTenantUserStore(pool)
createUserWithPassword(t, store, "alice@example.com", "korrektes-passwort")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
login := NewLoginService(store, issuer, "acme")
token, err := login.Login(context.Background(), "alice@example.com", "korrektes-passwort")
if err != nil {
t.Fatalf("login: %v", err)
}
if token == "" {
t.Fatal("erwartet nicht-leeres token")
}
if _, err := login.Login(context.Background(), "alice@example.com", "falsches-passwort"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials, habe %v", err)
}
if _, err := login.Login(context.Background(), "unbekannt@example.com", "irgendwas"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials bei unbekannter email, habe %v", err)
}
}
// Pruefung 1: kein Cross-Tenant-Login moeglich, obwohl dieselbe E-Mail in
// zwei unterschiedlichen Tenant-Datenbanken mit unterschiedlichen Passwoertern
// existiert.
func TestLoginService_NoCrossTenantLogin(t *testing.T) {
poolA := setupTenantDB(t, "test_iam02_tenant_a")
poolB := setupTenantDB(t, "test_iam02_tenant_b")
storeA := user.NewTenantUserStore(poolA)
storeB := user.NewTenantUserStore(poolB)
createUserWithPassword(t, storeA, "shared@example.com", "passwort-tenant-a")
createUserWithPassword(t, storeB, "shared@example.com", "passwort-tenant-b")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
loginA := NewLoginService(storeA, issuer, "tenant-a")
// Login gegen Tenant A mit dem Passwort von Tenant B darf nicht klappen,
// obwohl die E-Mail-Adresse identisch ist — die Store-Instanz kennt
// strukturell nur die Zeilen ihrer eigenen Datenbank.
if _, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-b"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials fuer fremdes tenant-passwort, habe %v", err)
}
token, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-a")
if err != nil {
t.Fatalf("login gegen eigenen tenant sollte klappen: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.TenantSlug != "tenant-a" {
t.Fatalf("token tenant = %q, want tenant-a", claims.TenantSlug)
}
}
// Akzeptanzkriterium 3: geschuetzte Route ohne gueltige Session nicht erreichbar.
func TestRequireAuth_BlocksWithoutValidCookie(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Kein Cookie.
req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
rec := httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne cookie: status = %d, want 401", rec.Code)
}
// Manipuliertes Cookie.
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: "kaputt.token.hier"})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("mit kaputtem cookie: status = %d, want 401", rec.Code)
}
// Gueltiges Token.
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("mit gueltigem cookie: status = %d, want 200", rec.Code)
}
}
-41
View File
@@ -1,41 +0,0 @@
package auth
import (
"context"
"net/http"
)
const CookieName = "nexarch_session"
type contextKey int
const claimsContextKey contextKey = iota
// RequireAuth schuetzt eine Route: ohne gueltiges, nicht abgelaufenes Token
// im Session-Cookie wird 401 zurueckgegeben und der Handler nicht aufgerufen
// (IAM-02 Akzeptanzkriterium 3).
func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(CookieName)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsContextKey, claims)
next(w, r.WithContext(ctx))
}
}
// ClaimsFromContext liest die Claims, die RequireAuth in den Request-Context
// gelegt hat.
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(claimsContextKey).(*Claims)
return c, ok
}
-28
View File
@@ -1,28 +0,0 @@
// Package auth implementiert Core IAM-02: Login/Logout, Passwort-Hashing und
// die Middleware zum Schutz von Routen. Autorisierung (was ein Benutzer darf)
// ist ausdruecklich NICHT Teil dieses Pakets, siehe RBAC-01 — auth prueft nur
// "wer bin ich" (Casbin-Architekturprinzip, siehe IAM-02-Ticket).
package auth
import "golang.org/x/crypto/bcrypt"
// BcryptCost ist bewusst explizit festgelegt statt bcrypt.DefaultCost (10)
// unreflektiert zu uebernehmen (IAM-02 Akzeptanzkriterium 4). Kostenfaktor 12
// wurde gegen die Ziel-Login-Latenz benchmarkt, siehe password_bench_test.go
// und den Pruefungs-Eintrag in der Commit-Nachricht.
const BcryptCost = 12
func HashPassword(plain string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword ist timing-safe: bcrypt.CompareHashAndPassword vergleicht
// konstant in der Zeit bzgl. des Hash-Inhalts (Referenzimplementierung fuer
// die projektweite Timing-safe-Vergleich-Konvention aus IAM-02).
func VerifyPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
-42
View File
@@ -1,42 +0,0 @@
package auth
import (
"testing"
"time"
)
// TargetLoginLatency ist der Zielwert aus IAM-02 Akzeptanzkriterium 4: der
// bcrypt-Vergleich allein darf die Login-Latenz nicht dominieren. 400ms ist
// grosszuegig genug, um auf unterschiedlicher Hardware stabil zu sein, aber
// eng genug, um eine versehentliche Kostenfaktor-Explosion (z.B. 16 statt 12)
// zuverlaessig aufzudecken.
const TargetLoginLatency = 400 * time.Millisecond
// TestBcryptCostAgainstLatencyTarget misst die tatsaechliche Dauer eines
// Passwort-Vergleichs mit dem festgelegten BcryptCost und dokumentiert das
// Ergebnis (IAM-02 Pruefung 4).
func TestBcryptCostAgainstLatencyTarget(t *testing.T) {
hash, err := HashPassword("benchmark-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
start := time.Now()
if !VerifyPassword(hash, "benchmark-passwort") {
t.Fatal("verifikation haette erfolgreich sein muessen")
}
elapsed := time.Since(start)
t.Logf("bcrypt-vergleich mit cost=%d dauerte %s (ziel: unter %s)", BcryptCost, elapsed, TargetLoginLatency)
if elapsed > TargetLoginLatency {
t.Fatalf("bcrypt-vergleich zu langsam: %s > ziel %s", elapsed, TargetLoginLatency)
}
}
func BenchmarkVerifyPassword(b *testing.B) {
hash, _ := HashPassword("benchmark-passwort")
b.ResetTimer()
for i := 0; i < b.N; i++ {
VerifyPassword(hash, "benchmark-passwort")
}
}
-28
View File
@@ -1,28 +0,0 @@
package auth
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("s3hr-geheim!")
if err != nil {
t.Fatalf("hash: %v", err)
}
if hash == "s3hr-geheim!" {
t.Fatal("passwort wurde nicht gehasht")
}
if !VerifyPassword(hash, "s3hr-geheim!") {
t.Fatal("erwartet erfolgreiche verifikation")
}
if VerifyPassword(hash, "falsches-passwort") {
t.Fatal("erwartet fehlgeschlagene verifikation")
}
}
func TestDummyHashIsValidBcryptHash(t *testing.T) {
// Stellt sicher, dass der Timing-Angleichs-Hash in login.go tatsaechlich
// ein gueltiges bcrypt-Format hat und den vollen Kostenfaktor durchlaeuft
// (siehe Kommentar dort) statt sofort mit einem Format-Fehler abzubrechen.
if VerifyPassword(dummyHash, "irgendein-text") {
t.Fatal("dummyHash sollte fuer beliebigen text nicht passen")
}
}
-63
View File
@@ -1,63 +0,0 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger
// Tokens), passend zur "so vertrauenswuerdig wie noetig"-Produkt-DNA.
const AccessTokenTTL = 30 * time.Minute
var ErrInvalidToken = errors.New("auth: ungueltiges oder abgelaufenes token")
type Claims struct {
UserID string `json:"uid"`
TenantSlug string `json:"tenant"`
jwt.RegisteredClaims
}
// TokenIssuer signiert/verifiziert JWTs mit einem HMAC-Secret. Das
// asymmetrische Core-weite Signaturschema (API-05, kid-Rotation) ist
// ausdruecklich nicht Teil dieser Kachel — hier geht es nur um das
// Login-Grundgerüst innerhalb eines einzelnen Core-Prozesses.
type TokenIssuer struct {
secret []byte
}
func NewTokenIssuer(secret string) *TokenIssuer {
return &TokenIssuer{secret: []byte(secret)}
}
func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
TenantSlug: tenantSlug,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(i.secret)
}
// Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene
// Tokens automatisch ab) — der Signaturvergleich in golang-jwt ist
// timing-safe (hmac.Equal).
func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return i.secret, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
-82
View File
@@ -1,82 +0,0 @@
package auth
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestTokenIssueAndVerify(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.UserID != "user-1" || claims.TenantSlug != "acme" {
t.Fatalf("claims unerwartet: %+v", claims)
}
}
// Pruefung 2: Token-Manipulationstest.
func TestTokenVerify_RejectsManipulatedPayload(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("unerwartetes token-format: %d teile", len(parts))
}
// Payload-Segment leicht veraendern (Signatur passt danach nicht mehr).
tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2]
if _, err := issuer.Verify(tampered); err == nil {
t.Fatal("erwartet fehler bei manipuliertem token, habe nil")
}
}
func TestTokenVerify_RejectsWrongSecret(t *testing.T) {
issuer := NewTokenIssuer("secret-a")
other := NewTokenIssuer("secret-b")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := other.Verify(token); err == nil {
t.Fatal("erwartet fehler bei falschem secret, habe nil")
}
}
// Pruefung 3: abgelaufenes Token erzwingt Neuanmeldung.
func TestTokenVerify_RejectsExpiredToken(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
claims := Claims{
UserID: "user-1",
TenantSlug: "acme",
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * AccessTokenTTL)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
expired, err := tok.SignedString([]byte("test-secret-nur-fuer-tests"))
if err != nil {
t.Fatalf("signieren: %v", err)
}
if _, err := issuer.Verify(expired); err == nil {
t.Fatal("erwartet fehler bei abgelaufenem token, habe nil")
}
}
+9
View File
@@ -19,6 +19,9 @@ type Config struct {
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
TenantDSNTemplate string
// TenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
// TEN-02 eine frisch provisionierte Tenant-Datenbank initialisiert.
TenantSchemaPath string
}
func Load() (Config, error) {
@@ -37,6 +40,11 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
}
schemaPath := os.Getenv("NEXARCH_TENANT_SCHEMA_PATH")
if schemaPath == "" {
return Config{}, fmt.Errorf("NEXARCH_TENANT_SCHEMA_PATH not set")
}
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
if addr == "" {
addr = ":8080"
@@ -47,5 +55,6 @@ func Load() (Config, error) {
RegistryDSN: dsn,
AdminDSN: adminDSN,
TenantDSNTemplate: dsnTemplate,
TenantSchemaPath: schemaPath,
}, nil
}
-126
View File
@@ -1,126 +0,0 @@
// Package secheaders implementiert Core API-08: eine gemeinsame
// Security-Header-Middleware/Bibliothek, die jedes Modul-Frontend (DMS,
// Mail, Workflow, AI, Connect) einbindet, statt Header pro Modul erneut zu
// definieren (Akzeptanzkriterium 3) — dieselbe Konsolidierungslogik wie
// beim zentralen Rate-Limiting (API-03). Die tatsaechliche EINBINDUNG in
// jedes einzelne Modul-Frontend ist ausdruecklich NICHT Teil dieser Kachel
// (siehe Ticket "Nicht Bestandteil").
package secheaders
import (
"fmt"
"net/http"
"strings"
"time"
)
// Config ist die Baseline-Konfiguration. DefaultConfig() liefert einen
// Ausgangspunkt OHNE 'unsafe-inline'/'unsafe-eval' (Akzeptanzkriterium 1)
// und mit angemessener HSTS-Laufzeit (Akzeptanzkriterium 2).
type Config struct {
// ContentSecurityPolicy wird unveraendert als Header-Wert gesendet.
ContentSecurityPolicy string
// HSTSMaxAge bestimmt die Gueltigkeitsdauer, die Browser sich merken.
HSTSMaxAge time.Duration
HSTSIncludeSubdomains bool
FrameOptions string
ReferrerPolicy string
ContentTypeNoSniff bool
}
// DefaultConfig liefert die dokumentierte Baseline: restriktive CSP ohne
// unsichere Direktiven, HSTS mit 180 Tagen (empfohlener Mindestwert fuer
// HSTS-Preloading-Listen liegt bei 1 Jahr — 180 Tage ist ein bewusst
// konservativer, aber bereits "angemessener" Ausgangswert, siehe
// Akzeptanzkriterium 2), Clickjacking- und MIME-Sniffing-Schutz.
func DefaultConfig() Config {
return Config{
ContentSecurityPolicy: strings.Join([]string{
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
}, "; "),
HSTSMaxAge: 180 * 24 * time.Hour,
HSTSIncludeSubdomains: true,
FrameOptions: "DENY",
ReferrerPolicy: "strict-origin-when-cross-origin",
ContentTypeNoSniff: true,
}
}
// RequiredHeaders sind die von dieser Baseline gesetzten Header-Namen — die
// Referenzliste fuer CheckCoverage (Akzeptanzkriterium/Pruefung 1).
var RequiredHeaders = []string{
"Content-Security-Policy",
"Strict-Transport-Security",
"X-Frame-Options",
"Referrer-Policy",
"X-Content-Type-Options",
}
func (c Config) hstsValue() string {
v := fmt.Sprintf("max-age=%d", int(c.HSTSMaxAge.Seconds()))
if c.HSTSIncludeSubdomains {
v += "; includeSubDomains"
}
return v
}
// Middleware setzt die Baseline-Header auf JEDER Antwort und erzwingt einen
// HTTP->HTTPS-Redirect, WENN die Anfrage nicht bereits ueber TLS ankam
// (Akzeptanzkriterium 2 / Pruefung 3) — serverseitig durchgesetzt, nicht nur
// dem Browser-HSTS-Cache ueberlassen (der erst nach dem ERSTEN sicheren
// Aufruf greift).
func Middleware(cfg Config, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !isSecure(r) {
target := "https://" + r.Host + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
return
}
SetHeaders(cfg, w)
next(w, r)
}
}
// SetHeaders setzt die Baseline-Header direkt, OHNE die HTTPS-Redirect-
// Pruefung von Middleware — nuetzlich fuer lokale Entwicklungs-/
// Demo-Umgebungen ohne TLS-Terminierung (siehe cmd/secheaders-devserver)
// und fuer Aufrufer, die den Redirect selbst schon anderweitig sicherstellen.
func SetHeaders(cfg Config, w http.ResponseWriter) {
w.Header().Set("Content-Security-Policy", cfg.ContentSecurityPolicy)
w.Header().Set("Strict-Transport-Security", cfg.hstsValue())
w.Header().Set("X-Frame-Options", cfg.FrameOptions)
w.Header().Set("Referrer-Policy", cfg.ReferrerPolicy)
if cfg.ContentTypeNoSniff {
w.Header().Set("X-Content-Type-Options", "nosniff")
}
}
// isSecure erkennt TLS direkt ODER ueber den ueblichen Reverse-Proxy-Header
// (X-Forwarded-Proto) — der Core-Dienst laeuft in der Praxis hinter einem
// TLS-terminierenden Proxy, sieht r.TLS also nie direkt gesetzt.
func isSecure(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
// CheckCoverage vergleicht die tatsaechlich gesetzten Header einer Antwort
// gegen RequiredHeaders und liefert die fehlenden — der "automatisierte
// Header-Scan" aus Pruefung 1, anwendbar auf jede Antwort eines beliebigen
// Modul-Frontends.
func CheckCoverage(headers http.Header) (missing []string) {
for _, name := range RequiredHeaders {
if headers.Get(name) == "" {
missing = append(missing, name)
}
}
return missing
}
-113
View File
@@ -1,113 +0,0 @@
package secheaders
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func secureRequest(method, target string) *http.Request {
req := httptest.NewRequest(method, target, nil)
req.Header.Set("X-Forwarded-Proto", "https")
return req
}
// Akzeptanzkriterium 1: CSP definiert ohne unsafe-inline/unsafe-eval.
func TestDefaultConfig_CSPHasNoUnsafeDirectives(t *testing.T) {
cfg := DefaultConfig()
if strings.Contains(cfg.ContentSecurityPolicy, "unsafe-inline") {
t.Fatal("csp enthaelt 'unsafe-inline', erwartet keine unsicheren direktiven")
}
if strings.Contains(cfg.ContentSecurityPolicy, "unsafe-eval") {
t.Fatal("csp enthaelt 'unsafe-eval', erwartet keine unsicheren direktiven")
}
if !strings.Contains(cfg.ContentSecurityPolicy, "default-src 'self'") {
t.Fatal("erwartet restriktive default-src 'self' als ausgangspunkt")
}
}
// Akzeptanzkriterium 2: HSTS mit angemessener max-age gesetzt.
func TestMiddleware_SetsHSTSWithSufficientMaxAge(t *testing.T) {
cfg := DefaultConfig()
handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
rec := httptest.NewRecorder()
handler(rec, secureRequest(http.MethodGet, "https://example.test/"))
hsts := rec.Header().Get("Strict-Transport-Security")
if !strings.Contains(hsts, "max-age=") {
t.Fatalf("hsts-header fehlt max-age: %q", hsts)
}
// "Angemessen" heisst hier: mindestens 30 Tage (2592000s) — deutlich
// ueber dem bekannten Anti-Pattern kurzer/fehlender max-age-Werte.
if cfg.HSTSMaxAge.Seconds() < 2592000 {
t.Fatalf("konfigurierte hsts-max-age = %v, erwartet mindestens 30 tage", cfg.HSTSMaxAge)
}
if !strings.Contains(hsts, "includeSubDomains") {
t.Fatalf("erwartet includeSubDomains im hsts-header: %q", hsts)
}
}
// Akzeptanzkriterium 3 + Pruefung 1: EINE gemeinsame Middleware setzt ALLE
// Baseline-Header vollstaendig — automatisiert ueberprueft mit CheckCoverage.
func TestMiddleware_ProducesFullHeaderCoverage(t *testing.T) {
cfg := DefaultConfig()
handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
rec := httptest.NewRecorder()
handler(rec, secureRequest(http.MethodGet, "https://example.test/"))
if missing := CheckCoverage(rec.Header()); len(missing) != 0 {
t.Fatalf("erwartet vollstaendige header-abdeckung, fehlen: %v", missing)
}
}
// Pruefung 1 (Kehrseite): CheckCoverage erkennt fehlende Header
// zuverlaessig — Voraussetzung dafuer, dass ein automatisierter Scan
// gegen ein Modul-Frontend OHNE eingebundene Middleware ueberhaupt etwas
// meldet.
func TestCheckCoverage_DetectsMissingHeadersOnBareHandler(t *testing.T) {
bareHandler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }
rec := httptest.NewRecorder()
bareHandler(rec, httptest.NewRequest(http.MethodGet, "/", nil))
missing := CheckCoverage(rec.Header())
if len(missing) != len(RequiredHeaders) {
t.Fatalf("erwartet %d fehlende header bei unbehandeltem handler, habe %d: %v",
len(RequiredHeaders), len(missing), missing)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: ein Aufruf ueber (simuliertes) HTTP
// wird server-seitig auf HTTPS umgeleitet, statt sich allein auf den
// HSTS-Browser-Cache zu verlassen (der erst nach dem ersten sicheren
// Aufruf greift).
func TestMiddleware_RedirectsInsecureRequestToHTTPS(t *testing.T) {
cfg := DefaultConfig()
called := false
handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "http://example.test/pfad?x=1", nil)
// Kein X-Forwarded-Proto, kein r.TLS -> unsicherer Aufruf.
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusMovedPermanently {
t.Fatalf("status = %d, want 301 (redirect auf https)", rec.Code)
}
location := rec.Header().Get("Location")
if !strings.HasPrefix(location, "https://") {
t.Fatalf("location = %q, want https://-praefix", location)
}
if !strings.Contains(location, "/pfad?x=1") {
t.Fatalf("location = %q, erwartet erhaltenen pfad+query", location)
}
if called {
t.Fatal("eigentlicher handler haette bei unsicherem aufruf nicht erreicht werden duerfen")
}
}
+49
View File
@@ -2,7 +2,10 @@ package tenant
import (
"encoding/json"
"errors"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
@@ -43,3 +46,49 @@ func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(t)
}
// OnboardingHandler stellt den Self-Service-Endpunkt aus TEN-02 bereit —
// eigener Handler statt Erweiterung von Handler.CreateTenant, damit die
// TEN-01-Schnittstelle (reines Provisioning ohne Admin-Konto) unveraendert
// bleibt.
type OnboardingHandler struct {
service *OnboardingService
}
func NewOnboardingHandler(s *OnboardingService) *OnboardingHandler {
return &OnboardingHandler{service: s}
}
type onboardRequest struct {
Slug string `json:"slug"`
Name string `json:"name"`
AdminEmail string `json:"admin_email"`
AdminName string `json:"admin_name"`
}
func (h *OnboardingHandler) Onboard(w http.ResponseWriter, r *http.Request) {
var req onboardRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
result, err := h.service.Onboard(r.Context(), req.Slug, req.Name, req.AdminEmail, req.AdminName)
if err != nil {
switch {
case errors.Is(err, ErrInvalidSlug),
errors.Is(err, ErrMissingField),
errors.Is(err, user.ErrInvalidEmail):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, ErrTenantExists):
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, "mandant konnte nicht angelegt werden", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(result)
}
+206
View File
@@ -0,0 +1,206 @@
package tenant
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrTenantNotFound = errors.New("tenant: nicht gefunden")
ErrInvalidTransition = errors.New("tenant: ungueltiger zustandsuebergang")
// ErrTenantNotActive wird von Lifecycle.CheckActive verwendet — bewusst
// EIN Fehler fuer suspendiert/zur-Loeschung-vorgemerkt/geloescht, da der
// Aufrufer (z.B. Login) nur wissen muss "kein Zugriff", nicht welcher der
// Nicht-aktiv-Zustaende genau vorliegt.
ErrTenantNotActive = errors.New("tenant: nicht aktiv")
)
func scanTenantWithLifecycle(row pgx.Row) (Tenant, error) {
var t Tenant
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status,
&t.CreatedAt, &t.PreviousStatus, &t.DeletionScheduledAt); err != nil {
return Tenant{}, err
}
return t, nil
}
// transition fuehrt einen bewachten Zustandsuebergang aus: das UPDATE greift
// nur, wenn der aktuelle Status einer von allowedFrom ist (atomarer
// Check-and-Set, kein Race zwischen Lesen und Schreiben). Greift es nicht,
// wird zwischen "Tenant existiert nicht" und "Uebergang nicht erlaubt"
// unterschieden, damit AC1 ("ungueltige Uebergaenge werden abgewiesen") einen
// sprechenden Fehler liefert statt eines stillen No-Ops.
func (r *Registry) transition(ctx context.Context, slug string, allowedFrom []Status, to Status, previousStatus *string, deletionAt *time.Time) (Tenant, error) {
from := make([]string, len(allowedFrom))
for i, s := range allowedFrom {
from[i] = string(s)
}
row := r.pool.QueryRow(ctx, `
UPDATE tenants
SET status = $2, previous_status = $3, deletion_scheduled_at = $4
WHERE slug = $1 AND status = ANY($5)
RETURNING id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
`, slug, string(to), previousStatus, deletionAt, from)
t, err := scanTenantWithLifecycle(row)
if err == nil {
return t, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return Tenant{}, fmt.Errorf("zustandsuebergang: %w", err)
}
existing, getErr := r.GetBySlug(ctx, slug)
if getErr != nil {
return Tenant{}, ErrTenantNotFound
}
return Tenant{}, fmt.Errorf("%w: von %q nach %q (aktuell: %q)", ErrInvalidTransition, allowedFrom, to, existing.Status)
}
// Suspend haelt die Daten des Mandanten unveraendert, sperrt aber den Zugriff
// (Akzeptanzkriterium 1) — es findet keine Loeschung/Migration statt.
func (r *Registry) Suspend(ctx context.Context, slug string) (Tenant, error) {
return r.transition(ctx, slug, []Status{StatusActive}, StatusSuspended, nil, nil)
}
// Reactivate stellt den Zustand vor der Suspendierung vollstaendig wieder her
// (Akzeptanzkriterium 2) — da Suspend keine weiteren Daten veraendert, genuegt
// die Rueckkehr nach StatusActive.
func (r *Registry) Reactivate(ctx context.Context, slug string) (Tenant, error) {
return r.transition(ctx, slug, []Status{StatusSuspended}, StatusActive, nil, nil)
}
// ScheduleDeletion merkt den Mandanten zur Loeschung vor und startet die
// Karenzzeit (Akzeptanzkriterium 3). previous_status wird festgehalten, damit
// CancelDeletion exakt dorthin zurueckkehren kann (aktiv ODER suspendiert).
func (r *Registry) ScheduleDeletion(ctx context.Context, slug string, grace time.Duration) (Tenant, error) {
existing, err := r.GetBySlug(ctx, slug)
if err != nil {
return Tenant{}, ErrTenantNotFound
}
prev := string(existing.Status)
deletionAt := time.Now().Add(grace)
return r.transition(ctx, slug, []Status{StatusActive, StatusSuspended}, StatusPendingDeletion, &prev, &deletionAt)
}
// CancelDeletion widerruft eine Loeschvormerkung innerhalb der Karenzzeit und
// stellt exakt den zuvor gesicherten Zustand wieder her.
func (r *Registry) CancelDeletion(ctx context.Context, slug string) (Tenant, error) {
existing, err := r.GetBySlug(ctx, slug)
if err != nil {
return Tenant{}, ErrTenantNotFound
}
if existing.Status != StatusPendingDeletion || existing.PreviousStatus == nil {
return Tenant{}, fmt.Errorf("%w: von %q nach aktiv/suspendiert (aktuell: %q)", ErrInvalidTransition, StatusPendingDeletion, existing.Status)
}
restoreTo := Status(*existing.PreviousStatus)
return r.transition(ctx, slug, []Status{StatusPendingDeletion}, restoreTo, nil, nil)
}
// Lifecycle fuehrt die tatsaechliche, physische Loeschung nach Ablauf der
// Karenzzeit aus (Datenbank-Drop) und stellt die Zugriffsschutz-Pruefung
// bereit. Getrennt von Registry, weil hierfuer zusaetzlich der adminPool
// (fuer DROP DATABASE) noetig ist, siehe internal/tenant.Provisioner.
type Lifecycle struct {
registry *Registry
adminPool *pgxpool.Pool
}
func NewLifecycle(registry *Registry, adminPool *pgxpool.Pool) *Lifecycle {
return &Lifecycle{registry: registry, adminPool: adminPool}
}
// CheckActive verweigert Zugriff fuer jeden Nicht-aktiv-Zustand und loggt den
// Vorgang strukturiert (Akzeptanzkriterium 1 / Pruefung 2).
func (l *Lifecycle) CheckActive(ctx context.Context, slug string) error {
t, err := l.registry.GetBySlug(ctx, slug)
if err != nil {
return ErrTenantNotFound
}
if t.Status != StatusActive {
slog.Warn("zugriff auf nicht-aktiven mandanten verweigert",
"tenant_slug", slug, "tenant_status", t.Status)
return ErrTenantNotActive
}
return nil
}
// ProcessDueDeletions loescht alle Mandanten-Datenbanken, deren Karenzzeit
// abgelaufen ist (Akzeptanzkriterium 3 / Pruefung 3). FOR UPDATE SKIP LOCKED
// folgt der projektweiten Postgres-Jobqueue-Konvention (siehe
// SKALIERUNGSKONZEPT.md) und macht die Funktion sicher fuer mehrere parallel
// laufende Core-Instanzen.
func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) {
tx, err := l.registry.pool.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("sweep-transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx, `
SELECT id, db_name FROM tenants
WHERE status = $1 AND deletion_scheduled_at <= now()
FOR UPDATE SKIP LOCKED
`, string(StatusPendingDeletion))
if err != nil {
return 0, fmt.Errorf("faellige loeschungen abfragen: %w", err)
}
type due struct{ id, dbName string }
var candidates []due
for rows.Next() {
var d due
if err := rows.Scan(&d.id, &d.dbName); err != nil {
rows.Close()
return 0, fmt.Errorf("faellige loeschung lesen: %w", err)
}
candidates = append(candidates, d)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
processed := 0
for _, c := range candidates {
if _, err := l.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, c.dbName)); err != nil {
return processed, fmt.Errorf("tenant-datenbank %q loeschen: %w", c.dbName, err)
}
if _, err := tx.Exec(ctx, `
UPDATE tenants SET status = $2, previous_status = NULL, deletion_scheduled_at = NULL
WHERE id = $1
`, c.id, string(StatusDeleted)); err != nil {
return processed, fmt.Errorf("tenant %q als geloescht markieren: %w", c.id, err)
}
processed++
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("sweep-transaktion committen: %w", err)
}
return processed, nil
}
// RunSweeper triggert ProcessDueDeletions periodisch, bis ctx beendet wird —
// die "In-Prozess-Worker-Goroutine" aus der projektweiten Jobqueue-Konvention.
func (l *Lifecycle) RunSweeper(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := l.ProcessDueDeletions(ctx); err != nil {
slog.Error("tenant-loeschung-sweep fehlgeschlagen", "error", err)
}
}
}
}
+253
View File
@@ -0,0 +1,253 @@
package tenant
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
registryPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
if _, err := registryPool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
previous_status TEXT,
deletion_scheduled_at TIMESTAMPTZ
)`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
registry := NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
lifecycle := NewLifecycle(registry, adminPool)
cleanup := func() {
registryPool.Close()
adminPool.Close()
}
_ = provisioner
return registry, lifecycle, adminPool, cleanup
}
func provisionTestTenant(t *testing.T, registry *Registry, adminPool *pgxpool.Pool, slug string) {
t.Helper()
dsnTemplate := strings.Replace(os.Getenv("TEST_ADMIN_DSN"), "/postgres?", "/%s?", 1)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
if _, err := provisioner.Provision(context.Background(), slug, slug); err != nil {
t.Fatalf("provision %s: %v", slug, err)
}
t.Cleanup(func() {
ctx := context.Background()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
_, _ = registry.pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
})
}
// Akzeptanzkriterium 1 (Suspend) + 2 (Reactivate) + Pruefung 1 (Uebergaenge).
func TestLifecycle_SuspendAndReactivate(t *testing.T) {
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
defer cleanup()
provisionTestTenant(t, registry, adminPool, "lc_suspend")
ctx := context.Background()
suspended, err := registry.Suspend(ctx, "lc_suspend")
if err != nil {
t.Fatalf("suspend: %v", err)
}
if suspended.Status != StatusSuspended {
t.Fatalf("status = %q, want suspended", suspended.Status)
}
reactivated, err := registry.Reactivate(ctx, "lc_suspend")
if err != nil {
t.Fatalf("reactivate: %v", err)
}
if reactivated.Status != StatusActive {
t.Fatalf("status = %q, want active", reactivated.Status)
}
}
// Pruefung 1: ungueltige Uebergaenge werden abgewiesen.
func TestLifecycle_RejectsInvalidTransitions(t *testing.T) {
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
defer cleanup()
provisionTestTenant(t, registry, adminPool, "lc_invalid")
ctx := context.Background()
// Reactivate auf einem bereits aktiven Tenant ist kein gueltiger Uebergang.
if _, err := registry.Reactivate(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
}
if _, err := registry.Suspend(ctx, "lc_invalid"); err != nil {
t.Fatalf("suspend: %v", err)
}
// Suspend auf einem bereits suspendierten Tenant ist ebenfalls ungueltig.
if _, err := registry.Suspend(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
}
// CancelDeletion ohne vorherige Loeschvormerkung ist ungueltig.
if _, err := registry.CancelDeletion(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
}
if _, err := registry.Suspend(ctx, "unbekannter-slug-xyz"); !errors.Is(err, ErrTenantNotFound) {
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
}
}
// Akzeptanzkriterium 3: Loeschung zweistufig mit Karenzzeit, innerhalb der
// Frist widerrufbar — sowohl aus 'active' als auch aus 'suspended' heraus,
// mit exakter Wiederherstellung des jeweiligen Vorzustands.
func TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState(t *testing.T) {
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
defer cleanup()
provisionTestTenant(t, registry, adminPool, "lc_cancel_active")
provisionTestTenant(t, registry, adminPool, "lc_cancel_suspended")
ctx := context.Background()
// Fall 1: aus 'active' heraus vorgemerkt und widerrufen.
scheduled, err := registry.ScheduleDeletion(ctx, "lc_cancel_active", time.Hour)
if err != nil {
t.Fatalf("schedule deletion: %v", err)
}
if scheduled.Status != StatusPendingDeletion {
t.Fatalf("status = %q, want pending_deletion", scheduled.Status)
}
if scheduled.DeletionScheduledAt == nil {
t.Fatal("erwartet gesetzte deletion_scheduled_at")
}
restored, err := registry.CancelDeletion(ctx, "lc_cancel_active")
if err != nil {
t.Fatalf("cancel deletion: %v", err)
}
if restored.Status != StatusActive {
t.Fatalf("status = %q, want active (vorheriger zustand)", restored.Status)
}
// Fall 2: aus 'suspended' heraus vorgemerkt und widerrufen — muss zu
// 'suspended' zurueckkehren, NICHT zu 'active'.
if _, err := registry.Suspend(ctx, "lc_cancel_suspended"); err != nil {
t.Fatalf("suspend: %v", err)
}
if _, err := registry.ScheduleDeletion(ctx, "lc_cancel_suspended", time.Hour); err != nil {
t.Fatalf("schedule deletion: %v", err)
}
restoredSuspended, err := registry.CancelDeletion(ctx, "lc_cancel_suspended")
if err != nil {
t.Fatalf("cancel deletion: %v", err)
}
if restoredSuspended.Status != StatusSuspended {
t.Fatalf("status = %q, want suspended (vorheriger zustand)", restoredSuspended.Status)
}
}
// Akzeptanzkriterium 1 + Pruefung 2: suspendierter Tenant erzeugt bei jedem
// Zugriffsversuch einen klaren Fehler.
func TestLifecycle_CheckActive_RejectsNonActive(t *testing.T) {
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
defer cleanup()
provisionTestTenant(t, registry, adminPool, "lc_checkactive")
ctx := context.Background()
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); err != nil {
t.Fatalf("aktiver tenant sollte durchgehen, habe %v", err)
}
if _, err := registry.Suspend(ctx, "lc_checkactive"); err != nil {
t.Fatalf("suspend: %v", err)
}
for i := 0; i < 3; i++ {
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); !errors.Is(err, ErrTenantNotActive) {
t.Fatalf("versuch %d: erwartet ErrTenantNotActive, habe %v", i, err)
}
}
if err := lifecycle.CheckActive(ctx, "nie-registriert"); !errors.Is(err, ErrTenantNotFound) {
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Loeschvorgang nach Ablauf der Karenzzeit
// automatisch ausgeloest (hier durch direkten Aufruf von ProcessDueDeletions,
// das RunSweeper periodisch aufruft).
func TestLifecycle_ProcessDueDeletions(t *testing.T) {
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
defer cleanup()
provisionTestTenant(t, registry, adminPool, "lc_due")
provisionTestTenant(t, registry, adminPool, "lc_not_due")
ctx := context.Background()
// lc_due: Karenzzeit liegt bereits in der Vergangenheit -> faellig.
if _, err := registry.ScheduleDeletion(ctx, "lc_due", -time.Minute); err != nil {
t.Fatalf("schedule deletion (due): %v", err)
}
// lc_not_due: Karenzzeit liegt weit in der Zukunft -> nicht faellig.
if _, err := registry.ScheduleDeletion(ctx, "lc_not_due", time.Hour); err != nil {
t.Fatalf("schedule deletion (not due): %v", err)
}
processed, err := lifecycle.ProcessDueDeletions(ctx)
if err != nil {
t.Fatalf("process due deletions: %v", err)
}
if processed != 1 {
t.Fatalf("erwartet genau 1 verarbeitete loeschung, habe %d", processed)
}
due, err := registry.GetBySlug(ctx, "lc_due")
if err != nil {
t.Fatalf("get lc_due: %v", err)
}
if due.Status != StatusDeleted {
t.Fatalf("lc_due status = %q, want deleted", due.Status)
}
notDue, err := registry.GetBySlug(ctx, "lc_not_due")
if err != nil {
t.Fatalf("get lc_not_due: %v", err)
}
if notDue.Status != StatusPendingDeletion {
t.Fatalf("lc_not_due status = %q, want pending_deletion (noch nicht faellig)", notDue.Status)
}
// Datenbank von lc_due wurde tatsaechlich physisch entfernt.
var exists bool
if err := adminPool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
dbNameForSlug("lc_due")).Scan(&exists); err != nil {
t.Fatalf("pg_database pruefen: %v", err)
}
if exists {
t.Fatal("erwartet, dass die tenant-datenbank von lc_due geloescht wurde")
}
}
+83
View File
@@ -0,0 +1,83 @@
package tenant
import (
"context"
"errors"
"fmt"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrMissingField = errors.New("tenant: pflichtfeld fehlt")
// OnboardingService implementiert TEN-02: Self-Service-Anlage eines neuen
// Mandanten inklusive Ersteinrichtung des Administrator-Kontos in einem
// Vorgang. Schlaegt die Admin-Anlage fehl, wird der bereits provisionierte
// Tenant per Deprovision wieder vollstaendig entfernt (Saga-Kompensation,
// da CREATE DATABASE keine echte cross-database Transaktion erlaubt).
type OnboardingService struct {
provisioner *Provisioner
// tenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
// die frisch angelegte Tenant-Datenbank initialisiert wird, bevor der
// erste Administrator angelegt werden kann.
tenantSchemaPath string
}
func NewOnboardingService(provisioner *Provisioner, tenantSchemaPath string) *OnboardingService {
return &OnboardingService{provisioner: provisioner, tenantSchemaPath: tenantSchemaPath}
}
type OnboardResult struct {
Tenant Tenant
Admin user.User
}
func (s *OnboardingService) Onboard(ctx context.Context, slug, tenantName, adminEmail, adminName string) (OnboardResult, error) {
if tenantName == "" {
return OnboardResult{}, fmt.Errorf("%w: name", ErrMissingField)
}
if adminName == "" {
return OnboardResult{}, fmt.Errorf("%w: admin-name", ErrMissingField)
}
if err := ValidateSlug(slug); err != nil {
return OnboardResult{}, err
}
if err := user.ValidateEmail(adminEmail); err != nil {
return OnboardResult{}, err
}
t, err := s.provisioner.Provision(ctx, slug, tenantName)
if err != nil {
return OnboardResult{}, err
}
admin, err := s.setupAdmin(ctx, t, adminEmail, adminName)
if err != nil {
_ = s.provisioner.Deprovision(ctx, t)
return OnboardResult{}, err
}
return OnboardResult{Tenant: t, Admin: admin}, nil
}
func (s *OnboardingService) setupAdmin(ctx context.Context, t Tenant, adminEmail, adminName string) (user.User, error) {
pool, err := pgxpool.New(ctx, t.DBDSN)
if err != nil {
return user.User{}, fmt.Errorf("verbindung zur neuen tenant-datenbank: %w", err)
}
defer pool.Close()
schema, err := os.ReadFile(s.tenantSchemaPath)
if err != nil {
return user.User{}, fmt.Errorf("tenant-schema lesen: %w", err)
}
if _, err := pool.Exec(ctx, string(schema)); err != nil {
return user.User{}, fmt.Errorf("tenant-schema anwenden: %w", err)
}
store := user.NewTenantUserStore(pool)
return store.Create(ctx, adminEmail, adminName)
}
+169
View File
@@ -0,0 +1,169 @@
package tenant
import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// schemaPathForTest liefert den Pfad zur echten Migration, damit Tests exakt
// dasselbe Schema anwenden wie die Produktion (keine Zweitkopie des SQL).
const schemaPathForTest = "../../migrations/tenant/0001_users.up.sql"
func TestOnboarding_ValidationErrors(t *testing.T) {
// Reine Eingabevalidierung, braucht keine Datenbank.
svc := NewOnboardingService(nil, schemaPathForTest)
ctx := context.Background()
cases := []struct {
name string
slug string
tenantName string
email string
adminName string
wantErr error
}{
{"leerer name", "acme", "", "a@b.de", "Admin", ErrMissingField},
{"leerer admin-name", "acme", "Acme", "a@b.de", "", ErrMissingField},
{"ungueltiger slug", "Acme!", "Acme", "a@b.de", "Admin", ErrInvalidSlug},
{"ungueltige email", "acme", "Acme", "keine-email", "Admin", user.ErrInvalidEmail},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := svc.Onboard(ctx, c.slug, c.tenantName, c.email, c.adminName)
if !errors.Is(err, c.wantErr) {
t.Fatalf("erwartet %v, habe %v", c.wantErr, err)
}
})
}
}
func newTestOnboardingService(t *testing.T) (*OnboardingService, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
registryPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
if _, err := registryPool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
registry := NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
svc := NewOnboardingService(provisioner, schemaPathForTest)
cleanup := func() {
registryPool.Close()
adminPool.Close()
}
return svc, cleanup
}
// Akzeptanzkriterien 1+2: Mandant + Erst-Admin in einem Vorgang.
func TestOnboarding_CreatesTenantAndAdmin(t *testing.T) {
svc, cleanup := newTestOnboardingService(t)
defer cleanup()
ctx := context.Background()
t.Cleanup(func() {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
p, _ := pgxpool.New(ctx, adminDSN)
defer p.Close()
_, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'onboardtest'`)
_, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_onboardtest`)
})
result, err := svc.Onboard(ctx, "onboardtest", "Onboard Test GmbH", "admin@onboardtest.example", "Erste Admin")
if err != nil {
t.Fatalf("onboard: %v", err)
}
if result.Tenant.Slug != "onboardtest" {
t.Fatalf("tenant slug = %q", result.Tenant.Slug)
}
if result.Admin.Email != "admin@onboardtest.example" {
t.Fatalf("admin email = %q", result.Admin.Email)
}
pool, err := pgxpool.New(ctx, result.Tenant.DBDSN)
if err != nil {
t.Fatalf("connect tenant db: %v", err)
}
defer pool.Close()
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "admin@onboardtest.example").Scan(&count); err != nil {
t.Fatalf("admin in tenant-db pruefen: %v", err)
}
if count != 1 {
t.Fatalf("erwartet genau einen admin in der tenant-db, habe %d", count)
}
}
// Akzeptanzkriterium 3 + Pruefung 1: doppelte Registrierung wird abgewiesen,
// auch bei zwei parallelen Anfragen entsteht kein Doppel-Tenant.
func TestOnboarding_RejectsDuplicateSlugConcurrently(t *testing.T) {
svc, cleanup := newTestOnboardingService(t)
defer cleanup()
ctx := context.Background()
t.Cleanup(func() {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
p, _ := pgxpool.New(ctx, adminDSN)
defer p.Close()
_, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'dupetest'`)
_, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_dupetest`)
})
var wg sync.WaitGroup
results := make([]error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, err := svc.Onboard(ctx, "dupetest", "Dupe Test", "admin@dupetest.example", "Admin")
results[i] = err
}(i)
}
wg.Wait()
successCount := 0
for _, err := range results {
if err == nil {
successCount++
} else if !errors.Is(err, ErrTenantExists) {
t.Fatalf("unerwarteter fehler: %v", err)
}
}
if successCount != 1 {
t.Fatalf("erwartet genau einen erfolgreichen onboarding-vorgang, habe %d", successCount)
}
}
+31
View File
@@ -2,11 +2,17 @@ package tenant
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrTenantExists wird zurueckgegeben, wenn Slug oder Datenbankname eines
// Mandanten bereits vergeben sind (TEN-02 Akzeptanzkriterium 3).
var ErrTenantExists = errors.New("tenant: slug bereits vergeben")
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
@@ -39,6 +45,14 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
var pgErr *pgconn.PgError
// 42P04 (duplicate_database) im Normalfall; bei zwei echt parallelen
// CREATE DATABASE auf denselben Namen liefert Postgres stattdessen den
// internen Unique-Index-Konflikt 23505 auf pg_database — beide Faelle
// bedeuten: der Tenant existiert bereits bzw. wird gerade angelegt.
if errors.As(err, &pgErr) && (pgErr.Code == "42P04" || pgErr.Code == "23505") {
return Tenant{}, ErrTenantExists
}
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
}
@@ -60,6 +74,10 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
if err != nil {
_ = tx.Rollback(ctx)
p.rollbackDatabase(ctx, dbName)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation auf slug
return Tenant{}, ErrTenantExists
}
return Tenant{}, err
}
@@ -76,3 +94,16 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
}
// Deprovision entfernt einen bereits erfolgreich registrierten Tenant wieder
// vollstaendig (Registry-Eintrag + Datenbank). Wird als Kompensation
// verwendet, wenn ein Schritt NACH Provision fehlschlaegt (z.B. die
// Admin-Account-Anlage in TEN-02) — Provision selbst bleibt dadurch die
// einzige Stelle, die "erfolgreich provisioniert" garantieren muss.
func (p *Provisioner) Deprovision(ctx context.Context, t Tenant) error {
if err := p.registry.Delete(ctx, t.ID); err != nil {
return err
}
p.rollbackDatabase(ctx, t.DBName)
return nil
}
+19 -6
View File
@@ -35,21 +35,34 @@ func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, e
}
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
var t Tenant
// previous_status/deletion_scheduled_at werden mitgelesen, damit TEN-04
// (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand
// ueber GetBySlug ansehen kann, statt eine eigene Abfrage zu duplizieren.
row := r.pool.QueryRow(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
FROM tenants WHERE slug = $1
`, slug)
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
t, err := scanTenantWithLifecycle(row)
if err != nil {
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
}
return t, nil
}
// Delete entfernt einen Registry-Eintrag. Wird von Provisioner.Deprovision
// als Kompensation verwendet, wenn nach erfolgreicher Registrierung ein
// nachgelagerter Schritt (z.B. Admin-Account-Anlage in TEN-02) fehlschlaegt.
func (r *Registry) Delete(ctx context.Context, id string) error {
if _, err := r.pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, id); err != nil {
return fmt.Errorf("tenant loeschen: %w", err)
}
return nil
}
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 {
@@ -59,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)
+9
View File
@@ -12,6 +12,10 @@ type Status string
const (
StatusActive Status = "active"
// Lebenszyklus-Zustaende aus TEN-04 (siehe internal/tenant/lifecycle.go).
StatusSuspended Status = "suspended"
StatusPendingDeletion Status = "pending_deletion"
StatusDeleted Status = "deleted"
)
type Tenant struct {
@@ -22,6 +26,11 @@ type Tenant struct {
DBDSN string
Status Status
CreatedAt time.Time
// PreviousStatus und DeletionScheduledAt sind nur waehrend
// StatusPendingDeletion gesetzt (TEN-04) — sie halten fest, in welchen
// Zustand CancelDeletion zurueckkehrt und wann die Karenzzeit ablaeuft.
PreviousStatus *string
DeletionScheduledAt *time.Time
}
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
+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)
}
}
+74
View File
@@ -0,0 +1,74 @@
package tenantsettings
import (
"encoding/json"
"net/http"
)
// Handler stellt Lesen/Schreiben der Tenant-Einstellungen als schlanke
// Vorbereitung der Schnittstelle bereit (API-01/TEN-05 sind nicht Teil
// dieser Kachel). tenantID wird hier als Pfad-/Query-Parameter erwartet,
// eine echte Auth-/Berechtigungspruefung kommt erst mit IAM-02/RBAC-01.
type Handler struct {
store *Store
}
func NewHandler(store *Store) *Handler {
return &Handler{store: store}
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
tenantID := r.URL.Query().Get("tenant_id")
if tenantID == "" {
http.Error(w, "tenant_id fehlt", http.StatusBadRequest)
return
}
settings, err := h.store.Get(r.Context(), tenantID)
if err != nil {
http.Error(w, "einstellungen konnten nicht geladen werden", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, settings)
}
type updateRequest struct {
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) Update(w http.ResponseWriter, r *http.Request) {
tenantID := r.URL.Query().Get("tenant_id")
if tenantID == "" {
http.Error(w, "tenant_id fehlt", http.StatusBadRequest)
return
}
var req updateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
settings, err := h.store.Update(r.Context(), tenantID, Patch{
DisplayName: req.DisplayName,
LogoURL: req.LogoURL,
ColorScheme: req.ColorScheme,
Timezone: req.Timezone,
Language: req.Language,
})
if err != nil {
http.Error(w, "einstellungen konnten nicht gespeichert werden", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, settings)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
+156
View File
@@ -0,0 +1,156 @@
package tenantsettings
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) (*Store, string, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.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()
);
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()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
var tenantA, tenantB string
if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_a', 'A', 'tenant_ts_a', 'unused') RETURNING id`).Scan(&tenantA); err != nil {
t.Fatalf("tenant a anlegen: %v", err)
}
if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_b', 'B', 'tenant_ts_b', 'unused') RETURNING id`).Scan(&tenantB); err != nil {
t.Fatalf("tenant b anlegen: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN ($1, $2)`, tenantA, tenantB)
_, _ = pool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN ($1, $2)`, tenantA, tenantB)
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id IN ($1, $2)`, tenantA, tenantB)
pool.Close()
}
return NewStore(pool), tenantA, tenantB, cleanup
}
func strp(s string) *string { return &s }
// Akzeptanzkriterium 2 + Pruefung 2: fehlende Werte liefern Defaults, kein Fehler.
func TestGet_UnsetTenantReturnsDefaults(t *testing.T) {
store, tenantA, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
got, err := store.Get(ctx, tenantA)
if err != nil {
t.Fatalf("get: %v", err)
}
want := Defaults()
if got != want {
t.Fatalf("erwartet defaults %+v, habe %+v", want, got)
}
}
// Akzeptanzkriterium 1 + 2: Teil-Update laesst nicht gesetzte Felder auf Default.
func TestUpdate_PartialPatchKeepsOtherFieldsAtDefault(t *testing.T) {
store, tenantA, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
got, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Acme GmbH")})
if err != nil {
t.Fatalf("update: %v", err)
}
if got.DisplayName != "Acme GmbH" {
t.Fatalf("erwartet 'Acme GmbH', habe %q", got.DisplayName)
}
d := Defaults()
if got.ColorScheme != d.ColorScheme || got.Timezone != d.Timezone || got.Language != d.Language {
t.Fatalf("nicht gesetzte felder haetten auf default bleiben sollen, habe %+v", got)
}
if got.Version != 1 {
t.Fatalf("erwartet version 1, habe %d", got.Version)
}
}
// Akzeptanzkriterium 1 + Pruefung 1: Aenderung eines Tenants wirkt sich
// nicht auf einen anderen aus.
func TestUpdate_IsolatedBetweenTenants(t *testing.T) {
store, tenantA, tenantB, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Tenant A")}); err != nil {
t.Fatalf("update a: %v", err)
}
gotB, err := store.Get(ctx, tenantB)
if err != nil {
t.Fatalf("get b: %v", err)
}
if gotB != Defaults() {
t.Fatalf("tenant b sollte unberuehrt bei defaults bleiben, habe %+v", gotB)
}
}
// Akzeptanzkriterium 3: Aenderungen versioniert nachvollziehbar.
func TestUpdate_HistoryTracksVersions(t *testing.T) {
store, tenantA, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v1")}); err != nil {
t.Fatalf("update 1: %v", err)
}
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v2")}); err != nil {
t.Fatalf("update 2: %v", err)
}
if _, err := store.Update(ctx, tenantA, Patch{ColorScheme: strp("dark")}); err != nil {
t.Fatalf("update 3: %v", err)
}
history, err := store.History(ctx, tenantA)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 3 {
t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history))
}
if history[0].DisplayName != "v1" || history[0].Version != 1 {
t.Fatalf("history[0] unerwartet: %+v", history[0])
}
if history[1].DisplayName != "v2" || history[1].Version != 2 {
t.Fatalf("history[1] unerwartet: %+v", history[1])
}
if history[2].ColorScheme != "dark" || history[2].DisplayName != "v2" || history[2].Version != 3 {
t.Fatalf("history[2] unerwartet (display_name haette aus vorherigem update erhalten bleiben sollen): %+v", history[2])
}
}
+187
View File
@@ -0,0 +1,187 @@
// Package tenantsettings implementiert Core TEN-03: pro-Tenant-Einstellungen
// (Anzeigename, Logo, Farbschema, Zeitzone, Sprache) mit versionierter
// Aenderungshistorie und dokumentierten Systemvoreinstellungen.
package tenantsettings
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Settings ist der vollstaendig AUFGELOESTE Stand (jede leere Einstellung ist
// bereits durch ihren Default ersetzt) — Aufrufer muessen sich nie selbst um
// Systemvoreinstellungen kuemmern (Akzeptanzkriterium 2).
type Settings struct {
DisplayName string
LogoURL string
ColorScheme string
Timezone string
Language string
Version int
}
// Defaults liefert die dokumentierten Systemvoreinstellungen.
func Defaults() Settings {
return Settings{
DisplayName: "",
LogoURL: "",
ColorScheme: "system",
Timezone: "UTC",
Language: "de",
Version: 0,
}
}
// Patch enthaelt nur die Felder, die tatsaechlich geaendert werden sollen —
// nil bedeutet "unveraendert lassen", nicht "auf Default zuruecksetzen".
type Patch struct {
DisplayName *string
LogoURL *string
ColorScheme *string
Timezone *string
Language *string
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Get liefert die aufgeloesten Einstellungen eines Tenants. Existiert noch
// kein Datensatz oder sind einzelne Spalten NULL, werden die jeweiligen
// Systemvoreinstellungen eingesetzt (Akzeptanzkriterium 2 / Pruefung 2) —
// niemals ein Fehler wegen fehlender Werte.
func (s *Store) Get(ctx context.Context, tenantID string) (Settings, error) {
var displayName, logoURL, colorScheme, timezone, language *string
var version int
row := s.pool.QueryRow(ctx, `
SELECT display_name, logo_url, color_scheme, timezone, language, version
FROM tenant_settings WHERE tenant_id = $1
`, tenantID)
err := row.Scan(&displayName, &logoURL, &colorScheme, &timezone, &language, &version)
if errors.Is(err, pgx.ErrNoRows) {
return Defaults(), nil
}
if err != nil {
return Settings{}, fmt.Errorf("einstellungen lesen: %w", err)
}
d := Defaults()
return Settings{
DisplayName: orDefault(displayName, d.DisplayName),
LogoURL: orDefault(logoURL, d.LogoURL),
ColorScheme: orDefault(colorScheme, d.ColorScheme),
Timezone: orDefault(timezone, d.Timezone),
Language: orDefault(language, d.Language),
Version: version,
}, nil
}
func orDefault(v *string, def string) string {
if v == nil {
return def
}
return *v
}
// Update wendet patch auf den aktuellen Stand an, erhoeht die Version und
// schreibt Stand + Historieneintrag atomar (Akzeptanzkriterium 3). Nicht in
// patch gesetzte Felder bleiben unveraendert (bzw. weiterhin auf Default,
// wenn sie es vorher schon waren).
func (s *Store) Update(ctx context.Context, tenantID string, patch Patch) (Settings, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return Settings{}, fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage *string
var version int
row := tx.QueryRow(ctx, `
SELECT display_name, logo_url, color_scheme, timezone, language, version
FROM tenant_settings WHERE tenant_id = $1 FOR UPDATE
`, tenantID)
err = row.Scan(&rawDisplayName, &rawLogoURL, &rawColorScheme, &rawTimezone, &rawLanguage, &version)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Settings{}, fmt.Errorf("aktuellen stand lesen: %w", err)
}
if patch.DisplayName != nil {
rawDisplayName = patch.DisplayName
}
if patch.LogoURL != nil {
rawLogoURL = patch.LogoURL
}
if patch.ColorScheme != nil {
rawColorScheme = patch.ColorScheme
}
if patch.Timezone != nil {
rawTimezone = patch.Timezone
}
if patch.Language != nil {
rawLanguage = patch.Language
}
newVersion := version + 1
if _, err := tx.Exec(ctx, `
INSERT INTO tenant_settings (tenant_id, display_name, logo_url, color_scheme, timezone, language, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (tenant_id) DO UPDATE SET
display_name = $2, logo_url = $3, color_scheme = $4, timezone = $5, language = $6, version = $7, updated_at = now()
`, tenantID, rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage, newVersion); err != nil {
return Settings{}, fmt.Errorf("einstellungen speichern: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO tenant_settings_history (tenant_id, display_name, logo_url, color_scheme, timezone, language, version, changed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
`, tenantID, rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage, newVersion); err != nil {
return Settings{}, fmt.Errorf("historie schreiben: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Settings{}, fmt.Errorf("transaktion committen: %w", err)
}
return s.Get(ctx, tenantID)
}
// History liefert die vollstaendige Versionshistorie eines Tenants
// (Akzeptanzkriterium 3 / Pruefung 3-Vorbereitung).
func (s *Store) History(ctx context.Context, tenantID string) ([]Settings, error) {
rows, err := s.pool.Query(ctx, `
SELECT display_name, logo_url, color_scheme, timezone, language, version
FROM tenant_settings_history WHERE tenant_id = $1 ORDER BY version
`, tenantID)
if err != nil {
return nil, fmt.Errorf("historie abfragen: %w", err)
}
defer rows.Close()
d := Defaults()
var out []Settings
for rows.Next() {
var displayName, logoURL, colorScheme, timezone, language *string
var version int
if err := rows.Scan(&displayName, &logoURL, &colorScheme, &timezone, &language, &version); err != nil {
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
}
out = append(out, Settings{
DisplayName: orDefault(displayName, d.DisplayName),
LogoURL: orDefault(logoURL, d.LogoURL),
ColorScheme: orDefault(colorScheme, d.ColorScheme),
Timezone: orDefault(timezone, d.Timezone),
Language: orDefault(language, d.Language),
Version: version,
})
}
return out, rows.Err()
}
-45
View File
@@ -103,51 +103,6 @@ func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, erro
`, id, StatusInactive))
}
// SetPasswordHash schreibt einen bereits berechneten bcrypt-Hash (siehe
// internal/auth, IAM-02). Der Store selbst kennt kein Klartext-Passwort.
func (s *TenantUserStore) SetPasswordHash(ctx context.Context, id, hash string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1
`, id, hash)
if err != nil {
return fmt.Errorf("passwort setzen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AuthCredentials wird ausschliesslich fuer den Login-Pfad (internal/auth)
// verwendet und traegt bewusst den password_hash, damit er nicht ueber den
// regulaeren User-Typ/JSON-Serialisierungspfad nach aussen dringen kann.
type AuthCredentials struct {
User User
PasswordHash string
}
// GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse
// aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit
// zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich
// ueber Tenant-Grenzen hinweg zu suchen (bekannter archivmail-Fehler, siehe
// IAM-02 "Bekannte Fehler vermeiden").
func (s *TenantUserStore) GetByEmailForAuth(ctx context.Context, email string) (AuthCredentials, error) {
var c AuthCredentials
row := s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at, password_hash
FROM users WHERE email = $1
`, email)
if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status,
&c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return AuthCredentials{}, ErrNotFound
}
return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err)
}
return c, nil
}
func scanUser(row pgx.Row) (User, error) {
var u User
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
@@ -0,0 +1,2 @@
ALTER TABLE tenants DROP COLUMN previous_status;
ALTER TABLE tenants DROP COLUMN deletion_scheduled_at;
+6
View File
@@ -0,0 +1,6 @@
-- Lebenszyklus-Zustaende fuer Mandanten (TEN-04, siehe core-kanban/tickets/TEN-04.md).
-- previous_status haelt den Zustand VOR einer Loeschvormerkung, damit
-- CancelDeletion "den vorherigen Zustand vollstaendig wiederherstellt"
-- (aktiv ODER suspendiert), statt hart auf 'active' zurueckzusetzen.
ALTER TABLE tenants ADD COLUMN previous_status TEXT;
ALTER TABLE tenants ADD COLUMN deletion_scheduled_at TIMESTAMPTZ;
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS tenant_settings_history;
DROP TABLE IF EXISTS tenant_settings;
+27
View File
@@ -0,0 +1,27 @@
-- Pro-Tenant-Einstellungen & Branding (TEN-03, siehe core-kanban/tickets/TEN-03.md).
-- Alle Spalten NULLABLE: ein fehlender Wert bedeutet "System-Default
-- verwenden" (Akzeptanzkriterium 2), niemals ein Fehler.
CREATE TABLE 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 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 INDEX tenant_settings_history_tenant_idx ON tenant_settings_history (tenant_id, version);
@@ -1 +0,0 @@
ALTER TABLE users DROP COLUMN password_hash;
@@ -1,3 +0,0 @@
-- Passwort-Hash-Spalte fuer Login (IAM-02, siehe core-kanban/tickets/IAM-02.md).
-- Enthaelt AUSSCHLIESSLICH den bcrypt-Hash, niemals das Klartext-Passwort.
ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
+9
View File
@@ -1,4 +1,11 @@
#!/usr/bin/env bash
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
@@ -6,6 +13,8 @@ ROLE="nexarch_test"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings_history CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
+6
View File
@@ -1,4 +1,10 @@
#!/usr/bin/env bash
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
+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"]
}