Feature: Screen-User-Verwaltung mit rollenbasiertem Zugriff

Neue Rolle screen_user: User können sich einloggen und nur ihre
zugeordneten Bildschirme verwalten. Admins behalten vollen Zugriff.

- Migration 003: users.role-Spalte + user_screen_permissions (M:N)
- Store: CreateScreenUser, ListScreenUsers, DeleteUser,
         GetAccessibleScreens, HasUserScreenAccess,
         AddUserToScreen, RemoveUserFromScreen, GetScreenUsers
- Middleware: RequireScreenAccess enforces screen-level access
  für alle /manage/{screenSlug}-Routen
- 4 neue Admin-Handler: CreateScreenUser, DeleteScreenUser,
  AddUserToScreen, RemoveUserFromScreen (+4 Routes)
- Admin-UI: Tab "Benutzer" (anlegen/löschen) + Screen-User-Modal
  (User zuordnen/entfernen) direkt in der Bildschirm-Tabelle
- Login: screen_user wird nach Login zum ersten zugänglichen Screen
  weitergeleitet; kein Zugang zu /admin

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-23 22:06:05 +01:00
parent 1e90bbbbc0
commit d1d86126c8
8 changed files with 683 additions and 42 deletions

View file

@ -0,0 +1,22 @@
-- Migration 003: Screen-User-Berechtigungssystem
-- Fügt die Rolle 'screen_user' und die M:N Tabelle user_screen_permissions hinzu.
-- Neue Spalte 'role' in users (DEFAULT 'screen_user' für zukünftige Nutzer).
ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'screen_user';
-- Bestehende Admins auf 'admin' setzen (alle User im Standard-Tenant morz).
UPDATE users SET role = 'admin'
WHERE tenant_id = (SELECT id FROM tenants WHERE slug = 'morz')
AND role IS DISTINCT FROM 'admin';
-- M:N-Tabelle: welche User dürfen welche Screens verwalten.
CREATE TABLE IF NOT EXISTS user_screen_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
screen_id TEXT NOT NULL REFERENCES screens(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, screen_id)
);
CREATE INDEX IF NOT EXISTS idx_user_screen_perms_user ON user_screen_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_screen_perms_screen ON user_screen_permissions(screen_id);

View file

@ -13,6 +13,17 @@ import (
"golang.org/x/crypto/bcrypt"
)
// handleScreenUserRedirect looks up accessible screens for a screen_user and
// redirects to the first one. If none exist, it redirects to an error page.
func handleScreenUserRedirect(w http.ResponseWriter, r *http.Request, screenStore *store.ScreenStore, user *store.User) {
screens, err := screenStore.GetAccessibleScreens(r.Context(), user.ID)
if err != nil || len(screens) == 0 {
http.Redirect(w, r, "/login?error=no_screens", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/manage/"+screens[0].Slug, http.StatusSeeOther)
}
const sessionTTL = 8 * time.Hour
// sessionCookieName ist ein Alias auf die zentrale Konstante (V5).
@ -26,21 +37,25 @@ type loginData struct {
}
// HandleLoginUI renders the login form (GET /login).
// If a valid session cookie is already present, the user is redirected to /admin
// (or the tenant dashboard once tenants are wired up in Phase 3).
func HandleLoginUI(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc {
// If a valid session cookie is already present, the user is redirected based on role.
func HandleLoginUI(authStore *store.AuthStore, screenStore *store.ScreenStore, cfg config.Config) http.HandlerFunc {
tmpl := template.Must(template.New("login").Parse(loginTmpl))
return func(w http.ResponseWriter, r *http.Request) {
// Redirect if already logged in.
if cookie, err := r.Cookie(sessionCookieName); err == nil {
if u, err := authStore.GetSessionUser(r.Context(), cookie.Value); err == nil {
if u.Role == "admin" {
switch u.Role {
case "admin":
http.Redirect(w, r, "/admin", http.StatusSeeOther)
} else if u.TenantSlug != "" {
case "screen_user":
handleScreenUserRedirect(w, r, screenStore, u)
default:
if u.TenantSlug != "" {
http.Redirect(w, r, "/tenant/"+u.TenantSlug+"/dashboard", http.StatusSeeOther)
} else {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
}
return
}
}
@ -58,7 +73,7 @@ func HandleLoginUI(authStore *store.AuthStore, cfg config.Config) http.HandlerFu
// HandleLoginPost handles form submission (POST /login).
// It validates credentials, creates a session, sets the session cookie and
// redirects the user based on their role or the ?next= parameter.
func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc {
func HandleLoginPost(authStore *store.AuthStore, screenStore *store.ScreenStore, cfg config.Config) http.HandlerFunc {
tmpl := template.Must(template.New("login").Parse(loginTmpl))
renderError := func(w http.ResponseWriter, next, msg string) {
@ -122,12 +137,14 @@ func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.Handler
}
switch user.Role {
case "admin":
http.Redirect(w, r, "/manage/", http.StatusSeeOther)
http.Redirect(w, r, "/admin", http.StatusSeeOther)
case "screen_user":
handleScreenUserRedirect(w, r, screenStore, user)
default:
if user.TenantSlug != "" {
http.Redirect(w, r, "/tenant/"+user.TenantSlug+"/dashboard", http.StatusSeeOther)
} else {
http.Redirect(w, r, "/manage/", http.StatusSeeOther)
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
}
}

View file

@ -261,7 +261,7 @@ const adminTmpl = `<!DOCTYPE html>
</div>
</nav>
<!-- Lösch-Bestätigungs-Modal -->
<!-- Lösch-Bestätigungs-Modal (Screens) -->
<div id="delete-modal" class="modal">
<div class="modal-background" onclick="closeDeleteModal()"></div>
<div class="modal-card">
@ -282,6 +282,43 @@ const adminTmpl = `<!DOCTYPE html>
</div>
</div>
<!-- Lösch-Bestätigungs-Modal (User) -->
<div id="delete-user-modal" class="modal">
<div class="modal-background" onclick="closeDeleteUserModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Benutzer löschen?</p>
<button class="delete" aria-label="Schließen" onclick="closeDeleteUserModal()"></button>
</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>
</section>
<footer class="modal-card-foot">
<form id="delete-user-modal-form" method="POST">
<button class="button is-danger" type="submit">Wirklich löschen</button>
</form>
<button class="button" onclick="closeDeleteUserModal()">Abbrechen</button>
</footer>
</div>
</div>
<!-- Screen-User-Verwaltungs-Modal -->
<div id="screen-users-modal" class="modal">
<div class="modal-background" onclick="closeScreenUsersModal()"></div>
<div class="modal-card" style="width:600px;max-width:95vw">
<header class="modal-card-head">
<p class="modal-card-title" id="screen-users-modal-title">Benutzer verwalten</p>
<button class="delete" aria-label="Schließen" onclick="closeScreenUsersModal()"></button>
</header>
<section class="modal-card-body" id="screen-users-modal-body">
</section>
<footer class="modal-card-foot">
<button class="button" onclick="closeScreenUsersModal()">Schließen</button>
</footer>
</div>
</div>
<script>
(function() {
var burger = document.querySelector('.navbar-burger[data-target="adminNavbar"]');
@ -302,8 +339,33 @@ function openDeleteModal(action, name) {
function closeDeleteModal() {
document.getElementById('delete-modal').classList.remove('is-active');
}
function openDeleteUserModal(action, name) {
document.getElementById('delete-user-modal-form').action = action;
document.getElementById('delete-user-modal-name').textContent = name;
document.getElementById('delete-user-modal').classList.add('is-active');
}
function closeDeleteUserModal() {
document.getElementById('delete-user-modal').classList.remove('is-active');
}
function openScreenUsersModal(screenId, screenName, html) {
document.getElementById('screen-users-modal-title').textContent = 'Benutzer: ' + screenName;
document.getElementById('screen-users-modal-body').innerHTML = html;
document.getElementById('screen-users-modal').classList.add('is-active');
// Re-inject CSRF tokens into newly added forms
injectCSRFNow();
}
function closeScreenUsersModal() {
document.getElementById('screen-users-modal').classList.remove('is-active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteModal();
if (e.key === 'Escape') {
closeDeleteModal();
closeDeleteUserModal();
closeScreenUsersModal();
}
});
</script>
<script>
@ -314,11 +376,19 @@ document.addEventListener('keydown', function(e) {
'uploaded': ' Medium erfolgreich hochgeladen.',
'deleted': ' Erfolgreich gelöscht.',
'saved': ' Änderungen gespeichert.',
'added': ' Erfolgreich hinzugefügt.'
'added': ' Erfolgreich hinzugefügt.',
'user_added': ' Benutzer angelegt.',
'user_deleted': ' Benutzer gelöscht.',
'user_added_to_screen': ' Benutzer zum Screen hinzugefügt.',
'user_removed_from_screen': ' Benutzer vom Screen entfernt.',
'error_empty': ' Benutzername und Passwort erforderlich.',
'error_exists': ' Benutzername bereits vergeben.',
'error_db': ' Datenbankfehler.'
};
var isError = msg.startsWith('error_');
var text = texts[msg] || ' Aktion erfolgreich.';
var n = document.createElement('div');
n.className = 'notification is-success';
n.className = 'notification ' + (isError ? 'is-warning' : 'is-success');
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(); });
@ -328,16 +398,45 @@ document.addEventListener('keydown', function(e) {
n.style.opacity = '0';
setTimeout(function() { n.remove(); }, 500);
}, 3000);
// Clean URL without reloading
var url = new URL(window.location.href);
url.searchParams.delete('msg');
history.replaceState(null, '', url.toString());
})();
</script>
<section class="section pt-0">
<div class="container">
<div class="box">
<!-- 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>
</li>
<li id="tab-users" class="{{if eq .ActiveTab "users"}}is-active{{end}}">
<a onclick="switchTab('users')">Benutzer</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');
var url = new URL(window.location.href);
url.searchParams.set('tab', name);
history.replaceState(null, '', url.toString());
}
document.addEventListener('DOMContentLoaded', function() {
var active = '{{.ActiveTab}}';
switchTab(active || 'screens');
});
</script>
<!-- Panel: Bildschirme -->
<div id="panel-screens" class="tab-panel box" style="border-radius:0 4px 4px 4px">
<h2 class="title is-5">Bildschirme</h2>
{{if .Screens}}
<div style="overflow-x: auto">
@ -348,16 +447,27 @@ document.addEventListener('keydown', function(e) {
<th>Slug</th>
<th>Format</th>
<th>Status</th>
<th>Benutzer</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Screens}}
{{$users := index $.ScreenUserMap .ID}}
<tr>
<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>
{{$screenID := .ID}}
{{$screenName := .Name}}
<button class="button is-small is-light"
type="button"
onclick="openScreenUsersModal({{$screenID | printf "%q"}}, {{$screenName | printf "%q"}}, buildScreenUsersHTML({{$screenID | printf "%q"}}, {{$screenName | printf "%q"}}))">
{{len $users}} Benutzer
</button>
</td>
<td>
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
&nbsp;
@ -374,9 +484,9 @@ document.addEventListener('keydown', function(e) {
{{else}}
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
{{end}}
</div>
<div class="box">
<hr>
<h2 class="title is-5">Neuen Bildschirm einrichten</h2>
<p class="mb-4 has-text-grey">
Fülle die Angaben aus. Der Bildschirm wird im Backend angelegt und du erhältst
@ -435,12 +545,9 @@ document.addEventListener('keydown', function(e) {
</div>
<button class="button is-primary" type="submit">Anlegen &amp; Anleitung generieren </button>
</form>
</div>
<div class="box">
<h2 class="title is-5">Bestehenden Screen manuell anlegen</h2>
<details>
<summary class="has-text-grey" style="cursor:pointer">Nur DB-Eintrag, kein Deployment (aufklappen)</summary>
<details class="mt-4">
<summary class="has-text-grey" style="cursor:pointer">Bestehenden Screen manuell anlegen (nur DB-Eintrag, kein Deployment)</summary>
<form method="POST" action="/admin/screens" class="mt-4">
<div class="columns is-vcentered">
<div class="column is-3">
@ -482,11 +589,151 @@ document.addEventListener('keydown', function(e) {
</div>
</form>
</details>
</div><!-- /panel-screens -->
<!-- Panel: Benutzer -->
<div id="panel-users" class="tab-panel box" style="border-radius:0 4px 4px 4px">
<h2 class="title is-5">Screen-Benutzer</h2>
<p class="has-text-grey mb-4">Screen-Benutzer können sich einloggen und nur ihre zugeordneten Bildschirme verwalten.</p>
{{if .ScreenUsers}}
<table class="table is-fullwidth is-hoverable is-striped mb-5">
<thead>
<tr>
<th>Benutzername</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{{range .ScreenUsers}}
<tr>
<td><strong>{{.Username}}</strong></td>
<td>{{.CreatedAt.Format "02.01.2006 15:04"}}</td>
<td>
<button class="button is-small is-danger is-outlined"
type="button"
onclick="openDeleteUserModal('/admin/users/{{.ID}}/delete', '{{.Username}}')">Löschen</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="has-text-grey mb-4">Noch keine Screen-Benutzer angelegt.</p>
{{end}}
<hr>
<h3 class="title is-6">Neuen Benutzer anlegen</h3>
<form method="POST" action="/admin/users">
<div class="columns is-vcentered">
<div class="column is-4">
<div class="field">
<label class="label">Benutzername</label>
<div class="control">
<input class="input" type="text" name="username" placeholder="z.B. alice" required
autocomplete="off">
</div>
</div>
</div>
<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>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-primary" type="submit">Benutzer anlegen</button>
</div>
</div>
</div>
</form>
</div><!-- /panel-users -->
</div>
</section>
<!-- Eingebettete Screen-User-Daten für das Modal (als JSON-Strings) -->
<script>
var _screenUsers = {{.ScreenUsers | screenUsersJSON}};
var _screenUserMap = {{.ScreenUserMap | screenUserMapJSON}};
function buildScreenUsersHTML(screenId, screenName) {
var users = _screenUserMap[screenId] || [];
var allUsers = _screenUsers || [];
// Bereits zugeordnete User-IDs
var assignedIds = {};
users.forEach(function(u) { assignedIds[u.id] = true; });
// Tabelle der zugeordneten User
var html = '';
if (users.length > 0) {
html += '<table class="table is-fullwidth is-narrow mb-4"><thead><tr><th>Benutzer</th><th></th></tr></thead><tbody>';
users.forEach(function(u) {
html += '<tr><td>' + escHtml(u.username) + '</td>';
html += '<td><form method="POST" action="/admin/screens/' + escHtml(screenId) + '/users/' + escHtml(u.id) + '/remove" style="display:inline">';
html += '<button class="button is-small is-danger is-outlined" type="submit">Entfernen</button></form></td></tr>';
});
html += '</tbody></table>';
} else {
html += '<p class="has-text-grey mb-4">Noch keine Benutzer zugeordnet.</p>';
}
// Dropdown mit verfügbaren Usern
var available = allUsers.filter(function(u) { return !assignedIds[u.id]; });
if (available.length > 0) {
html += '<form method="POST" action="/admin/screens/' + escHtml(screenId) + '/users">';
html += '<div class="field has-addons">';
html += '<div class="control is-expanded"><div class="select is-fullwidth"><select name="user_id">';
available.forEach(function(u) {
html += '<option value="' + escHtml(u.id) + '">' + escHtml(u.username) + '</option>';
});
html += '</select></div></div>';
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>';
} else {
html += '<p class="has-text-grey is-size-7">Alle Benutzer sind bereits zugeordnet.</p>';
}
return html;
}
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function injectCSRFNow() {
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
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);
}
});
}
</script>
<script>
(function() {
fetch('/api/v1/screens/status')

View file

@ -17,6 +17,16 @@ import (
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
// jsonSafe serializes v to a JSON string safe for inline use in a <script> block.
// It returns template.JS so the template engine does not HTML-escape it again.
func jsonSafe(v any) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("null")
}
return template.JS(b) //nolint:gosec
}
// renderTemplate rendert t mit data in einen Buffer und schreibt das Ergebnis erst
// dann in w, wenn kein Fehler aufgetreten ist. W7: Verhindert halb-gerendertes HTML.
func renderTemplate(w http.ResponseWriter, t *template.Template, data any) {
@ -58,6 +68,34 @@ func requireScreenAccess(w http.ResponseWriter, r *http.Request, screen *store.S
}
var tmplFuncs = template.FuncMap{
// screenUsersJSON serializes a []*store.User slice to JSON for inline JS.
"screenUsersJSON": func(users []*store.User) template.JS {
type entry struct {
ID string `json:"id"`
Username string `json:"username"`
}
out := make([]entry, 0, len(users))
for _, u := range users {
out = append(out, entry{ID: u.ID, Username: u.Username})
}
return jsonSafe(out)
},
// screenUserMapJSON serializes map[string][]*store.ScreenUserEntry to JSON.
"screenUserMapJSON": func(m map[string][]*store.ScreenUserEntry) template.JS {
type entry struct {
ID string `json:"id"`
Username string `json:"username"`
}
out := map[string][]entry{}
for screenID, users := range m {
entries := make([]entry, 0, len(users))
for _, u := range users {
entries = append(entries, entry{ID: u.ID, Username: u.Username})
}
out[screenID] = entries
}
return jsonSafe(out)
},
"typeIcon": func(t string) string {
switch t {
case "image":
@ -92,8 +130,8 @@ var tmplFuncs = template.FuncMap{
},
}
// HandleAdminUI renders the admin overview page.
func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
// HandleAdminUI renders the admin overview page (screens + users tabs).
func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore, auth *store.AuthStore) 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())
@ -106,13 +144,117 @@ func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore) http.
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// Default tenant slug for user management.
tenantSlug := "morz"
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
screenUsers, err := auth.ListScreenUsers(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// Build per-screen user lists for the modal.
screenUserMap := map[string][]*store.ScreenUserEntry{}
for _, sc := range allScreens {
users, err := screens.GetScreenUsers(r.Context(), sc.ID)
if err != nil {
continue
}
screenUserMap[sc.ID] = users
}
activeTab := r.URL.Query().Get("tab")
if activeTab == "" {
activeTab = "screens"
}
renderTemplate(w, t, map[string]any{
"Screens": allScreens,
"Tenants": allTenants,
"ScreenUsers": screenUsers,
"ScreenUserMap": screenUserMap,
"ActiveTab": activeTab,
})
}
}
// HandleCreateScreenUser creates a new screen_user for the default tenant.
func HandleCreateScreenUser(auth *store.AuthStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
if username == "" || password == "" {
http.Redirect(w, r, "/admin?tab=users&msg=error_empty", http.StatusSeeOther)
return
}
tenantSlug := "morz"
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
_, err := auth.CreateScreenUser(r.Context(), tenantSlug, username, password)
if err != nil {
http.Redirect(w, r, "/admin?tab=users&msg=error_exists", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin?tab=users&msg=user_added", http.StatusSeeOther)
}
}
// HandleDeleteScreenUser deletes a screen_user by ID.
func HandleDeleteScreenUser(auth *store.AuthStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userID")
if err := auth.DeleteUser(r.Context(), userID); err != nil {
http.Error(w, "Fehler beim Löschen", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin?tab=users&msg=user_deleted", http.StatusSeeOther)
}
}
// HandleAddUserToScreen grants a user access to a specific screen.
func HandleAddUserToScreen(screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenID := r.PathValue("screenID")
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
userID := strings.TrimSpace(r.FormValue("user_id"))
if userID == "" {
http.Redirect(w, r, "/admin?msg=error_empty", http.StatusSeeOther)
return
}
if err := screens.AddUserToScreen(r.Context(), userID, screenID); err != nil {
http.Redirect(w, r, "/admin?msg=error_db", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin?screen="+screenID+"&msg=user_added_to_screen", http.StatusSeeOther)
}
}
// HandleRemoveUserFromScreen removes a user's access to a specific screen.
func HandleRemoveUserFromScreen(screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenID := r.PathValue("screenID")
userID := r.PathValue("userID")
if err := screens.RemoveUserFromScreen(r.Context(), userID, screenID); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin?screen="+screenID+"&msg=user_removed_from_screen", http.StatusSeeOther)
}
}
// HandleManageUI renders the playlist management UI for a specific screen.
func HandleManageUI(
tenants *store.TenantStore,

View file

@ -83,6 +83,48 @@ func RequireTenantAccess(next http.Handler) http.Handler {
})
}
// RequireScreenAccess returns middleware that enforces per-screen access control.
// Admins bypass the check. Screen-Users must have an explicit entry in
// user_screen_permissions for the screen identified by the {screenSlug} path
// value. The screenStore is used to look up the screen and check permissions.
// Must be chained after RequireAuth.
func RequireScreenAccess(screenStore *store.ScreenStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Admins always have access.
if user.Role == "admin" {
next.ServeHTTP(w, r)
return
}
screenSlug := r.PathValue("screenSlug")
if screenSlug == "" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
screen, err := screenStore.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "Screen nicht gefunden", http.StatusNotFound)
return
}
ok, err := screenStore.HasUserScreenAccess(r.Context(), user.ID, screen.ID)
if err != nil || !ok {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// chain applies a list of middleware to a handler, wrapping outermost first.
// chain(m1, m2, m3)(h) == m1(m2(m3(h)))
func chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {

View file

@ -107,9 +107,9 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
// ── Auth (no auth middleware required) ────────────────────────────────
// K1: GET /login setzt CSRF-Cookie; POST /login und POST /logout werden per CSRF geprüft.
mux.Handle("GET /login", http.HandlerFunc(manage.HandleLoginUI(d.AuthStore, d.Config)))
mux.Handle("GET /login", http.HandlerFunc(manage.HandleLoginUI(d.AuthStore, d.ScreenStore, d.Config)))
// N1: Rate-Limiting auf /login (max. 5 Versuche/Minute pro IP).
mux.Handle("POST /login", RateLimitLogin(csrf(http.HandlerFunc(manage.HandleLoginPost(d.AuthStore, d.Config)))))
mux.Handle("POST /login", RateLimitLogin(csrf(http.HandlerFunc(manage.HandleLoginPost(d.AuthStore, d.ScreenStore, d.Config)))))
mux.Handle("POST /logout", csrf(http.HandlerFunc(manage.HandleLogoutPost(d.AuthStore, d.Config))))
// Shorthand middleware combinators for this router.
@ -123,10 +123,15 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
authTenant := func(h http.Handler) http.Handler {
return chain(h, RequireAuth(d.AuthStore), RequireTenantAccess, setCSRF, csrf)
}
// authScreen: wie authOnly, aber zusätzlich Screen-Zugriffsprüfung für screen_user.
// Admins und Tenant-User werden von RequireScreenAccess durchgelassen.
authScreen := func(h http.Handler) http.Handler {
return chain(h, RequireAuth(d.AuthStore), RequireScreenAccess(d.ScreenStore), setCSRF, csrf)
}
// ── Admin UI ──────────────────────────────────────────────────────────
mux.Handle("GET /admin",
authAdmin(http.HandlerFunc(manage.HandleAdminUI(d.TenantStore, d.ScreenStore))))
authAdmin(http.HandlerFunc(manage.HandleAdminUI(d.TenantStore, d.ScreenStore, d.AuthStore))))
mux.Handle("POST /admin/screens/provision",
authAdmin(http.HandlerFunc(manage.HandleProvisionUI(d.TenantStore, d.ScreenStore))))
mux.Handle("POST /admin/screens",
@ -134,21 +139,32 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
mux.Handle("POST /admin/screens/{screenId}/delete",
authAdmin(http.HandlerFunc(manage.HandleDeleteScreenUI(d.ScreenStore))))
// ── Screen-User-Verwaltung (nur Admin) ────────────────────────────────
mux.Handle("POST /admin/users",
authAdmin(http.HandlerFunc(manage.HandleCreateScreenUser(d.AuthStore))))
mux.Handle("POST /admin/users/{userID}/delete",
authAdmin(http.HandlerFunc(manage.HandleDeleteScreenUser(d.AuthStore))))
mux.Handle("POST /admin/screens/{screenID}/users",
authAdmin(http.HandlerFunc(manage.HandleAddUserToScreen(d.ScreenStore))))
mux.Handle("POST /admin/screens/{screenID}/users/{userID}/remove",
authAdmin(http.HandlerFunc(manage.HandleRemoveUserFromScreen(d.ScreenStore))))
// ── Playlist management UI ────────────────────────────────────────────
// authScreen enforces that screen_user only accesses their permitted screens.
mux.Handle("GET /manage/{screenSlug}",
authOnly(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))))
mux.Handle("POST /manage/{screenSlug}/upload",
authOnly(http.HandlerFunc(manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))))
authScreen(http.HandlerFunc(manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))))
mux.Handle("POST /manage/{screenSlug}/items",
authOnly(http.HandlerFunc(manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore, notifier))))
authScreen(http.HandlerFunc(manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/items/{itemId}",
authOnly(http.HandlerFunc(manage.HandleUpdateItemUI(d.PlaylistStore, d.ScreenStore, notifier))))
authScreen(http.HandlerFunc(manage.HandleUpdateItemUI(d.PlaylistStore, d.ScreenStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/items/{itemId}/delete",
authOnly(http.HandlerFunc(manage.HandleDeleteItemUI(d.PlaylistStore, d.ScreenStore, notifier))))
authScreen(http.HandlerFunc(manage.HandleDeleteItemUI(d.PlaylistStore, d.ScreenStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/reorder",
authOnly(http.HandlerFunc(manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore, notifier))))
authScreen(http.HandlerFunc(manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/media/{mediaId}/delete",
authOnly(http.HandlerFunc(manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir, notifier))))
authScreen(http.HandlerFunc(manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir, notifier))))
// ── JSON API — screens ────────────────────────────────────────────────
// Self-registration: no auth (player calls this on startup).

View file

@ -151,6 +151,70 @@ func (s *AuthStore) EnsureAdminUser(ctx context.Context, tenantSlug, password st
return nil
}
// CreateScreenUser creates a new user with role 'screen_user' for the tenant
// identified by tenantSlug. The password is hashed with bcrypt (cost 12).
// Returns pgx.ErrNoRows if the tenant does not exist, or a wrapped error if
// the username is already taken (unique constraint violation).
func (s *AuthStore) CreateScreenUser(ctx context.Context, tenantSlug, username, password string) (*User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return nil, fmt.Errorf("auth: hash password: %w", err)
}
row := s.pool.QueryRow(ctx,
`insert into users(tenant_id, username, password_hash, role)
values(
(select id from tenants where slug = $1),
$2, $3, 'screen_user'
)
returning id, tenant_id, coalesce(
(select slug from tenants where id = tenant_id), ''
), username, password_hash, role, created_at`,
tenantSlug, username, string(hash))
u, err := scanUserWithSlug(row)
if err != nil {
return nil, fmt.Errorf("auth: create screen user: %w", err)
}
return u, nil
}
// ListScreenUsers returns all users with role 'screen_user' for the given tenant.
func (s *AuthStore) ListScreenUsers(ctx context.Context, tenantSlug string) ([]*User, error) {
rows, err := s.pool.Query(ctx,
`select u.id, u.tenant_id, coalesce(t.slug, ''), u.username, u.password_hash, u.role, u.created_at
from users u
left join tenants t on t.id = u.tenant_id
where t.slug = $1 and u.role = 'screen_user'
order by u.username`, tenantSlug)
if err != nil {
return nil, fmt.Errorf("auth: list screen users: %w", err)
}
defer rows.Close()
var out []*User
for rows.Next() {
u, err := scanUserWithSlug(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// DeleteUser removes a user and all their session + screen permission records (CASCADE).
// It refuses to delete users with role 'admin' to prevent lockout.
func (s *AuthStore) DeleteUser(ctx context.Context, userID string) error {
tag, err := s.pool.Exec(ctx,
`delete from users where id = $1 and role != 'admin'`, userID)
if err != nil {
return fmt.Errorf("auth: delete user: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("auth: delete user: not found or is admin")
}
return nil
}
// ------------------------------------------------------------------
// scan helpers
// ------------------------------------------------------------------

View file

@ -53,6 +53,13 @@ type Playlist struct {
UpdatedAt time.Time `json:"updated_at"`
}
// ScreenUserEntry is a lightweight view used when listing users assigned to a screen.
type ScreenUserEntry struct {
ID string `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
}
type PlaylistItem struct {
ID string `json:"id"`
PlaylistID string `json:"playlist_id"`
@ -203,6 +210,90 @@ func (s *ScreenStore) Delete(ctx context.Context, id string) error {
return err
}
// GetAccessibleScreens returns all screens that userID has explicit access to
// via user_screen_permissions.
func (s *ScreenStore) GetAccessibleScreens(ctx context.Context, userID string) ([]*Screen, error) {
rows, err := s.pool.Query(ctx,
`select sc.id, sc.tenant_id, sc.slug, sc.name, sc.orientation, sc.created_at
from screens sc
join user_screen_permissions usp on usp.screen_id = sc.id
where usp.user_id = $1
order by sc.name`, userID)
if err != nil {
return nil, fmt.Errorf("screens: get accessible: %w", err)
}
defer rows.Close()
var out []*Screen
for rows.Next() {
sc, err := scanScreen(rows)
if err != nil {
return nil, err
}
out = append(out, sc)
}
return out, rows.Err()
}
// HasUserScreenAccess returns true when userID has an explicit permission entry
// for screenID in user_screen_permissions.
func (s *ScreenStore) HasUserScreenAccess(ctx context.Context, userID, screenID string) (bool, error) {
var ok bool
err := s.pool.QueryRow(ctx,
`select exists(
select 1 from user_screen_permissions
where user_id = $1 and screen_id = $2
)`, userID, screenID).Scan(&ok)
return ok, err
}
// AddUserToScreen creates a permission entry granting userID access to screenID.
// Silently succeeds if the entry already exists (ON CONFLICT DO NOTHING).
func (s *ScreenStore) AddUserToScreen(ctx context.Context, userID, screenID string) error {
_, err := s.pool.Exec(ctx,
`insert into user_screen_permissions(user_id, screen_id)
values($1, $2)
on conflict (user_id, screen_id) do nothing`,
userID, screenID)
if err != nil {
return fmt.Errorf("screens: add user to screen: %w", err)
}
return nil
}
// RemoveUserFromScreen deletes the permission entry for userID / screenID.
func (s *ScreenStore) RemoveUserFromScreen(ctx context.Context, userID, screenID string) error {
_, err := s.pool.Exec(ctx,
`delete from user_screen_permissions where user_id = $1 and screen_id = $2`,
userID, screenID)
if err != nil {
return fmt.Errorf("screens: remove user from screen: %w", err)
}
return nil
}
// GetScreenUsers returns all users that have explicit access to screenID.
func (s *ScreenStore) GetScreenUsers(ctx context.Context, screenID string) ([]*ScreenUserEntry, error) {
rows, err := s.pool.Query(ctx,
`select u.id, u.username, u.created_at
from users u
join user_screen_permissions usp on usp.user_id = u.id
where usp.screen_id = $1
order by u.username`, screenID)
if err != nil {
return nil, fmt.Errorf("screens: get screen users: %w", err)
}
defer rows.Close()
var out []*ScreenUserEntry
for rows.Next() {
var e ScreenUserEntry
if err := rows.Scan(&e.ID, &e.Username, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("scan screen user entry: %w", err)
}
out = append(out, &e)
}
return out, rows.Err()
}
func scanScreen(row interface {
Scan(dest ...any) error
}) (*Screen, error) {