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:
parent
1e90bbbbc0
commit
d1d86126c8
8 changed files with 683 additions and 42 deletions
|
|
@ -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);
|
||||
|
|
@ -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,20 +37,24 @@ 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" {
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
} else if u.TenantSlug != "" {
|
||||
http.Redirect(w, r, "/tenant/"+u.TenantSlug+"/dashboard", http.StatusSeeOther)
|
||||
} else {
|
||||
switch u.Role {
|
||||
case "admin":
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -311,14 +373,22 @@ document.addEventListener('keydown', function(e) {
|
|||
var msg = new URLSearchParams(window.location.search).get('msg');
|
||||
if (!msg) return;
|
||||
var texts = {
|
||||
'uploaded': '✓ Medium erfolgreich hochgeladen.',
|
||||
'deleted': '✓ Erfolgreich gelöscht.',
|
||||
'saved': '✓ Änderungen gespeichert.',
|
||||
'added': '✓ Erfolgreich hinzugefügt.'
|
||||
'uploaded': '✓ Medium erfolgreich hochgeladen.',
|
||||
'deleted': '✓ Erfolgreich gelöscht.',
|
||||
'saved': '✓ Änderungen gespeichert.',
|
||||
'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>
|
||||
|
||||
|
|
@ -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 & 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>
|
||||
|
||||
</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"> </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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue