fix(csrf): CSRF-Token für User-Logout in Manage- und Tenant-Dashboard
- HandleManageUI übergibt CSRFToken korrekt ans Template (leeres Hidden-Field blockierte JS-Inject-Snippet) - HandleTenantDashboard setzt CSRF-Cookie und befüllt CSRFToken in Template-Daten - tenant/csrf_helpers.go: setCSRFCookie im tenant-Package (Import-Cycle-Isolation) - Logout-Formular in tenantDashTmpl hat jetzt statisches CSRF-Hidden-Field - Doku: POST /logout und POST /login mit CSRF-Anforderungen dokumentiert Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
097cd58c0c
commit
47f65da228
9 changed files with 672 additions and 105 deletions
|
|
@ -6,6 +6,7 @@ Die Backend-API unterteilt sich in mehrere Bereiche:
|
|||
|
||||
- **Health & Meta**: System-Status und API-Informationen
|
||||
- **Player Status**: Status-Ingest und Diagnose vom Player
|
||||
- **Screenshot API**: On-Demand- und periodische Screenshots vom Player
|
||||
- **Screen Management**: CRUD und Registrierung von Screens
|
||||
- **Playlists**: Abruf und Verwaltung von Wiedergabelisten
|
||||
- **Media**: Upload und Verwaltung von Medien-Assets
|
||||
|
|
@ -519,6 +520,19 @@ Spezialendpoint zur Auflösung von Nachrichten-Wand-Anfragen (noch in Entwicklun
|
|||
|
||||
Alle Auth-Routen erfordern keine vorherige Authentifizierung.
|
||||
|
||||
### GET /
|
||||
|
||||
Root-Redirect auf `/login`.
|
||||
|
||||
- Anfragen auf exakt `/` werden per `303 See Other` zu `/login` weitergeleitet.
|
||||
- Anfragen auf unbekannte Pfade (z. B. `/irgendwas`) geben `404 Not Found` zurück (404-Guard).
|
||||
|
||||
**Status:**
|
||||
- `303 See Other` — Weiterleitung zu `/login`
|
||||
- `404 Not Found` — Pfad existiert nicht
|
||||
|
||||
---
|
||||
|
||||
### GET /login
|
||||
|
||||
Zeigt das Login-Formular.
|
||||
|
|
@ -536,9 +550,13 @@ Verarbeitet die Login-Eingabe.
|
|||
|
||||
**Request (Form-Encoded):**
|
||||
```
|
||||
username=admin&password=geheim
|
||||
username=admin&password=geheim&csrf_token=<token>
|
||||
```
|
||||
|
||||
Das CSRF-Token muss als verstecktes Formularfeld `csrf_token` mitgesendet werden.
|
||||
Der Token wird beim `GET /login` als Cookie `morz_csrf` gesetzt und in den Template-Daten
|
||||
als `{{.CSRFToken}}` bereitgestellt.
|
||||
|
||||
**Verhalten:**
|
||||
- Passwort wird per `bcrypt.CompareHashAndPassword` geprueft
|
||||
- Bei Erfolg wird ein `morz_session`-Cookie gesetzt (HttpOnly, Secure, 24h TTL)
|
||||
|
|
@ -555,6 +573,15 @@ username=admin&password=geheim
|
|||
|
||||
Meldet den aktuellen Benutzer ab.
|
||||
|
||||
**Request (Form-Encoded):**
|
||||
```
|
||||
csrf_token=<token>
|
||||
```
|
||||
|
||||
Das CSRF-Token muss als verstecktes Formularfeld `csrf_token` mitgesendet werden.
|
||||
Der aktuelle Token-Wert wird beim GET-Aufruf der aufrufenden Seite als `{{.CSRFToken}}`
|
||||
in die Template-Daten eingebettet und als Cookie `morz_csrf` gesetzt.
|
||||
|
||||
**Verhalten:**
|
||||
- Session wird in der DB geloescht (`DeleteSession`)
|
||||
- Cookie wird mit `MaxAge=-1` geloescht
|
||||
|
|
@ -562,6 +589,7 @@ Meldet den aktuellen Benutzer ab.
|
|||
|
||||
**Status:**
|
||||
- `303 See Other`
|
||||
- `403 Forbidden` — CSRF-Token fehlt oder ungueltig
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -787,6 +815,21 @@ Rückleitung zur Admin-Seite oder zum Screen-Detail.
|
|||
|
||||
## Playlist Management UI (Web-Formulare)
|
||||
|
||||
### GET /manage
|
||||
|
||||
Übersichtsseite für eingeloggte Benutzer.
|
||||
|
||||
**Auth:** `RequireAuth`.
|
||||
|
||||
**Verhalten:**
|
||||
- Admins und Tenant-User werden direkt zu ihrer Standard-Ansicht weitergeleitet.
|
||||
- Screen-User mit genau einem zugeordneten Screen werden direkt zu `GET /manage/{screenSlug}` weitergeleitet.
|
||||
- Screen-User mit mehreren zugeordneten Screens erhalten eine Übersichtsseite mit Links zu den einzelnen Screens.
|
||||
|
||||
**Response:** HTML-Seite oder Redirect (303 See Other).
|
||||
|
||||
---
|
||||
|
||||
### GET /manage/{screenSlug}
|
||||
|
||||
Verwaltungs-UI für die Playlist eines Screens.
|
||||
|
|
@ -1032,19 +1075,58 @@ Typische HTTP-Status:
|
|||
|
||||
---
|
||||
|
||||
## In Vorbereitung (Phase 6 / künftig)
|
||||
## Screenshot API
|
||||
|
||||
Die folgenden Endpoints sind derzeit vorbereitet, aber noch nicht vollständig implementiert:
|
||||
### POST /api/v1/player/screenshot
|
||||
|
||||
- `POST /api/v1/player/screenshot` — Upload von Player-Screenshots an den Backend-Server
|
||||
- Wird vom Agent unter `player/agent/internal/screenshot/screenshot.go` mit dem Intervall `MORZ_INFOBOARD_SCREENSHOT_EVERY` aufgerufen
|
||||
- Multipart-Request mit `screen_id`, `screenshot` (Datei), `mime_type`
|
||||
- Benötigt Backend-Handler für Persistierung und/oder Verarbeitung
|
||||
Vom Player-Agent aufgerufener Endpoint zum Hochladen eines Screenshots.
|
||||
|
||||
**Auth:** Keine.
|
||||
|
||||
**Request:** `multipart/form-data`, max. 3 MB.
|
||||
|
||||
| Feld | Typ | Pflicht | Beschreibung |
|
||||
|-------------|--------|---------|------------------------------------------------------|
|
||||
| `screen_id` | string | ja | Interne Screen-ID (entspricht dem Slug des Players) |
|
||||
| `screenshot`| Datei | ja | Screenshot-Datei (JPEG oder PNG) |
|
||||
|
||||
Der MIME-Typ wird aus dem `Content-Type`-Header des Datei-Parts übernommen. Fehlt er, wird `image/png` angenommen.
|
||||
|
||||
Der Screenshot wird im In-Memory-`ScreenshotStore` gespeichert (nicht persistiert, kein Filesystem-Zugriff).
|
||||
|
||||
**Response:**
|
||||
- `200 OK` — Screenshot gespeichert (kein Body)
|
||||
- `400 Bad Request` — `screen_id` fehlt, `screenshot`-Feld fehlt, oder Multipart-Parsing fehlgeschlagen
|
||||
- `500 Internal Server Error` — Lesefehler
|
||||
|
||||
---
|
||||
|
||||
### GET /api/v1/screens/{screenId}/screenshot
|
||||
|
||||
Ruft den zuletzt hochgeladenen Screenshot eines Screens ab.
|
||||
|
||||
**Auth:** `RequireAuth` (eingeloggter Benutzer).
|
||||
|
||||
**Path-Parameter:**
|
||||
- `screenId` — Screen-ID (wie beim Upload übergeben)
|
||||
|
||||
**Response:**
|
||||
- `200 OK` — Raw-Image-Daten mit korrektem `Content-Type` (z. B. `image/jpeg`), `Cache-Control: no-store`
|
||||
- `404 Not Found` — kein Screenshot für diese Screen-ID vorhanden
|
||||
|
||||
---
|
||||
|
||||
## Änderungshistorie
|
||||
|
||||
- **2026-03-24 (Update):** Screenshot-Endpoints implementiert und dokumentiert (Doris / Doku-Review)
|
||||
- `POST /api/v1/player/screenshot` — war als "In Vorbereitung" markiert, ist jetzt vollständig implementiert; Abschnitt komplett neu verfasst
|
||||
- `GET /api/v1/screens/{screenId}/screenshot` — neuer Endpoint, `authOnly`, liefert Raw-Image aus In-Memory-Store
|
||||
- `GET /manage` — neue Übersichtsseite für `screen_user` mit mehreren Screens, `authOnly`
|
||||
- **2026-03-24 (Update):** CSRF-Pflichtfelder in POST /login und POST /logout dokumentiert (Doris / Doku-Review)
|
||||
- `POST /login` und `POST /logout` erfordern `csrf_token` als Hidden-Field (Double-Submit-Cookie-Pattern)
|
||||
- Hinweis auf `morz_csrf`-Cookie und `{{.CSRFToken}}`-Template-Variable ergaenzt
|
||||
- **2026-03-24 (Update):** Root-Redirect dokumentiert (Doris / Doku-Review)
|
||||
- `GET /` — Redirect 303 auf `/login`, 404-Guard für unbekannte Pfade
|
||||
- **2026-03-23 (Update):** Screen-User Management Endpoints (Doris / Doku-Review)
|
||||
- `POST /admin/users` — Screen-User anlegen
|
||||
- `POST /admin/users/{userID}/delete` — Screen-User löschen
|
||||
|
|
|
|||
|
|
@ -23,10 +23,13 @@ Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System.
|
|||
- `internal/httpapi/csrf.go` — Double-Submit-Cookie CSRF-Schutz
|
||||
- `internal/httpapi/ratelimit.go` — Rate-Limiting fuer /login (Brute-Force-Schutz)
|
||||
- `internal/httpapi/uploads.go` — Upload-Handler konsolidiert
|
||||
- `internal/httpapi/screenshot.go` — Handler fuer Player-Screenshot-Upload und Screenshot-Abruf
|
||||
- `internal/httpapi/screenshot_store.go` — In-Memory-Store fuer Screenshots (`ScreenshotStore`, thread-safe via `sync.RWMutex`)
|
||||
- `internal/httpapi/manage/` — Admin-UI und Playlist-Management-UI
|
||||
- `internal/httpapi/manage/csrf_helpers.go` — CSRF-Token Helpers fuer Templates
|
||||
- `internal/httpapi/manage/csrf_helpers.go` — CSRF-Token Helpers fuer Templates (manage-Package)
|
||||
- `internal/httpapi/tenant/` — Tenant-Self-Service-Dashboard
|
||||
- `internal/mqttnotifier/` — MQTT-Notifizierungen
|
||||
- `internal/httpapi/tenant/csrf_helpers.go` — CSRF-Token Helpers fuer Templates (tenant-Package, Import-Cycle-Isolation)
|
||||
- `internal/mqttnotifier/` — MQTT-Notifizierungen (`NotifyChanged`, `RequestScreenshot`)
|
||||
- `internal/reqcontext/` — Context-Keys fuer authentifizierten User
|
||||
|
||||
## Datenbank-Stores
|
||||
|
|
@ -66,6 +69,7 @@ Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System.
|
|||
| GET | `/api/v1` | API-Entrypoint |
|
||||
| GET | `/api/v1/meta` | Metainformationen |
|
||||
| POST | `/api/v1/player/status` | Status-Ingest vom Player-Agent |
|
||||
| POST | `/api/v1/player/screenshot` | Screenshot-Upload vom Player-Agent |
|
||||
| GET | `/api/v1/screens/status` | Uebersicht aller Screen-Status |
|
||||
| GET | `/api/v1/screens/{screenId}/status` | Einzelner Screen-Status |
|
||||
| DELETE | `/api/v1/screens/{screenId}/status` | Screen-Status loeschen |
|
||||
|
|
@ -85,6 +89,7 @@ Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System.
|
|||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|-------------------------------------------|---------------------------------------|
|
||||
| GET | `/manage` | Screen-Uebersicht fuer screen_user |
|
||||
| GET | `/manage/{screenSlug}` | Playlist-Management-UI |
|
||||
| POST | `/manage/{screenSlug}/upload` | Medium fuer Screen hochladen |
|
||||
| POST | `/manage/{screenSlug}/items` | Item zur Playlist hinzufuegen |
|
||||
|
|
@ -99,6 +104,7 @@ Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System.
|
|||
| PUT | `/api/v1/playlists/{playlistId}/order` | Items reordnen (API) |
|
||||
| PATCH | `/api/v1/playlists/{playlistId}/duration` | Standard-Dauer setzen (API) |
|
||||
| DELETE | `/api/v1/media/{id}` | Medium loeschen (API) |
|
||||
| GET | `/api/v1/screens/{screenId}/screenshot` | Screenshot eines Screens abrufen |
|
||||
|
||||
### Nur Admins (`RequireAuth` + `RequireAdmin`)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,11 @@ func handleScreenUserRedirect(w http.ResponseWriter, r *http.Request, screenStor
|
|||
http.Redirect(w, r, "/login?error=no_screens", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/manage/"+screens[0].Slug, http.StatusSeeOther)
|
||||
if len(screens) == 1 {
|
||||
http.Redirect(w, r, "/manage/"+screens[0].Slug, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/manage", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
const sessionTTL = 8 * time.Hour
|
||||
|
|
@ -64,7 +68,19 @@ func HandleLoginUI(authStore *store.AuthStore, screenStore *store.ScreenStore, c
|
|||
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
||||
|
||||
next := r.URL.Query().Get("next")
|
||||
data := loginData{Next: sanitizeNext(next), CSRFToken: csrfToken}
|
||||
|
||||
// K1: ?error= Parameter auswerten und in lesbare Fehlermeldung übersetzen.
|
||||
var errorMsg string
|
||||
switch r.URL.Query().Get("error") {
|
||||
case "no_screens":
|
||||
errorMsg = "Ihr Konto hat noch keinen Bildschirm zugewiesen. Bitte wenden Sie sich an den Administrator."
|
||||
case "":
|
||||
// kein Fehler
|
||||
default:
|
||||
errorMsg = "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
}
|
||||
|
||||
data := loginData{Error: errorMsg, Next: sanitizeNext(next), CSRFToken: csrfToken}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = tmpl.Execute(w, data)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
package manage
|
||||
|
||||
const loginTmpl = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="de" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Anmelden – morz infoboard</title>
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
<style>
|
||||
|
|
@ -26,7 +27,7 @@ const loginTmpl = `<!DOCTYPE html>
|
|||
<h1 class="title is-4 has-text-centered mb-5">Anmelden</h1>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="notification is-danger is-light">
|
||||
<div class="notification is-danger is-light" role="alert">
|
||||
<button class="delete" onclick="this.parentElement.remove()"></button>
|
||||
{{.Error}}
|
||||
</div>
|
||||
|
|
@ -56,7 +57,7 @@ const loginTmpl = `<!DOCTYPE html>
|
|||
|
||||
<div class="field">
|
||||
<label class="label" for="password">Passwort</label>
|
||||
<div class="control has-icons-left">
|
||||
<div class="control has-icons-left has-icons-right">
|
||||
<input class="input" type="password" id="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
<span class="icon is-small is-left">
|
||||
|
|
@ -67,6 +68,13 @@ const loginTmpl = `<!DOCTYPE html>
|
|||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="icon is-small is-right" style="pointer-events:all;cursor:pointer" onclick="togglePw('password',this)" title="Passwort anzeigen/verbergen" aria-label="Passwort anzeigen/verbergen">
|
||||
<svg id="eye-login" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -80,21 +88,29 @@ const loginTmpl = `<!DOCTYPE html>
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function togglePw(fieldId, iconWrap) {
|
||||
var inp = document.getElementById(fieldId);
|
||||
if (!inp) return;
|
||||
inp.type = (inp.type === 'password') ? 'text' : 'password';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const provisionTmpl = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="de" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Einrichten – {{.Screen.Name}}</title>
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
<style>
|
||||
body { background: #f5f5f5; }
|
||||
pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 4px;
|
||||
overflow-x: auto; font-size: 0.9em; line-height: 1.5; }
|
||||
.step-number { background: #3273dc; color: #fff; border-radius: 50%;
|
||||
.step-number { background: var(--bulma-primary, hsl(229, 53%, 53%)); color: #fff; border-radius: 50%;
|
||||
width: 2rem; height: 2rem; display: inline-flex;
|
||||
align-items: center; justify-content: center;
|
||||
font-weight: bold; margin-right: 0.5rem; flex-shrink: 0; }
|
||||
|
|
@ -111,7 +127,18 @@ const provisionTmpl = `<!DOCTYPE html>
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="section">
|
||||
<section class="section pb-0 pt-3">
|
||||
<div class="container" style="max-width:860px">
|
||||
<nav class="breadcrumb" aria-label="breadcrumb">
|
||||
<ul>
|
||||
<li><a href="/admin">Admin</a></li>
|
||||
<li class="is-active"><a href="#" aria-current="page">Neuer Bildschirm</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section pt-2">
|
||||
<div class="container" style="max-width:860px">
|
||||
|
||||
<div class="notification is-success is-light">
|
||||
|
|
@ -192,7 +219,10 @@ ansible-playbook -i ansible/inventory.yml ansible/site.yml --limit {{.Screen.Slu
|
|||
<div class="step-body">
|
||||
<p class="title is-6">Fertig — Playlist befüllen</p>
|
||||
<p>Nach erfolgreichem Ansible-Lauf meldet sich der Bildschirm automatisch im Backend an und lädt seine Playlist. Jetzt kannst du Inhalte zuweisen:</p>
|
||||
<a class="button is-primary mt-3" href="/manage/{{.Screen.Slug}}">Playlist für «{{.Screen.Name}}» verwalten →</a>
|
||||
<div class="buttons mt-3">
|
||||
<a class="button is-primary" href="/manage/{{.Screen.Slug}}">Playlist für «{{.Screen.Name}}» verwalten →</a>
|
||||
<a class="button" href="/admin">← Zurück zu Admin</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -226,15 +256,17 @@ function downloadFile(content, filename) {
|
|||
</html>`
|
||||
|
||||
const adminTmpl = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="de" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>MORZ Infoboard – Admin</title>
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
<style>
|
||||
body { background: #f5f5f5; }
|
||||
.navbar { margin-bottom: 1.5rem; }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.is-active { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -254,7 +286,8 @@ const adminTmpl = `<!DOCTYPE html>
|
|||
<div class="navbar-end">
|
||||
<div class="navbar-item">
|
||||
<form method="POST" action="/logout">
|
||||
<button class="button is-light is-small" type="submit">Abmelden</button>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="button is-white is-outlined is-small" type="submit">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -271,7 +304,7 @@ const adminTmpl = `<!DOCTYPE html>
|
|||
</header>
|
||||
<section class="modal-card-body">
|
||||
<p>Soll <strong id="delete-modal-name"></strong> wirklich gelöscht werden?</p>
|
||||
<p class="has-text-grey is-size-7 mt-2">Alle Playlist-Einträge werden ebenfalls gelöscht.</p>
|
||||
<p class="help has-text-grey mt-2">Alle Playlist-Einträge werden ebenfalls gelöscht.</p>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<form id="delete-modal-form" method="POST">
|
||||
|
|
@ -292,7 +325,7 @@ const adminTmpl = `<!DOCTYPE html>
|
|||
</header>
|
||||
<section class="modal-card-body">
|
||||
<p>Soll Benutzer <strong id="delete-user-modal-name"></strong> wirklich gelöscht werden?</p>
|
||||
<p class="has-text-grey is-size-7 mt-2">Alle Screen-Zuordnungen werden ebenfalls entfernt.</p>
|
||||
<p class="help has-text-grey mt-2">Alle Screen-Zuordnungen werden ebenfalls entfernt.</p>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<form id="delete-user-modal-form" method="POST">
|
||||
|
|
@ -389,6 +422,7 @@ document.addEventListener('keydown', function(e) {
|
|||
var text = texts[msg] || '✓ Aktion erfolgreich.';
|
||||
var n = document.createElement('div');
|
||||
n.className = 'notification ' + (isError ? 'is-warning' : 'is-success');
|
||||
n.setAttribute('role', 'alert');
|
||||
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
|
||||
n.innerHTML = '<button class="delete"></button>' + text;
|
||||
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
|
||||
|
|
@ -404,26 +438,43 @@ document.addEventListener('keydown', function(e) {
|
|||
})();
|
||||
</script>
|
||||
|
||||
<section class="section pt-0">
|
||||
<section class="section pb-0 pt-3">
|
||||
<div class="container">
|
||||
<nav class="breadcrumb" aria-label="breadcrumb">
|
||||
<ul>
|
||||
<li class="is-active"><a href="#" aria-current="page">Admin</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section pt-2">
|
||||
<div class="container">
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs is-boxed mb-0">
|
||||
<ul>
|
||||
<li id="tab-screens" class="{{if eq .ActiveTab "screens"}}is-active{{end}}">
|
||||
<a onclick="switchTab('screens')">Bildschirme</a>
|
||||
<a><button type="button" role="tab" aria-selected="{{if eq .ActiveTab "screens"}}true{{else}}false{{end}}" onclick="switchTab('screens')" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;color:inherit">Bildschirme</button></a>
|
||||
</li>
|
||||
<li id="tab-users" class="{{if eq .ActiveTab "users"}}is-active{{end}}">
|
||||
<a onclick="switchTab('users')">Benutzer</a>
|
||||
<a><button type="button" role="tab" aria-selected="{{if eq .ActiveTab "users"}}true{{else}}false{{end}}" onclick="switchTab('users')" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;color:inherit">Benutzer</button></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<script>
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
|
||||
document.querySelectorAll('.tabs li').forEach(function(li) { li.classList.remove('is-active'); });
|
||||
document.getElementById('panel-' + name).style.display = '';
|
||||
document.getElementById('tab-' + name).classList.add('is-active');
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
|
||||
document.querySelectorAll('.tabs li').forEach(function(li) {
|
||||
li.classList.remove('is-active');
|
||||
var btn = li.querySelector('[role="tab"]');
|
||||
if (btn) btn.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
document.getElementById('panel-' + name).classList.add('is-active');
|
||||
var tabLi = document.getElementById('tab-' + name);
|
||||
tabLi.classList.add('is-active');
|
||||
var activeBtn = tabLi.querySelector('[role="tab"]');
|
||||
if (activeBtn) activeBtn.setAttribute('aria-selected', 'true');
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', name);
|
||||
history.replaceState(null, '', url.toString());
|
||||
|
|
@ -458,12 +509,14 @@ document.addEventListener('keydown', function(e) {
|
|||
<td><strong>{{.Name}}</strong></td>
|
||||
<td><code>{{.Slug}}</code></td>
|
||||
<td>{{orientationLabel .Orientation}}</td>
|
||||
<td id="status-{{.Slug}}"><span class="has-text-grey">⚪</span></td>
|
||||
<td id="status-{{.Slug}}"><span class="has-text-grey" aria-label="Status unbekannt">⚪</span></td>
|
||||
<td>
|
||||
{{$screenID := .ID}}
|
||||
{{$screenName := .Name}}
|
||||
<button class="button is-small is-light"
|
||||
type="button"
|
||||
data-screen-id="{{$screenID}}"
|
||||
data-screen-name="{{$screenName}}"
|
||||
onclick="openScreenUsersModal('{{$screenID}}', {{$screenName | printf "%q"}}, buildScreenUsersHTML('{{$screenID}}', {{$screenName | printf "%q"}}))">
|
||||
{{len $users}} Benutzer
|
||||
</button>
|
||||
|
|
@ -482,7 +535,7 @@ document.addEventListener('keydown', function(e) {
|
|||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
|
||||
<div class="notification is-light">Noch keine Bildschirme angelegt. Füge unten den ersten hinzu.</div>
|
||||
{{end}}
|
||||
|
||||
<hr>
|
||||
|
|
@ -547,7 +600,8 @@ document.addEventListener('keydown', function(e) {
|
|||
</form>
|
||||
|
||||
<details class="mt-4">
|
||||
<summary class="has-text-grey" style="cursor:pointer">Bestehenden Screen manuell anlegen (nur DB-Eintrag, kein Deployment)</summary>
|
||||
<summary style="cursor:pointer;font-weight:600;color:#4a4a4a">Bildschirm nur anlegen (ohne Deployment)</summary>
|
||||
<p class="help has-text-grey mt-1 mb-0">Legt nur einen Datenbank-Eintrag an — kein Ansible, kein Agent-Setup. Für Bildschirme, die bereits provisioniert sind oder manuell konfiguriert werden.</p>
|
||||
<form method="POST" action="/admin/screens" class="mt-4">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column is-3">
|
||||
|
|
@ -622,7 +676,7 @@ document.addEventListener('keydown', function(e) {
|
|||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="has-text-grey mb-4">Noch keine Screen-Benutzer angelegt.</p>
|
||||
<div class="notification is-light mb-4">Noch keine Screen-Benutzer angelegt. Lege unten den ersten an.</div>
|
||||
{{end}}
|
||||
|
||||
<hr>
|
||||
|
|
@ -641,10 +695,18 @@ document.addEventListener('keydown', function(e) {
|
|||
<div class="column is-4">
|
||||
<div class="field">
|
||||
<label class="label">Passwort</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="password" placeholder="Passwort" required
|
||||
autocomplete="new-password">
|
||||
<div class="control has-icons-right">
|
||||
<input class="input" type="password" id="admin-new-password" name="password" placeholder="Passwort (mind. 8 Zeichen)" required
|
||||
autocomplete="new-password" minlength="8">
|
||||
<span class="icon is-small is-right" style="pointer-events:all;cursor:pointer" onclick="togglePwAdmin()" title="Passwort anzeigen/verbergen" aria-label="Passwort anzeigen/verbergen">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<p class="help">Mindestens 8 Zeichen</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
|
|
@ -701,9 +763,9 @@ function buildScreenUsersHTML(screenId, screenName) {
|
|||
html += '<div class="control"><button class="button is-primary" type="submit">Hinzufügen</button></div>';
|
||||
html += '</div></form>';
|
||||
} else if (allUsers.length === 0) {
|
||||
html += '<p class="has-text-grey is-size-7">Lege zuerst Benutzer im Tab "Benutzer" an.</p>';
|
||||
html += '<p class="help has-text-grey">Lege zuerst Benutzer im Tab "Benutzer" an.</p>';
|
||||
} else {
|
||||
html += '<p class="has-text-grey is-size-7">Alle Benutzer sind bereits zugeordnet.</p>';
|
||||
html += '<p class="help has-text-grey">Alle Benutzer sind bereits zugeordnet.</p>';
|
||||
}
|
||||
|
||||
return html;
|
||||
|
|
@ -740,11 +802,18 @@ function injectCSRFNow() {
|
|||
.then(function(r) { return r.ok ? r.json() : null; })
|
||||
.then(function(data) {
|
||||
if (!data || !data.screens) return;
|
||||
var dots = { 'online': '🟢', 'degraded': '🟡', 'offline': '🔴' };
|
||||
var dots = {
|
||||
'online': { emoji: '🟢', label: 'Online' },
|
||||
'degraded': { emoji: '🟡', label: 'Eingeschränkt' },
|
||||
'offline': { emoji: '🔴', label: 'Offline' }
|
||||
};
|
||||
data.screens.forEach(function(s) {
|
||||
var cell = document.getElementById('status-' + s.screen_id);
|
||||
if (cell) {
|
||||
cell.innerHTML = (dots[s.derived_state] || '⚪') + ' <small>' + s.derived_state + '</small>';
|
||||
var info = dots[s.derived_state] || { emoji: '⚪', label: 'Unbekannt' };
|
||||
cell.innerHTML = '<span aria-hidden="true">' + info.emoji + '</span>'
|
||||
+ '<span class="is-sr-only">' + info.label + '</span>'
|
||||
+ ' <small>' + s.derived_state + '</small>';
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
@ -776,14 +845,43 @@ function injectCSRFNow() {
|
|||
}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
function togglePwAdmin() {
|
||||
var inp = document.getElementById('admin-new-password');
|
||||
if (inp) inp.type = (inp.type === 'password') ? 'text' : 'password';
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// M2: Auto-open Screen-User-Modal wenn ?screen= in URL vorhanden
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var autoScreen = new URLSearchParams(window.location.search).get('screen');
|
||||
if (!autoScreen) return;
|
||||
// Suche den passenden Screen in den eingebetteten Daten
|
||||
var allScreenData = document.querySelectorAll('[data-screen-id]');
|
||||
// Fallback: direkt openScreenUsersModal aufrufen falls screenId bekannt
|
||||
if (typeof openScreenUsersModal === 'function' && typeof buildScreenUsersHTML === 'function') {
|
||||
// Finde Screenname aus dem Button
|
||||
var btn = document.querySelector('[data-screen-id="' + autoScreen + '"]');
|
||||
if (btn) {
|
||||
var screenName = btn.getAttribute('data-screen-name') || autoScreen;
|
||||
openScreenUsersModal(autoScreen, screenName, buildScreenUsersHTML(autoScreen, screenName));
|
||||
}
|
||||
}
|
||||
// URL-Parameter entfernen ohne Reload
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.delete('screen');
|
||||
history.replaceState(null, '', url.toString());
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const manageTmpl = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="de" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Playlist – {{.Screen.Name}}</title>
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
<script src="/static/Sortable.min.js"></script>
|
||||
|
|
@ -792,9 +890,9 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
.drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; }
|
||||
.drag-handle:hover { color: #333; }
|
||||
.item-disabled td { opacity: 0.5; }
|
||||
.edit-row td { background: #fffbf0; padding: 0.75rem 1rem; }
|
||||
.edit-row td { background: var(--bulma-warning-light, hsl(48, 100%, 96%)); padding: 0.75rem 1rem; }
|
||||
.tag-type { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.sortable-ghost { background: #e8f4fd !important; }
|
||||
.sortable-ghost { background: var(--bulma-info-light, hsl(207, 61%, 94%)) !important; }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.is-active { display: block; }
|
||||
</style>
|
||||
|
|
@ -803,7 +901,7 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
|
||||
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="{{.BackLink}}">{{.BackLabel}}</a>
|
||||
{{if .IsAdmin}}<a class="navbar-item" href="{{.BackLink}}">{{.BackLabel}}</a>{{end}}
|
||||
<span class="navbar-item">
|
||||
<strong>{{.Screen.Name}}</strong>
|
||||
|
||||
|
|
@ -817,11 +915,24 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
</div>
|
||||
<div id="manageNavbar" class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
{{if gt (len .AccessibleScreens) 1}}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">Bildschirm wechseln</a>
|
||||
<div class="navbar-dropdown">
|
||||
{{range .AccessibleScreens}}
|
||||
<a class="navbar-item{{if eq .Slug $.Screen.Slug}} is-active{{end}}" href="/manage/{{.Slug}}">
|
||||
{{.Name}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item">
|
||||
<form method="POST" action="/logout">
|
||||
<button class="button is-light is-small" type="submit">Abmelden</button>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="button is-white is-outlined is-small" type="submit">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -861,6 +972,7 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
var text = texts[msg] || '✓ Aktion erfolgreich.';
|
||||
var n = document.createElement('div');
|
||||
n.className = 'notification is-success';
|
||||
n.setAttribute('role', 'alert');
|
||||
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
|
||||
n.innerHTML = '<button class="delete"></button>' + text;
|
||||
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
|
||||
|
|
@ -889,8 +1001,27 @@ document.addEventListener('keydown', function(e) {
|
|||
if (e.key === 'Escape') closeManageDeleteModal();
|
||||
});
|
||||
</script>
|
||||
<section class="section pt-4">
|
||||
<section class="section pb-0 pt-3">
|
||||
<div class="container">
|
||||
<nav class="breadcrumb" aria-label="breadcrumb">
|
||||
<ul>
|
||||
{{if .IsAdmin}}<li><a href="/admin">Admin</a></li>{{end}}
|
||||
<li class="is-active"><a href="#" aria-current="page">{{.Screen.Name}}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section pt-2">
|
||||
<div class="container">
|
||||
|
||||
<!-- ── Screenshot ── -->
|
||||
<div class="box" style="padding:0;overflow:hidden;margin-bottom:1.5rem">
|
||||
<img class="screen-thumb"
|
||||
data-src="/api/v1/screens/{{.Screen.Slug}}/screenshot"
|
||||
style="width:100%;max-height:220px;object-fit:cover;background:#222;display:block"
|
||||
alt="Screenshot {{.Screen.Name}}">
|
||||
</div>
|
||||
|
||||
<!-- ── Playlist ── -->
|
||||
<div class="box">
|
||||
|
|
@ -911,13 +1042,20 @@ document.addEventListener('keydown', function(e) {
|
|||
<tbody id="sortable-items">
|
||||
{{range .Items}}
|
||||
<tr id="item-{{.ID}}" class="{{if not .Enabled}}item-disabled{{end}}">
|
||||
<td class="drag-handle" role="button" aria-label="Reihenfolge ändern" tabindex="0" title="Ziehen zum Sortieren">⠿</td>
|
||||
<td style="white-space:nowrap">
|
||||
<span class="drag-handle" role="button" aria-label="Reihenfolge per Drag ändern" tabindex="0" title="Ziehen zum Sortieren">⠿</span>
|
||||
<button type="button" class="button is-small is-white px-1" style="min-width:1.6rem" title="Nach oben" aria-label="Eintrag nach oben" onclick="reorderMove('{{.ID}}', -1)">▲</button>
|
||||
<button type="button" class="button is-small is-white px-1" style="min-width:1.6rem" title="Nach unten" aria-label="Eintrag nach unten" onclick="reorderMove('{{.ID}}', 1)">▼</button>
|
||||
</td>
|
||||
<td>
|
||||
<span class="tag is-light tag-type">{{typeIcon .Type}} {{.Type}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{if .Title}}<strong>{{.Title}}</strong>{{else}}<em class="has-text-grey">{{shortSrc .Src}}</em>{{end}}</div>
|
||||
{{if .Title}}<small class="has-text-grey">{{shortSrc .Src}}</small>{{end}}
|
||||
{{if and .ValidFrom .ValidUntil}}<span class="tag is-info is-light is-small mt-1">{{formatDateDE .ValidFrom}} – {{formatDateDE .ValidUntil}}</span>
|
||||
{{else if .ValidFrom}}<span class="tag is-info is-light is-small mt-1">ab {{formatDateDE .ValidFrom}}</span>
|
||||
{{else if .ValidUntil}}<span class="tag is-info is-light is-small mt-1">bis {{formatDateDE .ValidUntil}}</span>{{end}}
|
||||
</td>
|
||||
<td>{{.DurationSeconds}} s</td>
|
||||
<td>
|
||||
|
|
@ -954,11 +1092,13 @@ document.addEventListener('keydown', function(e) {
|
|||
<label class="label is-small">Gültig ab</label>
|
||||
<input class="input is-small" type="datetime-local" name="valid_from"
|
||||
value="{{formatDT .ValidFrom}}">
|
||||
<p class="help">Zeiten in Zeitzone des Servers (Europe/Berlin)</p>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<label class="label is-small">Gültig bis</label>
|
||||
<input class="input is-small" type="datetime-local" name="valid_until"
|
||||
value="{{formatDT .ValidUntil}}">
|
||||
<p class="help">Zeiten in Zeitzone des Servers (Europe/Berlin)</p>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<label class="label is-small">Aktiv</label>
|
||||
|
|
@ -1038,7 +1178,7 @@ document.addEventListener('keydown', function(e) {
|
|||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="has-text-grey">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</p>
|
||||
<div class="notification is-light">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
|
|
@ -1048,8 +1188,8 @@ document.addEventListener('keydown', function(e) {
|
|||
|
||||
<div class="tabs" id="upload-tabs">
|
||||
<ul>
|
||||
<li id="tab-file" class="is-active"><a onclick="switchTab('file')">📁 Datei hochladen</a></li>
|
||||
<li id="tab-web"><a onclick="switchTab('web')">🌐 Webseite / URL</a></li>
|
||||
<li id="tab-file" class="is-active"><a><button type="button" role="tab" aria-selected="true" onclick="switchTab('file')" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;color:inherit">📁 Datei hochladen</button></a></li>
|
||||
<li id="tab-web"><a><button type="button" role="tab" aria-selected="false" onclick="switchTab('web')" style="background:none;border:none;padding:0;cursor:pointer;font:inherit;color:inherit">🌐 Webseite / URL</button></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
@ -1060,7 +1200,7 @@ document.addEventListener('keydown', function(e) {
|
|||
<div class="field">
|
||||
<label class="label">Typ</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select name="type">
|
||||
<select name="type" id="upload-type-select" onchange="updateFileAccept(this.value)">
|
||||
<option value="image">🖼 Bild</option>
|
||||
<option value="video">🎬 Video</option>
|
||||
<option value="pdf">📄 PDF</option>
|
||||
|
|
@ -1070,7 +1210,7 @@ document.addEventListener('keydown', function(e) {
|
|||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Titel <span class="has-text-grey is-size-7">(optional)</span></label>
|
||||
<label class="label">Titel <span class="has-text-grey">(optional)</span></label>
|
||||
<input class="input" type="text" name="title"
|
||||
placeholder="Wird aus Dateinamen abgeleitet, wenn leer">
|
||||
</div>
|
||||
|
|
@ -1111,7 +1251,7 @@ document.addEventListener('keydown', function(e) {
|
|||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Titel <span class="has-text-grey is-size-7">(optional)</span></label>
|
||||
<label class="label">Titel <span class="has-text-grey">(optional)</span></label>
|
||||
<input class="input" type="text" name="title" placeholder="Anzeigename">
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1145,7 +1285,11 @@ document.addEventListener('keydown', function(e) {
|
|||
function toggleEdit(id) {
|
||||
var row = document.getElementById('edit-' + id);
|
||||
if (row) {
|
||||
row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
|
||||
var isHidden = (row.style.display === 'none' || row.style.display === '');
|
||||
row.style.display = isHidden ? 'table-row' : 'none';
|
||||
if (isHidden) {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1155,16 +1299,82 @@ function switchTab(tab) {
|
|||
var panel = document.getElementById('panel-' + p);
|
||||
var tabEl = document.getElementById('tab-' + p);
|
||||
if (!panel || !tabEl) return;
|
||||
var btn = tabEl.querySelector('[role="tab"]');
|
||||
if (p === tab) {
|
||||
panel.classList.add('is-active');
|
||||
tabEl.classList.add('is-active');
|
||||
if (btn) btn.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
panel.classList.remove('is-active');
|
||||
tabEl.classList.remove('is-active');
|
||||
if (btn) btn.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// N3: Keyboard-Reorder per ▲/▼-Buttons
|
||||
function reorderMove(itemId, direction) {
|
||||
var tbody = document.getElementById('sortable-items');
|
||||
if (!tbody) return;
|
||||
var rows = Array.from(tbody.querySelectorAll('tr[id^="item-"]'));
|
||||
var idx = rows.findIndex(function(r) { return r.id === 'item-' + itemId; });
|
||||
if (idx < 0) return;
|
||||
var newIdx = idx + direction;
|
||||
if (newIdx < 0 || newIdx >= rows.length) return;
|
||||
|
||||
// DOM tauschen (auch die zugehörige edit-row mitnehmen)
|
||||
var itemRow = document.getElementById('item-' + itemId);
|
||||
var editRow = document.getElementById('edit-' + itemId);
|
||||
var targetItemRow = rows[newIdx];
|
||||
var targetEditRow = document.getElementById('edit-' + targetItemRow.id.replace('item-', ''));
|
||||
|
||||
if (direction < 0) {
|
||||
tbody.insertBefore(itemRow, targetItemRow);
|
||||
if (editRow) tbody.insertBefore(editRow, targetItemRow);
|
||||
if (targetEditRow) tbody.insertBefore(targetItemRow, editRow || itemRow.nextSibling);
|
||||
if (targetEditRow) tbody.insertBefore(targetEditRow, editRow || itemRow.nextSibling);
|
||||
} else {
|
||||
var after = (targetEditRow || targetItemRow).nextSibling;
|
||||
tbody.insertBefore(itemRow, after);
|
||||
if (editRow) tbody.insertBefore(editRow, after);
|
||||
}
|
||||
|
||||
// Neue Reihenfolge ans Backend schicken
|
||||
var ids = Array.from(tbody.querySelectorAll('tr[id^="item-"]')).map(function(r) {
|
||||
return r.id.replace('item-', '');
|
||||
});
|
||||
fetch('/manage/{{.Screen.Slug}}/reorder', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(ids)
|
||||
}).catch(function() {
|
||||
showManageError('Reihenfolge konnte nicht gespeichert werden.');
|
||||
});
|
||||
}
|
||||
|
||||
// M10: Datei-Accept-Attribut dynamisch anpassen
|
||||
function updateFileAccept(type) {
|
||||
var inp = document.getElementById('upload-file-input');
|
||||
if (!inp) return;
|
||||
var acceptMap = { 'image': 'image/*', 'video': 'video/*', 'pdf': 'application/pdf' };
|
||||
inp.accept = acceptMap[type] || 'image/*,video/*,application/pdf';
|
||||
}
|
||||
|
||||
function showManageError(msg) {
|
||||
var n = document.createElement('div');
|
||||
n.className = 'notification is-danger';
|
||||
n.setAttribute('role', 'alert');
|
||||
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
|
||||
n.innerHTML = '<button class="delete"></button>' + msg;
|
||||
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
|
||||
document.body.appendChild(n);
|
||||
setTimeout(function() {
|
||||
n.style.transition = 'opacity .5s';
|
||||
n.style.opacity = '0';
|
||||
setTimeout(function() { n.remove(); }, 500);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// Drag-and-drop reordering
|
||||
var sortableEl = document.getElementById('sortable-items');
|
||||
if (sortableEl) {
|
||||
|
|
@ -1181,6 +1391,14 @@ if (sortableEl) {
|
|||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(ids)
|
||||
}).then(function(response) {
|
||||
if (!response.ok) {
|
||||
showManageError('Reihenfolge konnte nicht gespeichert werden (HTTP ' + response.status + '). Seite wird neu geladen.');
|
||||
window.location.reload();
|
||||
}
|
||||
}).catch(function() {
|
||||
showManageError('Netzwerkfehler beim Speichern der Reihenfolge. Seite wird neu geladen.');
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -1267,6 +1485,84 @@ function startUpload() {
|
|||
}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
document.querySelectorAll('.screen-thumb').forEach(function(img) {
|
||||
img.src = img.dataset.src;
|
||||
});
|
||||
setTimeout(function() {
|
||||
document.querySelectorAll('.screen-thumb').forEach(function(img) {
|
||||
img.src = img.dataset.src + '?t=' + Date.now();
|
||||
});
|
||||
}, 4000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const screenOverviewTmpl = `<!DOCTYPE html>
|
||||
<html lang="de" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Bildschirme – morz infoboard</title>
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
<style>
|
||||
body { background: #f5f5f5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<span class="navbar-item"><strong>morz infoboard</strong></span>
|
||||
</div>
|
||||
<div class="navbar-menu">
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item">
|
||||
<form method="POST" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="button is-white is-outlined is-small" type="submit">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title is-4 mb-5">Meine Bildschirme</h1>
|
||||
<div class="columns is-multiline">
|
||||
{{range .Cards}}
|
||||
<div class="column is-one-third-desktop is-half-tablet">
|
||||
<div class="box" style="padding:0;overflow:hidden">
|
||||
<img class="screen-thumb"
|
||||
data-src="/api/v1/screens/{{.Screen.Slug}}/screenshot"
|
||||
alt="{{.Screen.Name}}"
|
||||
style="width:100%;height:180px;object-fit:cover;background:#222;display:block">
|
||||
<div style="padding:1rem">
|
||||
<p class="title is-5 mb-3">{{.Screen.Name}}</p>
|
||||
<a class="button is-primary is-fullwidth" href="/manage/{{.Screen.Slug}}">Verwalten</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
document.querySelectorAll('.screen-thumb').forEach(function(img) {
|
||||
img.src = img.dataset.src;
|
||||
});
|
||||
setTimeout(function() {
|
||||
document.querySelectorAll('.screen-thumb').forEach(function(img) {
|
||||
img.src = img.dataset.src + '?t=' + Date.now();
|
||||
});
|
||||
}, 4000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/fileutil"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
|
||||
|
|
@ -129,10 +130,16 @@ var tmplFuncs = template.FuncMap{
|
|||
}
|
||||
return t.Format("2006-01-02T15:04")
|
||||
},
|
||||
"formatDateDE": func(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("02.01.2006 15:04")
|
||||
},
|
||||
}
|
||||
|
||||
// HandleAdminUI renders the admin overview page (screens + users tabs).
|
||||
func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore, auth *store.AuthStore) http.HandlerFunc {
|
||||
func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore, auth *store.AuthStore, cfg config.Config) http.HandlerFunc {
|
||||
t := template.Must(template.New("admin").Funcs(tmplFuncs).Parse(adminTmpl))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
allScreens, err := screens.ListAll(r.Context())
|
||||
|
|
@ -172,12 +179,20 @@ func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore, auth
|
|||
activeTab = "screens"
|
||||
}
|
||||
|
||||
// M2: ?screen= Parameter weitergeben damit das Template den Screen-Modal öffnen kann.
|
||||
autoOpenScreen := r.URL.Query().Get("screen")
|
||||
|
||||
// M6: CSRF-Token an Template-Daten weitergeben.
|
||||
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
||||
|
||||
renderTemplate(w, t, map[string]any{
|
||||
"Screens": allScreens,
|
||||
"Tenants": allTenants,
|
||||
"ScreenUsers": screenUsers,
|
||||
"ScreenUserMap": screenUserMap,
|
||||
"ActiveTab": activeTab,
|
||||
"Screens": allScreens,
|
||||
"Tenants": allTenants,
|
||||
"ScreenUsers": screenUsers,
|
||||
"ScreenUserMap": screenUserMap,
|
||||
"ActiveTab": activeTab,
|
||||
"AutoOpenScreen": autoOpenScreen,
|
||||
"CSRFToken": csrfToken,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -219,7 +234,7 @@ func HandleDeleteScreenUser(auth *store.AuthStore) http.HandlerFunc {
|
|||
if err := auth.DeleteUser(r.Context(), userID); err != nil {
|
||||
slog.Error("delete screen user failed", "event", "delete_screen_user_failed",
|
||||
"user_id", userID, "err", err)
|
||||
http.Error(w, "Fehler beim Löschen", http.StatusInternalServerError)
|
||||
http.Redirect(w, r, "/admin?tab=users&msg=error_db", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin?tab=users&msg=user_deleted", http.StatusSeeOther)
|
||||
|
|
@ -236,16 +251,16 @@ func HandleAddUserToScreen(screens *store.ScreenStore) http.HandlerFunc {
|
|||
}
|
||||
userID := strings.TrimSpace(r.FormValue("user_id"))
|
||||
if userID == "" {
|
||||
http.Redirect(w, r, "/admin?msg=error_empty", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=error_empty", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := screens.AddUserToScreen(r.Context(), userID, screenID); err != nil {
|
||||
slog.Error("add user to screen failed", "event", "add_user_to_screen_failed",
|
||||
"screen_id", screenID, "user_id", userID, "err", err)
|
||||
http.Redirect(w, r, "/admin?msg=error_db", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=error_db", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin?screen="+screenID+"&msg=user_added_to_screen", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=user_added_to_screen", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -257,10 +272,47 @@ func HandleRemoveUserFromScreen(screens *store.ScreenStore) http.HandlerFunc {
|
|||
if err := screens.RemoveUserFromScreen(r.Context(), userID, screenID); err != nil {
|
||||
slog.Error("remove user from screen failed", "event", "remove_user_from_screen_failed",
|
||||
"screen_id", screenID, "user_id", userID, "err", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=error_db", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin?screen="+screenID+"&msg=user_removed_from_screen", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=user_removed_from_screen", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
type screenCard struct {
|
||||
Screen *store.Screen
|
||||
}
|
||||
|
||||
// HandleScreenOverview renders a card-based overview of all accessible screens for a screen_user.
|
||||
func HandleScreenOverview(screens *store.ScreenStore, notifier *mqttnotifier.Notifier, cfg config.Config) http.HandlerFunc {
|
||||
t := template.Must(template.New("screenOverview").Funcs(tmplFuncs).Parse(screenOverviewTmpl))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u := reqcontext.UserFromContext(r.Context())
|
||||
if u == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
accessible, err := screens.GetAccessibleScreens(r.Context(), u.ID)
|
||||
if err != nil || len(accessible) == 0 {
|
||||
http.Redirect(w, r, "/login?error=no_screens", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if len(accessible) == 1 {
|
||||
http.Redirect(w, r, "/manage/"+accessible[0].Slug, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
for _, sc := range accessible {
|
||||
notifier.RequestScreenshot(sc.Slug)
|
||||
}
|
||||
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
||||
cards := make([]screenCard, 0, len(accessible))
|
||||
for _, sc := range accessible {
|
||||
cards = append(cards, screenCard{Screen: sc})
|
||||
}
|
||||
renderTemplate(w, t, map[string]any{
|
||||
"Cards": cards,
|
||||
"CSRFToken": csrfToken,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,6 +322,8 @@ func HandleManageUI(
|
|||
screens *store.ScreenStore,
|
||||
media *store.MediaStore,
|
||||
playlists *store.PlaylistStore,
|
||||
cfg config.Config,
|
||||
notifier *mqttnotifier.Notifier,
|
||||
) http.HandlerFunc {
|
||||
t := template.Must(template.New("manage").Funcs(tmplFuncs).Parse(manageTmpl))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -281,6 +335,8 @@ func HandleManageUI(
|
|||
return
|
||||
}
|
||||
|
||||
notifier.RequestScreenshot(screen.Slug)
|
||||
|
||||
// K2: Tenant-Isolation — nur eigener Tenant oder Admin.
|
||||
if !requireScreenAccess(w, r, screen) {
|
||||
return
|
||||
|
|
@ -320,6 +376,9 @@ func HandleManageUI(
|
|||
}
|
||||
}
|
||||
|
||||
// M6: CSRF-Token an Template-Daten weitergeben.
|
||||
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
||||
|
||||
// Determine back-navigation based on ?from= query parameter.
|
||||
backLink := "/admin"
|
||||
backLabel := "← Admin"
|
||||
|
|
@ -334,15 +393,43 @@ func HandleManageUI(
|
|||
}
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
var accessibleScreens []*store.Screen
|
||||
if u := reqcontext.UserFromContext(r.Context()); u != nil {
|
||||
switch u.Role {
|
||||
case "admin":
|
||||
isAdmin = true
|
||||
accessibleScreens, _ = screens.ListAll(r.Context())
|
||||
case "screen_user":
|
||||
accessibleScreens, _ = screens.GetAccessibleScreens(r.Context(), u.ID)
|
||||
default:
|
||||
// tenant_user und ähnliche Rollen: alle Screens des eigenen Tenants.
|
||||
if u.TenantID != "" {
|
||||
accessibleScreens, _ = screens.List(r.Context(), u.TenantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// M5: Server-Timezone ermitteln — bevorzugt aus TZ-Env-Variable, sonst aus der
|
||||
// lokalen Zeit-Location des Servers.
|
||||
serverTimezone := os.Getenv("TZ")
|
||||
if serverTimezone == "" {
|
||||
serverTimezone = time.Now().Location().String()
|
||||
}
|
||||
|
||||
renderTemplate(w, t, map[string]any{
|
||||
"Screen": screen,
|
||||
"Tenant": tenant,
|
||||
"Playlist": playlist,
|
||||
"Items": items,
|
||||
"Assets": assets,
|
||||
"AddedAssets": addedAssets,
|
||||
"BackLink": backLink,
|
||||
"BackLabel": backLabel,
|
||||
"Screen": screen,
|
||||
"Tenant": tenant,
|
||||
"Playlist": playlist,
|
||||
"Items": items,
|
||||
"Assets": assets,
|
||||
"AddedAssets": addedAssets,
|
||||
"BackLink": backLink,
|
||||
"BackLabel": backLabel,
|
||||
"IsAdmin": isAdmin,
|
||||
"AccessibleScreens": accessibleScreens,
|
||||
"ServerTimezone": serverTimezone,
|
||||
"CSRFToken": csrfToken,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -351,14 +438,14 @@ func HandleManageUI(
|
|||
func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
http.Redirect(w, r, "/admin?tab=screens&msg=error_bad_form", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
slug := strings.TrimSpace(r.FormValue("slug"))
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
orientation := r.FormValue("orientation")
|
||||
if slug == "" || name == "" {
|
||||
http.Error(w, "slug und name erforderlich", http.StatusBadRequest)
|
||||
http.Redirect(w, r, "/admin?tab=screens&msg=error_empty", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if orientation == "" {
|
||||
|
|
@ -371,16 +458,20 @@ func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore
|
|||
}
|
||||
tenant, err := tenants.Get(r.Context(), tenantSlug)
|
||||
if err != nil {
|
||||
http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError)
|
||||
slog.Error("create screen: tenant not found", "event", "create_screen_tenant_not_found",
|
||||
"tenant_slug", tenantSlug, "err", err)
|
||||
http.Redirect(w, r, "/admin?tab=screens&msg=error_tenant", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = screens.Create(r.Context(), tenant.ID, slug, name, orientation)
|
||||
if err != nil {
|
||||
http.Error(w, "Interner Fehler", http.StatusInternalServerError)
|
||||
slog.Error("create screen failed", "event", "create_screen_failed",
|
||||
"tenant_slug", tenantSlug, "slug", slug, "err", err)
|
||||
http.Redirect(w, r, "/admin?tab=screens&msg=error_exists", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin?msg=added", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin?tab=screens&msg=added", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -389,7 +480,7 @@ func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) h
|
|||
t := template.Must(template.New("provision").Funcs(tmplFuncs).Parse(provisionTmpl))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
http.Redirect(w, r, "/admin?tab=screens&msg=error_bad_form", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
slug := strings.TrimSpace(r.FormValue("slug"))
|
||||
|
|
@ -399,7 +490,7 @@ func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) h
|
|||
orientation := r.FormValue("orientation")
|
||||
|
||||
if slug == "" || ip == "" {
|
||||
http.Error(w, "slug und IP-Adresse erforderlich", http.StatusBadRequest)
|
||||
http.Redirect(w, r, "/admin?tab=screens&msg=error_empty", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
|
|
@ -418,13 +509,17 @@ func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) h
|
|||
}
|
||||
tenant, err := tenants.Get(r.Context(), tenantSlug)
|
||||
if err != nil {
|
||||
http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError)
|
||||
slog.Error("provision screen: tenant not found", "event", "provision_screen_tenant_not_found",
|
||||
"tenant_slug", tenantSlug, "err", err)
|
||||
http.Redirect(w, r, "/admin?tab=screens&msg=error_tenant", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
screen, err := screens.Upsert(r.Context(), tenant.ID, slug, name, orientation)
|
||||
if err != nil {
|
||||
http.Error(w, "Interner Fehler", http.StatusInternalServerError)
|
||||
slog.Error("provision screen failed", "event", "provision_screen_failed",
|
||||
"tenant_slug", tenantSlug, "slug", slug, "err", err)
|
||||
http.Redirect(w, r, "/admin?tab=screens&msg=error_db", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ type RouterDeps struct {
|
|||
MediaStore *store.MediaStore
|
||||
PlaylistStore *store.PlaylistStore
|
||||
AuthStore *store.AuthStore
|
||||
Notifier *mqttnotifier.Notifier
|
||||
Config config.Config
|
||||
Notifier *mqttnotifier.Notifier
|
||||
ScreenshotStore *ScreenshotStore
|
||||
Config config.Config
|
||||
UploadDir string
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
|
@ -36,6 +37,15 @@ func NewRouter(deps RouterDeps) http.Handler {
|
|||
})
|
||||
})
|
||||
|
||||
// ── Root redirect ────────────────────────────────────────────────────
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// ── Status / diagnostic UI ───────────────────────────────────────────
|
||||
mux.HandleFunc("GET /status", handleStatusPage(deps.StatusStore))
|
||||
mux.HandleFunc("GET /status/{screenId}", handleScreenDetailPage(deps.StatusStore))
|
||||
|
|
@ -58,6 +68,7 @@ func NewRouter(deps RouterDeps) http.Handler {
|
|||
|
||||
// ── Player status (existing) ──────────────────────────────────────────
|
||||
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(deps.StatusStore))
|
||||
mux.HandleFunc("POST /api/v1/player/screenshot", handlePlayerScreenshot(deps.ScreenshotStore))
|
||||
mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(deps.StatusStore))
|
||||
mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(deps.StatusStore))
|
||||
mux.HandleFunc("DELETE /api/v1/screens/{screenId}/status", handleDeletePlayerStatus(deps.StatusStore))
|
||||
|
|
@ -131,7 +142,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
|||
|
||||
// ── Admin UI ──────────────────────────────────────────────────────────
|
||||
mux.Handle("GET /admin",
|
||||
authAdmin(http.HandlerFunc(manage.HandleAdminUI(d.TenantStore, d.ScreenStore, d.AuthStore))))
|
||||
authAdmin(http.HandlerFunc(manage.HandleAdminUI(d.TenantStore, d.ScreenStore, d.AuthStore, d.Config))))
|
||||
mux.Handle("POST /admin/screens/provision",
|
||||
authAdmin(http.HandlerFunc(manage.HandleProvisionUI(d.TenantStore, d.ScreenStore))))
|
||||
mux.Handle("POST /admin/screens",
|
||||
|
|
@ -151,8 +162,10 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
|||
|
||||
// ── Playlist management UI ────────────────────────────────────────────
|
||||
// authScreen enforces that screen_user only accesses their permitted screens.
|
||||
mux.Handle("GET /manage",
|
||||
authOnly(http.HandlerFunc(manage.HandleScreenOverview(d.ScreenStore, notifier, d.Config))))
|
||||
mux.Handle("GET /manage/{screenSlug}",
|
||||
authScreen(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore))))
|
||||
authScreen(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore, d.Config, notifier))))
|
||||
mux.Handle("POST /manage/{screenSlug}/upload",
|
||||
authScreen(http.HandlerFunc(manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))))
|
||||
mux.Handle("POST /manage/{screenSlug}/items",
|
||||
|
|
@ -166,6 +179,10 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
|||
mux.Handle("POST /manage/{screenSlug}/media/{mediaId}/delete",
|
||||
authScreen(http.HandlerFunc(manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir, notifier))))
|
||||
|
||||
// ── Screenshot API ────────────────────────────────────────────────────
|
||||
mux.Handle("GET /api/v1/screens/{screenId}/screenshot",
|
||||
authOnly(http.HandlerFunc(handleGetScreenshot(d.ScreenshotStore))))
|
||||
|
||||
// ── JSON API — screens ────────────────────────────────────────────────
|
||||
// Self-registration: no auth (player calls this on startup).
|
||||
mux.HandleFunc("POST /api/v1/screens/register",
|
||||
|
|
@ -202,7 +219,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
|||
|
||||
// ── Tenant self-service dashboard ─────────────────────────────────────
|
||||
mux.Handle("GET /tenant/{tenantSlug}/dashboard",
|
||||
authTenant(http.HandlerFunc(tenant.HandleTenantDashboard(d.TenantStore, d.ScreenStore, d.MediaStore))))
|
||||
authTenant(http.HandlerFunc(tenant.HandleTenantDashboard(d.TenantStore, d.ScreenStore, d.MediaStore, d.Config))))
|
||||
mux.Handle("POST /tenant/{tenantSlug}/upload",
|
||||
authTenant(http.HandlerFunc(tenant.HandleTenantUpload(d.TenantStore, d.MediaStore, uploadDir))))
|
||||
mux.Handle("POST /tenant/{tenantSlug}/media/{mediaId}/delete",
|
||||
|
|
|
|||
31
server/backend/internal/httpapi/tenant/csrf_helpers.go
Normal file
31
server/backend/internal/httpapi/tenant/csrf_helpers.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package tenant
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const csrfCookieName = "morz_csrf"
|
||||
|
||||
// setCSRFCookie setzt (oder erneuert) den CSRF-Cookie und gibt das Token zurück.
|
||||
func setCSRFCookie(w http.ResponseWriter, r *http.Request, devMode bool) string {
|
||||
if c, err := r.Cookie(csrfCookieName); err == nil && c.Value != "" {
|
||||
return c.Value
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return ""
|
||||
}
|
||||
token := hex.EncodeToString(buf)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: false,
|
||||
Secure: !devMode,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 8 * 3600,
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
package tenant
|
||||
|
||||
const tenantDashTmpl = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="de" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Mein Dashboard – morz infoboard</title>
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
<style>
|
||||
|
|
@ -25,7 +26,8 @@ const tenantDashTmpl = `<!DOCTYPE html>
|
|||
<div class="navbar-end">
|
||||
<div class="navbar-item">
|
||||
<form method="POST" action="/logout">
|
||||
<button class="button is-light is-small" type="submit">Abmelden</button>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="button is-white is-outlined is-small" type="submit">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -38,7 +40,7 @@ const tenantDashTmpl = `<!DOCTYPE html>
|
|||
<h1 class="title is-4 mb-4">{{.Tenant.Name}}</h1>
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="notification is-success is-light mb-4">
|
||||
<div class="notification is-success is-light mb-4" role="alert">
|
||||
<button class="delete" onclick="this.parentElement.remove()"></button>
|
||||
{{.Flash}}
|
||||
</div>
|
||||
|
|
@ -61,11 +63,11 @@ const tenantDashTmpl = `<!DOCTYPE html>
|
|||
{{if .Screens}}
|
||||
<div class="columns is-multiline">
|
||||
{{range .Screens}}
|
||||
<div class="column is-4">
|
||||
<div class="column is-4-desktop is-6-tablet">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<p class="title is-5">
|
||||
{{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}}
|
||||
<span aria-label="{{if eq .Orientation "portrait"}}Hochformat{{else}}Querformat{{end}}">{{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}}</span>{{/* Fallback: leerer Wert wird als Querformat behandelt */}}
|
||||
{{.Name}}
|
||||
</p>
|
||||
<p class="subtitle is-6 has-text-grey">
|
||||
|
|
@ -147,6 +149,11 @@ const tenantDashTmpl = `<!DOCTYPE html>
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<div id="upload-error" class="notification is-danger is-light mt-3" style="display:none">
|
||||
<button class="delete" onclick="this.parentElement.style.display='none'"></button>
|
||||
<span id="upload-error-text"></span>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 class="title is-5">Vorhandene Medien</h2>
|
||||
|
|
@ -167,13 +174,13 @@ const tenantDashTmpl = `<!DOCTYPE html>
|
|||
<tr>
|
||||
<td>{{typeIcon .Type}}</td>
|
||||
<td>{{.Title}}</td>
|
||||
<td class="has-text-grey is-size-7">
|
||||
<td class="has-text-grey">
|
||||
{{if .SizeBytes}}{{humanSize .SizeBytes}}{{else}}–{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="/tenant/{{$.Tenant.Slug}}/media/{{.ID}}/delete"
|
||||
onsubmit="return confirm('Wirklich löschen?')">
|
||||
onsubmit="return confirm('Medium löschen? Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.')">
|
||||
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
|
|
@ -226,6 +233,16 @@ function toggleUploadFields() {
|
|||
document.getElementById('url-field').style.display = (t === 'web') ? '' : 'none';
|
||||
}
|
||||
|
||||
// ── Upload-Fehleranzeige ──────────────────────────────────────────────────────
|
||||
function showUploadError(msg) {
|
||||
var errDiv = document.getElementById('upload-error');
|
||||
var errText = document.getElementById('upload-error-text');
|
||||
if (!errDiv || !errText) return;
|
||||
errText.textContent = msg;
|
||||
errDiv.style.display = 'block';
|
||||
setTimeout(function() { errDiv.style.display = 'none'; }, 8000);
|
||||
}
|
||||
|
||||
// ── Upload-Fortschrittsbalken ─────────────────────────────────────────────────
|
||||
(function() {
|
||||
var form = document.getElementById('upload-form');
|
||||
|
|
@ -237,9 +254,11 @@ function toggleUploadFields() {
|
|||
if (!file) return;
|
||||
e.preventDefault();
|
||||
|
||||
var wrap = document.getElementById('upload-progress-wrap');
|
||||
var bar = document.getElementById('upload-progress');
|
||||
var btn = document.getElementById('upload-btn');
|
||||
var wrap = document.getElementById('upload-progress-wrap');
|
||||
var bar = document.getElementById('upload-progress');
|
||||
var btn = document.getElementById('upload-btn');
|
||||
var errDiv = document.getElementById('upload-error');
|
||||
if (errDiv) errDiv.style.display = 'none';
|
||||
wrap.style.display = 'block';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Lädt hoch…';
|
||||
|
|
@ -256,14 +275,14 @@ function toggleUploadFields() {
|
|||
if (xhr.status >= 200 && xhr.status < 400) {
|
||||
window.location.href = window.location.pathname + '?tab=media&flash=uploaded';
|
||||
} else {
|
||||
alert('Upload fehlgeschlagen: ' + xhr.responseText);
|
||||
showUploadError('Upload fehlgeschlagen: ' + xhr.responseText);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Hochladen';
|
||||
wrap.style.display = 'none';
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('error', function() {
|
||||
alert('Netzwerkfehler beim Upload.');
|
||||
showUploadError('Netzwerkfehler beim Upload.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Hochladen';
|
||||
wrap.style.display = 'none';
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/fileutil"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
|
@ -48,6 +49,7 @@ func HandleTenantDashboard(
|
|||
tenantStore *store.TenantStore,
|
||||
screenStore *store.ScreenStore,
|
||||
mediaStore *store.MediaStore,
|
||||
cfg config.Config,
|
||||
) http.HandlerFunc {
|
||||
t := template.Must(template.New("tenant-dash").Funcs(tmplFuncs).Parse(tenantDashTmpl))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -94,13 +96,16 @@ func HandleTenantDashboard(
|
|||
}
|
||||
}
|
||||
|
||||
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
||||
|
||||
// W7: Template in Buffer rendern, erst bei Erfolg an Client senden.
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, map[string]any{
|
||||
"Tenant": tenant,
|
||||
"Screens": screens,
|
||||
"Assets": assets,
|
||||
"Flash": flash,
|
||||
"Tenant": tenant,
|
||||
"Screens": screens,
|
||||
"Assets": assets,
|
||||
"Flash": flash,
|
||||
"CSRFToken": csrfToken,
|
||||
}); err != nil {
|
||||
http.Error(w, "Interner Fehler (Template)", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue