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). 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 ```bash
cp teams.yaml.example teams.yaml
# teams.yaml mit deinen Teams befüllen
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env 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: Standardwerte lokal:
- **Daten-Ordner:** `data/` (jedes Team unter `data/<team-id>/content/`) - **Daten-Ordner:** `data/` (jedes Team unter `data/<team-id>/content/`)
- **Admin-Token:** `dev-token` - **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. seine eigenen Markdown-Dateien — im Web, über die REST-API/`deploy.sh` und über MCP.
Ein globaler Admin-Token sieht alle Teams. 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 Es gibt keinen eigenen team-losen Modus — aber auch keinen Zusatzaufwand: fehlt
committen — steht in `.gitignore`): `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 ```yaml
teams: 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 Ein entferntes Team verschwindet nur aus der Registry, seine Dateien auf der
Platte bleiben unangetastet. 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 ### Web-Zugriff
Beim Aufruf des Wikis im Browser wird zuerst der Zugriffskey abgefragt (`/login`). Beim Aufruf des Wikis im Browser wird zuerst der Zugriffskey abgefragt (`/login`).
Nach erfolgreichem Login sieht der Besucher nur die Seiten seines Teams — Navigation, 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 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 `ADMIN_TOKEN` funktioniert ebenfalls als Login, landet danach aber zunächst auf der
Team-Übersicht (`/switch-team`) zum Umschalten. [Team-Verwaltung](#admin-webinterface) (`/admin/teams`) statt direkt im Wiki eines
Teams.
### REST-API / `deploy.sh` ### 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` | `/static/*` | CSS und statische Dateien |
| `GET`/`POST` | `/login` | Team-Key eingeben, Session-Cookie setzen | | `GET`/`POST` | `/login` | Team-Key eingeben, Session-Cookie setzen |
| `GET`/`POST` | `/logout` | Session-Cookie löschen | | `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` | `/auth/:pfad` | Seiten-Passwort absenden, Cookie setzen |
| `POST` | `/api/upload` | Datei hochladen (Team-Key oder Admin-Token erforderlich) | | `POST` | `/api/upload` | Datei hochladen (Team-Key oder Admin-Token erforderlich) |
| `DELETE` | `/api/delete` | Datei löschen (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. │ │ Behauptung, nie den Zugriffskey selbst.
│ │ │ │
│ ├── auth.go ← Team-Login fürs Web: Formular (GET/POST /login), Logout │ ├── 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 │ │ Nutzt den LoginRateLimiter (ratelimit.go) gegen Brute-Force
│ │ und schreibt Login-Versuche ins Audit-Log. clientIP() │ │ und schreibt Login-Versuche ins Audit-Log. clientIP()
│ │ liest bewusst nur r.RemoteAddr, keinen X-Forwarded-For- │ │ liest bewusst nur r.RemoteAddr, keinen X-Forwarded-For-
@ -147,7 +150,8 @@ go-wiki/
├── middleware/ ├── middleware/
│ ├── session.go ← RequireTeamSession: schützt die Web-Ansicht ("/", │ ├── 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 │ │ Prüft den Session-Cookie (handler.VerifySession) und legt
│ │ das aufgelöste Team bzw. den Admin-Status im Request- │ │ das aufgelöste Team bzw. den Admin-Status im Request-
│ │ Context ab. Ohne gültige Session: Redirect zu /login. │ │ 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) — │ ├── login.html ← HTML-Template für den Team-Login (GET/POST /login) —
│ │ fragt den Zugriffskey des Teams oder den Admin-Token ab. │ │ fragt den Zugriffskey des Teams oder den Admin-Token ab.
│ │ │ │
│ ├── switch-team.html ← HTML-Template für die Team-Auswahl des Admin-Zugangs │ ├── teams-admin.html ← HTML-Template für die Team-Verwaltung (GET /admin/teams) —
│ │ (GET/POST /switch-team) — listet alle Teams aus der │ │ Tabelle aller Teams mit Umbenennen/Key-neu-erzeugen/Löschen/
│ │ Registry als Buttons. │ │ Ö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 │ └── audit.html ← HTML-Template für die Audit-Log-Ansicht
│ (GET /admin/audit) — Tabelle mit Zeit, Team, Akteur, │ (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/registry.go (TeamRegistry.Resolve) Key → Team oder Admin, timing-sicher
→ handler/audit.go (Log) login_success / login_failure → handler/audit.go (Log) login_success / login_failure
→ handler/session.go (SignSession) signierten Cookie setzen → 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 } Admin klickt "Öffnen" bei einem Team in /admin/teams
→ handler/auth.go (HandleSwitchTeam) nur mit Admin-Session erreichbar → POST /switch-team { team }
→ handler/session.go (SignSession) Cookie "admin:<team-id>" setzen → handler/auth.go (HandleSwitchTeam) nur mit Admin-Session erreichbar
→ Redirect zu "/" → 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 ## Datenfluss: KI-Tool liest/schreibt Seiten via MCP

View file

@ -21,19 +21,13 @@ type loginTemplateData struct {
Error string 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. // AuthHandler verwaltet Login, Logout und die Team-Umschaltung für Admins.
type AuthHandler struct { type AuthHandler struct {
registry *TeamRegistry registry *TeamRegistry
sessionSecret []byte sessionSecret []byte
loginTemplate *template.Template loginTemplate *template.Template
switchTeamTemplate *template.Template rateLimiter *LoginRateLimiter
rateLimiter *LoginRateLimiter audit *AuditLogger
audit *AuditLogger
} }
// NewAuthHandler lädt die Templates und erstellt einen neuen AuthHandler. // 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 return nil, err
} }
switchTmpl, err := template.ParseFS(templatesFS, "templates/switch-team.html")
if err != nil {
return nil, err
}
return &AuthHandler{ return &AuthHandler{
registry: registry, registry: registry,
sessionSecret: sessionSecret, sessionSecret: sessionSecret,
loginTemplate: loginTmpl, loginTemplate: loginTmpl,
switchTeamTemplate: switchTmpl, rateLimiter: NewLoginRateLimiter(),
rateLimiter: NewLoginRateLimiter(), audit: audit,
audit: audit,
}, nil }, nil
} }
@ -99,7 +87,7 @@ func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
actor := "team" actor := "team"
if isAdmin { if isAdmin {
subject = "admin" subject = "admin"
redirectTo = "/switch-team" redirectTo = "/admin/teams"
actor = "admin" actor = "admin"
} else { } else {
subject = "team:" + team.ID 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) http.Redirect(w, r, "/login", http.StatusSeeOther)
} }
// HandleSwitchTeam zeigt dem Admin eine Team-Auswahl (GET) und setzt das aktive // HandleSwitchTeam aktiviert für den Admin ein Team in der Session (POST) und
// Team in der Session (POST). Nur erreichbar mit einer Admin-Session. // 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) { func (h *AuthHandler) HandleSwitchTeam(w http.ResponseWriter, r *http.Request) {
if !IsAdminFromContext(r.Context()) { if !IsAdminFromContext(r.Context()) {
http.Redirect(w, r, "/", http.StatusSeeOther) 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 { if r.Method == http.MethodGet {
h.switchTeamTemplate.Execute(w, switchTeamTemplateData{Teams: h.registry.All()}) http.Redirect(w, r, "/admin/teams", http.StatusSeeOther)
return 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 package handler
import ( import (
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -69,6 +71,74 @@ func LoadTeamsConfig(path string) ([]TeamConfig, error) {
return parsed.Teams, nil 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- // StartTeamsConfigWatcher prüft alle interval einmal die Änderungszeit der Team-
// Konfigurationsdatei und lädt sie bei einer Änderung neu in die Registry — so // Konfigurationsdatei und lädt sie bei einer Änderung neu in die Registry — so
// wirken neue/geänderte/entfernte Teams ohne Serverneustart. // 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) // (w = ResponseWriter = womit wir antworten; r = Request = was der Browser geschickt hat)
func (h *WikiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *WikiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Das Team wurde von middleware.RequireTeamSession bereits aufgelöst und im // Das Team wurde von middleware.RequireTeamSession bereits aufgelöst und im
// Context abgelegt. Kein Team → Admin ohne aktive Auswahl (zur Team-Übersicht) // Context abgelegt. Kein Team → Admin ohne aktive Auswahl (zur Team-Verwaltung,
// oder ein Zustand der eigentlich nicht vorkommen sollte (zur Sicherheit: /login). // die jetzt auch die Team-Auswahl übernimmt) oder ein Zustand der eigentlich
// nicht vorkommen sollte (zur Sicherheit: /login).
team, ok := TeamFromContext(r.Context()) team, ok := TeamFromContext(r.Context())
if !ok { if !ok {
if IsAdminFromContext(r.Context()) { if IsAdminFromContext(r.Context()) {
http.Redirect(w, r, "/switch-team", http.StatusSeeOther) http.Redirect(w, r, "/admin/teams", http.StatusSeeOther)
return return
} }
http.Redirect(w, r, "/login", http.StatusSeeOther) 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()) team, ok := TeamFromContext(r.Context())
if !ok { if !ok {
if IsAdminFromContext(r.Context()) { if IsAdminFromContext(r.Context()) {
http.Redirect(w, r, "/switch-team", http.StatusSeeOther) http.Redirect(w, r, "/admin/teams", http.StatusSeeOther)
return return
} }
http.Redirect(w, r, "/login", http.StatusSeeOther) 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. // Das Ergebnis ist ein einzelnes Binary das überall läuft ohne externe Dateien.
//go:embed templates/* //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/* //go:embed static/*
var staticFS embed.FS // enthält static/style.css var staticFS embed.FS // enthält static/style.css
@ -61,6 +61,12 @@ func main() {
log.Fatal("Daten-Ordner konnte nicht erstellt werden:", err) 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 — // 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. // legt für jedes Team den Ordner data/<id>/content an und baut dessen PageCache auf.
teamsCfg, err := handler.LoadTeamsConfig(teamsConfig) teamsCfg, err := handler.LoadTeamsConfig(teamsConfig)
@ -99,6 +105,11 @@ func main() {
log.Fatal("Fehler beim Laden des Audit-Log-Templates:", err) 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) apiHandler := handler.NewAPIHandler(registry, auditLogger)
mcpHandler := handler.NewMCPHandler(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("/switch-team", middleware.RequireTeamSession(http.HandlerFunc(authHandler.HandleSwitchTeam), registry, sessionSecretBytes))
mux.Handle("/admin/audit", middleware.RequireTeamSession(http.HandlerFunc(auditPageHandler.HandleAuditPage), 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 // 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 // löst den Bearer-Key gegen die TeamRegistry auf; der Admin-Token braucht zusätzlich
// den Parameter "team", siehe handler/api.go). // den Parameter "team", siehe handler/api.go).

View file

@ -432,6 +432,118 @@ body.sidebar-collapsed .sidebar {
background: #fafafa; 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 ────────────────────────────────────────────────────── */ /* ── Passwort-Formular ────────────────────────────────────────────────────── */
.password-page { .password-page {

View file

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