Erweiterung Admin Interface für bessere Teamverwaltung
This commit is contained in:
parent
d86235564b
commit
07e1305b3c
11 changed files with 759 additions and 74 deletions
80
README.md
80
README.md
|
|
@ -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 <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/<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) |
|
||||||
|
|
|
||||||
31
REPOMAP.md
31
REPOMAP.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
338
handler/teams_admin.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
22
main.go
|
|
@ -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).
|
||||||
|
|
|
||||||
112
static/style.css
112
static/style.css
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
100
templates/teams-admin.html
Normal 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>
|
||||||
Loading…
Add table
Reference in a new issue