TEN-05: backend-api + dev-server + next.js tenant-verwaltungsoberflaeche
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
// Package tenantadmin implementiert Core TEN-05: das Backend-API fuer die
|
||||||
|
// Tenant-Verwaltungsoberflaeche. Enthaelt KEINE eigene Provisioning-/
|
||||||
|
// Lifecycle-/Settings-Logik, sondern ist ein duenner Vermittler ueber
|
||||||
|
// internal/tenant (TEN-01/TEN-04), internal/tenantsettings (TEN-03) und
|
||||||
|
// internal/user.SuperadminStore (Berechtigungspruefung) — Ticket-Vorgabe:
|
||||||
|
// "Verwaltungsoberflaeche im Stil einer schlanken Zitadel-Console".
|
||||||
|
package tenantadmin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotSuperadmin wird geliefert, wenn der Aufrufer keine aktive
|
||||||
|
// Superadmin-Identitaet hat (Akzeptanzkriterium 1 / Pruefung 1: die
|
||||||
|
// Oberflaeche zeigt Mandanten nur berechtigten Superadmins).
|
||||||
|
var ErrNotSuperadmin = errors.New("tenantadmin: aufrufer ist kein aktiver superadmin")
|
||||||
|
|
||||||
|
// ErrMissingDisplayName wird geliefert, wenn ein Einstellungs-Update ohne
|
||||||
|
// das Pflichtfeld Anzeigename versucht wird (Akzeptanzkriterium 2 / Pruefung 2).
|
||||||
|
var ErrMissingDisplayName = errors.New("tenantadmin: anzeigename ist ein pflichtfeld")
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
registry *tenant.Registry
|
||||||
|
lifecycle *tenant.Lifecycle
|
||||||
|
settings *tenantsettings.Store
|
||||||
|
superadmins *user.SuperadminStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(registry *tenant.Registry, lifecycle *tenant.Lifecycle, settings *tenantsettings.Store, superadmins *user.SuperadminStore) *Handler {
|
||||||
|
return &Handler{registry: registry, lifecycle: lifecycle, settings: settings, superadmins: superadmins}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireSuperadmin prueft, dass der Aufrufer ein EXISTIERENDER, AKTIVER
|
||||||
|
// Superadmin ist — es gibt (bewusst, siehe internal/user.SuperadminStore)
|
||||||
|
// keine Tenant-Scoping-Dimension fuer Superadmins: wer ueberhaupt Zugriff
|
||||||
|
// hat, sieht alle Mandanten. Alles andere wird abgelehnt, bevor irgendeine
|
||||||
|
// Mandantendatei gelesen wird (Fail-Safe-Default).
|
||||||
|
func (h *Handler) requireSuperadmin(ctx context.Context, superadminID string) error {
|
||||||
|
if superadminID == "" {
|
||||||
|
return ErrNotSuperadmin
|
||||||
|
}
|
||||||
|
admin, err := h.superadmins.Get(ctx, superadminID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrNotSuperadmin
|
||||||
|
}
|
||||||
|
if admin.Status != user.StatusActive {
|
||||||
|
return ErrNotSuperadmin
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantListItem ist die fuer die Uebersichtsliste relevante Projektion
|
||||||
|
// (Akzeptanzkriterium 1).
|
||||||
|
type TenantListItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTenants liefert alle Mandanten, optional gefiltert nach Suchbegriff
|
||||||
|
// (Slug/Name, Teilstring, case-insensitive) und Status — beides serverseitig,
|
||||||
|
// damit die Oberflaeche nicht selbst ueber unautorisierte Datensaetze
|
||||||
|
// filtern muss (Akzeptanzkriterium 1: Suche und Filter).
|
||||||
|
func (h *Handler) ListTenants(ctx context.Context, superadminID, search, statusFilter string) ([]TenantListItem, error) {
|
||||||
|
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := h.registry.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mandanten auflisten: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
search = strings.ToLower(strings.TrimSpace(search))
|
||||||
|
out := make([]TenantListItem, 0, len(all))
|
||||||
|
for _, t := range all {
|
||||||
|
if statusFilter != "" && string(t.Status) != statusFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if search != "" && !strings.Contains(strings.ToLower(t.Slug), search) && !strings.Contains(strings.ToLower(t.Name), search) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, TenantListItem{ID: t.ID, Slug: t.Slug, Name: t.Name, Status: string(t.Status)})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantDetail buendelt Stammdaten und Einstellungen fuer die Detailansicht.
|
||||||
|
type TenantDetail struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Settings tenantsettings.Settings `json:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetTenantDetail(ctx context.Context, superadminID, slug string) (TenantDetail, error) {
|
||||||
|
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
|
||||||
|
return TenantDetail{}, err
|
||||||
|
}
|
||||||
|
t, err := h.registry.GetBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return TenantDetail{}, fmt.Errorf("mandant laden: %w", err)
|
||||||
|
}
|
||||||
|
s, err := h.settings.Get(ctx, t.ID)
|
||||||
|
if err != nil {
|
||||||
|
return TenantDetail{}, fmt.Errorf("einstellungen laden: %w", err)
|
||||||
|
}
|
||||||
|
return TenantDetail{ID: t.ID, Slug: t.Slug, Name: t.Name, Status: string(t.Status), Settings: s}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsPatch ist die vom Formular gesendete Aenderung. DisplayName ist
|
||||||
|
// KEIN Zeiger, weil es Pflichtfeld ist (Akzeptanzkriterium 2) — die anderen
|
||||||
|
// Felder bleiben optional (Zeiger = "unveraendert lassen", siehe
|
||||||
|
// tenantsettings.Patch).
|
||||||
|
type SettingsPatch struct {
|
||||||
|
DisplayName string
|
||||||
|
LogoURL *string
|
||||||
|
ColorScheme *string
|
||||||
|
Timezone *string
|
||||||
|
Language *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings validiert das Pflichtfeld Anzeigename, BEVOR irgendein
|
||||||
|
// Schreibzugriff erfolgt (Akzeptanzkriterium 2 / Pruefung 2: unvollstaendige
|
||||||
|
// Pflichtfelder werden serverseitig verhindert, nicht nur clientseitig).
|
||||||
|
func (h *Handler) UpdateSettings(ctx context.Context, superadminID, slug string, patch SettingsPatch) (tenantsettings.Settings, error) {
|
||||||
|
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
|
||||||
|
return tenantsettings.Settings{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(patch.DisplayName) == "" {
|
||||||
|
return tenantsettings.Settings{}, ErrMissingDisplayName
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := h.registry.GetBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return tenantsettings.Settings{}, fmt.Errorf("mandant laden: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName := patch.DisplayName
|
||||||
|
return h.settings.Update(ctx, t.ID, tenantsettings.Patch{
|
||||||
|
DisplayName: &displayName,
|
||||||
|
LogoURL: patch.LogoURL,
|
||||||
|
ColorScheme: patch.ColorScheme,
|
||||||
|
Timezone: patch.Timezone,
|
||||||
|
Language: patch.Language,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LifecycleAction sind die von der Oberflaeche ausloesbaren Aktionen
|
||||||
|
// (Akzeptanzkriterium 3) — je EIN Wort pro Aktion, damit ein Bestaetigungs-
|
||||||
|
// dialog im Frontend darauf verzweigen kann, ohne HTTP-Interna zu kennen.
|
||||||
|
type LifecycleAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionSuspend LifecycleAction = "suspend"
|
||||||
|
ActionReactivate LifecycleAction = "reactivate"
|
||||||
|
ActionScheduleDeletion LifecycleAction = "schedule_deletion"
|
||||||
|
ActionCancelDeletion LifecycleAction = "cancel_deletion"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrUnknownAction = errors.New("tenantadmin: unbekannte lifecycle-aktion")
|
||||||
|
|
||||||
|
// DefaultDeletionGracePeriod ist die Karenzzeit, die die Oberflaeche beim
|
||||||
|
// Ausloesen von ActionScheduleDeletion verwendet — Konfiguration dieses
|
||||||
|
// Pakets, nicht von internal/tenant (das lifecycle.go generisch mit einer
|
||||||
|
// uebergebenen Dauer arbeitet, siehe TEN-04).
|
||||||
|
const DefaultDeletionGracePeriod = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// PerformLifecycleAction fuehrt EINEN der vier Uebergaenge aus. Ungueltige
|
||||||
|
// Zustandsuebergaenge (z.B. "suspend" auf einen bereits geloeschten Tenant)
|
||||||
|
// werden von internal/tenant.Lifecycle selbst mit ErrInvalidTransition
|
||||||
|
// abgelehnt (siehe TEN-04) — dieses Paket dupliziert diese Pruefung nicht.
|
||||||
|
func (h *Handler) PerformLifecycleAction(ctx context.Context, superadminID, slug string, action LifecycleAction) (tenant.Tenant, error) {
|
||||||
|
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
|
||||||
|
return tenant.Tenant{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case ActionSuspend:
|
||||||
|
return h.registry.Suspend(ctx, slug)
|
||||||
|
case ActionReactivate:
|
||||||
|
return h.registry.Reactivate(ctx, slug)
|
||||||
|
case ActionScheduleDeletion:
|
||||||
|
return h.registry.ScheduleDeletion(ctx, slug, DefaultDeletionGracePeriod)
|
||||||
|
case ActionCancelDeletion:
|
||||||
|
return h.registry.CancelDeletion(ctx, slug)
|
||||||
|
default:
|
||||||
|
return tenant.Tenant{}, ErrUnknownAction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP-Bindung ---
|
||||||
|
//
|
||||||
|
// Der Aufrufer wird bewusst als expliziter Query-/Body-Parameter
|
||||||
|
// "superadmin" statt aus einem Auth-Header gelesen — Session-/Token-basierte
|
||||||
|
// Authentifizierung ist Sache von IAM-01/IAM-02 und wird hier NICHT
|
||||||
|
// dupliziert (Kein Umbau angrenzender Bereiche); dieser Handler ist ein
|
||||||
|
// duenner Entwicklungs-/Testzugang, der genau die in requireSuperadmin
|
||||||
|
// beschriebene Berechtigungspruefung durchsetzt.
|
||||||
|
|
||||||
|
func (h *Handler) ListTenantsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
items, err := h.ListTenants(r.Context(), q.Get("superadmin"), q.Get("search"), q.Get("status"))
|
||||||
|
writeResult(w, items, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) TenantDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
detail, err := h.GetTenantDetail(r.Context(), q.Get("superadmin"), q.Get("slug"))
|
||||||
|
writeResult(w, detail, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsRequest struct {
|
||||||
|
Superadmin string `json:"superadmin"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
LogoURL *string `json:"logo_url"`
|
||||||
|
ColorScheme *string `json:"color_scheme"`
|
||||||
|
Timezone *string `json:"timezone"`
|
||||||
|
Language *string `json:"language"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req settingsRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.UpdateSettings(r.Context(), req.Superadmin, req.Slug, SettingsPatch{
|
||||||
|
DisplayName: req.DisplayName,
|
||||||
|
LogoURL: req.LogoURL,
|
||||||
|
ColorScheme: req.ColorScheme,
|
||||||
|
Timezone: req.Timezone,
|
||||||
|
Language: req.Language,
|
||||||
|
})
|
||||||
|
writeResult(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type lifecycleRequest struct {
|
||||||
|
Superadmin string `json:"superadmin"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Action LifecycleAction `json:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) LifecycleActionHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req lifecycleRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.PerformLifecycleAction(r.Context(), req.Superadmin, req.Slug, req.Action)
|
||||||
|
writeResult(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(r *http.Request, v any) error {
|
||||||
|
return json.NewDecoder(r.Body).Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResult(w http.ResponseWriter, body any, err error) {
|
||||||
|
if err == nil {
|
||||||
|
writeJSON(w, http.StatusOK, body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotSuperadmin):
|
||||||
|
writeError(w, http.StatusForbidden, err.Error())
|
||||||
|
case errors.Is(err, ErrMissingDisplayName):
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
case errors.Is(err, ErrUnknownAction), errors.Is(err, tenant.ErrInvalidTransition):
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
case errors.Is(err, tenant.ErrTenantNotFound):
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package tenantadmin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testEnv struct {
|
||||||
|
handler *Handler
|
||||||
|
superadminID string
|
||||||
|
slug string
|
||||||
|
adminPool *pgxpool.Pool
|
||||||
|
registry *tenant.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) (testEnv, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
registryPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registry pool: %v", err)
|
||||||
|
}
|
||||||
|
adminPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin pool: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := registryPool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
||||||
|
db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), previous_status TEXT, deletion_scheduled_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_settings (
|
||||||
|
tenant_id UUID PRIMARY KEY REFERENCES tenants(id), display_name TEXT, logo_url TEXT,
|
||||||
|
color_scheme TEXT, timezone TEXT, language TEXT, version INT NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_settings_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, display_name TEXT,
|
||||||
|
logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT, version INT NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS superadmins (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := tenant.NewRegistry(registryPool)
|
||||||
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||||
|
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
||||||
|
lifecycle := tenant.NewLifecycle(registry, adminPool)
|
||||||
|
settingsStore := tenantsettings.NewStore(registryPool)
|
||||||
|
superadmins := user.NewSuperadminStore(registryPool)
|
||||||
|
|
||||||
|
slug := fmt.Sprintf("tadm_%d", time.Now().UnixNano()%1_000_000_000)
|
||||||
|
if _, err := provisioner.Provision(ctx, slug, "Test Mandant "+slug); err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, err := superadmins.Create(ctx, fmt.Sprintf("admin-%d@example.com", time.Now().UnixNano()), "Test Superadmin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("superadmin anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewHandler(registry, lifecycle, settingsStore, superadmins)
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug))
|
||||||
|
_, _ = registryPool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN (SELECT id FROM tenants WHERE slug = $1)`, slug)
|
||||||
|
_, _ = registryPool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN (SELECT id FROM tenants WHERE slug = $1)`, slug)
|
||||||
|
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
|
||||||
|
_, _ = registryPool.Exec(ctx, `DELETE FROM superadmins WHERE id = $1`, admin.ID)
|
||||||
|
registryPool.Close()
|
||||||
|
adminPool.Close()
|
||||||
|
}
|
||||||
|
return testEnv{handler: handler, superadminID: admin.ID, slug: slug, adminPool: adminPool, registry: registry}, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: nur berechtigte (aktive) Superadmins
|
||||||
|
// sehen die Mandantenliste ueberhaupt.
|
||||||
|
func TestListTenants_RejectsNonSuperadmin(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := env.handler.ListTenants(ctx, "irgendeine-nicht-existierende-id", "", "")
|
||||||
|
if !errors.Is(err, ErrNotSuperadmin) {
|
||||||
|
t.Fatalf("erwartet ErrNotSuperadmin, habe: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTenants_ShowsSearchAndStatusFilterResults(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
all, err := env.handler.ListTenants(ctx, env.superadminID, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, item := range all {
|
||||||
|
if item.Slug == env.slug {
|
||||||
|
found = true
|
||||||
|
if item.Status != "active" {
|
||||||
|
t.Fatalf("status = %q, want active", item.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("erwartet test-tenant in ungefilterter liste")
|
||||||
|
}
|
||||||
|
|
||||||
|
bySearch, err := env.handler.ListTenants(ctx, env.superadminID, env.slug, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list mit suche: %v", err)
|
||||||
|
}
|
||||||
|
if len(bySearch) != 1 || bySearch[0].Slug != env.slug {
|
||||||
|
t.Fatalf("suche nach slug lieferte unerwartetes ergebnis: %+v", bySearch)
|
||||||
|
}
|
||||||
|
|
||||||
|
byWrongStatus, err := env.handler.ListTenants(ctx, env.superadminID, env.slug, "suspended")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list mit statusfilter: %v", err)
|
||||||
|
}
|
||||||
|
if len(byWrongStatus) != 0 {
|
||||||
|
t.Fatalf("statusfilter haette test-tenant (status=active) ausfiltern muessen, habe: %+v", byWrongStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: fehlender Pflichtwert (Anzeigename)
|
||||||
|
// wird serverseitig abgelehnt, bevor etwas gespeichert wird.
|
||||||
|
func TestUpdateSettings_RejectsMissingDisplayName(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := env.handler.UpdateSettings(ctx, env.superadminID, env.slug, SettingsPatch{DisplayName: " "})
|
||||||
|
if !errors.Is(err, ErrMissingDisplayName) {
|
||||||
|
t.Fatalf("erwartet ErrMissingDisplayName, habe: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSettings_AppliesValidPatch(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
updated, err := env.handler.UpdateSettings(ctx, env.superadminID, env.slug, SettingsPatch{DisplayName: "Neuer Name"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
if updated.DisplayName != "Neuer Name" {
|
||||||
|
t.Fatalf("displayname = %q, want 'Neuer Name'", updated.DisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := env.handler.GetTenantDetail(ctx, env.superadminID, env.slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("detail: %v", err)
|
||||||
|
}
|
||||||
|
if detail.Settings.DisplayName != "Neuer Name" {
|
||||||
|
t.Fatalf("detail zeigt nicht den aktualisierten namen: %+v", detail.Settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3: Lifecycle-Aktionen sind ausloesbar und wirken sich
|
||||||
|
// auf den tatsaechlichen Mandantenstatus aus.
|
||||||
|
func TestPerformLifecycleAction_SuspendAndReactivate(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
suspended, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionSuspend)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
if suspended.Status != tenant.StatusSuspended {
|
||||||
|
t.Fatalf("status = %q, want suspended", suspended.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
reactivated, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionReactivate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reactivate: %v", err)
|
||||||
|
}
|
||||||
|
if reactivated.Status != tenant.StatusActive {
|
||||||
|
t.Fatalf("status = %q, want active", reactivated.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPerformLifecycleAction_RejectsInvalidTransition(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Reaktivieren eines bereits aktiven Tenants ist kein gueltiger Uebergang.
|
||||||
|
_, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionReactivate)
|
||||||
|
if !errors.Is(err, tenant.ErrInvalidTransition) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidTransition, habe: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPerformLifecycleAction_RejectsNonSuperadmin(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := env.handler.PerformLifecycleAction(ctx, "keine-berechtigung", env.slug, ActionSuspend)
|
||||||
|
if !errors.Is(err, ErrNotSuperadmin) {
|
||||||
|
t.Fatalf("erwartet ErrNotSuperadmin, habe: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const metadata = {
|
||||||
|
title: "NEXARCH Mandantenverwaltung",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body style={{ fontFamily: "system-ui, sans-serif", margin: 0, background: "#f5f6f8" }}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
fetchTenants,
|
||||||
|
fetchTenantDetail,
|
||||||
|
updateSettings,
|
||||||
|
performLifecycleAction,
|
||||||
|
type TenantListItem,
|
||||||
|
type TenantDetail,
|
||||||
|
type LifecycleAction,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
const ACTION_LABEL: Record<LifecycleAction, string> = {
|
||||||
|
suspend: "Suspendieren",
|
||||||
|
reactivate: "Reaktivieren",
|
||||||
|
schedule_deletion: "Löschung vormerken",
|
||||||
|
cancel_deletion: "Löschung abbrechen",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_CONFIRM: Record<LifecycleAction, string> = {
|
||||||
|
suspend: "Mandant wirklich suspendieren? Benutzer können sich danach nicht mehr anmelden.",
|
||||||
|
reactivate: "Mandant wirklich reaktivieren?",
|
||||||
|
schedule_deletion:
|
||||||
|
"Mandant wirklich zur Löschung vormerken? Nach der Karenzzeit wird er unwiderruflich gelöscht.",
|
||||||
|
cancel_deletion: "Vorgemerkte Löschung wirklich abbrechen?",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [superadminId, setSuperadminId] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [tenants, setTenants] = useState<TenantListItem[] | null>(null);
|
||||||
|
const [detail, setDetail] = useState<TenantDetail | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const items = await fetchTenants(superadminId.trim(), search.trim(), status);
|
||||||
|
setTenants(items);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message ?? "Unbekannter Fehler");
|
||||||
|
setTenants(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(slug: string) {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const d = await fetchTenantDetail(superadminId.trim(), slug);
|
||||||
|
setDetail(d);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message ?? "Unbekannter Fehler");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmitSettings(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!detail) return;
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
const form = new FormData(e.currentTarget);
|
||||||
|
const displayName = String(form.get("displayName") ?? "").trim();
|
||||||
|
if (!displayName) {
|
||||||
|
setFormError("Anzeigename ist ein Pflichtfeld.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSettings(
|
||||||
|
superadminId.trim(),
|
||||||
|
detail.slug,
|
||||||
|
displayName,
|
||||||
|
String(form.get("colorScheme") ?? ""),
|
||||||
|
String(form.get("timezone") ?? ""),
|
||||||
|
String(form.get("language") ?? "")
|
||||||
|
);
|
||||||
|
await openDetail(detail.slug);
|
||||||
|
} catch (e: any) {
|
||||||
|
setFormError(e.message ?? "Speichern fehlgeschlagen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLifecycleAction(action: LifecycleAction) {
|
||||||
|
if (!detail) return;
|
||||||
|
if (!window.confirm(ACTION_CONFIRM[action])) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await performLifecycleAction(superadminId.trim(), detail.slug, action);
|
||||||
|
await openDetail(detail.slug);
|
||||||
|
await loadList();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message ?? "Aktion fehlgeschlagen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 900, margin: "0 auto", padding: "2rem 1rem" }}>
|
||||||
|
<h1>Mandantenverwaltung</h1>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem", flexWrap: "wrap" }}>
|
||||||
|
<input
|
||||||
|
value={superadminId}
|
||||||
|
onChange={(e) => setSuperadminId(e.target.value)}
|
||||||
|
placeholder="Superadmin-ID"
|
||||||
|
style={{ padding: "0.5rem" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Suche (Slug/Name)"
|
||||||
|
style={{ padding: "0.5rem" }}
|
||||||
|
/>
|
||||||
|
<select value={status} onChange={(e) => setStatus(e.target.value)} style={{ padding: "0.5rem" }}>
|
||||||
|
<option value="">Alle Status</option>
|
||||||
|
<option value="active">Aktiv</option>
|
||||||
|
<option value="suspended">Suspendiert</option>
|
||||||
|
<option value="pending_deletion">Löschung vorgemerkt</option>
|
||||||
|
<option value="deleted">Gelöscht</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={loadList} style={{ padding: "0.5rem 1rem" }}>
|
||||||
|
Anzeigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: "#c62828" }} role="alert">
|
||||||
|
Fehler: {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tenants && (
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: "left", borderBottom: "2px solid #ddd" }}>
|
||||||
|
<th style={{ padding: "0.5rem" }}>Slug</th>
|
||||||
|
<th style={{ padding: "0.5rem" }}>Name</th>
|
||||||
|
<th style={{ padding: "0.5rem" }}>Status</th>
|
||||||
|
<th style={{ padding: "0.5rem" }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<tr key={t.id} style={{ borderBottom: "1px solid #eee" }}>
|
||||||
|
<td style={{ padding: "0.5rem" }}>{t.slug}</td>
|
||||||
|
<td style={{ padding: "0.5rem" }}>{t.name}</td>
|
||||||
|
<td style={{ padding: "0.5rem" }}>{t.status}</td>
|
||||||
|
<td style={{ padding: "0.5rem" }}>
|
||||||
|
<button onClick={() => openDetail(t.slug)}>Details</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{tenants.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} style={{ padding: "0.5rem" }}>
|
||||||
|
Keine Mandanten gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail && (
|
||||||
|
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
|
||||||
|
<h2>
|
||||||
|
{detail.name} ({detail.slug}) — Status: {detail.status}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmitSettings} style={{ display: "grid", gap: "0.75rem", maxWidth: 400 }}>
|
||||||
|
{formError && (
|
||||||
|
<p style={{ color: "#c62828" }} role="alert">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
Anzeigename (Pflichtfeld)
|
||||||
|
<input
|
||||||
|
name="displayName"
|
||||||
|
required
|
||||||
|
defaultValue={detail.settings.DisplayName}
|
||||||
|
style={{ width: "100%", padding: "0.4rem" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Farbschema
|
||||||
|
<input
|
||||||
|
name="colorScheme"
|
||||||
|
defaultValue={detail.settings.ColorScheme}
|
||||||
|
style={{ width: "100%", padding: "0.4rem" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Zeitzone
|
||||||
|
<input
|
||||||
|
name="timezone"
|
||||||
|
defaultValue={detail.settings.Timezone}
|
||||||
|
style={{ width: "100%", padding: "0.4rem" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Sprache
|
||||||
|
<input
|
||||||
|
name="language"
|
||||||
|
defaultValue={detail.settings.Language}
|
||||||
|
style={{ width: "100%", padding: "0.4rem" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Einstellungen speichern</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1.5rem", flexWrap: "wrap" }}>
|
||||||
|
{(Object.keys(ACTION_LABEL) as LifecycleAction[]).map((action) => (
|
||||||
|
<button key={action} onClick={() => onLifecycleAction(action)}>
|
||||||
|
{ACTION_LABEL[action]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Duenner Client fuer das TEN-05-Backend-API (internal/tenantadmin) — keine
|
||||||
|
// eigene Provisioning-/Lifecycle-/Validierungslogik ausser der Pflichtfeld-
|
||||||
|
// Vorpruefung im Formular (Akzeptanzkriterium 2), die zusaetzlich serverseitig
|
||||||
|
// durchgesetzt wird.
|
||||||
|
export type TenantListItem = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Settings = {
|
||||||
|
DisplayName: string;
|
||||||
|
LogoURL: string;
|
||||||
|
ColorScheme: string;
|
||||||
|
Timezone: string;
|
||||||
|
Language: string;
|
||||||
|
Version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantDetail = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
settings: Settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
function apiBase(): string {
|
||||||
|
const base = process.env.NEXT_PUBLIC_TENANTADMIN_API_URL;
|
||||||
|
if (!base) {
|
||||||
|
throw new Error(
|
||||||
|
"NEXT_PUBLIC_TENANTADMIN_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle<T>(res: Response): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Anfrage fehlgeschlagen (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenants(
|
||||||
|
superadminId: string,
|
||||||
|
search: string,
|
||||||
|
status: string
|
||||||
|
): Promise<TenantListItem[]> {
|
||||||
|
const params = new URLSearchParams({ superadmin: superadminId, search, status });
|
||||||
|
const res = await fetch(`${apiBase()}/admin/tenants?${params}`, { cache: "no-store" });
|
||||||
|
return handle<TenantListItem[]>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenantDetail(
|
||||||
|
superadminId: string,
|
||||||
|
slug: string
|
||||||
|
): Promise<TenantDetail> {
|
||||||
|
const params = new URLSearchParams({ superadmin: superadminId, slug });
|
||||||
|
const res = await fetch(`${apiBase()}/admin/tenants/detail?${params}`, { cache: "no-store" });
|
||||||
|
return handle<TenantDetail>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSettings(
|
||||||
|
superadminId: string,
|
||||||
|
slug: string,
|
||||||
|
displayName: string,
|
||||||
|
colorScheme: string,
|
||||||
|
timezone: string,
|
||||||
|
language: string
|
||||||
|
): Promise<Settings> {
|
||||||
|
const res = await fetch(`${apiBase()}/admin/tenants/settings`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
superadmin: superadminId,
|
||||||
|
slug,
|
||||||
|
display_name: displayName,
|
||||||
|
color_scheme: colorScheme,
|
||||||
|
timezone,
|
||||||
|
language,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return handle<Settings>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LifecycleAction =
|
||||||
|
| "suspend"
|
||||||
|
| "reactivate"
|
||||||
|
| "schedule_deletion"
|
||||||
|
| "cancel_deletion";
|
||||||
|
|
||||||
|
export async function performLifecycleAction(
|
||||||
|
superadminId: string,
|
||||||
|
slug: string,
|
||||||
|
action: LifecycleAction
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`${apiBase()}/admin/tenants/lifecycle`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ superadmin: superadminId, slug, action }),
|
||||||
|
});
|
||||||
|
await handle<unknown>(res);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "nexarch-tenant-admin",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "14.2.35",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "20.14.9",
|
||||||
|
"@types/react": "18.3.3",
|
||||||
|
"@types/react-dom": "18.3.0",
|
||||||
|
"typescript": "5.5.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user