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).
|
||||
|
||||
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 <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
|
||||
|
||||
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) |
|
||||
|
|
|
|||
31
REPOMAP.md
31
REPOMAP.md
|
|
@ -82,7 +82,10 @@ go-wiki/
|
|||
│ │ Behauptung, nie den Zugriffskey selbst.
|
||||
│ │
|
||||
│ ├── auth.go ← Team-Login fürs Web: Formular (GET/POST /login), Logout
|
||||
│ │ (/logout) und Team-Umschaltung für Admins (/switch-team).
|
||||
│ │ (/logout) und das Umschalten des Admins in ein Team
|
||||
│ │ (POST /switch-team, ausgelöst von den "Öffnen"-Buttons in
|
||||
│ │ templates/teams-admin.html — GET /switch-team ist nur ein
|
||||
│ │ Redirect-Alias auf /admin/teams).
|
||||
│ │ Nutzt den LoginRateLimiter (ratelimit.go) gegen Brute-Force
|
||||
│ │ und schreibt Login-Versuche ins Audit-Log. clientIP()
|
||||
│ │ liest bewusst nur r.RemoteAddr, keinen X-Forwarded-For-
|
||||
|
|
@ -147,7 +150,8 @@ go-wiki/
|
|||
│
|
||||
├── middleware/
|
||||
│ ├── session.go ← RequireTeamSession: schützt die Web-Ansicht ("/",
|
||||
│ │ "/search", "/auth/", "/switch-team", "/admin/audit").
|
||||
│ │ "/search", "/auth/", "/switch-team", "/admin/audit",
|
||||
│ │ "/admin/teams" und dessen Aktions-Routen).
|
||||
│ │ Prüft den Session-Cookie (handler.VerifySession) und legt
|
||||
│ │ das aufgelöste Team bzw. den Admin-Status im Request-
|
||||
│ │ Context ab. Ohne gültige Session: Redirect zu /login.
|
||||
|
|
@ -180,9 +184,11 @@ go-wiki/
|
|||
│ ├── login.html ← HTML-Template für den Team-Login (GET/POST /login) —
|
||||
│ │ fragt den Zugriffskey des Teams oder den Admin-Token ab.
|
||||
│ │
|
||||
│ ├── switch-team.html ← HTML-Template für die Team-Auswahl des Admin-Zugangs
|
||||
│ │ (GET/POST /switch-team) — listet alle Teams aus der
|
||||
│ │ Registry als Buttons.
|
||||
│ ├── teams-admin.html ← HTML-Template für die Team-Verwaltung (GET /admin/teams) —
|
||||
│ │ Tabelle aller Teams mit Umbenennen/Key-neu-erzeugen/Löschen/
|
||||
│ │ Öffnen (schaltet den Admin per POST /switch-team in dieses
|
||||
│ │ Team) sowie das Formular zum Anlegen eines neuen Teams. Landet
|
||||
│ │ der Admin nach dem Login oder ohne aktives Team hier.
|
||||
│ │
|
||||
│ └── audit.html ← HTML-Template für die Audit-Log-Ansicht
|
||||
│ (GET /admin/audit) — Tabelle mit Zeit, Team, Akteur,
|
||||
|
|
@ -229,12 +235,17 @@ Browser POST /login { key }
|
|||
→ handler/registry.go (TeamRegistry.Resolve) Key → Team oder Admin, timing-sicher
|
||||
→ handler/audit.go (Log) login_success / login_failure
|
||||
→ handler/session.go (SignSession) signierten Cookie setzen
|
||||
→ Redirect zu "/" (Team) oder "/switch-team" (Admin)
|
||||
→ Redirect zu "/" (Team) oder "/admin/teams" (Admin — Team-Verwaltung ist zugleich
|
||||
die Team-Übersicht/-Auswahl)
|
||||
|
||||
Admin POST /switch-team { team }
|
||||
→ handler/auth.go (HandleSwitchTeam) nur mit Admin-Session erreichbar
|
||||
→ handler/session.go (SignSession) Cookie "admin:<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
|
||||
|
|
|
|||
|
|
@ -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
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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
22
main.go
|
|
@ -25,7 +25,7 @@ import (
|
|||
// Das Ergebnis ist ein einzelnes Binary das überall läuft ohne externe Dateien.
|
||||
|
||||
//go:embed templates/*
|
||||
var templatesFS embed.FS // enthält templates/page.html, password.html, login.html, switch-team.html
|
||||
var templatesFS embed.FS // enthält templates/page.html, password.html, login.html, teams-admin.html, audit.html
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS // enthält static/style.css
|
||||
|
|
@ -61,6 +61,12 @@ func main() {
|
|||
log.Fatal("Daten-Ordner konnte nicht erstellt werden:", err)
|
||||
}
|
||||
|
||||
// Fehlt teams.yaml komplett (typischer Erststart), automatisch eine mit einem
|
||||
// Standard-Team anlegen statt abzubrechen — siehe handler.EnsureTeamsConfig.
|
||||
if err := handler.EnsureTeamsConfig(teamsConfig); err != nil {
|
||||
log.Fatal("Team-Konfiguration konnte nicht angelegt werden:", err)
|
||||
}
|
||||
|
||||
// Team-Konfiguration laden (siehe teams.yaml.example) und die Registry aufbauen —
|
||||
// legt für jedes Team den Ordner data/<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).
|
||||
|
|
|
|||
112
static/style.css
112
static/style.css
|
|
@ -432,6 +432,118 @@ body.sidebar-collapsed .sidebar {
|
|||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* ── Team-Verwaltung (Admin) ──────────────────────────────────────────────── */
|
||||
|
||||
.teams-admin-page {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
background: #f0f9f0;
|
||||
border: 1px solid #cceecc;
|
||||
color: #226622;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.new-key-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.new-key {
|
||||
background: #fff;
|
||||
border: 1px solid #cceecc;
|
||||
padding: 0.2em 0.5em;
|
||||
border-radius: 3px;
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
font-size: 0.9em;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.current-team-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.current-team-box a {
|
||||
color: #0066cc;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.current-team-box a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.active-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
color: #226622;
|
||||
font-weight: 600;
|
||||
padding: 0.35rem 0.2rem;
|
||||
}
|
||||
|
||||
/* Mehrere kleine Formulare nebeneinander in einer Tabellenzelle (Umbenennen,
|
||||
Key neu erzeugen, Löschen) statt jeweils eine eigene Zeile/Seite. */
|
||||
.teams-admin-table .inline-form {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0 0.4rem 0.3rem 0;
|
||||
}
|
||||
|
||||
.teams-admin-actions {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.inline-form input[type="text"] {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.inline-form button {
|
||||
width: auto;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.inline-form button.danger {
|
||||
background: #cc3333;
|
||||
}
|
||||
|
||||
.inline-form button.danger:hover {
|
||||
background: #a82929;
|
||||
}
|
||||
|
||||
.teams-admin-create-form {
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.teams-admin-create-form input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.teams-admin-create-form input[type="text"]:focus {
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15);
|
||||
}
|
||||
|
||||
/* ── Passwort-Formular ────────────────────────────────────────────────────── */
|
||||
|
||||
.password-page {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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