Tenant-Feature Phase 3c + Phase 4: Register-Fix + Tenant-Dashboard UI

Phase 3c:
- register.go: hardcoded "morz" durch cfg.DefaultTenantSlug ersetzt

Phase 4:
- neues Package httpapi/tenant: HandleTenantDashboard, HandleTenantUpload, HandleTenantDeleteMedia
- tenantDashTmpl: Navbar, zwei Tabs (Monitore/Mediathek), Status-Polling, Upload-Fortschritt
- router.go: /tenant/{tenantSlug}/... Routen hinter RequireAuth+RequireTenantAccess
- manage/templates.go: Abmelden-Button in Admin-UI und Manage-UI Navbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-23 18:08:32 +01:00
parent 27c4562175
commit fb8d598e9e
5 changed files with 586 additions and 5 deletions

View file

@ -5,16 +5,17 @@ import (
"net/http"
"strings"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
// HandleRegisterScreen is called by the player agent on startup.
// It upserts the screen in the default tenant (morz) so that all
// It upserts the screen in the default tenant so that all
// deployed screens appear automatically in the admin UI.
//
// POST /api/v1/screens/register
// Body: {"slug":"info10","name":"Info10 Bildschirm","orientation":"landscape"}
func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
Slug string `json:"slug"`
@ -39,8 +40,8 @@ func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore
body.Orientation = "landscape"
}
// v1: single tenant — always register under "morz".
tenant, err := tenants.Get(r.Context(), "morz")
// Register under the configured default tenant.
tenant, err := tenants.Get(r.Context(), cfg.DefaultTenantSlug)
if err != nil {
http.Error(w, "default tenant not found", http.StatusInternalServerError)
return

View file

@ -250,6 +250,13 @@ const adminTmpl = `<!DOCTYPE html>
<div class="navbar-start">
<a class="navbar-item" href="/status">Diagnose</a>
</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>
</form>
</div>
</div>
</div>
</nav>
@ -538,6 +545,13 @@ const manageTmpl = `<!DOCTYPE html>
<div id="manageNavbar" class="navbar-menu">
<div class="navbar-start">
</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>
</form>
</div>
</div>
</div>
</nav>

View file

@ -6,6 +6,7 @@ import (
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage"
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/tenant"
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
@ -136,7 +137,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
// ── JSON API — screens ────────────────────────────────────────────────
// Self-registration: no auth (player calls this on startup).
mux.HandleFunc("POST /api/v1/screens/register",
manage.HandleRegisterScreen(d.TenantStore, d.ScreenStore))
manage.HandleRegisterScreen(d.TenantStore, d.ScreenStore, d.Config))
mux.Handle("GET /api/v1/tenants/{tenantSlug}/screens",
authTenant(http.HandlerFunc(manage.HandleListScreens(d.TenantStore, d.ScreenStore))))
mux.Handle("POST /api/v1/tenants/{tenantSlug}/screens",
@ -166,4 +167,12 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
authOnly(http.HandlerFunc(manage.HandleReorder(d.PlaylistStore, notifier))))
mux.Handle("PATCH /api/v1/playlists/{playlistId}/duration",
authOnly(http.HandlerFunc(manage.HandleUpdatePlaylistDuration(d.PlaylistStore))))
// ── Tenant self-service dashboard ─────────────────────────────────────
mux.Handle("GET /tenant/{tenantSlug}/dashboard",
authTenant(http.HandlerFunc(tenant.HandleTenantDashboard(d.TenantStore, d.ScreenStore, d.MediaStore))))
mux.Handle("POST /tenant/{tenantSlug}/upload",
authTenant(http.HandlerFunc(tenant.HandleTenantUpload(d.TenantStore, d.MediaStore, uploadDir))))
mux.Handle("POST /tenant/{tenantSlug}/media/{mediaId}/delete",
authTenant(http.HandlerFunc(tenant.HandleTenantDeleteMedia(d.TenantStore, d.MediaStore, uploadDir))))
}

View file

@ -0,0 +1,299 @@
package tenant
const tenantDashTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mein Dashboard morz infoboard</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
body { background: #f5f5f5; min-height: 100vh; }
.navbar { margin-bottom: 0; }
.tab-content { display: none; }
.tab-content.is-active { display: block; }
.progress-bar-wrap { display: none; margin-top: .5rem; }
</style>
</head>
<body>
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<span class="navbar-item"><strong>📺 Infoboard</strong></span>
</div>
<div class="navbar-menu">
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<button class="button is-light is-small" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
<section class="section">
<div class="container">
<h1 class="title is-4 mb-4">{{.Tenant.Name}}</h1>
{{if .Flash}}
<div class="notification is-success is-light mb-4">
<button class="delete" onclick="this.parentElement.remove()"></button>
{{.Flash}}
</div>
{{end}}
<div class="tabs is-boxed mb-0" id="dash-tabs">
<ul>
<li class="is-active" data-tab="monitors">
<a><span>Meine Monitore</span></a>
</li>
<li data-tab="media">
<a><span>Mediathek</span></a>
</li>
</ul>
</div>
<!-- Tab A: Meine Monitore -->
<div id="tab-monitors" class="tab-content is-active">
<div class="box" style="border-top-left-radius:0">
{{if .Screens}}
<div class="columns is-multiline">
{{range .Screens}}
<div class="column is-4">
<div class="card">
<div class="card-content">
<p class="title is-5">
{{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}}
{{.Name}}
</p>
<p class="subtitle is-6 has-text-grey">
{{if eq .Orientation "portrait"}}Hochformat{{else}}Querformat{{end}}
</p>
<div id="status-{{.Slug}}" class="mb-3">
<span class="tag is-warning">Unbekannt</span>
</div>
</div>
<footer class="card-footer">
<a class="card-footer-item"
href="/manage/{{.Slug}}?from=tenant">
Playlist bearbeiten
</a>
</footer>
</div>
</div>
{{end}}
</div>
{{else}}
<p class="has-text-grey">Noch keine Monitore zugewiesen.</p>
{{end}}
</div>
</div>
<!-- Tab B: Mediathek -->
<div id="tab-media" class="tab-content">
<div class="box" style="border-top-left-radius:0">
<h2 class="title is-5">Medium hochladen</h2>
<form id="upload-form" method="POST"
action="/tenant/{{.Tenant.Slug}}/upload"
enctype="multipart/form-data">
<div class="field">
<label class="label">Typ</label>
<div class="control">
<div class="select">
<select name="type" id="upload-type" onchange="toggleUploadFields()">
<option value="image">Bild</option>
<option value="video">Video</option>
<option value="pdf">PDF</option>
<option value="web">Website (URL)</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Titel (optional)</label>
<div class="control">
<input class="input" type="text" name="title" placeholder="Wird aus Dateinamen abgeleitet">
</div>
</div>
<div id="file-field" class="field">
<label class="label">Datei</label>
<div class="control">
<input class="input" type="file" name="file" id="upload-file"
accept="image/*,video/*,application/pdf">
</div>
<div class="progress-bar-wrap" id="upload-progress-wrap">
<progress class="progress is-info" id="upload-progress" value="0" max="100"></progress>
</div>
</div>
<div id="url-field" class="field" style="display:none">
<label class="label">URL</label>
<div class="control">
<input class="input" type="url" name="url" placeholder="https://...">
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit" id="upload-btn">Hochladen</button>
</div>
</div>
</form>
<hr>
<h2 class="title is-5">Vorhandene Medien</h2>
{{if .Assets}}
<div style="overflow-x:auto">
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th>Typ</th>
<th>Titel</th>
<th>Größe</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Assets}}
<tr>
<td>{{typeIcon .Type}}</td>
<td>{{.Title}}</td>
<td class="has-text-grey is-size-7">
{{if .SizeBytes}}{{humanSize .SizeBytes}}{{else}}{{end}}
</td>
<td>
<form method="POST"
action="/tenant/{{$.Tenant.Slug}}/media/{{.ID}}/delete"
onsubmit="return confirm('Wirklich löschen?')">
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="has-text-grey">Noch keine Medien hochgeladen.</p>
{{end}}
</div>
</div><!-- /tab-media -->
</div>
</section>
<script>
// ── Tab-Switching ────────────────────────────────────────────────────────────
(function() {
var tabs = document.querySelectorAll('#dash-tabs li[data-tab]');
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) { t.classList.remove('is-active'); });
tab.classList.add('is-active');
document.querySelectorAll('.tab-content').forEach(function(c) {
c.classList.remove('is-active');
});
document.getElementById('tab-' + tab.dataset.tab).classList.add('is-active');
// Sync URL ohne Reload
var url = new URL(window.location.href);
url.searchParams.set('tab', tab.dataset.tab);
history.replaceState(null, '', url.toString());
});
});
// Beim Laden ggf. Tab aus URL herstellen
var tabParam = new URLSearchParams(window.location.search).get('tab');
if (tabParam) {
var target = document.querySelector('#dash-tabs li[data-tab="' + tabParam + '"]');
if (target) target.click();
}
})();
// ── Upload-Formular Typ-Felder ────────────────────────────────────────────────
function toggleUploadFields() {
var t = document.getElementById('upload-type').value;
document.getElementById('file-field').style.display = (t === 'web') ? 'none' : '';
document.getElementById('url-field').style.display = (t === 'web') ? '' : 'none';
}
// ── Upload-Fortschrittsbalken ─────────────────────────────────────────────────
(function() {
var form = document.getElementById('upload-form');
if (!form) return;
form.addEventListener('submit', function(e) {
var t = document.getElementById('upload-type').value;
if (t === 'web') return; // kein XHR für URL-Typ
var file = document.getElementById('upload-file').files[0];
if (!file) return;
e.preventDefault();
var wrap = document.getElementById('upload-progress-wrap');
var bar = document.getElementById('upload-progress');
var btn = document.getElementById('upload-btn');
wrap.style.display = 'block';
btn.disabled = true;
btn.textContent = 'Lädt hoch';
var fd = new FormData(form);
var xhr = new XMLHttpRequest();
xhr.open('POST', form.action);
xhr.upload.addEventListener('progress', function(ev) {
if (ev.lengthComputable) {
bar.value = Math.round(ev.loaded / ev.total * 100);
}
});
xhr.addEventListener('load', function() {
if (xhr.status >= 200 && xhr.status < 400) {
window.location.href = window.location.pathname + '?tab=media&flash=uploaded';
} else {
alert('Upload fehlgeschlagen: ' + xhr.responseText);
btn.disabled = false;
btn.textContent = 'Hochladen';
wrap.style.display = 'none';
}
});
xhr.addEventListener('error', function() {
alert('Netzwerkfehler beim Upload.');
btn.disabled = false;
btn.textContent = 'Hochladen';
wrap.style.display = 'none';
});
xhr.send(fd);
});
})();
// ── Status-Polling alle 30 s ──────────────────────────────────────────────────
(function() {
function pollStatus() {
fetch('/api/v1/screens/status')
.then(function(r) { return r.json(); })
.then(function(list) {
if (!Array.isArray(list)) return;
list.forEach(function(s) {
var el = document.getElementById('status-' + s.screen_id);
if (!el) return;
var ok = s.status === 'ok' || s.status === 'online';
var cls = ok ? 'is-success' : 'is-danger';
var text = ok ? 'Online' : 'Offline';
el.innerHTML = '<span class="tag ' + cls + '">' + text + '</span>';
});
})
.catch(function() { /* ignore */ });
}
pollStatus();
setInterval(pollStatus, 30000);
})();
</script>
</body>
</html>`

View file

@ -0,0 +1,258 @@
// Package tenant implements the tenant self-service dashboard UI.
package tenant
import (
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
var tmplFuncs = template.FuncMap{
"typeIcon": func(t string) string {
switch t {
case "image":
return "🖼"
case "video":
return "🎬"
case "pdf":
return "📄"
case "web":
return "🌐"
default:
return "📁"
}
},
"humanSize": func(b int64) string {
switch {
case b >= 1<<20:
return fmt.Sprintf("%.1f MB", float64(b)/float64(1<<20))
case b >= 1<<10:
return fmt.Sprintf("%.0f KB", float64(b)/float64(1<<10))
default:
return fmt.Sprintf("%d B", b)
}
},
}
const maxUploadSize = 512 << 20 // 512 MB
// HandleTenantDashboard renders the tenant self-service dashboard.
func HandleTenantDashboard(
tenantStore *store.TenantStore,
screenStore *store.ScreenStore,
mediaStore *store.MediaStore,
) http.HandlerFunc {
t := template.Must(template.New("tenant-dash").Funcs(tmplFuncs).Parse(tenantDashTmpl))
return func(w http.ResponseWriter, r *http.Request) {
tenantSlug := r.PathValue("tenantSlug")
tenant, err := tenantStore.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "Tenant nicht gefunden: "+tenantSlug, http.StatusNotFound)
return
}
screens, err := screenStore.List(r.Context(), tenant.ID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
assets, err := mediaStore.List(r.Context(), tenant.ID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// Flash message: prefer ?flash= (from tenant upload redirect),
// also accept legacy ?msg= used by manage handlers.
flash := ""
if f := r.URL.Query().Get("flash"); f != "" {
switch f {
case "uploaded":
flash = "Medium erfolgreich hochgeladen."
case "deleted":
flash = "Medium erfolgreich gelöscht."
default:
flash = f
}
} else if m := r.URL.Query().Get("msg"); m != "" {
switch m {
case "uploaded":
flash = "Medium erfolgreich hochgeladen."
case "deleted":
flash = "Medium erfolgreich gelöscht."
default:
flash = m
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t.Execute(w, map[string]any{ //nolint:errcheck
"Tenant": tenant,
"Screens": screens,
"Assets": assets,
"Flash": flash,
})
}
}
// HandleTenantUpload handles multipart file uploads and web-URL registrations
// from the tenant dashboard, then redirects back.
func HandleTenantUpload(
tenantStore *store.TenantStore,
mediaStore *store.MediaStore,
uploadDir string,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tenantSlug := r.PathValue("tenantSlug")
tenant, err := tenantStore.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "Tenant nicht gefunden: "+tenantSlug, http.StatusNotFound)
return
}
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "Upload zu groß oder ungültig", http.StatusBadRequest)
return
}
assetType := strings.TrimSpace(r.FormValue("type"))
title := strings.TrimSpace(r.FormValue("title"))
switch assetType {
case "web":
url := strings.TrimSpace(r.FormValue("url"))
if url == "" {
http.Error(w, "URL erforderlich", http.StatusBadRequest)
return
}
if title == "" {
title = url
}
_, err = mediaStore.Create(r.Context(), tenant.ID, title, "web", "", url, "", 0)
case "image", "video", "pdf":
file, header, ferr := r.FormFile("file")
if ferr != nil {
http.Error(w, "Datei erforderlich", http.StatusBadRequest)
return
}
defer file.Close()
if title == "" {
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
}
mimeType := header.Header.Get("Content-Type")
// Derive asset type from MIME if more specific.
if detected := mimeToAssetType(mimeType); detected != "" {
assetType = detected
}
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext)
destPath := filepath.Join(uploadDir, filename)
dest, ferr := os.Create(destPath)
if ferr != nil {
http.Error(w, "Speicherfehler", http.StatusInternalServerError)
return
}
defer dest.Close()
size, cerr := io.Copy(dest, file)
if cerr != nil {
os.Remove(destPath) //nolint:errcheck
http.Error(w, "Schreibfehler", http.StatusInternalServerError)
return
}
storagePath := "/uploads/" + filename
_, err = mediaStore.Create(r.Context(), tenant.ID, title, assetType, storagePath, "", mimeType, size)
default:
http.Error(w, "Unbekannter Typ", http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/tenant/"+tenantSlug+"/dashboard?tab=media&flash=uploaded", http.StatusSeeOther)
}
}
// HandleTenantDeleteMedia deletes a media asset owned by the tenant.
func HandleTenantDeleteMedia(
tenantStore *store.TenantStore,
mediaStore *store.MediaStore,
uploadDir string,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tenantSlug := r.PathValue("tenantSlug")
mediaID := r.PathValue("mediaId")
// Verify tenant exists.
tenant, err := tenantStore.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "Tenant nicht gefunden", http.StatusNotFound)
return
}
asset, err := mediaStore.Get(r.Context(), mediaID)
if err != nil {
http.Error(w, "Medium nicht gefunden", http.StatusNotFound)
return
}
// Ownership check.
if asset.TenantID != tenant.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if asset.StoragePath != "" {
os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck
}
mediaStore.Delete(r.Context(), mediaID) //nolint:errcheck
http.Redirect(w, r, "/tenant/"+tenantSlug+"/dashboard?tab=media&flash=deleted", http.StatusSeeOther)
}
}
// mimeToAssetType derives the asset type from a MIME type string.
func mimeToAssetType(mime string) string {
mime = strings.ToLower(strings.TrimSpace(mime))
switch {
case strings.HasPrefix(mime, "image/"):
return "image"
case strings.HasPrefix(mime, "video/"):
return "video"
case mime == "application/pdf":
return "pdf"
default:
return ""
}
}
// sanitize converts a string to a safe filename component.
func sanitize(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
out := b.String()
if len(out) > 40 {
out = out[:40]
}
return out
}