Erweiterung Admin Interface für bessere Teamverwaltung

This commit is contained in:
Tom 2026-07-27 23:10:49 +02:00
parent d86235564b
commit 07e1305b3c
11 changed files with 759 additions and 74 deletions

View file

@ -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: <zufälliger key>
```
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/<team-id>/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 &lt;Team&gt;"-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/<id>/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) |

View file

@ -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:<team-id>" 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:<team-id>" 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 <Team>" wieder zum zuletzt aktiven Team.
```
## Datenfluss: KI-Tool liest/schreibt Seiten via MCP

View file

@ -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
}

338
handler/teams_admin.go Normal file
View file

@ -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:<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
}

View file

@ -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.

View file

@ -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)

22
main.go
View file

@ -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/<id>/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).

View file

@ -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 {

View file

@ -20,11 +20,12 @@
<div class="layout">
<nav class="sidebar" id="sidebar">
{{/* Menü-Links (für Admins zusätzlich Team-Umschaltung + Audit-Log),
dann eine Trennlinie und darunter das aktive Team. */}}
{{/* Menü-Links (für Admins zusätzlich Team-Verwaltung, die auch die
Team-Umschaltung übernimmt, + Audit-Log), dann eine Trennlinie
und darunter das aktive Team. */}}
<div class="sidebar-menu">
{{if .IsAdmin}}
<a href="/switch-team">Team wechseln</a>
<a href="/admin/teams">Teams verwalten</a>
<a href="/admin/audit">Audit-Log</a>
{{end}}
<a href="/logout">Logout</a>

View file

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Team wählen Wiki</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="password-page">
<div class="card">
<h1>Team wählen</h1>
<p>Als Admin kannst du dir jedes Team ansehen.</p>
<form method="POST" action="/switch-team">
{{range .Teams}}
<button type="submit" name="team" value="{{.ID}}">{{.Name}}</button>
{{end}}
</form>
</div>
</body>
</html>

100
templates/teams-admin.html Normal file
View file

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Teams verwalten Wiki</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="layout">
<main class="content teams-admin-page">
<h1>Teams verwalten</h1>
<p class="audit-intro">Teams anlegen, umbenennen, Zugriffskeys neu erzeugen und löschen — die Änderungen
landen sofort in <code>teams.yaml</code> und wirken ohne Serverneustart.</p>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{end}}
{{if .Notice}}
<div class="notice">{{.Notice}}</div>
{{end}}
{{if .NewKey}}
<div class="notice new-key-box">
Zugriffskey für <strong>{{.NewKeyTeamID}}</strong> (wird nur jetzt angezeigt, danach nicht mehr
abrufbar — am besten gleich kopieren):
<code class="new-key">{{.NewKey}}</code>
</div>
{{end}}
{{/* .CurrentTeam ist gesetzt, wenn der Admin bereits per "Öffnen" in ein Team
gewechselt hat — dann ein direkter Weg zurück zum Wiki dieses Teams, ohne
erst wieder in der Tabelle danach suchen zu müssen. */}}
{{if .CurrentTeam}}
<div class="notice current-team-box">
Aktives Team: <strong>{{.CurrentTeam.Name}}</strong>
<a href="/">Zurück zu {{.CurrentTeam.Name}} →</a>
</div>
{{end}}
<div class="audit-table-wrap">
<table class="audit-table teams-admin-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{{$current := .CurrentTeam}}
{{range .Teams}}
<tr>
<td>{{.ID}}</td>
<td>
<form method="POST" action="/admin/teams/rename" class="inline-form">
<input type="hidden" name="id" value="{{.ID}}">
<input type="text" name="name" value="{{.Name}}" required>
<button type="submit">Umbenennen</button>
</form>
</td>
<td class="teams-admin-actions">
{{if and $current (eq $current.ID .ID)}}
<span class="active-badge">Aktiv</span>
{{else}}
<form method="POST" action="/switch-team" class="inline-form">
<input type="hidden" name="team" value="{{.ID}}">
<button type="submit">Öffnen</button>
</form>
{{end}}
<form method="POST" action="/admin/teams/regenerate-key" class="inline-form">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" onclick="return confirm('Neuen Zugriffskey für {{.ID}} erzeugen? Der alte Key wird sofort ungültig.')">Key neu erzeugen</button>
</form>
<form method="POST" action="/admin/teams/delete" class="inline-form">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="danger" onclick="return confirm('Team {{.ID}} wirklich löschen? Die Markdown-Dateien bleiben erhalten, der Zugriff aber nicht.')">Löschen</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<h2>Neues Team anlegen</h2>
<form method="POST" action="/admin/teams/create" class="teams-admin-create-form">
<label for="id">Team-ID</label>
<input type="text" id="id" name="id" placeholder="z.B. gamma" pattern="[a-z0-9_-]+" required>
<label for="name">Anzeigename</label>
<input type="text" id="name" name="name" placeholder="z.B. Team Gamma">
<button type="submit">Team anlegen</button>
</form>
</main>
</div>
</body>
</html>