diff --git a/frontend/js/app.js b/frontend/js/app.js
index 88881c5..4b17160 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -20,6 +20,7 @@ import * as designer from './views/designer.js';
import * as playlists from './views/playlists.js';
import { applyBranding } from './branding.js';
import { t } from './i18n.js';
+import { isPlatformAdmin } from './utils.js';
const app = document.getElementById('app');
const sidebar = document.querySelector('.sidebar');
@@ -227,9 +228,9 @@ function updateSidebarUser() {
const user = getCurrentUser();
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');
- if (adminNav) adminNav.style.display = user.role === 'superadmin' ? '' : 'none';
+ if (adminNav) adminNav.style.display = isPlatformAdmin(user) ? '' : 'none';
let userEl = document.getElementById('sidebarUser');
if (!userEl) {
diff --git a/frontend/js/utils.js b/frontend/js/utils.js
index fb88256..cee0fb5 100644
--- a/frontend/js/utils.js
+++ b/frontend/js/utils.js
@@ -3,3 +3,11 @@ export function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
}
+
+// 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'));
+}
diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js
index 3e695d6..307eac2 100644
--- a/frontend/js/views/admin.js
+++ b/frontend/js/views/admin.js
@@ -1,6 +1,6 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
-import { esc } from '../utils.js';
+import { esc, isPlatformAdmin } from '../utils.js';
import { t } from '../i18n.js';
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) {
const user = JSON.parse(localStorage.getItem('user') || '{}');
- if (user.role !== 'superadmin') {
+ if (!isPlatformAdmin(user)) {
container.innerHTML = `
${t('admin.access_denied')}
${t('admin.access_denied_desc')}
`;
return;
}
diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js
index fea70c5..0dc2e57 100644
--- a/frontend/js/views/settings.js
+++ b/frontend/js/views/settings.js
@@ -1,7 +1,7 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.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';
export async function render(container) {
@@ -11,7 +11,7 @@ export async function render(container) {
let user;
try { user = await api.getMe(); localStorage.setItem('user', JSON.stringify(user)); }
catch { user = JSON.parse(localStorage.getItem('user') || '{}'); }
- const isSuperAdmin = user.role === 'superadmin';
+ const isSuperAdmin = isPlatformAdmin(user);
const isAdmin = user.role === 'admin' || isSuperAdmin;
container.innerHTML = `
@@ -327,11 +327,11 @@ async function loadWhiteLabel() {
const token = localStorage.getItem('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().
const user = JSON.parse(localStorage.getItem('user') || '{}');
const section = document.getElementById('whiteLabelSection');
- if (section && user.plan_id !== 'enterprise' && user.role !== 'superadmin') {
+ if (section && user.plan_id !== 'enterprise' && !isPlatformAdmin(user)) {
section.innerHTML = `
${t('settings.white_label')}
diff --git a/server/lib/permissions.js b/server/lib/permissions.js
new file mode 100644
index 0000000..3b4cc36
--- /dev/null
+++ b/server/lib/permissions.js
@@ -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,
+};
diff --git a/server/lib/tenancy.js b/server/lib/tenancy.js
new file mode 100644
index 0000000..62574f6
--- /dev/null
+++ b/server/lib/tenancy.js
@@ -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,
+};
diff --git a/server/middleware/auth.js b/server/middleware/auth.js
index a5404c2..e6e9477 100644
--- a/server/middleware/auth.js
+++ b/server/middleware/auth.js
@@ -2,9 +2,14 @@ const jwt = require('jsonwebtoken');
const config = require('../config');
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(
- { 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,
{ algorithm: 'HS256', expiresIn: config.jwtExpiry }
);
@@ -40,11 +45,14 @@ function requireAuth(req, res, next) {
const decoded = verifyToken(token);
if (decoded.recovery) {
req.user = recoveryUser(decoded);
+ req.jwtWorkspaceId = null;
return next();
}
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' });
req.user = user;
+ // Tenancy middleware reads this on the resolver step.
+ req.jwtWorkspaceId = decoded.current_workspace_id || null;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
@@ -61,6 +69,7 @@ function optionalAuth(req, res, next) {
req.user = decoded.recovery
? recoveryUser(decoded)
: 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) {
// Token invalid, continue without user
}
@@ -68,20 +77,30 @@ function optionalAuth(req, res, 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) {
- 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' });
}
next();
}
-// Require superadmin role (platform owner only)
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' });
}
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 };
diff --git a/server/routes/activity.js b/server/routes/activity.js
index 7590af0..f91d325 100644
--- a/server/routes/activity.js
+++ b/server/routes/activity.js
@@ -1,11 +1,12 @@
const express = require('express');
const router = express.Router();
const { getActivity, pruneActivityLog } = require('../services/activity');
+const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Get activity log
router.get('/', (req, res) => {
const { device_id, limit, offset } = req.query;
- const isAdmin = req.user.role === 'superadmin';
+ const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const activity = getActivity({
userId: isAdmin ? null : req.user.id,
@@ -19,7 +20,7 @@ router.get('/', (req, res) => {
// Prune old logs (admin only)
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();
res.json({ success: true });
});
diff --git a/server/routes/assignments.js b/server/routes/assignments.js
index 71a339f..00005d7 100644
--- a/server/routes/assignments.js
+++ b/server/routes/assignments.js
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
+const { ELEVATED_ROLES } = require('../middleware/auth');
// Mark playlist as draft (called after any item mutation)
function markDraft(playlistId) {
@@ -12,7 +13,7 @@ function markDraft(playlistId) {
function checkDeviceAccess(req, res, paramName = 'deviceId') {
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 (!['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;
}
return true;
@@ -65,7 +66,7 @@ router.post('/device/:deviceId', (req, res) => {
if (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 (!['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' });
}
}
@@ -105,7 +106,7 @@ router.post('/device/:deviceId', (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);
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' });
}
@@ -131,7 +132,7 @@ router.put('/: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);
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' });
}
diff --git a/server/routes/auth.js b/server/routes/auth.js
index 794dfcf..2bbfa70 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -5,10 +5,48 @@ const https = require('https');
const { v4: uuidv4 } = require('uuid');
const { OAuth2Client } = require('google-auth-library');
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 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) {
try {
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 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 role = userCount === 0 ? 'superadmin' : 'user';
+ const role = userCount === 0 ? 'platform_admin' : 'user';
const isFirstUser = userCount === 0;
const plan = (isFirstUser && config.selfHosted) ? 'enterprise' : 'pro'; // Start on Pro trial
const trialStarted = isFirstUser && config.selfHosted ? null : Math.floor(Date.now() / 1000);
@@ -61,10 +100,11 @@ router.post('/register', (req, res) => {
VALUES (?, ?, ?, ?, 'local', ?, ?, ?, ?)
`).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 token = generateToken(user);
+ 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 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
@@ -84,9 +124,10 @@ router.post('/login', (req, res) => {
}
logSuccessfulLogin(user.id, email, getClientIp(req));
- const token = generateToken(user);
+ const workspaceId = ensureDefaultOrgForUser(user);
+ const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user;
- res.json({ token, user: safeUser });
+ res.json({ token, user: safeUser, current_workspace_id: workspaceId });
});
// ==================== Google OAuth ====================
@@ -111,7 +152,7 @@ router.post('/google', async (req, res) => {
}
const id = uuidv4();
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 plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro';
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);
}
- const token = generateToken(user);
+ const workspaceId = ensureDefaultOrgForUser(user);
+ const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user;
- res.json({ token, user: safeUser });
+ res.json({ token, user: safeUser, current_workspace_id: workspaceId });
} catch (err) {
console.error('Google auth error:', err);
res.status(401).json({ error: 'Google authentication failed' });
@@ -189,7 +231,7 @@ router.post('/microsoft', async (req, res) => {
}
const id = uuidv4();
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 plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro';
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);
}
- const token = generateToken(user);
+ const workspaceId = ensureDefaultOrgForUser(user);
+ const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user;
- res.json({ token, user: safeUser });
+ res.json({ token, user: safeUser, current_workspace_id: workspaceId });
} catch (err) {
console.error('Microsoft auth error:', err);
res.status(401).json({ error: 'Microsoft authentication failed' });
@@ -238,9 +281,60 @@ function getMicrosoftProfile(accessToken) {
// ==================== User Management ====================
-// Get current user
-router.get('/me', requireAuth, (req, res) => {
- res.json(req.user);
+// Get current user + tenancy context.
+// Phase 2.1: response shape extended with current_workspace, current_organization,
+// 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
@@ -270,9 +364,9 @@ router.put('/me', requireAuth, (req, res) => {
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) => {
- 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();
res.json(users);
} 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` });
}
- 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
- // 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).
if (target.role !== 'user') {
return res.status(403).json({ error: 'Admins can only reset passwords for regular users' });
diff --git a/server/routes/content.js b/server/routes/content.js
index 303a65b..639ecc6 100644
--- a/server/routes/content.js
+++ b/server/routes/content.js
@@ -8,6 +8,7 @@ const upload = require('../middleware/upload');
const config = require('../config');
const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription');
const { sanitizeString } = require('../middleware/sanitize');
+const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Multer captures file.originalname directly from the multipart filename header,
// 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).
// folder_id filter: omit for everything; "root" or "" for root-level only; for that folder.
router.get('/', (req, res) => {
- const isAdmin = req.user.role === 'superadmin';
+ const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const folder = req.query.folder;
const folderId = req.query.folder_id;
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
router.get('/folders', (req, res) => {
- const isAdmin = req.user.role === 'superadmin';
+ const isAdmin = PLATFORM_ROLES.includes(req.user.role);
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`
).all(...(isAdmin ? [] : [req.user.id]));
@@ -218,7 +219,7 @@ function extractYoutubeId(url) {
function checkContentAccess(req, res) {
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 (!['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;
}
return content;
@@ -257,7 +258,7 @@ router.put('/:id', (req, res) => {
if (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' });
- const isSuperadmin = req.user.role === 'superadmin';
+ const isSuperadmin = PLATFORM_ROLES.includes(req.user.role);
if (!isSuperadmin && target.user_id !== req.user.id) {
return res.status(403).json({ error: 'Cannot move content to another user\'s folder' });
}
diff --git a/server/routes/device-groups.js b/server/routes/device-groups.js
index 6f38922..4053c29 100644
--- a/server/routes/device-groups.js
+++ b/server/routes/device-groups.js
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
+const { ELEVATED_ROLES } = require('../middleware/auth');
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
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' });
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 (!['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' });
}
try {
diff --git a/server/routes/devices.js b/server/routes/devices.js
index 85bb090..46e66c0 100644
--- a/server/routes/devices.js
+++ b/server/routes/devices.js
@@ -1,10 +1,11 @@
const express = require('express');
const router = express.Router();
const { db } = require('../db/database');
+const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// List devices for current user (admins see all)
router.get('/', (req, res) => {
- const isAdmin = req.user.role === 'superadmin';
+ const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const devices = db.prepare(`
SELECT d.*,
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)
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' });
}
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);
if (!device) return res.status(404).json({ error: 'Device not found' });
// 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;
if (!teamAccess) return res.status(403).json({ error: 'Access denied' });
device._teamRole = teamAccess.role; // Pass team role for frontend to check
@@ -109,7 +110,7 @@ router.get('/:id', (req, res) => {
function checkDeviceOwnership(req, res) {
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 (!['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
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') {
diff --git a/server/routes/folders.js b/server/routes/folders.js
index fe57ecc..9f23e0f 100644
--- a/server/routes/folders.js
+++ b/server/routes/folders.js
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
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;
@@ -22,7 +23,7 @@ function ownedFolder(req, folderId) {
if (!UUID_RE.test(folderId)) return null;
const row = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folderId);
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;
return row;
}
@@ -30,7 +31,7 @@ function ownedFolder(req, folderId) {
// List folders for the current user. Returns the full tree as a flat array;
// the client builds the hierarchy from parent_id.
router.get('/', (req, res) => {
- const isAdmin = req.user.role === 'superadmin';
+ const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const rows = isAdmin
? 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);
@@ -43,7 +44,7 @@ router.post('/', (req, res) => {
if (!name) return res.status(400).json({ error: 'name is required' });
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) {
const { count } = db.prepare('SELECT COUNT(*) AS count FROM content_folders WHERE user_id = ?').get(req.user.id);
if (count >= MAX_FOLDERS_PER_USER) {
diff --git a/server/routes/kiosk.js b/server/routes/kiosk.js
index 86e05b6..6c69619 100644
--- a/server/routes/kiosk.js
+++ b/server/routes/kiosk.js
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
+const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Escape HTML to prevent XSS
function escapeHtml(str) {
@@ -24,7 +25,7 @@ function safeNumber(val, fallback) {
// List kiosk pages
router.get('/', (req, res) => {
- const isAdmin = req.user.role === 'superadmin';
+ const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const pages = db.prepare(
`SELECT * FROM kiosk_pages ${isAdmin ? '' : 'WHERE user_id = ?'} ORDER BY created_at DESC`
).all(...(isAdmin ? [] : [req.user.id]));
@@ -35,7 +36,7 @@ router.get('/', (req, res) => {
function checkKioskAccess(req, res) {
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 (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;
}
return page;
diff --git a/server/routes/layouts.js b/server/routes/layouts.js
index d3dacd8..a5978d7 100644
--- a/server/routes/layouts.js
+++ b/server/routes/layouts.js
@@ -2,11 +2,12 @@ const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
+const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// List layouts (user's + templates)
router.get('/', (req, res) => {
const showTemplates = req.query.templates === 'true';
- const isAdmin = req.user.role === 'superadmin';
+ const isAdmin = PLATFORM_ROLES.includes(req.user.role);
let layouts;
if (showTemplates) {
@@ -28,7 +29,7 @@ router.get('/', (req, res) => {
function checkLayoutAccess(req, res) {
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.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;
}
return layout;
@@ -74,7 +75,7 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => {
const layout = checkLayoutAccess(req, res);
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;
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) => {
const layout = checkLayoutAccess(req, res);
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);
res.json({ success: true });
@@ -182,7 +183,7 @@ router.post('/:id/duplicate', (req, res) => {
router.put('/device/:deviceId', (req, res) => {
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 (!['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;
db.prepare("UPDATE devices SET layout_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(layout_id || null, req.params.deviceId);
diff --git a/server/routes/playlists.js b/server/routes/playlists.js
index 00bf847..96ce968 100644
--- a/server/routes/playlists.js
+++ b/server/routes/playlists.js
@@ -4,6 +4,7 @@ const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const config = require('../config');
+const { ELEVATED_ROLES } = require('../middleware/auth');
// Re-probe video duration with ffprobe if content.duration_sec is missing
async function probeAndUpdateDuration(content) {
@@ -239,7 +240,7 @@ router.post('/:id/items', requirePlaylistOwnership, async (req, res) => {
if (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 (!['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' });
}
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);
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' });
}
diff --git a/server/routes/reports.js b/server/routes/reports.js
index 809535b..9e31579 100644
--- a/server/routes/reports.js
+++ b/server/routes/reports.js
@@ -1,10 +1,11 @@
const express = require('express');
const router = express.Router();
const { db } = require('../db/database');
+const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Helper: scope reports to user's devices
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] };
}
@@ -38,7 +39,7 @@ router.get('/summary', (req, res) => {
let deviceFilter = '';
const params = [startEpoch, endEpoch];
// 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 = ?)';
params.push(req.user.id);
}
diff --git a/server/routes/schedules.js b/server/routes/schedules.js
index 22c3cff..c2a043f 100644
--- a/server/routes/schedules.js
+++ b/server/routes/schedules.js
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
+const { ELEVATED_ROLES } = require('../middleware/auth');
// Helper: build the expanded schedule query for a device (device-level + group-level)
function getDeviceSchedulesQuery() {
@@ -57,7 +58,7 @@ router.get('/', (req, res) => {
router.get('/device/:deviceId', (req, res) => {
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 (!['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);
res.json(schedules);
@@ -71,7 +72,7 @@ router.get('/week', (req, res) => {
// Verify device ownership
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 (!['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();
weekStart.setHours(0, 0, 0, 0);
@@ -111,14 +112,14 @@ router.post('/', (req, res) => {
if (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 (!['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' });
}
}
if (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 (!['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' });
}
}
@@ -140,7 +141,7 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => {
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id);
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 changing target, enforce mutual exclusion
@@ -206,7 +207,7 @@ router.put('/:id', (req, res) => {
router.delete('/:id', (req, res) => {
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 (!['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);
res.json({ success: true });
});
diff --git a/server/routes/status.js b/server/routes/status.js
index 03e39bb..192e8e3 100644
--- a/server/routes/status.js
+++ b/server/routes/status.js
@@ -5,6 +5,7 @@ const os = require('os');
const path = require('path');
const fs = require('fs');
const config = require('../config');
+const { PLATFORM_ROLES } = require('../middleware/auth');
// Public status page
router.get('/', (req, res) => {
@@ -45,7 +46,7 @@ router.get('/backup', (req, res) => {
const config = require('../config');
const decoded = jwt.verify(token, config.jwtSecret);
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 {
return res.status(401).json({ error: 'Invalid token' });
}
diff --git a/server/routes/teams.js b/server/routes/teams.js
index 5783e7d..8e39ef1 100644
--- a/server/routes/teams.js
+++ b/server/routes/teams.js
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
+const { ELEVATED_ROLES } = require('../middleware/auth');
// List user's teams
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 = ?')
.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(`
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) => {
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.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) {
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) => {
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.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);
res.json({ success: true });
@@ -127,7 +128,7 @@ router.put('/:id/members/:userId', (req, res) => {
// 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);
- 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' });
}
@@ -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' });
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' });
}
@@ -156,7 +157,7 @@ router.delete('/:id/members/:userId', (req, res) => {
function checkTeamAccess(req, res) {
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND 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' });
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);
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) {
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;
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' });
- const isAdmin = ['admin', 'superadmin'].includes(req.user.role);
+ const isAdmin = ELEVATED_ROLES.includes(req.user.role);
if (!isAdmin && device.user_id !== req.user.id) {
return res.status(403).json({ error: 'You do not own this device' });
}
diff --git a/server/routes/video-walls.js b/server/routes/video-walls.js
index 2637b4c..19057d4 100644
--- a/server/routes/video-walls.js
+++ b/server/routes/video-walls.js
@@ -2,13 +2,14 @@ const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
+const { PLATFORM_ROLES } = require('../middleware/auth');
// Visibility model (matches widgets/users):
// superadmin: all walls
// admin: own + walls owned by members of teams this admin owns
// user: own only
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();
}
if (user.role === 'admin') {
@@ -28,7 +29,7 @@ function listVisibleWalls(user) {
}
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 (user.role === 'admin') {
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
// wall and silently take over the playlist + wall_id on those rows.
// 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';
for (const d of devices) {
const dev = db.prepare('SELECT user_id, team_id FROM devices WHERE id = ?').get(d.device_id);
diff --git a/server/routes/widgets.js b/server/routes/widgets.js
index e7cb4c6..188abc7 100644
--- a/server/routes/widgets.js
+++ b/server/routes/widgets.js
@@ -5,6 +5,7 @@ const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
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,
// 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)
// user: own + public (null owner)
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();
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);
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)
- 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;
}
return widget;
diff --git a/server/server.js b/server/server.js
index db809d0..5272a9a 100644
--- a/server/server.js
+++ b/server/server.js
@@ -294,27 +294,32 @@ app.get('/api/content/:id/thumbnail', (req, res) => {
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');
-app.use('/api/devices', requireAuth, require('./routes/devices'));
-app.use('/api/content', requireAuth, require('./routes/content'));
-app.use('/api/folders', requireAuth, require('./routes/folders'));
-app.use('/api/assignments', requireAuth, require('./routes/assignments'));
-app.use('/api/provision', requireAuth, require('./routes/provisioning'));
-app.use('/api/layouts', requireAuth, require('./routes/layouts'));
+const { resolveTenancy } = require('./lib/tenancy');
+app.use('/api/devices', requireAuth, resolveTenancy, require('./routes/devices'));
+app.use('/api/content', requireAuth, resolveTenancy, require('./routes/content'));
+app.use('/api/folders', requireAuth, resolveTenancy, require('./routes/folders'));
+app.use('/api/assignments', requireAuth, resolveTenancy, require('./routes/assignments'));
+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)
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
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/schedules', requireAuth, require('./routes/schedules'));
-app.use('/api/walls', requireAuth, require('./routes/video-walls'));
-app.use('/api/teams', requireAuth, require('./routes/teams'));
-app.use('/api/reports', requireAuth, require('./routes/reports'));
-app.use('/api/groups', requireAuth, require('./routes/device-groups'));
-app.use('/api/playlists', requireAuth, require('./routes/playlists'));
-app.use('/api/activity', requireAuth, require('./routes/activity'));
-app.use('/api/white-label', requireAuth, require('./routes/white-label'));
+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, resolveTenancy, require('./routes/schedules'));
+app.use('/api/walls', requireAuth, resolveTenancy, require('./routes/video-walls'));
+app.use('/api/teams', requireAuth, resolveTenancy, require('./routes/teams'));
+app.use('/api/reports', requireAuth, resolveTenancy, require('./routes/reports'));
+app.use('/api/groups', requireAuth, resolveTenancy, require('./routes/device-groups'));
+app.use('/api/playlists', requireAuth, resolveTenancy, require('./routes/playlists'));
+app.use('/api/activity', requireAuth, resolveTenancy, require('./routes/activity'));
+app.use('/api/white-label', requireAuth, resolveTenancy, require('./routes/white-label'));
// Kiosk render is public (accessed by devices), CRUD is protected
app.get('/api/kiosk/:id/render', (req, res, next) => {
// 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) => {
if (req._skipAuth) return next();
requireAuth(req, res, next);
-}, require('./routes/kiosk'));
+}, resolveTenancy, require('./routes/kiosk'));
// Frontend version hash (changes when files are modified, triggers soft reload)
const crypto = require('crypto');