morz-infoboard/server/backend/internal/httpapi/tenant/templates.go
Jesko Anschütz 47f65da228 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>
2026-03-24 14:26:52 +01:00

343 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package tenant
const tenantDashTmpl = `<!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>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">
<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-4">{{.Tenant.Name}}</h1>
{{if .Flash}}
<div class="notification is-success is-light mb-4" role="alert">
<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-desktop is-6-tablet">
<div class="card">
<div class="card-content">
<p class="title is-5">
<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">
{{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>
<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>
{{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">
{{if .SizeBytes}}{{humanSize .SizeBytes}}{{else}}{{end}}
</td>
<td>
<form method="POST"
action="/tenant/{{$.Tenant.Slug}}/media/{{.ID}}/delete"
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>
</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-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');
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');
var errDiv = document.getElementById('upload-error');
if (errDiv) errDiv.style.display = 'none';
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 {
showUploadError('Upload fehlgeschlagen: ' + xhr.responseText);
btn.disabled = false;
btn.textContent = 'Hochladen';
wrap.style.display = 'none';
}
});
xhr.addEventListener('error', function() {
showUploadError('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>
<script>
// K1: CSRF Double-Submit — füge Token aus Cookie in alle POST-Formulare ein.
(function() {
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
function injectCSRF() {
var token = getCookie('morz_csrf');
if (!token) return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (!f.querySelector('input[name="csrf_token"]')) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
f.appendChild(inp);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectCSRF);
} else {
injectCSRF();
}
})();
</script>
</body>
</html>`