// Dieses File implementiert das Admin-Webinterface zur Team-Verwaltung unter // /admin/teams: Teams anlegen, umbenennen, den Zugriffskey neu erzeugen und // Teams löschen — alles direkt aus dem Browser statt von Hand in teams.yaml. // // Nur mit dem globalen Admin-Zugang erreichbar (siehe IsAdminFromContext). // Jede Änderung schreibt sofort teams.yaml neu (SaveTeamsConfig) und lädt die // laufende TeamRegistry per Reload() neu — ohne Serverneustart, siehe // registry.go. Der bestehende mtime-Watcher (StartTeamsConfigWatcher) würde // dieselbe Änderung sonst erst mit bis zu 5 Sekunden Verzögerung übernehmen. package handler import ( "fmt" "html/template" "io/fs" "net/http" "strings" "sync" ) // teamsAdminTemplateData bündelt die Daten für das teams-admin.html-Template. type teamsAdminTemplateData struct { Teams []*Team Error string Notice string // CurrentTeam ist gesetzt, wenn der Admin gerade in ein Team umgeschaltet hat // (Cookie "admin:", siehe HandleSwitchTeam) — das Template zeigt dann oben // einen "Zurück zu "-Link zu "/" an, damit der Admin nicht erst wieder // über die Tabelle suchen muss. CurrentTeam *Team // NewKey wird nur direkt nach dem Anlegen eines Teams oder dem Neu-Erzeugen // eines Keys gesetzt. Der Klartext-Key steht sonst nirgends im Web — nur // in teams.yaml auf dem Server — deshalb zeigen wir ihn hier genau einmal an. NewKey string NewKeyTeamID string } // TeamsAdminHandler verwaltet das Admin-Webinterface für Teams. type TeamsAdminHandler struct { configPath string registry *TeamRegistry audit *AuditLogger template *template.Template // mu verhindert, dass zwei gleichzeitige Änderungen (zwei Browser-Tabs, // zwei Admins) sich beim Lesen+Schreiben von teams.yaml gegenseitig // überschreiben ("Lost Update"). Änderungen an teams.yaml sind selten, // ein einfacher Mutex reicht völlig — genau wie beim mtime-Watcher. mu sync.Mutex } // NewTeamsAdminHandler lädt das Template und erstellt einen neuen TeamsAdminHandler. func NewTeamsAdminHandler(templatesFS fs.FS, configPath string, registry *TeamRegistry, audit *AuditLogger) (*TeamsAdminHandler, error) { tmpl, err := template.ParseFS(templatesFS, "templates/teams-admin.html") if err != nil { return nil, err } return &TeamsAdminHandler{ configPath: configPath, registry: registry, audit: audit, template: tmpl, }, nil } // requireAdmin schreibt bei fehlendem Admin-Zugang eine 403-Antwort und gibt // false zurück — der Aufrufer muss dann sofort zurückkehren. func (h *TeamsAdminHandler) requireAdmin(w http.ResponseWriter, r *http.Request) bool { if !IsAdminFromContext(r.Context()) { http.Error(w, "Nur für den Admin-Zugang verfügbar", http.StatusForbidden) return false } return true } // render zeigt die Team-Übersicht an. Die Team-Liste wird immer frisch aus der // Registry gelesen, damit sie auch nach einer soeben gespeicherten Änderung // aktuell ist. CurrentTeam kommt aus dem Request-Context — falls der Admin // bereits per /switch-team in ein Team gewechselt ist. func (h *TeamsAdminHandler) render(w http.ResponseWriter, r *http.Request, data teamsAdminTemplateData) { data.Teams = h.registry.All() if team, ok := TeamFromContext(r.Context()); ok { data.CurrentTeam = team } h.template.Execute(w, data) } // HandleTeamsPage verarbeitet GET /admin/teams — zeigt die Team-Übersicht und // das Formular zum Anlegen eines neuen Teams. Admins landen hier direkt nach // dem Login (siehe HandleLogin) statt erst ein Team wählen zu müssen. func (h *TeamsAdminHandler) HandleTeamsPage(w http.ResponseWriter, r *http.Request) { if !h.requireAdmin(w, r) { return } h.render(w, r, teamsAdminTemplateData{}) } // HandleCreateTeam verarbeitet POST /admin/teams/create. func (h *TeamsAdminHandler) HandleCreateTeam(w http.ResponseWriter, r *http.Request) { if !h.requireAdmin(w, r) { return } if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) return } id := strings.TrimSpace(r.FormValue("id")) name := strings.TrimSpace(r.FormValue("name")) if name == "" { name = id } h.mu.Lock() defer h.mu.Unlock() configs, err := LoadTeamsConfig(h.configPath) if err != nil { h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("teams.yaml konnte nicht gelesen werden: %v", err)}) return } // Dieselben Regeln wie beim Laden von teams.yaml (siehe LoadTeamsConfig) — // eine ungültige ID darf gar nicht erst gespeichert werden. if !teamIDPattern.MatchString(id) { h.render(w, r, teamsAdminTemplateData{Error: "Ungültige Team-ID — erlaubt sind nur a-z, 0-9, '-' und '_'."}) return } if id == "admin" { h.render(w, r, teamsAdminTemplateData{Error: `Team-ID "admin" ist reserviert und darf nicht vergeben werden.`}) return } for _, cfg := range configs { if cfg.ID == id { h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("Team-ID %q gibt es bereits.", id)}) return } } key, err := randomTeamKey() if err != nil { h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("Zugriffskey konnte nicht erzeugt werden: %v", err)}) return } configs = append(configs, TeamConfig{ID: id, Name: name, Key: key}) if err := h.saveAndReload(configs); err != nil { h.render(w, r, teamsAdminTemplateData{Error: err.Error()}) return } h.audit.Log(AuditEvent{Team: id, Actor: "admin", Action: "team_created", Source: "web"}) h.render(w, r, teamsAdminTemplateData{ Notice: fmt.Sprintf("Team %q angelegt.", id), NewKey: key, NewKeyTeamID: id, }) } // HandleRenameTeam verarbeitet POST /admin/teams/rename — ändert nur den // Anzeigenamen, ID und Zugriffskey bleiben unverändert. func (h *TeamsAdminHandler) HandleRenameTeam(w http.ResponseWriter, r *http.Request) { if !h.requireAdmin(w, r) { return } if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) return } id := r.FormValue("id") name := strings.TrimSpace(r.FormValue("name")) if name == "" { h.render(w, r, teamsAdminTemplateData{Error: "Name darf nicht leer sein."}) return } h.mu.Lock() defer h.mu.Unlock() configs, err := LoadTeamsConfig(h.configPath) if err != nil { h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("teams.yaml konnte nicht gelesen werden: %v", err)}) return } found := false for i := range configs { if configs[i].ID == id { configs[i].Name = name found = true break } } if !found { h.render(w, r, teamsAdminTemplateData{Error: "Unbekanntes Team."}) return } if err := h.saveAndReload(configs); err != nil { h.render(w, r, teamsAdminTemplateData{Error: err.Error()}) return } h.audit.Log(AuditEvent{Team: id, Actor: "admin", Action: "team_renamed", Source: "web"}) h.render(w, r, teamsAdminTemplateData{Notice: fmt.Sprintf("Team %q umbenannt.", id)}) } // HandleRegenerateKey verarbeitet POST /admin/teams/regenerate-key — erzeugt // für ein bestehendes Team einen neuen, zufälligen Zugriffskey. Der alte Key // wird damit sofort ungültig (bestehende Logins/API-Clients mit dem alten Key // müssen den neuen Key eintragen). func (h *TeamsAdminHandler) HandleRegenerateKey(w http.ResponseWriter, r *http.Request) { if !h.requireAdmin(w, r) { return } if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) return } id := r.FormValue("id") h.mu.Lock() defer h.mu.Unlock() configs, err := LoadTeamsConfig(h.configPath) if err != nil { h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("teams.yaml konnte nicht gelesen werden: %v", err)}) return } key, err := randomTeamKey() if err != nil { h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("Zugriffskey konnte nicht erzeugt werden: %v", err)}) return } found := false for i := range configs { if configs[i].ID == id { configs[i].Key = key found = true break } } if !found { h.render(w, r, teamsAdminTemplateData{Error: "Unbekanntes Team."}) return } if err := h.saveAndReload(configs); err != nil { h.render(w, r, teamsAdminTemplateData{Error: err.Error()}) return } h.audit.Log(AuditEvent{Team: id, Actor: "admin", Action: "team_key_regenerated", Source: "web"}) h.render(w, r, teamsAdminTemplateData{ Notice: fmt.Sprintf("Neuer Zugriffskey für Team %q erzeugt.", id), NewKey: key, NewKeyTeamID: id, }) } // HandleDeleteTeam verarbeitet POST /admin/teams/delete. Die Markdown-Dateien // des Teams unter data//content bleiben auf der Platte erhalten (siehe // Doku-Kommentar an TeamRegistry.Reload) — nur der Zugriff über Login/API/MCP // wird entfernt. func (h *TeamsAdminHandler) HandleDeleteTeam(w http.ResponseWriter, r *http.Request) { if !h.requireAdmin(w, r) { return } if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) return } id := r.FormValue("id") h.mu.Lock() defer h.mu.Unlock() configs, err := LoadTeamsConfig(h.configPath) if err != nil { h.render(w, r, teamsAdminTemplateData{Error: fmt.Sprintf("teams.yaml konnte nicht gelesen werden: %v", err)}) return } // Das letzte verbleibende Team darf nicht gelöscht werden — eine leere // teams.yaml ist ungültig (siehe LoadTeamsConfig) und würde den Server // beim nächsten Neustart nicht mehr starten lassen. if len(configs) <= 1 { h.render(w, r, teamsAdminTemplateData{Error: "Das letzte verbleibende Team kann nicht gelöscht werden."}) return } remaining := make([]TeamConfig, 0, len(configs)-1) found := false for _, cfg := range configs { if cfg.ID == id { found = true continue } remaining = append(remaining, cfg) } if !found { h.render(w, r, teamsAdminTemplateData{Error: "Unbekanntes Team."}) return } if err := h.saveAndReload(remaining); err != nil { h.render(w, r, teamsAdminTemplateData{Error: err.Error()}) return } h.audit.Log(AuditEvent{Team: id, Actor: "admin", Action: "team_deleted", Source: "web"}) h.render(w, r, teamsAdminTemplateData{Notice: fmt.Sprintf("Team %q gelöscht.", id)}) } // saveAndReload schreibt configs nach teams.yaml und lädt die laufende // TeamRegistry sofort neu. func (h *TeamsAdminHandler) saveAndReload(configs []TeamConfig) error { if err := SaveTeamsConfig(h.configPath, configs); err != nil { return err } if err := h.registry.Reload(configs); err != nil { return fmt.Errorf("Registry konnte nicht neu geladen werden: %w", err) } return nil }