feat(workspaces): rename via switcher dropdown - new PATCH /api/workspaces/:id route, per-row pencil affordance in switcher (visible only when caller can_admin), small rename modal with name + slug fields, validation (name <=80 chars, slug ^[a-z0-9]+(?:-[a-z0-9]+)*$ <=60 chars, blank slug -> NULL), 409 on per-org slug collision. Permission gating via new canAdminWorkspace(db, user, ws) helper in lib/permissions.js - reused-ready for future Phase 3 admin actions. /me query now joins organization_members to compute can_admin per accessible_workspaces entry. Drive-by fixes surfaced: (1) activityLogger method filter was missing PATCH, added; (2) routes that operate on a target workspace by URL param need to stamp req.workspaceId from the param so activityLogger captures the right tenant attribution - documented in the route. Smoke fixture: switcher-test@local.test is workspace_admin of Studio A and workspace_editor of Field Crew (no org_owner) so the can_admin true/false split is exercised in one login.

This commit is contained in:
ScreenTinker 2026-05-12 11:06:55 -05:00
parent 0c91390e56
commit 56da64d0cd
9 changed files with 235 additions and 7 deletions

View file

@ -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;

View file

@ -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'),

View file

@ -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 = `
<div class="modal">
<div class="modal-header">
<h3>Rename workspace</h3>
<button class="btn-icon" type="button" data-rename-close aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="renameWsName">Name</label>
<input id="renameWsName" type="text" class="input" maxlength="80" value="${esc(workspace.name || '')}" style="width:100%">
</div>
<div class="form-group">
<label for="renameWsSlug">Slug <span style="color:var(--text-muted);font-weight:400">(optional, URL-safe)</span></label>
<input id="renameWsSlug" type="text" class="input" maxlength="60" value="${esc(workspace.slug || '')}" placeholder="e.g. studio-a" style="width:100%">
<div style="color:var(--text-muted);font-size:11px;margin-top:4px">Lowercase letters, digits, hyphens. Must be unique within the organization.</div>
</div>
<div id="renameWsError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-rename-close>Cancel</button>
<button class="btn btn-primary" type="button" id="renameWsSave">Save</button>
</div>
</div>
`;
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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}

View file

@ -46,6 +46,13 @@ export function renderWorkspaceSwitcher(me) {
<div class="ws-name">${esc(w.name)}</div>
<div class="ws-org">${esc(w.organization_name || '')}</div>
</div>
${w.can_admin ? `
<button class="workspace-switcher-pencil" type="button" data-rename-id="${esc(w.id)}" aria-label="Rename workspace" title="Rename">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>
</svg>
</button>
` : ''}
</div>
`).join('')}
</div>
@ -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 {

View file

@ -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,

View file

@ -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)

View file

@ -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;

View file

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

View file

@ -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;