diff --git a/server/routes/status.js b/server/routes/status.js index 3a8b9a2..2f1bb3f 100644 --- a/server/routes/status.js +++ b/server/routes/status.js @@ -323,7 +323,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => { const newId = uuid.v4(); idMap.widgets[w.id] = newId; const config = typeof w.config === 'string' ? w.config : JSON.stringify(w.config || {}); - db.prepare(`INSERT INTO widgets (id, user_id, widget_type, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, w.widget_type, w.name, config, w.created_at || Math.floor(Date.now() / 1000)); + db.prepare(`INSERT INTO widgets (id, user_id, workspace_id, widget_type, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, w.widget_type, w.name, config, w.created_at || Math.floor(Date.now() / 1000)); stats.widgets++; } diff --git a/server/routes/widgets.js b/server/routes/widgets.js index 188abc7..fddf89f 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -6,16 +6,23 @@ const { v4: uuidv4 } = require('uuid'); const { db } = require('../db/database'); const appConfig = require('../config'); const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth'); +// Phase 2.2d: workspace-aware access. Same pattern as devices.js / content.js. +const { accessContext } = require('../lib/tenancy'); // 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 -// before the widget is saved (post-save they're reachable via the widget-reference gate). +// scoped to the caller's current workspace. Lets the srcdoc preview iframe show +// logos/bg images before the widget is saved (post-save they're reachable via +// the widget-reference gate). const MAX_INLINE_BYTES = 10 * 1024 * 1024; // 10MB cap — base64 expands ~1.33x const MIME_RE = /^image\/[a-zA-Z0-9.+-]+$/; -function inlineUserContent(html, userId) { +function inlineUserContent(html, workspaceId) { + if (!workspaceId) return html; return html.replace(/\/api\/content\/([a-f0-9-]+)\/(file|thumbnail)/gi, (match, id, kind) => { - const c = db.prepare('SELECT filepath, thumbnail_path, mime_type, user_id FROM content WHERE id = ?').get(id); - if (!c || c.user_id !== userId) return match; + const c = db.prepare('SELECT filepath, thumbnail_path, mime_type, workspace_id FROM content WHERE id = ?').get(id); + // Inline content only when it lives in the caller's workspace, or is a + // platform-template row (workspace_id IS NULL) shared with everyone. + if (!c) return match; + if (c.workspace_id && c.workspace_id !== workspaceId) return match; const filename = kind === 'thumbnail' ? c.thumbnail_path : c.filepath; if (!filename) return match; const mime = kind === 'thumbnail' ? 'image/jpeg' : c.mime_type; @@ -58,71 +65,72 @@ function safeUrl(url) { } catch { return 'about:blank'; } } -// List widgets. -// Visibility model: -// superadmin: all widgets -// admin: own + public (null owner) + widgets owned by members of teams -// this admin owns (matches /auth/users visibility) -// user: own + public (null owner) +// List widgets accessible to the caller's current workspace, plus any +// platform-template rows (workspace_id IS NULL) shared with all workspaces. +// Phase 2.2d: workspace-scoped. Cross-workspace visibility comes from +// switch-workspace, not a special list branch. router.get('/', (req, res) => { - if (PLATFORM_ROLES.includes(req.user.role)) { - const widgets = db.prepare('SELECT * FROM widgets ORDER BY created_at DESC').all(); - return res.json(widgets); - } - if (req.user.role === 'admin') { - const widgets = db.prepare(` - SELECT DISTINCT w.* FROM widgets w - LEFT JOIN team_members tm_target ON w.user_id = tm_target.user_id - LEFT JOIN team_members tm_admin - ON tm_admin.team_id = tm_target.team_id - AND tm_admin.user_id = ? - AND tm_admin.role = 'owner' - WHERE w.user_id = ? - OR w.user_id IS NULL - OR tm_admin.team_id IS NOT NULL - ORDER BY w.created_at DESC - `).all(req.user.id, req.user.id); - return res.json(widgets); - } + if (!req.workspaceId) return res.json([]); const widgets = db.prepare( - 'SELECT * FROM widgets WHERE user_id = ? OR user_id IS NULL ORDER BY created_at DESC' - ).all(req.user.id); + 'SELECT * FROM widgets WHERE (workspace_id = ? OR workspace_id IS NULL) ORDER BY created_at DESC' + ).all(req.workspaceId); res.json(widgets); }); -// Create widget +// Create widget in the caller's current workspace. router.post('/', (req, res) => { + if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating widgets.' }); const { widget_type, name, config } = req.body; if (!widget_type || !name) return res.status(400).json({ error: 'widget_type and name required' }); const id = uuidv4(); - db.prepare('INSERT INTO widgets (id, user_id, widget_type, name, config) VALUES (?, ?, ?, ?, ?)') - .run(id, req.user.id, widget_type, name, JSON.stringify(config || {})); + db.prepare('INSERT INTO widgets (id, user_id, workspace_id, widget_type, name, config) VALUES (?, ?, ?, ?, ?, ?)') + .run(id, req.user.id, req.workspaceId, widget_type, name, JSON.stringify(config || {})); res.status(201).json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(id)); }); -// Helper: check widget ownership -function checkWidgetAccess(req, res) { +// Phase 2.2d: workspace-aware access. Mirrors the device/content pattern. +// Platform-template widgets (workspace_id IS NULL) are readable by anyone +// authenticated and writable only by platform_admin. +function checkWidgetRead(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 && !ELEVATED_ROLES.includes(req.user.role) && widget.user_id && widget.user_id !== req.user.id) { - res.status(403).json({ error: 'Access denied' }); return null; + if (!widget.workspace_id) return widget; + const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(widget.workspace_id); + const ctx = ws && accessContext(req.user.id, req.user.role, ws); + if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; } + return widget; +} + +function checkWidgetWrite(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; } + if (!widget.workspace_id) { + if (!PLATFORM_ROLES.includes(req.user.role)) { + res.status(403).json({ error: 'Platform admin required to modify shared widgets' }); return null; + } + return widget; + } + const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(widget.workspace_id); + const ctx = ws && accessContext(req.user.id, req.user.role, ws); + if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; } + if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') { + res.status(403).json({ error: 'Read-only access' }); return null; } return widget; } // Get widget router.get('/:id', (req, res) => { - const widget = checkWidgetAccess(req, res); + const widget = checkWidgetRead(req, res); if (!widget) return; res.json(widget); }); // Update widget router.put('/:id', (req, res) => { - const widget = checkWidgetAccess(req, res); + const widget = checkWidgetWrite(req, res); if (!widget) return; const { name, config } = req.body; @@ -134,7 +142,7 @@ router.put('/:id', (req, res) => { // Delete widget router.delete('/:id', (req, res) => { - const widget = checkWidgetAccess(req, res); + const widget = checkWidgetWrite(req, res); if (!widget) return; db.prepare('DELETE FROM widgets WHERE id = ?').run(req.params.id); res.json({ success: true }); @@ -170,7 +178,7 @@ router.post('/preview', (req, res) => { if (!widget_type || typeof widget_type !== 'string') return res.status(400).json({ error: 'widget_type required' }); if (!KNOWN_WIDGET_TYPES.has(widget_type)) return res.status(400).json({ error: 'Unknown widget_type' }); let html = renderWidgetHtml(widget_type, config || {}); - if (req.user && req.user.id) html = inlineUserContent(html, req.user.id); + if (req.workspaceId) html = inlineUserContent(html, req.workspaceId); res.setHeader('Content-Type', 'text/html'); res.send(html); }); diff --git a/server/server.js b/server/server.js index 8202cab..b2a55bf 100644 --- a/server/server.js +++ b/server/server.js @@ -273,11 +273,12 @@ app.get('/api/content/:id/file', (req, res) => { if (!content) return res.status(404).json({ error: 'Content not found' }); if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' }); const inPlaylist = db.prepare('SELECT id FROM playlist_items WHERE content_id = ? LIMIT 1').get(req.params.id); - // Scope widget lookup to content owner's widgets only — prevents a user from unlocking - // another user's content by creating their own widget that references the UUID. + // Scope widget lookup to widgets in the content's workspace — prevents a user + // in another workspace from unlocking this content by creating a widget that + // references the UUID. Phase 2.2d: keyed off content.workspace_id (was user_id). // Perf note: LIKE scan on widgets.config is O(n) per request. Fine at current scale // (<100 widgets); revisit with a content_widget_refs join table if this grows. - const inWidget = inPlaylist ? null : db.prepare('SELECT id FROM widgets WHERE user_id = ? AND config LIKE ? LIMIT 1').get(content.user_id, `%/api/content/${req.params.id}/%`); + const inWidget = inPlaylist ? null : db.prepare('SELECT id FROM widgets WHERE workspace_id = ? AND config LIKE ? LIMIT 1').get(content.workspace_id, `%/api/content/${req.params.id}/%`); if (!inPlaylist && !inWidget) return res.status(403).json({ error: 'Content not assigned to any playlist or widget' }); const safePath = path.resolve(config.contentDir, path.basename(content.filepath)); if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' });