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');