diff --git a/frontend/css/main.css b/frontend/css/main.css index 86429ce..1131c0e 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -90,6 +90,14 @@ body { .workspace-switcher-item .ws-org { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.workspace-switcher-pencil { + flex-shrink: 0; visibility: hidden; + background: none; border: none; padding: 4px; + color: var(--text-muted); cursor: pointer; + border-radius: 4px; transition: all var(--transition); +} +.workspace-switcher-item:hover .workspace-switcher-pencil { visibility: visible; } +.workspace-switcher-pencil:hover { color: var(--accent); background: var(--bg-input); } .nav-links { flex: 1; diff --git a/frontend/js/api.js b/frontend/js/api.js index 778f491..543537f 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -157,6 +157,7 @@ export const api = { getMe: () => request('/auth/me'), updateMe: (data) => request('/auth/me', { method: 'PUT', body: JSON.stringify(data) }), switchWorkspace: (workspaceId) => request('/auth/switch-workspace', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }) }), + renameWorkspace: (id, data) => request(`/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), // Admin - Users getUsers: () => request('/auth/users'), diff --git a/frontend/js/components/workspace-rename-modal.js b/frontend/js/components/workspace-rename-modal.js new file mode 100644 index 0000000..19296b5 --- /dev/null +++ b/frontend/js/components/workspace-rename-modal.js @@ -0,0 +1,82 @@ +import { api } from '../api.js'; + +// Open a rename modal for the given workspace. Uses the existing .modal-overlay +// / .modal / .modal-header / .modal-body / .modal-footer CSS classes. On +// successful save, reloads the page (matches the workspace-switch flow). +export function openWorkspaceRenameModal(workspace) { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + + const nameInput = overlay.querySelector('#renameWsName'); + const slugInput = overlay.querySelector('#renameWsSlug'); + const errorEl = overlay.querySelector('#renameWsError'); + const saveBtn = overlay.querySelector('#renameWsSave'); + nameInput.focus(); + nameInput.select(); + + function close() { overlay.remove(); document.removeEventListener('keydown', onKey); } + function onKey(e) { + if (e.key === 'Escape') close(); + else if (e.key === 'Enter' && (e.target === nameInput || e.target === slugInput)) save(); + } + document.addEventListener('keydown', onKey); + + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + overlay.querySelectorAll('[data-rename-close]').forEach(b => b.addEventListener('click', close)); + + async function save() { + errorEl.style.display = 'none'; + const name = nameInput.value.trim(); + const slug = slugInput.value.trim(); + if (!name) { showError('Name cannot be empty'); return; } + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + try { + await api.renameWorkspace(workspace.id, { name, slug }); + window.location.reload(); + } catch (err) { + saveBtn.disabled = false; + saveBtn.textContent = 'Save'; + showError(err.message || 'Rename failed'); + } + } + function showError(msg) { + errorEl.textContent = msg; + errorEl.style.display = 'block'; + } + + saveBtn.addEventListener('click', save); +} + +function esc(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); +} diff --git a/frontend/js/components/workspace-switcher.js b/frontend/js/components/workspace-switcher.js index cf0367d..968eefd 100644 --- a/frontend/js/components/workspace-switcher.js +++ b/frontend/js/components/workspace-switcher.js @@ -46,6 +46,13 @@ export function renderWorkspaceSwitcher(me) {
${esc(w.name)}
${esc(w.organization_name || '')}
+ ${w.can_admin ? ` + + ` : ''} `).join('')} @@ -59,8 +66,24 @@ export function renderWorkspaceSwitcher(me) { button.setAttribute('aria-expanded', String(opening)); }); + // Pencil click opens the rename modal. Must stopPropagation so the click + // doesn't bubble up to the switcher-item's switch handler. + container.querySelectorAll('.workspace-switcher-pencil').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const wsId = btn.dataset.renameId; + const ws = sorted.find(w => w.id === wsId); + if (!ws) return; + container.classList.remove('open'); + const { openWorkspaceRenameModal } = await import('./workspace-rename-modal.js'); + openWorkspaceRenameModal(ws); + }); + }); + container.querySelectorAll('.workspace-switcher-item').forEach(item => { - item.addEventListener('click', async () => { + item.addEventListener('click', async (e) => { + // Ignore clicks that originated on the pencil (it has its own handler). + if (e.target.closest('.workspace-switcher-pencil')) return; const wsId = item.dataset.workspaceId; if (wsId === currentId) { container.classList.remove('open'); return; } try { diff --git a/server/lib/permissions.js b/server/lib/permissions.js index 3b4cc36..8dc1e8b 100644 --- a/server/lib/permissions.js +++ b/server/lib/permissions.js @@ -93,9 +93,25 @@ function requirePlatformAdmin(req, res, next) { next(); } +// Decoupled "can admin this workspace" predicate. Unlike canAdmin(req) above, +// this takes an explicit (user, workspace) pair instead of reading from req, +// so it works for routes that operate on a target workspace specified by URL +// param (rename, future settings/delete) rather than the caller's currently +// active one. Does its own DB lookups against workspace_members + organization_members. +function canAdminWorkspace(db, user, workspace) { + if (!user || !workspace) return false; + if (user.role === 'platform_admin' || user.role === 'superadmin') return true; + const om = db.prepare('SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?') + .get(workspace.organization_id, user.id); + if (om && (om.role === 'org_owner' || om.role === 'org_admin')) return true; + const wm = db.prepare('SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?') + .get(workspace.id, user.id); + return wm && wm.role === 'workspace_admin'; +} + module.exports = { // boolean predicates - canRead, canWrite, canAdmin, isOrgAdmin, isOrgOwner, + canRead, canWrite, canAdmin, canAdminWorkspace, isOrgAdmin, isOrgOwner, // express middleware requireWorkspace, requireWorkspaceRead, diff --git a/server/routes/auth.js b/server/routes/auth.js index dbbb612..ce04777 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -300,25 +300,41 @@ router.get('/me', requireAuth, resolveTenancy, (req, res) => { // the signed JWT (not user-supplied), so non-admins cannot reach the admin // branch. No cap on the admin list yet - revisit at 50+ workspaces when // dropdown UX without search starts to degrade. + // + // Each accessible_workspaces entry also carries `can_admin: bool` so the + // UI can render admin affordances (rename pencil etc.) only where the + // caller has permission. The server still enforces permission on the + // actual mutation routes regardless of this advisory flag. const isPlatformAdmin = req.user.role === 'platform_admin' || req.user.role === 'superadmin'; const accessible = isPlatformAdmin ? db.prepare(` SELECT w.id, w.name, w.organization_id, o.name AS organization_name, - wm.role AS workspace_role + wm.role AS workspace_role, om.role AS org_role FROM workspaces w JOIN organizations o ON o.id = w.organization_id LEFT JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ? + LEFT JOIN organization_members om ON om.organization_id = w.organization_id AND om.user_id = ? ORDER BY o.name, w.name - `).all(req.user.id) + `).all(req.user.id, req.user.id) : db.prepare(` SELECT w.id, w.name, w.organization_id, o.name AS organization_name, - wm.role AS workspace_role + wm.role AS workspace_role, om.role AS org_role FROM workspace_members wm JOIN workspaces w ON w.id = wm.workspace_id JOIN organizations o ON o.id = w.organization_id + LEFT JOIN organization_members om ON om.organization_id = w.organization_id AND om.user_id = ? WHERE wm.user_id = ? ORDER BY o.name, w.name - `).all(req.user.id); + `).all(req.user.id, req.user.id); + + // Compute can_admin per workspace. Mirrors canAdminWorkspace() in lib/permissions.js + // but uses already-joined org_role to avoid another N+1 query per workspace. + for (const w of accessible) { + w.can_admin = isPlatformAdmin + || w.org_role === 'org_owner' || w.org_role === 'org_admin' + || w.workspace_role === 'workspace_admin'; + delete w.org_role; // internal-only; don't leak to client + } const currentOrg = req.organizationId ? db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(req.organizationId) diff --git a/server/routes/workspaces.js b/server/routes/workspaces.js new file mode 100644 index 0000000..73010af --- /dev/null +++ b/server/routes/workspaces.js @@ -0,0 +1,77 @@ +const express = require('express'); +const router = express.Router(); +const { db } = require('../db/database'); +const { canAdminWorkspace } = require('../lib/permissions'); + +// Workspace management routes. Operates on a target workspace specified by +// URL param, NOT the caller's currently active workspace - so this router +// does NOT use resolveTenancy. Permission is gated via canAdminWorkspace() +// which evaluates against the target workspace, not req.workspaceRole. + +const NAME_MAX = 80; +const SLUG_MAX = 60; +const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +// Rename a workspace. MVP scope: name + slug only. Permission: platform_admin, +// org_owner/admin of the parent org, or workspace_admin of the target ws. +router.patch('/:id', (req, res) => { + const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.params.id); + if (!ws) return res.status(404).json({ error: 'Workspace not found' }); + if (!canAdminWorkspace(db, req.user, ws)) { + return res.status(403).json({ error: 'Admin access required' }); + } + + // Stamp the target workspace_id so activityLogger captures the right + // tenant attribution. This route doesn't use resolveTenancy (operates on + // a URL-param target, not the caller's active workspace), so req.workspaceId + // would otherwise be undefined and the audit row would have NULL workspace. + req.workspaceId = ws.id; + + const updates = []; + const values = []; + + if (req.body.name !== undefined) { + const name = String(req.body.name).trim(); + if (!name) return res.status(400).json({ error: 'Name cannot be empty' }); + if (name.length > NAME_MAX) return res.status(400).json({ error: `Name must be ${NAME_MAX} characters or fewer` }); + updates.push('name = ?'); + values.push(name); + } + + if (req.body.slug !== undefined) { + // Empty string -> NULL (workspace has no slug). Otherwise normalize + + // validate against the URL-safe segment pattern. + const raw = String(req.body.slug || '').trim().toLowerCase(); + if (raw === '') { + updates.push('slug = NULL'); + } else { + if (raw.length > SLUG_MAX) return res.status(400).json({ error: `Slug must be ${SLUG_MAX} characters or fewer` }); + if (!SLUG_RE.test(raw)) { + return res.status(400).json({ error: 'Slug must be lowercase letters, digits, and hyphens (no leading/trailing/double hyphens)' }); + } + updates.push('slug = ?'); + values.push(raw); + } + } + + if (updates.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + updates.push("updated_at = strftime('%s','now')"); + values.push(req.params.id); + + try { + db.prepare(`UPDATE workspaces SET ${updates.join(', ')} WHERE id = ?`).run(...values); + } catch (e) { + if (e.code === 'SQLITE_CONSTRAINT_UNIQUE' || /UNIQUE/i.test(e.message)) { + return res.status(409).json({ error: 'Slug already used in this organization' }); + } + throw e; + } + + const updated = db.prepare('SELECT id, name, slug, organization_id FROM workspaces WHERE id = ?').get(req.params.id); + res.json(updated); +}); + +module.exports = router; diff --git a/server/server.js b/server/server.js index 8b3bb4c..30b53b3 100644 --- a/server/server.js +++ b/server/server.js @@ -312,6 +312,11 @@ const { resolveTenancy } = require('./lib/tenancy'); const { activityLogger } = require('./services/activity'); app.use(activityLogger); +// /api/workspaces: management endpoints that operate on a target workspace +// (URL param), not the caller's currently active one. Hence requireAuth only, +// no resolveTenancy. Permission gated per-handler via canAdminWorkspace(). +app.use('/api/workspaces', requireAuth, require('./routes/workspaces')); + 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')); diff --git a/server/services/activity.js b/server/services/activity.js index b9a9ddd..f9d94fc 100644 --- a/server/services/activity.js +++ b/server/services/activity.js @@ -72,7 +72,7 @@ function activityLogger(req, res, next) { const originalJson = res.json.bind(res); res.json = function(data) { // Only log successful mutations - if (['POST', 'PUT', 'DELETE'].includes(req.method) && res.statusCode < 400) { + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method) && res.statusCode < 400) { const action = `${req.method} ${req.baseUrl || ''}${req.route?.path || req.path}`; const userId = req.user?.id; const deviceId = req.params?.id || req.params?.deviceId || req.body?.device_id;