338 lines
10 KiB
Go
338 lines
10 KiB
Go
// 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:<id>", siehe HandleSwitchTeam) — das Template zeigt dann oben
|
|
// einen "Zurück zu <Team>"-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/<id>/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
|
|
}
|