From 07e1305b3cfba8ddf24e2f0299f2d8823a009684 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 27 Jul 2026 23:10:49 +0200 Subject: [PATCH] =?UTF-8?q?Erweiterung=20Admin=20Interface=20f=C3=BCr=20be?= =?UTF-8?q?ssere=20Teamverwaltung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 80 ++++++++- REPOMAP.md | 31 ++-- handler/auth.go | 43 ++--- handler/teams_admin.go | 338 +++++++++++++++++++++++++++++++++++++ handler/teams_config.go | 70 ++++++++ handler/wiki.go | 9 +- main.go | 22 ++- static/style.css | 112 ++++++++++++ templates/page.html | 7 +- templates/switch-team.html | 21 --- templates/teams-admin.html | 100 +++++++++++ 11 files changed, 759 insertions(+), 74 deletions(-) create mode 100644 handler/teams_admin.go delete mode 100644 templates/switch-team.html create mode 100644 templates/teams-admin.html diff --git a/README.md b/README.md index eacf3fe..06a12ee 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,29 @@ go run main.go Der Server läuft dann auf [http://localhost:8080](http://localhost:8080). -Vor dem ersten Start brauchst du eine `teams.yaml` (siehe [Teams](#teams--zugriffskeys) unten) und ein `SESSION_SECRET`: +Vor dem ersten Start brauchst du nur ein `SESSION_SECRET`: ```bash -cp teams.yaml.example teams.yaml -# teams.yaml mit deinen Teams befüllen echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env ``` +Für **mehrere Teams** legst du vorher selbst eine `teams.yaml` an (siehe +[Teams](#teams--zugriffskeys) unten). Für den **Ein-Personen-Betrieb** brauchst +du das nicht: fehlt `teams.yaml` beim Start, wird automatisch eine mit einem +einzelnen Standard-Team samt zufällig erzeugtem Zugriffskey angelegt — der Key +steht dann im Server-Log: + +```bash +go run main.go +# ... +# teams.yaml nicht gefunden — Standard-Team "default" angelegt (Datei: teams.yaml) +# Zugriffs-Key zum Einloggen: +``` + +Diesen Key zum Einloggen unter `/login` verwenden. Weitere Teams können danach +jederzeit von Hand in `teams.yaml` ergänzt werden (siehe unten) — der Server +überschreibt eine einmal angelegte `teams.yaml` nie automatisch. + Standardwerte lokal: - **Daten-Ordner:** `data/` (jedes Team unter `data//content/`) - **Admin-Token:** `dev-token` @@ -33,10 +48,22 @@ Das Wiki ist Multi-Tenant: jedes Team hat einen eigenen Zugriffskey und sieht nu seine eigenen Markdown-Dateien — im Web, über die REST-API/`deploy.sh` und über MCP. Ein globaler Admin-Token sieht alle Teams. -### Teams anlegen +### Ein-Personen-Betrieb ("Single User") -Teams werden in `teams.yaml` konfiguriert (Vorlage: `teams.yaml.example`, niemals -committen — steht in `.gitignore`): +Es gibt keinen eigenen team-losen Modus — aber auch keinen Zusatzaufwand: fehlt +`teams.yaml` beim Serverstart, wird automatisch eine mit genau einem Standard-Team +(`default`) und zufällig erzeugtem Key angelegt (siehe [Lokal +starten](#lokal-starten) oben). Damit läuft der Server sofort ohne manuelle +Konfiguration, verhält sich intern aber weiterhin wie Multi-Tenant mit einem Team. + +Der Admin-Login (`ADMIN_TOKEN`) landet immer zuerst auf der [Team-Verwaltung](#admin-webinterface) +(`/admin/teams`) — auch wenn nur ein Team existiert. Von dort führt ein Klick +auf "Öffnen" direkt ins Wiki dieses einen Teams. + +### Teams anlegen (mehrere Teams / Verwaltung) + +Teams werden in `teams.yaml` konfiguriert (Vorlage: `teams.yaml.example`, +niemals committen — steht in `.gitignore`): ```yaml teams: @@ -58,13 +85,42 @@ Team) werden alle paar Sekunden automatisch übernommen — **kein Neustart nöt Ein entferntes Team verschwindet nur aus der Registry, seine Dateien auf der Platte bleiben unangetastet. +#### Admin-Webinterface + +Statt `teams.yaml` von Hand zu bearbeiten, kannst du Teams auch unter +**`/admin/teams`** verwalten (nur mit dem globalen Admin-Zugang erreichbar, +Link auch in der Sidebar unter "Teams verwalten"). Der Admin-Login landet +immer direkt hier — die Team-Verwaltung ist zugleich die Team-Übersicht: + +- **Anlegen** — ID + optionaler Anzeigename, der Zugriffskey wird automatisch + zufällig erzeugt und einmalig angezeigt (danach nicht mehr abrufbar — am + besten sofort kopieren/weitergeben). +- **Öffnen** — schaltet den Admin in dieses Team um und zeigt dessen Wiki unter + `/` (genau wie das frühere `/switch-team`, nur direkt aus der Tabelle statt + einer eigenen Auswahlseite). Solange ein Team aktiv ist, zeigt die + Team-Verwaltung oben einen "Zurück zu <Team>"-Link, in der Sidebar des + Wikis führt "Teams verwalten" jederzeit wieder zurück. +- **Umbenennen** — ändert nur den Anzeigenamen, ID und Key bleiben gleich. +- **Key neu erzeugen** — macht den bisherigen Key sofort ungültig, der neue + wird einmalig angezeigt. +- **Löschen** — entfernt das Team aus `teams.yaml`; die Markdown-Dateien unter + `data//content/` bleiben erhalten. Das letzte verbleibende Team lässt + sich nicht löschen (eine leere `teams.yaml` lässt den Server nicht mehr + starten). + +Jede Änderung schreibt sofort `teams.yaml` neu und wirkt ohne Serverneustart — +technisch identisch zum manuellen Bearbeiten, nur ohne Editor. Alle Aktionen +landen zusätzlich im [Audit-Log](#audit-log) (`team_created`, `team_renamed`, +`team_key_regenerated`, `team_deleted`). + ### Web-Zugriff Beim Aufruf des Wikis im Browser wird zuerst der Zugriffskey abgefragt (`/login`). Nach erfolgreichem Login sieht der Besucher nur die Seiten seines Teams — Navigation, Suche und alles andere sind vollständig getrennt von anderen Teams. Der globale -`ADMIN_TOKEN` funktioniert ebenfalls als Login, zeigt danach aber zunächst eine -Team-Übersicht (`/switch-team`) zum Umschalten. +`ADMIN_TOKEN` funktioniert ebenfalls als Login, landet danach aber zunächst auf der +[Team-Verwaltung](#admin-webinterface) (`/admin/teams`) statt direkt im Wiki eines +Teams. ### REST-API / `deploy.sh` @@ -522,7 +578,13 @@ Der BM25-Index liegt vollständig im RAM. Die folgende Tabelle zeigt grobe Richt | `GET` | `/static/*` | CSS und statische Dateien | | `GET`/`POST` | `/login` | Team-Key eingeben, Session-Cookie setzen | | `GET`/`POST` | `/logout` | Session-Cookie löschen | -| `GET`/`POST` | `/switch-team` | Team-Umschaltung für den Admin-Zugang | +| `GET` | `/admin/teams` | Teams verwalten — Übersicht, Anlegen-Formular (Admin-Zugang erforderlich) | +| `POST` | `/admin/teams/create` | Team anlegen (Admin-Zugang erforderlich) | +| `POST` | `/admin/teams/rename` | Team umbenennen (Admin-Zugang erforderlich) | +| `POST` | `/admin/teams/regenerate-key` | Zugriffskey eines Teams neu erzeugen (Admin-Zugang erforderlich) | +| `POST` | `/admin/teams/delete` | Team löschen (Admin-Zugang erforderlich) | +| `GET` | `/admin/audit` | Audit-Log ansehen (Admin-Zugang erforderlich) | +| `POST` | `/switch-team` | Admin schaltet in ein Team um (ausgelöst von "Öffnen" in `/admin/teams`) | | `POST` | `/auth/:pfad` | Seiten-Passwort absenden, Cookie setzen | | `POST` | `/api/upload` | Datei hochladen (Team-Key oder Admin-Token erforderlich) | | `DELETE` | `/api/delete` | Datei löschen (Team-Key oder Admin-Token erforderlich) | diff --git a/REPOMAP.md b/REPOMAP.md index d0ec688..bf581bb 100644 --- a/REPOMAP.md +++ b/REPOMAP.md @@ -82,7 +82,10 @@ go-wiki/ │ │ Behauptung, nie den Zugriffskey selbst. │ │ │ ├── auth.go ← Team-Login fürs Web: Formular (GET/POST /login), Logout -│ │ (/logout) und Team-Umschaltung für Admins (/switch-team). +│ │ (/logout) und das Umschalten des Admins in ein Team +│ │ (POST /switch-team, ausgelöst von den "Öffnen"-Buttons in +│ │ templates/teams-admin.html — GET /switch-team ist nur ein +│ │ Redirect-Alias auf /admin/teams). │ │ Nutzt den LoginRateLimiter (ratelimit.go) gegen Brute-Force │ │ und schreibt Login-Versuche ins Audit-Log. clientIP() │ │ liest bewusst nur r.RemoteAddr, keinen X-Forwarded-For- @@ -147,7 +150,8 @@ go-wiki/ │ ├── middleware/ │ ├── session.go ← RequireTeamSession: schützt die Web-Ansicht ("/", -│ │ "/search", "/auth/", "/switch-team", "/admin/audit"). +│ │ "/search", "/auth/", "/switch-team", "/admin/audit", +│ │ "/admin/teams" und dessen Aktions-Routen). │ │ Prüft den Session-Cookie (handler.VerifySession) und legt │ │ das aufgelöste Team bzw. den Admin-Status im Request- │ │ Context ab. Ohne gültige Session: Redirect zu /login. @@ -180,9 +184,11 @@ go-wiki/ │ ├── login.html ← HTML-Template für den Team-Login (GET/POST /login) — │ │ fragt den Zugriffskey des Teams oder den Admin-Token ab. │ │ -│ ├── switch-team.html ← HTML-Template für die Team-Auswahl des Admin-Zugangs -│ │ (GET/POST /switch-team) — listet alle Teams aus der -│ │ Registry als Buttons. +│ ├── teams-admin.html ← HTML-Template für die Team-Verwaltung (GET /admin/teams) — +│ │ Tabelle aller Teams mit Umbenennen/Key-neu-erzeugen/Löschen/ +│ │ Öffnen (schaltet den Admin per POST /switch-team in dieses +│ │ Team) sowie das Formular zum Anlegen eines neuen Teams. Landet +│ │ der Admin nach dem Login oder ohne aktives Team hier. │ │ │ └── audit.html ← HTML-Template für die Audit-Log-Ansicht │ (GET /admin/audit) — Tabelle mit Zeit, Team, Akteur, @@ -229,12 +235,17 @@ Browser POST /login { key } → handler/registry.go (TeamRegistry.Resolve) Key → Team oder Admin, timing-sicher → handler/audit.go (Log) login_success / login_failure → handler/session.go (SignSession) signierten Cookie setzen - → Redirect zu "/" (Team) oder "/switch-team" (Admin) + → Redirect zu "/" (Team) oder "/admin/teams" (Admin — Team-Verwaltung ist zugleich + die Team-Übersicht/-Auswahl) -Admin POST /switch-team { team } - → handler/auth.go (HandleSwitchTeam) nur mit Admin-Session erreichbar - → handler/session.go (SignSession) Cookie "admin:" setzen - → Redirect zu "/" +Admin klickt "Öffnen" bei einem Team in /admin/teams + → POST /switch-team { team } + → handler/auth.go (HandleSwitchTeam) nur mit Admin-Session erreichbar + → handler/session.go (SignSession) Cookie "admin:" setzen + → Redirect zu "/" zeigt das Wiki dieses Teams + + → Von dort per Sidebar-Link "Teams verwalten" jederzeit zurück zu /admin/teams, + dort zeigt "Zurück zu " wieder zum zuletzt aktiven Team. ``` ## Datenfluss: KI-Tool liest/schreibt Seiten via MCP diff --git a/handler/auth.go b/handler/auth.go index dedec9f..9194833 100644 --- a/handler/auth.go +++ b/handler/auth.go @@ -21,19 +21,13 @@ type loginTemplateData struct { Error string } -// switchTeamTemplateData bündelt die Daten für das switch-team.html-Template. -type switchTeamTemplateData struct { - Teams []*Team -} - // AuthHandler verwaltet Login, Logout und die Team-Umschaltung für Admins. type AuthHandler struct { - registry *TeamRegistry - sessionSecret []byte - loginTemplate *template.Template - switchTeamTemplate *template.Template - rateLimiter *LoginRateLimiter - audit *AuditLogger + registry *TeamRegistry + sessionSecret []byte + loginTemplate *template.Template + rateLimiter *LoginRateLimiter + audit *AuditLogger } // NewAuthHandler lädt die Templates und erstellt einen neuen AuthHandler. @@ -43,18 +37,12 @@ func NewAuthHandler(templatesFS fs.FS, registry *TeamRegistry, sessionSecret []b return nil, err } - switchTmpl, err := template.ParseFS(templatesFS, "templates/switch-team.html") - if err != nil { - return nil, err - } - return &AuthHandler{ - registry: registry, - sessionSecret: sessionSecret, - loginTemplate: loginTmpl, - switchTeamTemplate: switchTmpl, - rateLimiter: NewLoginRateLimiter(), - audit: audit, + registry: registry, + sessionSecret: sessionSecret, + loginTemplate: loginTmpl, + rateLimiter: NewLoginRateLimiter(), + audit: audit, }, nil } @@ -99,7 +87,7 @@ func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { actor := "team" if isAdmin { subject = "admin" - redirectTo = "/switch-team" + redirectTo = "/admin/teams" actor = "admin" } else { subject = "team:" + team.ID @@ -141,8 +129,11 @@ func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", http.StatusSeeOther) } -// HandleSwitchTeam zeigt dem Admin eine Team-Auswahl (GET) und setzt das aktive -// Team in der Session (POST). Nur erreichbar mit einer Admin-Session. +// HandleSwitchTeam aktiviert für den Admin ein Team in der Session (POST) und +// springt bei einem versehentlichen GET zur Team-Verwaltung, die die eigentliche +// Team-Auswahl übernimmt (siehe TeamsAdminHandler und templates/teams-admin.html — +// dort hat jedes Team einen "Öffnen"-Button, der genau dieses POST auslöst). +// Nur erreichbar mit einer Admin-Session. func (h *AuthHandler) HandleSwitchTeam(w http.ResponseWriter, r *http.Request) { if !IsAdminFromContext(r.Context()) { http.Redirect(w, r, "/", http.StatusSeeOther) @@ -150,7 +141,7 @@ func (h *AuthHandler) HandleSwitchTeam(w http.ResponseWriter, r *http.Request) { } if r.Method == http.MethodGet { - h.switchTeamTemplate.Execute(w, switchTeamTemplateData{Teams: h.registry.All()}) + http.Redirect(w, r, "/admin/teams", http.StatusSeeOther) return } diff --git a/handler/teams_admin.go b/handler/teams_admin.go new file mode 100644 index 0000000..1e99656 --- /dev/null +++ b/handler/teams_admin.go @@ -0,0 +1,338 @@ +// 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 +} diff --git a/handler/teams_config.go b/handler/teams_config.go index 897ea58..1c95ce9 100644 --- a/handler/teams_config.go +++ b/handler/teams_config.go @@ -7,6 +7,8 @@ package handler import ( + "crypto/rand" + "encoding/hex" "fmt" "log" "os" @@ -69,6 +71,74 @@ func LoadTeamsConfig(path string) ([]TeamConfig, error) { return parsed.Teams, nil } +// SaveTeamsConfig schreibt eine Team-Liste als YAML nach path — das Gegenstück +// zu LoadTeamsConfig. Genutzt vom Admin-Webinterface (teams_admin.go) um +// Änderungen (Team anlegen/umbenennen/löschen, Key neu erzeugen) dauerhaft in +// teams.yaml zu speichern. +func SaveTeamsConfig(path string, teams []TeamConfig) error { + data, err := yaml.Marshal(teamsFile{Teams: teams}) + if err != nil { + return fmt.Errorf("Team-Konfiguration konnte nicht serialisiert werden: %w", err) + } + + // Gleiche Rechte wie EnsureTeamsConfig weiter unten (0600) — teams.yaml + // enthält Klartext-Keys und soll nur vom Server-Prozess lesbar sein. + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("teams.yaml konnte nicht geschrieben werden: %w", err) + } + + return nil +} + +// EnsureTeamsConfig legt beim allerersten Start automatisch eine teams.yaml mit +// einem einzelnen Team an, falls unter path noch keine Datei existiert — so lässt +// sich der Server im Ein-Personen-Betrieb sofort starten, ohne vorher von Hand +// teams.yaml.example zu kopieren und einen Key einzutragen. +// +// Existiert path bereits (auch mit ungültigem Inhalt), passiert nichts — das +// bleibt ein Fehler von LoadTeamsConfig weiter unten. Sonst würde ein aus Versehen +// leer gespeicherter Inhalt stillschweigend durch ein neues Default-Team ersetzt. +func EnsureTeamsConfig(path string) error { + if _, err := os.Stat(path); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("teams.yaml konnte nicht geprüft werden: %w", err) + } + + key, err := randomTeamKey() + if err != nil { + return fmt.Errorf("Key für Standard-Team konnte nicht erzeugt werden: %w", err) + } + + content := fmt.Sprintf(`# Automatisch beim ersten Start angelegt (siehe handler.EnsureTeamsConfig). +# Für mehrere Teams: weitere Einträge unter "teams:" ergänzen — Format siehe +# teams.yaml.example. +teams: + - id: default + name: "Mein Wiki" + key: %q +`, key) + + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + return fmt.Errorf("teams.yaml konnte nicht angelegt werden: %w", err) + } + + log.Printf("teams.yaml nicht gefunden — Standard-Team \"default\" angelegt (Datei: %s)", path) + log.Printf("Zugriffs-Key zum Einloggen: %s", key) + + return nil +} + +// randomTeamKey erzeugt einen zufälligen, hex-kodierten Zugriffskey für ein neu +// angelegtes Team. +func randomTeamKey() (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + // StartTeamsConfigWatcher prüft alle interval einmal die Änderungszeit der Team- // Konfigurationsdatei und lädt sie bei einer Änderung neu in die Registry — so // wirken neue/geänderte/entfernte Teams ohne Serverneustart. diff --git a/handler/wiki.go b/handler/wiki.go index 085edcf..1d75b77 100644 --- a/handler/wiki.go +++ b/handler/wiki.go @@ -100,12 +100,13 @@ func NewWikiHandler(templatesFS fs.FS) (*WikiHandler, error) { // (w = ResponseWriter = womit wir antworten; r = Request = was der Browser geschickt hat) func (h *WikiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Das Team wurde von middleware.RequireTeamSession bereits aufgelöst und im - // Context abgelegt. Kein Team → Admin ohne aktive Auswahl (zur Team-Übersicht) - // oder ein Zustand der eigentlich nicht vorkommen sollte (zur Sicherheit: /login). + // Context abgelegt. Kein Team → Admin ohne aktive Auswahl (zur Team-Verwaltung, + // die jetzt auch die Team-Auswahl übernimmt) oder ein Zustand der eigentlich + // nicht vorkommen sollte (zur Sicherheit: /login). team, ok := TeamFromContext(r.Context()) if !ok { if IsAdminFromContext(r.Context()) { - http.Redirect(w, r, "/switch-team", http.StatusSeeOther) + http.Redirect(w, r, "/admin/teams", http.StatusSeeOther) return } http.Redirect(w, r, "/login", http.StatusSeeOther) @@ -344,7 +345,7 @@ func (h *WikiHandler) HandleSearch(w http.ResponseWriter, r *http.Request) { team, ok := TeamFromContext(r.Context()) if !ok { if IsAdminFromContext(r.Context()) { - http.Redirect(w, r, "/switch-team", http.StatusSeeOther) + http.Redirect(w, r, "/admin/teams", http.StatusSeeOther) return } http.Redirect(w, r, "/login", http.StatusSeeOther) diff --git a/main.go b/main.go index 5846d4e..5515dac 100644 --- a/main.go +++ b/main.go @@ -25,7 +25,7 @@ import ( // Das Ergebnis ist ein einzelnes Binary das überall läuft ohne externe Dateien. //go:embed templates/* -var templatesFS embed.FS // enthält templates/page.html, password.html, login.html, switch-team.html +var templatesFS embed.FS // enthält templates/page.html, password.html, login.html, teams-admin.html, audit.html //go:embed static/* var staticFS embed.FS // enthält static/style.css @@ -61,6 +61,12 @@ func main() { log.Fatal("Daten-Ordner konnte nicht erstellt werden:", err) } + // Fehlt teams.yaml komplett (typischer Erststart), automatisch eine mit einem + // Standard-Team anlegen statt abzubrechen — siehe handler.EnsureTeamsConfig. + if err := handler.EnsureTeamsConfig(teamsConfig); err != nil { + log.Fatal("Team-Konfiguration konnte nicht angelegt werden:", err) + } + // Team-Konfiguration laden (siehe teams.yaml.example) und die Registry aufbauen — // legt für jedes Team den Ordner data//content an und baut dessen PageCache auf. teamsCfg, err := handler.LoadTeamsConfig(teamsConfig) @@ -99,6 +105,11 @@ func main() { log.Fatal("Fehler beim Laden des Audit-Log-Templates:", err) } + teamsAdminHandler, err := handler.NewTeamsAdminHandler(templatesFS, teamsConfig, registry, auditLogger) + if err != nil { + log.Fatal("Fehler beim Laden des Team-Verwaltungs-Templates:", err) + } + apiHandler := handler.NewAPIHandler(registry, auditLogger) mcpHandler := handler.NewMCPHandler(registry, auditLogger) @@ -129,6 +140,15 @@ func main() { mux.Handle("/switch-team", middleware.RequireTeamSession(http.HandlerFunc(authHandler.HandleSwitchTeam), registry, sessionSecretBytes)) mux.Handle("/admin/audit", middleware.RequireTeamSession(http.HandlerFunc(auditPageHandler.HandleAuditPage), registry, sessionSecretBytes)) + // Team-Verwaltung — Teams anlegen/umbenennen/löschen und Keys neu erzeugen. + // Wie /admin/audit über die Team-Session erreichbar, der Handler selbst prüft + // zusätzlich IsAdminFromContext (siehe handler/teams_admin.go). + mux.Handle("/admin/teams", middleware.RequireTeamSession(http.HandlerFunc(teamsAdminHandler.HandleTeamsPage), registry, sessionSecretBytes)) + mux.Handle("/admin/teams/create", middleware.RequireTeamSession(http.HandlerFunc(teamsAdminHandler.HandleCreateTeam), registry, sessionSecretBytes)) + mux.Handle("/admin/teams/rename", middleware.RequireTeamSession(http.HandlerFunc(teamsAdminHandler.HandleRenameTeam), registry, sessionSecretBytes)) + mux.Handle("/admin/teams/regenerate-key", middleware.RequireTeamSession(http.HandlerFunc(teamsAdminHandler.HandleRegenerateKey), registry, sessionSecretBytes)) + mux.Handle("/admin/teams/delete", middleware.RequireTeamSession(http.HandlerFunc(teamsAdminHandler.HandleDeleteTeam), registry, sessionSecretBytes)) + // REST-API — durch Team-Key oder Admin-Token geschützt (middleware.RequireTeamOrAdminKey // löst den Bearer-Key gegen die TeamRegistry auf; der Admin-Token braucht zusätzlich // den Parameter "team", siehe handler/api.go). diff --git a/static/style.css b/static/style.css index c110b89..5a7656e 100644 --- a/static/style.css +++ b/static/style.css @@ -432,6 +432,118 @@ body.sidebar-collapsed .sidebar { background: #fafafa; } +/* ── Team-Verwaltung (Admin) ──────────────────────────────────────────────── */ + +.teams-admin-page { + max-width: 1100px; +} + +.notice { + background: #f0f9f0; + border: 1px solid #cceecc; + color: #226622; + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.new-key-box { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.new-key { + background: #fff; + border: 1px solid #cceecc; + padding: 0.2em 0.5em; + border-radius: 3px; + font-family: "SF Mono", "Fira Code", monospace; + font-size: 0.9em; + user-select: all; +} + +.current-team-box { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.current-team-box a { + color: #0066cc; + font-weight: 600; + text-decoration: none; +} + +.current-team-box a:hover { + text-decoration: underline; +} + +.active-badge { + display: inline-block; + font-size: 0.8rem; + color: #226622; + font-weight: 600; + padding: 0.35rem 0.2rem; +} + +/* Mehrere kleine Formulare nebeneinander in einer Tabellenzelle (Umbenennen, + Key neu erzeugen, Löschen) statt jeweils eine eigene Zeile/Seite. */ +.teams-admin-table .inline-form { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin: 0 0.4rem 0.3rem 0; +} + +.teams-admin-actions { + white-space: normal; +} + +.inline-form input[type="text"] { + padding: 0.3rem 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.85rem; + width: 10rem; +} + +.inline-form button { + width: auto; + padding: 0.35rem 0.7rem; + font-size: 0.85rem; +} + +.inline-form button.danger { + background: #cc3333; +} + +.inline-form button.danger:hover { + background: #a82929; +} + +.teams-admin-create-form { + max-width: 380px; +} + +.teams-admin-create-form input[type="text"] { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 1rem; + margin-bottom: 1rem; + outline: none; +} + +.teams-admin-create-form input[type="text"]:focus { + border-color: #0066cc; + box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15); +} + /* ── Passwort-Formular ────────────────────────────────────────────────────── */ .password-page { diff --git a/templates/page.html b/templates/page.html index b62a5df..754a0c5 100644 --- a/templates/page.html +++ b/templates/page.html @@ -20,11 +20,12 @@