Phase 2.1: tenancy middleware, permission helpers, JWT workspace context, frontend + backend role-rename compat

This commit is contained in:
ScreenTinker 2026-05-11 20:02:00 -05:00
parent d8492f3720
commit 2954fd1a84
24 changed files with 499 additions and 102 deletions

View file

@ -20,6 +20,7 @@ import * as designer from './views/designer.js';
import * as playlists from './views/playlists.js'; import * as playlists from './views/playlists.js';
import { applyBranding } from './branding.js'; import { applyBranding } from './branding.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
import { isPlatformAdmin } from './utils.js';
const app = document.getElementById('app'); const app = document.getElementById('app');
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
@ -227,9 +228,9 @@ function updateSidebarUser() {
const user = getCurrentUser(); const user = getCurrentUser();
if (!user) return; if (!user) return;
// Show admin nav only for superadmins // Show admin nav only for platform admins (legacy 'superadmin' or Phase 1 renamed 'platform_admin')
const adminNav = document.getElementById('adminNavItem'); const adminNav = document.getElementById('adminNavItem');
if (adminNav) adminNav.style.display = user.role === 'superadmin' ? '' : 'none'; if (adminNav) adminNav.style.display = isPlatformAdmin(user) ? '' : 'none';
let userEl = document.getElementById('sidebarUser'); let userEl = document.getElementById('sidebarUser');
if (!userEl) { if (!userEl) {

View file

@ -3,3 +3,11 @@ export function esc(str) {
if (str == null) return ''; if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
} }
// Phase 2.1: the Phase 1 schema migration renamed the legacy 'superadmin'
// role to 'platform_admin'. Existing frontend checks still match the old
// string; this helper accepts both so we don't have to splatter the array
// at every call site. Use everywhere the UI gates on platform-level access.
export function isPlatformAdmin(user) {
return !!(user && (user.role === 'superadmin' || user.role === 'platform_admin'));
}

View file

@ -1,6 +1,6 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc } from '../utils.js'; import { esc, isPlatformAdmin } from '../utils.js';
import { t } from '../i18n.js'; import { t } from '../i18n.js';
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' }); const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
@ -8,7 +8,7 @@ const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opt
export async function render(container) { export async function render(container) {
const user = JSON.parse(localStorage.getItem('user') || '{}'); const user = JSON.parse(localStorage.getItem('user') || '{}');
if (user.role !== 'superadmin') { if (!isPlatformAdmin(user)) {
container.innerHTML = `<div class="empty-state"><h3>${t('admin.access_denied')}</h3><p>${t('admin.access_denied_desc')}</p></div>`; container.innerHTML = `<div class="empty-state"><h3>${t('admin.access_denied')}</h3><p>${t('admin.access_denied_desc')}</p></div>`;
return; return;
} }

View file

@ -1,7 +1,7 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { getLanguage, setLanguage, getAvailableLanguages, t, tn } from '../i18n.js'; import { getLanguage, setLanguage, getAvailableLanguages, t, tn } from '../i18n.js';
import { esc } from '../utils.js'; import { esc, isPlatformAdmin } from '../utils.js';
import { resetBranding } from '../branding.js'; import { resetBranding } from '../branding.js';
export async function render(container) { export async function render(container) {
@ -11,7 +11,7 @@ export async function render(container) {
let user; let user;
try { user = await api.getMe(); localStorage.setItem('user', JSON.stringify(user)); } try { user = await api.getMe(); localStorage.setItem('user', JSON.stringify(user)); }
catch { user = JSON.parse(localStorage.getItem('user') || '{}'); } catch { user = JSON.parse(localStorage.getItem('user') || '{}'); }
const isSuperAdmin = user.role === 'superadmin'; const isSuperAdmin = isPlatformAdmin(user);
const isAdmin = user.role === 'admin' || isSuperAdmin; const isAdmin = user.role === 'admin' || isSuperAdmin;
container.innerHTML = ` container.innerHTML = `
@ -327,11 +327,11 @@ async function loadWhiteLabel() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const headers = { Authorization: `Bearer ${token}` }; const headers = { Authorization: `Bearer ${token}` };
// Only show white-label for enterprise/superadmin. // Only show white-label for enterprise plans or platform admins.
// Use the fresh user cached by render() above, which called api.getMe(). // Use the fresh user cached by render() above, which called api.getMe().
const user = JSON.parse(localStorage.getItem('user') || '{}'); const user = JSON.parse(localStorage.getItem('user') || '{}');
const section = document.getElementById('whiteLabelSection'); const section = document.getElementById('whiteLabelSection');
if (section && user.plan_id !== 'enterprise' && user.role !== 'superadmin') { if (section && user.plan_id !== 'enterprise' && !isPlatformAdmin(user)) {
section.innerHTML = ` section.innerHTML = `
<h3>${t('settings.white_label')}</h3> <h3>${t('settings.white_label')}</h3>
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px;text-align:center"> <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px;text-align:center">

107
server/lib/permissions.js Normal file
View file

@ -0,0 +1,107 @@
// Phase 2.1: permission helpers.
//
// Routes call these as Express middleware to gate access, or as predicate
// functions to branch within a handler. They presume resolveTenancy has
// already attached req.workspaceId / req.workspaceRole / req.orgRole /
// req.isPlatformAdmin.
//
// Layering (top wins):
// 1. req.isPlatformAdmin -> allow anything
// 2. req.orgRole in {org_owner, org_admin} -> allow read/write/admin within the org
// org_owner also has billing.write and org.delete (not exposed in 2.1)
// 3. req.workspaceRole in {workspace_admin, workspace_editor, workspace_viewer}
// gates resource access per the role's bands
'use strict';
function canRead(req) {
if (req.isPlatformAdmin) return true;
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
return !!req.workspaceRole; // any workspace_member can read
}
function canWrite(req) {
if (req.isPlatformAdmin) return true;
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
return req.workspaceRole === 'workspace_admin' || req.workspaceRole === 'workspace_editor';
}
function canAdmin(req) {
if (req.isPlatformAdmin) return true;
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
return req.workspaceRole === 'workspace_admin';
}
function isOrgAdmin(req) {
if (req.isPlatformAdmin) return true;
return req.orgRole === 'org_owner' || req.orgRole === 'org_admin';
}
function isOrgOwner(req) {
if (req.isPlatformAdmin) return true;
return req.orgRole === 'org_owner';
}
// ---- middleware variants ----
function requireWorkspace(req, res, next) {
if (!req.workspaceId) {
return res.status(403).json({ error: 'No workspace context' });
}
next();
}
function requireWorkspaceRead(req, res, next) {
if (!canRead(req)) {
return res.status(403).json({ error: 'Workspace access required' });
}
next();
}
function requireWorkspaceWrite(req, res, next) {
if (!canWrite(req)) {
return res.status(403).json({ error: 'Workspace editor or admin required' });
}
next();
}
function requireWorkspaceAdmin(req, res, next) {
if (!canAdmin(req)) {
return res.status(403).json({ error: 'Workspace admin required' });
}
next();
}
function requireOrgAdmin(req, res, next) {
if (!isOrgAdmin(req)) {
return res.status(403).json({ error: 'Organization admin required' });
}
next();
}
function requireOrgOwner(req, res, next) {
if (!isOrgOwner(req)) {
return res.status(403).json({ error: 'Organization owner required' });
}
next();
}
function requirePlatformAdmin(req, res, next) {
if (!req.user || req.user.role !== 'platform_admin') {
return res.status(403).json({ error: 'Platform admin required' });
}
next();
}
module.exports = {
// boolean predicates
canRead, canWrite, canAdmin, isOrgAdmin, isOrgOwner,
// express middleware
requireWorkspace,
requireWorkspaceRead,
requireWorkspaceWrite,
requireWorkspaceAdmin,
requireOrgAdmin,
requireOrgOwner,
requirePlatformAdmin,
};

148
server/lib/tenancy.js Normal file
View file

@ -0,0 +1,148 @@
// Phase 2.1: per-request tenancy resolver.
//
// Runs after requireAuth (which sets req.user and req.jwtWorkspaceId).
// Resolves the active workspace context for this request and attaches:
//
// req.workspaceId string | null the workspace this request operates in
// req.workspace object | null the full workspaces row
// req.organizationId string | null parent org of req.workspace
// req.workspaceRole string | null 'workspace_admin' | 'workspace_editor' | 'workspace_viewer'
// req.orgRole string | null 'org_owner' | 'org_admin'
// req.isPlatformAdmin boolean shortcut for req.user.role === 'platform_admin'
// req.actingAs boolean true when the user reached this workspace via
// org-level or platform-level access rather than
// a direct workspace_members row
//
// Resolution order, top wins:
// 1. X-Workspace-Id header (for explicit per-request override)
// 2. ?workspace_id= query param (same purpose, easier in browser dev)
// 3. JWT current_workspace_id (the user's last switched-to workspace)
// 4. First workspace_members row for user (sorted by joined_at ASC)
// 5. For platform_admin only: any workspace
//
// Steps 1-3 are validated against access. If a stale value (e.g. user was
// removed from the workspace) is found, it's discarded and we fall through.
'use strict';
const { db } = require('../db/database');
function membershipOf(userId, workspaceId) {
return db.prepare(
'SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?'
).get(workspaceId, userId);
}
function orgMembershipOf(userId, organizationId) {
return db.prepare(
'SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?'
).get(organizationId, userId);
}
function loadWorkspace(workspaceId) {
if (!workspaceId) return null;
return db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspaceId);
}
function firstAccessibleWorkspace(userId) {
return db.prepare(`
SELECT w.* FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE wm.user_id = ?
ORDER BY wm.joined_at ASC
LIMIT 1
`).get(userId);
}
// Check whether userId can access workspace via any path (member, org admin,
// or platform admin). Returns the access context: { workspaceRole, actingAs }
// or null if no access.
function accessContext(userId, role, workspace) {
const isPlatformAdmin = role === 'platform_admin';
const wsMembership = membershipOf(userId, workspace.id);
if (wsMembership) {
return { workspaceRole: wsMembership.role, actingAs: false };
}
const orgMembership = orgMembershipOf(userId, workspace.organization_id);
if (orgMembership && (orgMembership.role === 'org_owner' || orgMembership.role === 'org_admin')) {
return { workspaceRole: null, actingAs: true };
}
if (isPlatformAdmin) {
return { workspaceRole: null, actingAs: true };
}
return null;
}
function resolveTenancy(req, res, next) {
if (!req.user) {
// Should not happen when chained after requireAuth, but tolerate optionalAuth flows.
return next();
}
const isPlatformAdmin = req.user.role === 'platform_admin';
req.isPlatformAdmin = isPlatformAdmin;
// Build the ordered candidate list of workspace_ids to try.
const candidates = [];
const headerWs = (req.headers['x-workspace-id'] || '').trim();
if (headerWs) candidates.push(headerWs);
if (req.query && req.query.workspace_id) candidates.push(String(req.query.workspace_id));
if (req.jwtWorkspaceId) candidates.push(req.jwtWorkspaceId);
let workspace = null;
let context = null;
for (const wsId of candidates) {
const ws = loadWorkspace(wsId);
if (!ws) continue;
const ctx = accessContext(req.user.id, req.user.role, ws);
if (!ctx) continue;
workspace = ws;
context = ctx;
break;
}
if (!workspace) {
// Fall back to the user's first workspace_members row.
const first = firstAccessibleWorkspace(req.user.id);
if (first) {
workspace = first;
const wm = membershipOf(req.user.id, first.id);
context = { workspaceRole: wm.role, actingAs: false };
} else if (isPlatformAdmin) {
// Platform admin with no direct memberships: pick any workspace (acting-as).
const any = db.prepare('SELECT * FROM workspaces LIMIT 1').get();
if (any) {
workspace = any;
context = { workspaceRole: null, actingAs: true };
}
}
}
if (workspace) {
req.workspaceId = workspace.id;
req.workspace = workspace;
req.organizationId = workspace.organization_id;
req.workspaceRole = context.workspaceRole;
req.actingAs = context.actingAs;
const orgMembership = orgMembershipOf(req.user.id, workspace.organization_id);
req.orgRole = orgMembership ? orgMembership.role : null;
} else {
req.workspaceId = null;
req.workspace = null;
req.organizationId = null;
req.workspaceRole = null;
req.orgRole = null;
req.actingAs = false;
}
next();
}
module.exports = {
resolveTenancy,
// Exported for testing / direct use by routes that need ad-hoc checks.
accessContext,
membershipOf,
orgMembershipOf,
firstAccessibleWorkspace,
};

View file

@ -2,9 +2,14 @@ const jwt = require('jsonwebtoken');
const config = require('../config'); const config = require('../config');
const { db } = require('../db/database'); const { db } = require('../db/database');
function generateToken(user) { // Phase 2.1: JWT now optionally carries the user's current workspace_id so
// the tenancy middleware can resolve scope without an extra DB lookup on
// every request. Callers that don't know the workspace yet (legacy paths,
// recovery tokens) pass null and the tenancy resolver falls back to the
// user's first accessible workspace.
function generateToken(user, currentWorkspaceId) {
return jwt.sign( return jwt.sign(
{ id: user.id, email: user.email, role: user.role }, { id: user.id, email: user.email, role: user.role, current_workspace_id: currentWorkspaceId || null },
config.jwtSecret, config.jwtSecret,
{ algorithm: 'HS256', expiresIn: config.jwtExpiry } { algorithm: 'HS256', expiresIn: config.jwtExpiry }
); );
@ -40,11 +45,14 @@ function requireAuth(req, res, next) {
const decoded = verifyToken(token); const decoded = verifyToken(token);
if (decoded.recovery) { if (decoded.recovery) {
req.user = recoveryUser(decoded); req.user = recoveryUser(decoded);
req.jwtWorkspaceId = null;
return next(); return next();
} }
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id); const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id);
if (!user) return res.status(401).json({ error: 'User not found' }); if (!user) return res.status(401).json({ error: 'User not found' });
req.user = user; req.user = user;
// Tenancy middleware reads this on the resolver step.
req.jwtWorkspaceId = decoded.current_workspace_id || null;
next(); next();
} catch (err) { } catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' }); return res.status(401).json({ error: 'Invalid or expired token' });
@ -61,6 +69,7 @@ function optionalAuth(req, res, next) {
req.user = decoded.recovery req.user = decoded.recovery
? recoveryUser(decoded) ? recoveryUser(decoded)
: db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id); : db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id);
req.jwtWorkspaceId = decoded.current_workspace_id || null;
} catch (err) { } catch (err) {
// Token invalid, continue without user // Token invalid, continue without user
} }
@ -68,20 +77,30 @@ function optionalAuth(req, res, next) {
next(); next();
} }
// Require admin role (admin or superadmin) // Phase 2.1: role rename. Phase 1 renamed 'superadmin' to 'platform_admin' and
// dropped the in-between 'admin' role. These two guards are widened to accept
// either spelling so existing callers keep working without per-route edits.
// New code should prefer requirePlatformAdmin / requireOrgAdmin / workspace
// role guards from server/lib/permissions.js.
const PLATFORM_ROLES = ['superadmin', 'platform_admin'];
const ELEVATED_ROLES = ['admin', 'superadmin', 'platform_admin'];
function requireAdmin(req, res, next) { function requireAdmin(req, res, next) {
if (!req.user || !['admin', 'superadmin'].includes(req.user.role)) { if (!req.user || !ELEVATED_ROLES.includes(req.user.role)) {
return res.status(403).json({ error: 'Admin access required' }); return res.status(403).json({ error: 'Admin access required' });
} }
next(); next();
} }
// Require superadmin role (platform owner only)
function requireSuperAdmin(req, res, next) { function requireSuperAdmin(req, res, next) {
if (!req.user || req.user.role !== 'superadmin') { if (!req.user || !PLATFORM_ROLES.includes(req.user.role)) {
return res.status(403).json({ error: 'Platform admin access required' }); return res.status(403).json({ error: 'Platform admin access required' });
} }
next(); next();
} }
module.exports = { generateToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin }; // Preferred alias for new code.
const requirePlatformAdmin = requireSuperAdmin;
module.exports = { generateToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, PLATFORM_ROLES, ELEVATED_ROLES };

View file

@ -1,11 +1,12 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getActivity, pruneActivityLog } = require('../services/activity'); const { getActivity, pruneActivityLog } = require('../services/activity');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Get activity log // Get activity log
router.get('/', (req, res) => { router.get('/', (req, res) => {
const { device_id, limit, offset } = req.query; const { device_id, limit, offset } = req.query;
const isAdmin = req.user.role === 'superadmin'; const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const activity = getActivity({ const activity = getActivity({
userId: isAdmin ? null : req.user.id, userId: isAdmin ? null : req.user.id,
@ -19,7 +20,7 @@ router.get('/', (req, res) => {
// Prune old logs (admin only) // Prune old logs (admin only)
router.delete('/prune', (req, res) => { router.delete('/prune', (req, res) => {
if (!['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Admin only' }); if (!ELEVATED_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Admin only' });
pruneActivityLog(); pruneActivityLog();
res.json({ success: true }); res.json({ success: true });
}); });

View file

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { ELEVATED_ROLES } = require('../middleware/auth');
// Mark playlist as draft (called after any item mutation) // Mark playlist as draft (called after any item mutation)
function markDraft(playlistId) { function markDraft(playlistId) {
@ -12,7 +13,7 @@ function markDraft(playlistId) {
function checkDeviceAccess(req, res, paramName = 'deviceId') { function checkDeviceAccess(req, res, paramName = 'deviceId') {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params[paramName]); const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params[paramName]);
if (!device) { res.status(404).json({ error: 'Device not found' }); return false; } if (!device) { res.status(404).json({ error: 'Device not found' }); return false; }
if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) { if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id && device.user_id !== req.user.id) {
res.status(403).json({ error: 'Access denied' }); return false; res.status(403).json({ error: 'Access denied' }); return false;
} }
return true; return true;
@ -65,7 +66,7 @@ router.post('/device/:deviceId', (req, res) => {
if (content_id) { if (content_id) {
const content = db.prepare('SELECT id, user_id FROM content WHERE id = ?').get(content_id); const content = db.prepare('SELECT id, user_id FROM content WHERE id = ?').get(content_id);
if (!content) return res.status(404).json({ error: 'Content not found' }); if (!content) return res.status(404).json({ error: 'Content not found' });
if (!['admin','superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) { if (!ELEVATED_ROLES.includes(req.user.role) && content.user_id && content.user_id !== req.user.id) {
return res.status(403).json({ error: 'Content not owned by you' }); return res.status(403).json({ error: 'Content not owned by you' });
} }
} }
@ -105,7 +106,7 @@ router.post('/device/:deviceId', (req, res) => {
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const item = db.prepare('SELECT pi.*, p.user_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id); const item = db.prepare('SELECT pi.*, p.user_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id);
if (!item) return res.status(404).json({ error: 'Item not found' }); if (!item) return res.status(404).json({ error: 'Item not found' });
if (!['admin','superadmin'].includes(req.user.role) && item.user_id !== req.user.id) { if (!ELEVATED_ROLES.includes(req.user.role) && item.user_id !== req.user.id) {
return res.status(403).json({ error: 'Access denied' }); return res.status(403).json({ error: 'Access denied' });
} }
@ -131,7 +132,7 @@ router.put('/:id', (req, res) => {
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const item = db.prepare('SELECT pi.*, p.user_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id); const item = db.prepare('SELECT pi.*, p.user_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id);
if (!item) return res.status(404).json({ error: 'Item not found' }); if (!item) return res.status(404).json({ error: 'Item not found' });
if (!['admin','superadmin'].includes(req.user.role) && item.user_id !== req.user.id) { if (!ELEVATED_ROLES.includes(req.user.role) && item.user_id !== req.user.id) {
return res.status(403).json({ error: 'Access denied' }); return res.status(403).json({ error: 'Access denied' });
} }

View file

@ -5,10 +5,48 @@ const https = require('https');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { OAuth2Client } = require('google-auth-library'); const { OAuth2Client } = require('google-auth-library');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { generateToken, requireAuth, requireAdmin, requireSuperAdmin } = require('../middleware/auth'); const { generateToken, requireAuth, requireAdmin, requireSuperAdmin, PLATFORM_ROLES } = require('../middleware/auth');
const { resolveTenancy } = require('../lib/tenancy');
const { logActivity, getClientIp } = require('../services/activity'); const { logActivity, getClientIp } = require('../services/activity');
const config = require('../config'); const config = require('../config');
// Phase 2.1: find or create the user's default org+workspace. Returns the
// workspace_id to embed in the JWT. Idempotent: if the user already has
// memberships (e.g. migrated from Phase 1), returns the first one without
// creating anything.
function ensureDefaultOrgForUser(user) {
const existing = db.prepare(`
SELECT w.id FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE wm.user_id = ?
ORDER BY wm.joined_at ASC LIMIT 1
`).get(user.id);
if (existing) return existing.id;
// No memberships -> mint a fresh org and Default workspace owned by user.
const orgId = uuidv4();
const wsId = uuidv4();
const orgName = (user.name && user.name.trim())
? `${user.name}'s organization`
: `${user.email}'s organization`;
const tx = db.transaction(() => {
db.prepare(`INSERT INTO organizations (
id, name, owner_user_id, plan_id,
stripe_customer_id, stripe_subscription_id,
subscription_status, subscription_ends
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
orgId, orgName, user.id, user.plan_id || 'free',
user.stripe_customer_id || null, user.stripe_subscription_id || null,
user.subscription_status || 'active', user.subscription_ends || null
);
db.prepare(`INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_owner')`).run(orgId, user.id);
db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, 'Default', ?)`).run(wsId, orgId, user.id);
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(wsId, user.id);
});
tx();
return wsId;
}
function logFailedLogin(email, ip, reason) { function logFailedLogin(email, ip, reason) {
try { try {
db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (NULL, ?, ?, ?)') db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (NULL, ?, ?, ?)')
@ -49,9 +87,10 @@ router.post('/register', (req, res) => {
const id = uuidv4(); const id = uuidv4();
const passwordHash = bcrypt.hashSync(password, 10); const passwordHash = bcrypt.hashSync(password, 10);
// First user becomes admin with enterprise plan (self-hosted) or free plan with Pro trial // First user becomes platform_admin with enterprise plan (self-hosted) or free plan with Pro trial.
// Phase 1 renamed the legacy 'superadmin' role to 'platform_admin'; new bootstrap users get the new name directly.
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user'; const role = userCount === 0 ? 'platform_admin' : 'user';
const isFirstUser = userCount === 0; const isFirstUser = userCount === 0;
const plan = (isFirstUser && config.selfHosted) ? 'enterprise' : 'pro'; // Start on Pro trial const plan = (isFirstUser && config.selfHosted) ? 'enterprise' : 'pro'; // Start on Pro trial
const trialStarted = isFirstUser && config.selfHosted ? null : Math.floor(Date.now() / 1000); const trialStarted = isFirstUser && config.selfHosted ? null : Math.floor(Date.now() / 1000);
@ -61,10 +100,11 @@ router.post('/register', (req, res) => {
VALUES (?, ?, ?, ?, 'local', ?, ?, ?, ?) VALUES (?, ?, ?, ?, 'local', ?, ?, ?, ?)
`).run(id, email.toLowerCase(), name || email.split('@')[0], passwordHash, role, plan, trialStarted, trialStarted ? 'pro' : null); `).run(id, email.toLowerCase(), name || email.split('@')[0], passwordHash, role, plan, trialStarted, trialStarted ? 'pro' : null);
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(id); const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, stripe_customer_id, stripe_subscription_id, subscription_status, subscription_ends FROM users WHERE id = ?').get(id);
const token = generateToken(user); const workspaceId = ensureDefaultOrgForUser(user);
const token = generateToken(user, workspaceId);
res.status(201).json({ token, user }); res.status(201).json({ token, user, current_workspace_id: workspaceId });
}); });
// Login // Login
@ -84,9 +124,10 @@ router.post('/login', (req, res) => {
} }
logSuccessfulLogin(user.id, email, getClientIp(req)); logSuccessfulLogin(user.id, email, getClientIp(req));
const token = generateToken(user); const workspaceId = ensureDefaultOrgForUser(user);
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user; const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser }); res.json({ token, user: safeUser, current_workspace_id: workspaceId });
}); });
// ==================== Google OAuth ==================== // ==================== Google OAuth ====================
@ -111,7 +152,7 @@ router.post('/google', async (req, res) => {
} }
const id = uuidv4(); const id = uuidv4();
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user'; const role = userCount === 0 ? 'platform_admin' : 'user';
const isFirst = userCount === 0; const isFirst = userCount === 0;
const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro'; const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro';
const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000); const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000);
@ -134,9 +175,10 @@ router.post('/google', async (req, res) => {
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
} }
const token = generateToken(user); const workspaceId = ensureDefaultOrgForUser(user);
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user; const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser }); res.json({ token, user: safeUser, current_workspace_id: workspaceId });
} catch (err) { } catch (err) {
console.error('Google auth error:', err); console.error('Google auth error:', err);
res.status(401).json({ error: 'Google authentication failed' }); res.status(401).json({ error: 'Google authentication failed' });
@ -189,7 +231,7 @@ router.post('/microsoft', async (req, res) => {
} }
const id = uuidv4(); const id = uuidv4();
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user'; const role = userCount === 0 ? 'platform_admin' : 'user';
const isFirst = userCount === 0; const isFirst = userCount === 0;
const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro'; const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro';
const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000); const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000);
@ -210,9 +252,10 @@ router.post('/microsoft', async (req, res) => {
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
} }
const token = generateToken(user); const workspaceId = ensureDefaultOrgForUser(user);
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user; const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser }); res.json({ token, user: safeUser, current_workspace_id: workspaceId });
} catch (err) { } catch (err) {
console.error('Microsoft auth error:', err); console.error('Microsoft auth error:', err);
res.status(401).json({ error: 'Microsoft authentication failed' }); res.status(401).json({ error: 'Microsoft authentication failed' });
@ -238,9 +281,60 @@ function getMicrosoftProfile(accessToken) {
// ==================== User Management ==================== // ==================== User Management ====================
// Get current user // Get current user + tenancy context.
router.get('/me', requireAuth, (req, res) => { // Phase 2.1: response shape extended with current_workspace, current_organization,
res.json(req.user); // roles, and the list of accessible workspaces. Legacy fields (user object at
// the top level) are preserved so existing frontend code continues to work.
router.get('/me', requireAuth, resolveTenancy, (req, res) => {
const accessible = db.prepare(`
SELECT w.id, w.name, w.organization_id, o.name AS organization_name, wm.role AS workspace_role
FROM workspace_members wm
JOIN workspaces w ON w.id = wm.workspace_id
JOIN organizations o ON o.id = w.organization_id
WHERE wm.user_id = ?
ORDER BY o.name, w.name
`).all(req.user.id);
const currentOrg = req.organizationId
? db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(req.organizationId)
: null;
res.json({
...req.user,
current_workspace_id: req.workspaceId,
current_workspace: req.workspace ? { id: req.workspace.id, name: req.workspace.name, organization_id: req.workspace.organization_id } : null,
current_organization: currentOrg,
current_workspace_role: req.workspaceRole,
current_org_role: req.orgRole,
is_platform_admin: req.isPlatformAdmin,
acting_as: req.actingAs,
accessible_workspaces: accessible,
});
});
// Switch the active workspace. Validates the user has access (direct
// workspace_member, org-level admin in the parent org, or platform_admin),
// then mints a fresh JWT with the new current_workspace_id.
router.post('/switch-workspace', requireAuth, (req, res) => {
const { workspace_id } = req.body || {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspace_id);
if (!ws) return res.status(404).json({ error: 'Workspace not found' });
const isPlatformAdmin = req.user.role === 'platform_admin' || req.user.role === 'superadmin';
const wsMember = db.prepare('SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?').get(ws.id, req.user.id);
const orgMember = db.prepare(`
SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?
`).get(ws.organization_id, req.user.id);
const canAct = isPlatformAdmin
|| !!wsMember
|| (orgMember && (orgMember.role === 'org_owner' || orgMember.role === 'org_admin'));
if (!canAct) return res.status(403).json({ error: 'Access denied to that workspace' });
const token = generateToken(req.user, ws.id);
res.json({ token, current_workspace_id: ws.id });
}); });
// Update current user // Update current user
@ -270,9 +364,9 @@ router.put('/me', requireAuth, (req, res) => {
res.json(user); res.json(user);
}); });
// List users - superadmins see all, admins see team members only // List users - platform admins see all, admins see team members only
router.get('/users', requireAuth, requireAdmin, (req, res) => { router.get('/users', requireAuth, requireAdmin, (req, res) => {
if (req.user.role === 'superadmin') { if (PLATFORM_ROLES.includes(req.user.role)) {
const users = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, created_at, last_login FROM users ORDER BY created_at ASC').all(); const users = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, created_at, last_login FROM users ORDER BY created_at ASC').all();
res.json(users); res.json(users);
} else { } else {
@ -322,9 +416,9 @@ router.put('/users/:id/password', requireAuth, requireAdmin, (req, res) => {
return res.status(400).json({ error: `User signs in via ${target.auth_provider} — password reset does not apply` }); return res.status(400).json({ error: `User signs in via ${target.auth_provider} — password reset does not apply` });
} }
if (req.user.role !== 'superadmin') { if (!PLATFORM_ROLES.includes(req.user.role)) {
// Admin path: must own a team that includes the target, and target must // Admin path: must own a team that includes the target, and target must
// be a regular user (cannot reset another admin's or a superadmin's // be a regular user (cannot reset another admin's or a platform_admin's
// password — that would be a lateral-takeover vector). // password — that would be a lateral-takeover vector).
if (target.role !== 'user') { if (target.role !== 'user') {
return res.status(403).json({ error: 'Admins can only reset passwords for regular users' }); return res.status(403).json({ error: 'Admins can only reset passwords for regular users' });

View file

@ -8,6 +8,7 @@ const upload = require('../middleware/upload');
const config = require('../config'); const config = require('../config');
const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription'); const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription');
const { sanitizeString } = require('../middleware/sanitize'); const { sanitizeString } = require('../middleware/sanitize');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Multer captures file.originalname directly from the multipart filename header, // Multer captures file.originalname directly from the multipart filename header,
// bypassing sanitizeBody. Apply the same HTML-escape here so a filename like // bypassing sanitizeBody. Apply the same HTML-escape here so a filename like
@ -42,7 +43,7 @@ function validateRemoteUrl(url) {
// List content for current user (admins see all). // List content for current user (admins see all).
// folder_id filter: omit for everything; "root" or "" for root-level only; <uuid> for that folder. // folder_id filter: omit for everything; "root" or "" for root-level only; <uuid> for that folder.
router.get('/', (req, res) => { router.get('/', (req, res) => {
const isAdmin = req.user.role === 'superadmin'; const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const folder = req.query.folder; const folder = req.query.folder;
const folderId = req.query.folder_id; const folderId = req.query.folder_id;
let sql = `SELECT * FROM content ${isAdmin ? 'WHERE 1=1' : 'WHERE (user_id = ? OR user_id IS NULL)'}`; let sql = `SELECT * FROM content ${isAdmin ? 'WHERE 1=1' : 'WHERE (user_id = ? OR user_id IS NULL)'}`;
@ -64,7 +65,7 @@ router.get('/', (req, res) => {
// Get folders list // Get folders list
router.get('/folders', (req, res) => { router.get('/folders', (req, res) => {
const isAdmin = req.user.role === 'superadmin'; const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const folders = db.prepare( const folders = db.prepare(
`SELECT folder, COUNT(*) as count FROM content WHERE folder IS NOT NULL ${isAdmin ? '' : 'AND (user_id = ? OR user_id IS NULL)'} GROUP BY folder ORDER BY folder` `SELECT folder, COUNT(*) as count FROM content WHERE folder IS NOT NULL ${isAdmin ? '' : 'AND (user_id = ? OR user_id IS NULL)'} GROUP BY folder ORDER BY folder`
).all(...(isAdmin ? [] : [req.user.id])); ).all(...(isAdmin ? [] : [req.user.id]));
@ -218,7 +219,7 @@ function extractYoutubeId(url) {
function checkContentAccess(req, res) { function checkContentAccess(req, res) {
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id); const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id);
if (!content) { res.status(404).json({ error: 'Content not found' }); return null; } if (!content) { res.status(404).json({ error: 'Content not found' }); return null; }
if (!['admin','superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) { if (!ELEVATED_ROLES.includes(req.user.role) && content.user_id && content.user_id !== req.user.id) {
res.status(403).json({ error: 'Access denied' }); return null; res.status(403).json({ error: 'Access denied' }); return null;
} }
return content; return content;
@ -257,7 +258,7 @@ router.put('/:id', (req, res) => {
if (folder_id) { if (folder_id) {
const target = db.prepare('SELECT user_id FROM content_folders WHERE id = ?').get(folder_id); const target = db.prepare('SELECT user_id FROM content_folders WHERE id = ?').get(folder_id);
if (!target) return res.status(400).json({ error: 'Invalid folder_id' }); if (!target) return res.status(400).json({ error: 'Invalid folder_id' });
const isSuperadmin = req.user.role === 'superadmin'; const isSuperadmin = PLATFORM_ROLES.includes(req.user.role);
if (!isSuperadmin && target.user_id !== req.user.id) { if (!isSuperadmin && target.user_id !== req.user.id) {
return res.status(403).json({ error: 'Cannot move content to another user\'s folder' }); return res.status(403).json({ error: 'Cannot move content to another user\'s folder' });
} }

View file

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { ELEVATED_ROLES } = require('../middleware/auth');
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/; const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
const ALLOWED_COMMANDS = ['screen_on', 'screen_off', 'launch', 'update', 'reboot', 'shutdown']; const ALLOWED_COMMANDS = ['screen_on', 'screen_off', 'launch', 'update', 'reboot', 'shutdown'];
@ -117,7 +118,7 @@ router.post('/:id/devices', requireGroupOwnership, (req, res) => {
if (!device_id) return res.status(400).json({ error: 'device_id required' }); if (!device_id) return res.status(400).json({ error: 'device_id required' });
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id); const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) { if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id && device.user_id !== req.user.id) {
return res.status(403).json({ error: 'Access denied' }); return res.status(403).json({ error: 'Access denied' });
} }
try { try {

View file

@ -1,10 +1,11 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// List devices for current user (admins see all) // List devices for current user (admins see all)
router.get('/', (req, res) => { router.get('/', (req, res) => {
const isAdmin = req.user.role === 'superadmin'; const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const devices = db.prepare(` const devices = db.prepare(`
SELECT d.*, SELECT d.*,
t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb, t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb,
@ -33,7 +34,7 @@ router.get('/', (req, res) => {
// List unclaimed provisioning devices (admin only) // List unclaimed provisioning devices (admin only)
router.get('/unassigned', (req, res) => { router.get('/unassigned', (req, res) => {
if (!['admin', 'superadmin'].includes(req.user.role)) { if (!ELEVATED_ROLES.includes(req.user.role)) {
return res.status(403).json({ error: 'Admin access required' }); return res.status(403).json({ error: 'Admin access required' });
} }
const devices = db.prepare(` const devices = db.prepare(`
@ -50,7 +51,7 @@ router.get('/:id', (req, res) => {
const device = db.prepare('SELECT d.*, u.email as owner_email, u.name as owner_name FROM devices d LEFT JOIN users u ON d.user_id = u.id WHERE d.id = ?').get(req.params.id); const device = db.prepare('SELECT d.*, u.email as owner_email, u.name as owner_name FROM devices d LEFT JOIN users u ON d.user_id = u.id WHERE d.id = ?').get(req.params.id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
// Check access: admin, owner, or team member // Check access: admin, owner, or team member
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) { if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id !== req.user.id) {
const teamAccess = device.team_id ? db.prepare('SELECT role FROM team_members WHERE team_id = ? AND user_id = ?').get(device.team_id, req.user.id) : null; const teamAccess = device.team_id ? db.prepare('SELECT role FROM team_members WHERE team_id = ? AND user_id = ?').get(device.team_id, req.user.id) : null;
if (!teamAccess) return res.status(403).json({ error: 'Access denied' }); if (!teamAccess) return res.status(403).json({ error: 'Access denied' });
device._teamRole = teamAccess.role; // Pass team role for frontend to check device._teamRole = teamAccess.role; // Pass team role for frontend to check
@ -109,7 +110,7 @@ router.get('/:id', (req, res) => {
function checkDeviceOwnership(req, res) { function checkDeviceOwnership(req, res) {
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id); const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) { res.status(404).json({ error: 'Device not found' }); return null; } if (!device) { res.status(404).json({ error: 'Device not found' }); return null; }
if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) { if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id && device.user_id !== req.user.id) {
// Check team membership // Check team membership
const teamAccess = device.team_id ? db.prepare('SELECT role FROM team_members WHERE team_id = ? AND user_id = ?').get(device.team_id, req.user.id) : null; const teamAccess = device.team_id ? db.prepare('SELECT role FROM team_members WHERE team_id = ? AND user_id = ?').get(device.team_id, req.user.id) : null;
if (!teamAccess || teamAccess.role === 'viewer') { if (!teamAccess || teamAccess.role === 'viewer') {

View file

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES } = require('../middleware/auth');
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@ -22,7 +23,7 @@ function ownedFolder(req, folderId) {
if (!UUID_RE.test(folderId)) return null; if (!UUID_RE.test(folderId)) return null;
const row = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folderId); const row = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folderId);
if (!row) return null; if (!row) return null;
const isSuperadmin = req.user.role === 'superadmin'; const isSuperadmin = PLATFORM_ROLES.includes(req.user.role);
if (!isSuperadmin && row.user_id !== req.user.id) return null; if (!isSuperadmin && row.user_id !== req.user.id) return null;
return row; return row;
} }
@ -30,7 +31,7 @@ function ownedFolder(req, folderId) {
// List folders for the current user. Returns the full tree as a flat array; // List folders for the current user. Returns the full tree as a flat array;
// the client builds the hierarchy from parent_id. // the client builds the hierarchy from parent_id.
router.get('/', (req, res) => { router.get('/', (req, res) => {
const isAdmin = req.user.role === 'superadmin'; const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const rows = isAdmin const rows = isAdmin
? db.prepare('SELECT * FROM content_folders ORDER BY name COLLATE NOCASE').all() ? db.prepare('SELECT * FROM content_folders ORDER BY name COLLATE NOCASE').all()
: db.prepare('SELECT * FROM content_folders WHERE user_id = ? ORDER BY name COLLATE NOCASE').all(req.user.id); : db.prepare('SELECT * FROM content_folders WHERE user_id = ? ORDER BY name COLLATE NOCASE').all(req.user.id);
@ -43,7 +44,7 @@ router.post('/', (req, res) => {
if (!name) return res.status(400).json({ error: 'name is required' }); if (!name) return res.status(400).json({ error: 'name is required' });
if (name.length > 100) return res.status(400).json({ error: 'name too long' }); if (name.length > 100) return res.status(400).json({ error: 'name too long' });
const isSuperadmin = req.user.role === 'superadmin'; const isSuperadmin = PLATFORM_ROLES.includes(req.user.role);
if (!isSuperadmin) { if (!isSuperadmin) {
const { count } = db.prepare('SELECT COUNT(*) AS count FROM content_folders WHERE user_id = ?').get(req.user.id); const { count } = db.prepare('SELECT COUNT(*) AS count FROM content_folders WHERE user_id = ?').get(req.user.id);
if (count >= MAX_FOLDERS_PER_USER) { if (count >= MAX_FOLDERS_PER_USER) {

View file

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Escape HTML to prevent XSS // Escape HTML to prevent XSS
function escapeHtml(str) { function escapeHtml(str) {
@ -24,7 +25,7 @@ function safeNumber(val, fallback) {
// List kiosk pages // List kiosk pages
router.get('/', (req, res) => { router.get('/', (req, res) => {
const isAdmin = req.user.role === 'superadmin'; const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const pages = db.prepare( const pages = db.prepare(
`SELECT * FROM kiosk_pages ${isAdmin ? '' : 'WHERE user_id = ?'} ORDER BY created_at DESC` `SELECT * FROM kiosk_pages ${isAdmin ? '' : 'WHERE user_id = ?'} ORDER BY created_at DESC`
).all(...(isAdmin ? [] : [req.user.id])); ).all(...(isAdmin ? [] : [req.user.id]));
@ -35,7 +36,7 @@ router.get('/', (req, res) => {
function checkKioskAccess(req, res) { function checkKioskAccess(req, res) {
const page = db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(req.params.id); const page = db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(req.params.id);
if (!page) { res.status(404).json({ error: 'Page not found' }); return null; } if (!page) { res.status(404).json({ error: 'Page not found' }); return null; }
if (req.user && !['admin','superadmin'].includes(req.user.role) && page.user_id !== req.user.id) { if (req.user && !ELEVATED_ROLES.includes(req.user.role) && page.user_id !== req.user.id) {
res.status(403).json({ error: 'Access denied' }); return null; res.status(403).json({ error: 'Access denied' }); return null;
} }
return page; return page;

View file

@ -2,11 +2,12 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// List layouts (user's + templates) // List layouts (user's + templates)
router.get('/', (req, res) => { router.get('/', (req, res) => {
const showTemplates = req.query.templates === 'true'; const showTemplates = req.query.templates === 'true';
const isAdmin = req.user.role === 'superadmin'; const isAdmin = PLATFORM_ROLES.includes(req.user.role);
let layouts; let layouts;
if (showTemplates) { if (showTemplates) {
@ -28,7 +29,7 @@ router.get('/', (req, res) => {
function checkLayoutAccess(req, res) { function checkLayoutAccess(req, res) {
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
if (!layout) { res.status(404).json({ error: 'Layout not found' }); return null; } if (!layout) { res.status(404).json({ error: 'Layout not found' }); return null; }
if (!layout.is_template && !['admin','superadmin'].includes(req.user.role) && layout.user_id !== req.user.id) { if (!layout.is_template && !ELEVATED_ROLES.includes(req.user.role) && layout.user_id !== req.user.id) {
res.status(403).json({ error: 'Access denied' }); return null; res.status(403).json({ error: 'Access denied' }); return null;
} }
return layout; return layout;
@ -74,7 +75,7 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const layout = checkLayoutAccess(req, res); const layout = checkLayoutAccess(req, res);
if (!layout) return; if (!layout) return;
if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot edit templates' }); if (layout.is_template && !ELEVATED_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Cannot edit templates' });
const { name, width, height } = req.body; const { name, width, height } = req.body;
if (name) db.prepare('UPDATE layouts SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id); if (name) db.prepare('UPDATE layouts SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id);
@ -90,7 +91,7 @@ router.put('/:id', (req, res) => {
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const layout = checkLayoutAccess(req, res); const layout = checkLayoutAccess(req, res);
if (!layout) return; if (!layout) return;
if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot delete templates' }); if (layout.is_template && !ELEVATED_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Cannot delete templates' });
db.prepare('DELETE FROM layouts WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM layouts WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });
@ -182,7 +183,7 @@ router.post('/:id/duplicate', (req, res) => {
router.put('/device/:deviceId', (req, res) => { router.put('/device/:deviceId', (req, res) => {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
const { layout_id } = req.body; const { layout_id } = req.body;
db.prepare("UPDATE devices SET layout_id = ?, updated_at = strftime('%s','now') WHERE id = ?") db.prepare("UPDATE devices SET layout_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(layout_id || null, req.params.deviceId); .run(layout_id || null, req.params.deviceId);

View file

@ -4,6 +4,7 @@ const path = require('path');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const config = require('../config'); const config = require('../config');
const { ELEVATED_ROLES } = require('../middleware/auth');
// Re-probe video duration with ffprobe if content.duration_sec is missing // Re-probe video duration with ffprobe if content.duration_sec is missing
async function probeAndUpdateDuration(content) { async function probeAndUpdateDuration(content) {
@ -239,7 +240,7 @@ router.post('/:id/items', requirePlaylistOwnership, async (req, res) => {
if (content_id) { if (content_id) {
const content = db.prepare('SELECT id, user_id, duration_sec, mime_type, filepath FROM content WHERE id = ?').get(content_id); const content = db.prepare('SELECT id, user_id, duration_sec, mime_type, filepath FROM content WHERE id = ?').get(content_id);
if (!content) return res.status(404).json({ error: 'Content not found' }); if (!content) return res.status(404).json({ error: 'Content not found' });
if (!['admin', 'superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) { if (!ELEVATED_ROLES.includes(req.user.role) && content.user_id && content.user_id !== req.user.id) {
return res.status(403).json({ error: 'Content not owned by you' }); return res.status(403).json({ error: 'Content not owned by you' });
} }
if (duration_sec === undefined || duration_sec === null) { if (duration_sec === undefined || duration_sec === null) {
@ -377,7 +378,7 @@ router.post('/:id/assign', requirePlaylistOwnership, (req, res) => {
const device = db.prepare('SELECT id, user_id FROM devices WHERE id = ?').get(device_id); const device = db.prepare('SELECT id, user_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin', 'superadmin'].includes(req.user.role) && device.user_id !== req.user.id) { if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id !== req.user.id) {
return res.status(403).json({ error: 'Device not owned by you' }); return res.status(403).json({ error: 'Device not owned by you' });
} }

View file

@ -1,10 +1,11 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Helper: scope reports to user's devices // Helper: scope reports to user's devices
function getUserDeviceFilter(user) { function getUserDeviceFilter(user) {
if (user.role === 'superadmin') return { sql: '', params: [] }; if (PLATFORM_ROLES.includes(user.role)) return { sql: '', params: [] };
return { sql: ' AND d.user_id = ?', params: [user.id] }; return { sql: ' AND d.user_id = ?', params: [user.id] };
} }
@ -38,7 +39,7 @@ router.get('/summary', (req, res) => {
let deviceFilter = ''; let deviceFilter = '';
const params = [startEpoch, endEpoch]; const params = [startEpoch, endEpoch];
// Scope to user's devices (non-admin) // Scope to user's devices (non-admin)
if (!['admin','superadmin'].includes(req.user.role)) { if (!ELEVATED_ROLES.includes(req.user.role)) {
deviceFilter += ' AND device_id IN (SELECT id FROM devices WHERE user_id = ?)'; deviceFilter += ' AND device_id IN (SELECT id FROM devices WHERE user_id = ?)';
params.push(req.user.id); params.push(req.user.id);
} }

View file

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { ELEVATED_ROLES } = require('../middleware/auth');
// Helper: build the expanded schedule query for a device (device-level + group-level) // Helper: build the expanded schedule query for a device (device-level + group-level)
function getDeviceSchedulesQuery() { function getDeviceSchedulesQuery() {
@ -57,7 +58,7 @@ router.get('/', (req, res) => {
router.get('/device/:deviceId', (req, res) => { router.get('/device/:deviceId', (req, res) => {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
const schedules = db.prepare(getDeviceSchedulesQuery()).all(req.params.deviceId, req.params.deviceId); const schedules = db.prepare(getDeviceSchedulesQuery()).all(req.params.deviceId, req.params.deviceId);
res.json(schedules); res.json(schedules);
@ -71,7 +72,7 @@ router.get('/week', (req, res) => {
// Verify device ownership // Verify device ownership
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id); const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
const weekStart = date ? new Date(date) : new Date(); const weekStart = date ? new Date(date) : new Date();
weekStart.setHours(0, 0, 0, 0); weekStart.setHours(0, 0, 0, 0);
@ -111,14 +112,14 @@ router.post('/', (req, res) => {
if (device_id) { if (device_id) {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id); const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) { if (!ELEVATED_ROLES.includes(req.user.role) && device.user_id !== req.user.id) {
return res.status(403).json({ error: 'Access denied' }); return res.status(403).json({ error: 'Access denied' });
} }
} }
if (group_id) { if (group_id) {
const group = db.prepare('SELECT user_id FROM device_groups WHERE id = ?').get(group_id); const group = db.prepare('SELECT user_id FROM device_groups WHERE id = ?').get(group_id);
if (!group) return res.status(404).json({ error: 'Group not found' }); if (!group) return res.status(404).json({ error: 'Group not found' });
if (!['admin','superadmin'].includes(req.user.role) && group.user_id !== req.user.id) { if (!ELEVATED_ROLES.includes(req.user.role) && group.user_id !== req.user.id) {
return res.status(403).json({ error: 'Access denied' }); return res.status(403).json({ error: 'Access denied' });
} }
} }
@ -140,7 +141,7 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id); const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id);
if (!schedule) return res.status(404).json({ error: 'Schedule not found' }); if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
const isAdmin = ['admin','superadmin'].includes(req.user.role); const isAdmin = ELEVATED_ROLES.includes(req.user.role);
if (!isAdmin && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); if (!isAdmin && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
// If changing target, enforce mutual exclusion // If changing target, enforce mutual exclusion
@ -206,7 +207,7 @@ router.put('/:id', (req, res) => {
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id); const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id);
if (!schedule) return res.status(404).json({ error: 'Schedule not found' }); if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
if (!['admin','superadmin'].includes(req.user.role) && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); if (!ELEVATED_ROLES.includes(req.user.role) && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
db.prepare('DELETE FROM schedules WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM schedules WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });
}); });

View file

@ -5,6 +5,7 @@ const os = require('os');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const config = require('../config'); const config = require('../config');
const { PLATFORM_ROLES } = require('../middleware/auth');
// Public status page // Public status page
router.get('/', (req, res) => { router.get('/', (req, res) => {
@ -45,7 +46,7 @@ router.get('/backup', (req, res) => {
const config = require('../config'); const config = require('../config');
const decoded = jwt.verify(token, config.jwtSecret); const decoded = jwt.verify(token, config.jwtSecret);
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(decoded.id); const user = db.prepare('SELECT role FROM users WHERE id = ?').get(decoded.id);
if (!user || user.role !== 'superadmin') return res.status(403).json({ error: 'Superadmin only' }); if (!user || !PLATFORM_ROLES.includes(user.role)) return res.status(403).json({ error: 'Platform admin only' });
} catch { } catch {
return res.status(401).json({ error: 'Invalid token' }); return res.status(401).json({ error: 'Invalid token' });
} }

View file

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { ELEVATED_ROLES } = require('../middleware/auth');
// List user's teams // List user's teams
router.get('/', (req, res) => { router.get('/', (req, res) => {
@ -35,7 +36,7 @@ router.get('/:id', (req, res) => {
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?') const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?')
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!membership && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Not a member' }); if (!membership && !ELEVATED_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Not a member' });
team.members = db.prepare(` team.members = db.prepare(`
SELECT tm.*, u.email, u.name as user_name, u.avatar_url SELECT tm.*, u.email, u.name as user_name, u.avatar_url
@ -54,7 +55,7 @@ router.get('/:id', (req, res) => {
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id); const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' }); if (!team) return res.status(404).json({ error: 'Team not found' });
if (team.owner_id !== req.user.id && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Owner only' }); if (team.owner_id !== req.user.id && !ELEVATED_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Owner only' });
if (req.body.name) { if (req.body.name) {
db.prepare('UPDATE teams SET name = ? WHERE id = ?').run(req.body.name, req.params.id); db.prepare('UPDATE teams SET name = ? WHERE id = ?').run(req.body.name, req.params.id);
@ -66,7 +67,7 @@ router.put('/:id', (req, res) => {
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id); const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' }); if (!team) return res.status(404).json({ error: 'Team not found' });
if (team.owner_id !== req.user.id && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Owner only' }); if (team.owner_id !== req.user.id && !ELEVATED_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Owner only' });
db.prepare('DELETE FROM teams WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM teams WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });
@ -127,7 +128,7 @@ router.put('/:id/members/:userId', (req, res) => {
// Only team owner or admin can change roles // Only team owner or admin can change roles
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?').get(req.params.id, req.user.id); const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!['admin','superadmin'].includes(req.user.role) && (!membership || membership.role !== 'owner')) { if (!ELEVATED_ROLES.includes(req.user.role) && (!membership || membership.role !== 'owner')) {
return res.status(403).json({ error: 'Only team owner can change roles' }); return res.status(403).json({ error: 'Only team owner can change roles' });
} }
@ -143,7 +144,7 @@ router.delete('/:id/members/:userId', (req, res) => {
if (team.owner_id === req.params.userId) return res.status(400).json({ error: 'Cannot remove owner' }); if (team.owner_id === req.params.userId) return res.status(400).json({ error: 'Cannot remove owner' });
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?').get(req.params.id, req.user.id); const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!['admin','superadmin'].includes(req.user.role) && (!membership || membership.role !== 'owner')) { if (!ELEVATED_ROLES.includes(req.user.role) && (!membership || membership.role !== 'owner')) {
return res.status(403).json({ error: 'Only team owner can remove members' }); return res.status(403).json({ error: 'Only team owner can remove members' });
} }
@ -156,7 +157,7 @@ router.delete('/:id/members/:userId', (req, res) => {
function checkTeamAccess(req, res) { function checkTeamAccess(req, res) {
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?') const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?')
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!membership && !['admin','superadmin'].includes(req.user.role)) { if (!membership && !ELEVATED_ROLES.includes(req.user.role)) {
res.status(403).json({ error: 'Not a team member' }); res.status(403).json({ error: 'Not a team member' });
return false; return false;
} }
@ -173,7 +174,7 @@ router.post('/:id/devices', (req, res) => {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id); const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
const isAdmin = ['admin', 'superadmin'].includes(req.user.role); const isAdmin = ELEVATED_ROLES.includes(req.user.role);
if (!isAdmin && device.user_id !== req.user.id) { if (!isAdmin && device.user_id !== req.user.id) {
return res.status(403).json({ error: 'You do not own this device' }); return res.status(403).json({ error: 'You do not own this device' });
} }
@ -188,7 +189,7 @@ router.delete('/:id/devices/:deviceId', (req, res) => {
if (!checkTeamAccess(req, res)) return; if (!checkTeamAccess(req, res)) return;
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
const isAdmin = ['admin', 'superadmin'].includes(req.user.role); const isAdmin = ELEVATED_ROLES.includes(req.user.role);
if (!isAdmin && device.user_id !== req.user.id) { if (!isAdmin && device.user_id !== req.user.id) {
return res.status(403).json({ error: 'You do not own this device' }); return res.status(403).json({ error: 'You do not own this device' });
} }

View file

@ -2,13 +2,14 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES } = require('../middleware/auth');
// Visibility model (matches widgets/users): // Visibility model (matches widgets/users):
// superadmin: all walls // superadmin: all walls
// admin: own + walls owned by members of teams this admin owns // admin: own + walls owned by members of teams this admin owns
// user: own only // user: own only
function listVisibleWalls(user) { function listVisibleWalls(user) {
if (user.role === 'superadmin') { if (PLATFORM_ROLES.includes(user.role)) {
return db.prepare('SELECT * FROM video_walls ORDER BY created_at DESC').all(); return db.prepare('SELECT * FROM video_walls ORDER BY created_at DESC').all();
} }
if (user.role === 'admin') { if (user.role === 'admin') {
@ -28,7 +29,7 @@ function listVisibleWalls(user) {
} }
function userCanAccessWall(user, wall) { function userCanAccessWall(user, wall) {
if (user.role === 'superadmin') return true; if (PLATFORM_ROLES.includes(user.role)) return true;
if (wall.user_id === user.id) return true; if (wall.user_id === user.id) return true;
if (user.role === 'admin') { if (user.role === 'admin') {
const ownsTeamWithOwner = db.prepare(` const ownsTeamWithOwner = db.prepare(`
@ -195,7 +196,7 @@ router.put('/:id/devices', (req, res) => {
// Without this a user could attach another tenant's devices to their own // Without this a user could attach another tenant's devices to their own
// wall and silently take over the playlist + wall_id on those rows. // wall and silently take over the playlist + wall_id on those rows.
// Mirrors the per-device check in device-groups.js. // Mirrors the per-device check in device-groups.js.
if (!['superadmin'].includes(req.user.role)) { if (!PLATFORM_ROLES.includes(req.user.role)) {
const isAdmin = req.user.role === 'admin'; const isAdmin = req.user.role === 'admin';
for (const d of devices) { for (const d of devices) {
const dev = db.prepare('SELECT user_id, team_id FROM devices WHERE id = ?').get(d.device_id); const dev = db.prepare('SELECT user_id, team_id FROM devices WHERE id = ?').get(d.device_id);

View file

@ -5,6 +5,7 @@ const path = require('path');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const appConfig = require('../config'); const appConfig = require('../config');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// For preview only: inline /api/content/:id/file and /thumbnail URLs as data URIs, // For preview only: inline /api/content/:id/file and /thumbnail URLs as data URIs,
// scoped to the current user. Lets the srcdoc preview iframe show logos/bg images // scoped to the current user. Lets the srcdoc preview iframe show logos/bg images
@ -64,7 +65,7 @@ function safeUrl(url) {
// this admin owns (matches /auth/users visibility) // this admin owns (matches /auth/users visibility)
// user: own + public (null owner) // user: own + public (null owner)
router.get('/', (req, res) => { router.get('/', (req, res) => {
if (req.user.role === 'superadmin') { if (PLATFORM_ROLES.includes(req.user.role)) {
const widgets = db.prepare('SELECT * FROM widgets ORDER BY created_at DESC').all(); const widgets = db.prepare('SELECT * FROM widgets ORDER BY created_at DESC').all();
return res.json(widgets); return res.json(widgets);
} }
@ -106,7 +107,7 @@ function checkWidgetAccess(req, res) {
const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id); const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
if (!widget) { res.status(404).json({ error: 'Widget not found' }); return null; } if (!widget) { res.status(404).json({ error: 'Widget not found' }); return null; }
// Allow access if: admin, owner, no owner (public), or render route (no req.user) // Allow access if: admin, owner, no owner (public), or render route (no req.user)
if (req.user && !['admin','superadmin'].includes(req.user.role) && widget.user_id && widget.user_id !== req.user.id) { if (req.user && !ELEVATED_ROLES.includes(req.user.role) && widget.user_id && widget.user_id !== req.user.id) {
res.status(403).json({ error: 'Access denied' }); return null; res.status(403).json({ error: 'Access denied' }); return null;
} }
return widget; return widget;

View file

@ -294,27 +294,32 @@ app.get('/api/content/:id/thumbnail', (req, res) => {
res.sendFile(safePath); res.sendFile(safePath);
}); });
// Protected API Routes // Protected API Routes.
// Phase 2.1: resolveTenancy runs right after requireAuth on every resource
// route. It attaches req.workspaceId, req.workspaceRole, req.orgRole,
// req.isPlatformAdmin, req.actingAs. Route handlers in 2.1 don't read these
// yet (they still filter by user_id); 2.2 will migrate them one route at a time.
const { requireAuth } = require('./middleware/auth'); const { requireAuth } = require('./middleware/auth');
app.use('/api/devices', requireAuth, require('./routes/devices')); const { resolveTenancy } = require('./lib/tenancy');
app.use('/api/content', requireAuth, require('./routes/content')); app.use('/api/devices', requireAuth, resolveTenancy, require('./routes/devices'));
app.use('/api/folders', requireAuth, require('./routes/folders')); app.use('/api/content', requireAuth, resolveTenancy, require('./routes/content'));
app.use('/api/assignments', requireAuth, require('./routes/assignments')); app.use('/api/folders', requireAuth, resolveTenancy, require('./routes/folders'));
app.use('/api/provision', requireAuth, require('./routes/provisioning')); app.use('/api/assignments', requireAuth, resolveTenancy, require('./routes/assignments'));
app.use('/api/layouts', requireAuth, require('./routes/layouts')); app.use('/api/provision', requireAuth, resolveTenancy, require('./routes/provisioning'));
app.use('/api/layouts', requireAuth, resolveTenancy, require('./routes/layouts'));
// Widget render is public (accessed by devices) // Widget render is public (accessed by devices)
app.get('/api/widgets/:id/render', (req, res, next) => { req._skipAuth = true; next(); }); app.get('/api/widgets/:id/render', (req, res, next) => { req._skipAuth = true; next(); });
// Rate limit preview endpoint — it inlines user content as base64 which is memory-intensive // Rate limit preview endpoint — it inlines user content as base64 which is memory-intensive
app.use('/api/widgets/preview', rateLimit(60000, 30)); app.use('/api/widgets/preview', rateLimit(60000, 30));
app.use('/api/widgets', (req, res, next) => { if (req._skipAuth) return next(); requireAuth(req, res, next); }, require('./routes/widgets')); app.use('/api/widgets', (req, res, next) => { if (req._skipAuth) return next(); requireAuth(req, res, next); }, resolveTenancy, require('./routes/widgets'));
app.use('/api/schedules', requireAuth, require('./routes/schedules')); app.use('/api/schedules', requireAuth, resolveTenancy, require('./routes/schedules'));
app.use('/api/walls', requireAuth, require('./routes/video-walls')); app.use('/api/walls', requireAuth, resolveTenancy, require('./routes/video-walls'));
app.use('/api/teams', requireAuth, require('./routes/teams')); app.use('/api/teams', requireAuth, resolveTenancy, require('./routes/teams'));
app.use('/api/reports', requireAuth, require('./routes/reports')); app.use('/api/reports', requireAuth, resolveTenancy, require('./routes/reports'));
app.use('/api/groups', requireAuth, require('./routes/device-groups')); app.use('/api/groups', requireAuth, resolveTenancy, require('./routes/device-groups'));
app.use('/api/playlists', requireAuth, require('./routes/playlists')); app.use('/api/playlists', requireAuth, resolveTenancy, require('./routes/playlists'));
app.use('/api/activity', requireAuth, require('./routes/activity')); app.use('/api/activity', requireAuth, resolveTenancy, require('./routes/activity'));
app.use('/api/white-label', requireAuth, require('./routes/white-label')); app.use('/api/white-label', requireAuth, resolveTenancy, require('./routes/white-label'));
// Kiosk render is public (accessed by devices), CRUD is protected // Kiosk render is public (accessed by devices), CRUD is protected
app.get('/api/kiosk/:id/render', (req, res, next) => { app.get('/api/kiosk/:id/render', (req, res, next) => {
// Let it through to the kiosk route without auth // Let it through to the kiosk route without auth
@ -324,7 +329,7 @@ app.get('/api/kiosk/:id/render', (req, res, next) => {
app.use('/api/kiosk', (req, res, next) => { app.use('/api/kiosk', (req, res, next) => {
if (req._skipAuth) return next(); if (req._skipAuth) return next();
requireAuth(req, res, next); requireAuth(req, res, next);
}, require('./routes/kiosk')); }, resolveTenancy, require('./routes/kiosk'));
// Frontend version hash (changes when files are modified, triggers soft reload) // Frontend version hash (changes when files are modified, triggers soft reload)
const crypto = require('crypto'); const crypto = require('crypto');