mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
feat(admin): Workspace column + inline move/assign on the Users page
Adds a "Workspace" column (after Plan) to the platform Users admin table so a platform_admin can see and reassign a user's workspace inline, alongside the Role/Plan dropdowns. Single-workspace move/assign model. Backend: - GET /api/auth/users (platform branch): one aggregate query adds workspace_count and, for exactly-one membership, the workspace id/name + org name (no N+1). - PUT /api/admin/users/:id/workspace (requirePlatformAdmin - operator excluded): move (1 membership) or assign (0) into the chosen workspace, default role workspace_viewer, in a transaction; no-op if already there; REFUSES (400) a user with >1 membership (manage in the members view). logActivity admin_set_user_workspace. Frontend (admin.js): - Editable <select> only for a 'user' with 0/1 membership; multi-membership -> read-only "N workspaces", platform staff -> read-only "Platform (all)". - Options grouped by org via <optgroup>, built ONCE from /me's accessible_workspaces (same source as the Add User picker) and reused per row. - Picking "Unassigned" or the same workspace is a no-op so a stray pick can't strip a membership. Success -> toast + refresh. EN i18n only. Tests: 4 added (single-membership move 200 + changed, zero-membership assign 200, multi-membership 400 refused, non-platform-admin/operator 403). npm test 16/16. Verified headless: column renders, selected value correct, "Platform (all)" for staff, and a dropdown move persisted (throwaway user, cleaned up). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
65691e26da
commit
7615eabdd5
|
|
@ -794,7 +794,12 @@ export default {
|
|||
'admin.col.last_login': 'Last Login',
|
||||
'admin.col.role': 'Role',
|
||||
'admin.col.plan': 'Plan',
|
||||
'admin.col.workspace': 'Workspace',
|
||||
'admin.col.actions': 'Actions',
|
||||
'admin.workspace.unassigned': 'Unassigned',
|
||||
'admin.workspace.multi': '{n} workspaces',
|
||||
'admin.workspace.multi_hint': 'Belongs to multiple workspaces - manage in the workspace members view',
|
||||
'admin.workspace.platform_all': 'Platform (all)',
|
||||
'admin.col.devices': 'Devices',
|
||||
'admin.col.storage': 'Storage',
|
||||
'admin.col.monthly': 'Monthly',
|
||||
|
|
@ -818,6 +823,7 @@ export default {
|
|||
'admin.server_status': 'Server Status',
|
||||
'admin.toast.role_updated': 'Role updated',
|
||||
'admin.toast.plan_updated': 'Plan updated',
|
||||
'admin.toast.workspace_updated': 'Workspace updated',
|
||||
'admin.toast.user_removed': 'User removed',
|
||||
'admin.reset_password': 'Reset Password',
|
||||
'admin.prompt_reset_password': 'Enter a new password for {email} (minimum 8 characters):',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,47 @@ const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opt
|
|||
// were normalized away. #13 adds 'platform_operator' (cross-org staff).
|
||||
const PLATFORM_ROLE_OPTIONS = ['user', 'platform_operator', 'platform_admin'];
|
||||
|
||||
// Platform staff have cross-org access (no single workspace), so the Workspace
|
||||
// column shows read-only "Platform (all)" for them. Note utils.isPlatformAdmin
|
||||
// only covers admin/superadmin; operators are staff here too.
|
||||
function isPlatformStaffRole(role) {
|
||||
return role === 'platform_admin' || role === 'superadmin' || role === 'platform_operator';
|
||||
}
|
||||
|
||||
// Build the org-grouped workspace <option> list ONCE (reused for every editable
|
||||
// row). Source is /me's accessible_workspaces (already ORDER BY org, name), same
|
||||
// as the Add User picker. Leading blank = "Unassigned"; selecting it is a no-op.
|
||||
function buildWorkspaceOptions(list) {
|
||||
let html = `<option value="">${esc(t('admin.workspace.unassigned'))}</option>`;
|
||||
let currentOrg = null;
|
||||
for (const w of list) {
|
||||
const org = w.organization_name || '—';
|
||||
if (org !== currentOrg) {
|
||||
if (currentOrg !== null) html += '</optgroup>';
|
||||
html += `<optgroup label="${esc(org)}">`;
|
||||
currentOrg = org;
|
||||
}
|
||||
html += `<option value="${esc(w.id)}">${esc(w.name)}</option>`;
|
||||
}
|
||||
if (currentOrg !== null) html += '</optgroup>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// Workspace cell for one user row. Editable <select> only for a 'user' with 0 or
|
||||
// 1 membership; multi-membership users and platform staff render read-only.
|
||||
function workspaceCell(u, optionsHtml) {
|
||||
if (isPlatformStaffRole(u.role)) {
|
||||
return `<td style="padding:8px"><span style="color:var(--text-muted);font-size:12px">${t('admin.workspace.platform_all')}</span></td>`;
|
||||
}
|
||||
const count = u.workspace_count || 0;
|
||||
if (count > 1) {
|
||||
return `<td style="padding:8px"><span style="color:var(--text-muted);font-size:12px" title="${esc(t('admin.workspace.multi_hint'))}">${t('admin.workspace.multi', { n: count })}</span></td>`;
|
||||
}
|
||||
return `<td style="padding:8px">
|
||||
<select class="input" style="max-width:180px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-ws-user="${esc(u.id)}" data-current="${esc(u.workspace_id || '')}">${optionsHtml}</select>
|
||||
</td>`;
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
if (!isPlatformAdmin(user)) {
|
||||
|
|
@ -69,8 +110,14 @@ export async function render(container) {
|
|||
async function loadUsers() {
|
||||
const el = document.getElementById('allUsersTable');
|
||||
try {
|
||||
const [users, plans] = await Promise.all([API('/auth/users'), fetch('/api/subscription/plans').then(r => r.json())]);
|
||||
const [users, plans, me] = await Promise.all([
|
||||
API('/auth/users'),
|
||||
fetch('/api/subscription/plans').then(r => r.json()),
|
||||
api.getMe().catch(() => ({})), // workspace-picker source (same as Add User modal)
|
||||
]);
|
||||
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
// Build the org-grouped <optgroup> workspace options ONCE, reuse per row.
|
||||
const wsOptionsHtml = buildWorkspaceOptions(Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces : []);
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="table-wrap">
|
||||
|
|
@ -81,6 +128,7 @@ async function loadUsers() {
|
|||
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.last_login')}</th>
|
||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.role')}</th>
|
||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.plan')}</th>
|
||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.workspace')}</th>
|
||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.actions')}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
|
|
@ -99,6 +147,7 @@ async function loadUsers() {
|
|||
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
${workspaceCell(u, wsOptionsHtml)}
|
||||
<td style="padding:8px;white-space:nowrap">
|
||||
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
|
||||
${!isPlatformAdmin(u) ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">${t('admin.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('admin.owner')}</span>`}
|
||||
|
|
@ -129,6 +178,25 @@ async function loadUsers() {
|
|||
};
|
||||
});
|
||||
|
||||
// Workspace move/assign (editable rows only: a 'user' with 0 or 1 membership).
|
||||
// Set the current selection per row (the shared options string carries no
|
||||
// per-row `selected`), then move/assign on change. Picking "Unassigned" or
|
||||
// the same workspace is a no-op so a stray pick can't strip a membership.
|
||||
el.querySelectorAll('[data-ws-user]').forEach(select => {
|
||||
select.value = select.dataset.current || '';
|
||||
select.onchange = async () => {
|
||||
const wsId = select.value;
|
||||
const current = select.dataset.current || '';
|
||||
if (!wsId || wsId === current) { select.value = current; return; }
|
||||
try {
|
||||
const r = await API(`/admin/users/${select.dataset.wsUser}/workspace`, { method: 'PUT', body: JSON.stringify({ workspaceId: wsId }) });
|
||||
if (r && r.error) { showToast(r.error, 'error'); loadUsers(); return; }
|
||||
showToast(t('admin.toast.workspace_updated'), 'success');
|
||||
loadUsers();
|
||||
} catch (err) { showToast(err.message, 'error'); loadUsers(); }
|
||||
};
|
||||
});
|
||||
|
||||
// Reset password handlers
|
||||
el.querySelectorAll('[data-reset-pw-user]').forEach(btn => {
|
||||
btn.onclick = async () => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const bcrypt = require('bcryptjs');
|
|||
const { v4: uuidv4 } = require('uuid');
|
||||
const { db } = require('../db/database');
|
||||
const { canAdminWorkspace } = require('../lib/permissions');
|
||||
const { requirePlatformAdmin } = require('../middleware/auth');
|
||||
const { logActivity, getClientIp } = require('../services/activity');
|
||||
|
||||
// Admin-provisioned user creation (#10). Operates on a target workspace
|
||||
|
|
@ -99,4 +100,51 @@ router.post('/users', (req, res) => {
|
|||
res.status(201).json({ ...created, workspace_id: ws.id, workspace_role: role });
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/workspace - move/assign a SINGLE-workspace user to a
|
||||
// different workspace (platform Users admin page). Platform-admin only: this is
|
||||
// a cross-org, platform-level action (requirePlatformAdmin excludes
|
||||
// platform_operator, mirroring the page gating).
|
||||
//
|
||||
// Single-workspace model: refuses (400) a user who belongs to >1 workspace -
|
||||
// a single pick must never silently clobber multiple memberships; those are
|
||||
// managed in the workspace members view. Mirrors the frontend guard.
|
||||
router.put('/users/:id/workspace', requirePlatformAdmin, (req, res) => {
|
||||
const workspaceId = String(req.body?.workspaceId || '').trim();
|
||||
if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' });
|
||||
|
||||
const target = db.prepare('SELECT id, email FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const memberships = db.prepare('SELECT workspace_id FROM workspace_members WHERE user_id = ?').all(target.id);
|
||||
if (memberships.length > 1) {
|
||||
return res.status(400).json({ error: 'User belongs to multiple workspaces - manage in the workspace members view' });
|
||||
}
|
||||
|
||||
const ws = db.prepare('SELECT id, name, organization_id FROM workspaces WHERE id = ?').get(workspaceId);
|
||||
if (!ws) return res.status(404).json({ error: 'Workspace not found' });
|
||||
|
||||
const org = db.prepare('SELECT name FROM organizations WHERE id = ?').get(ws.organization_id);
|
||||
|
||||
// No-op if the chosen workspace is already their sole membership (preserve role).
|
||||
if (memberships.length === 1 && memberships[0].workspace_id === ws.id) {
|
||||
const cur = db.prepare('SELECT role FROM workspace_members WHERE user_id = ? AND workspace_id = ?').get(target.id, ws.id);
|
||||
return res.json({ user_id: target.id, workspace_id: ws.id, workspace_name: ws.name, organization_name: org?.name || null, role: cur ? cur.role : 'workspace_viewer', unchanged: true });
|
||||
}
|
||||
|
||||
req.workspaceId = ws.id; // audit attribution
|
||||
// Move (drop the existing single membership) or assign (none to drop), then
|
||||
// add the chosen one at the default role. Guarded above to <=1 membership, so
|
||||
// the DELETE removes at most one row.
|
||||
const txn = db.transaction(() => {
|
||||
db.prepare('DELETE FROM workspace_members WHERE user_id = ?').run(target.id);
|
||||
db.prepare('INSERT INTO workspace_members (workspace_id, user_id, role, invited_by) VALUES (?, ?, ?, ?)')
|
||||
.run(ws.id, target.id, 'workspace_viewer', req.user.id);
|
||||
});
|
||||
txn();
|
||||
|
||||
logActivity(req.user.id, 'admin_set_user_workspace', `target: ${target.email}, workspace: ${ws.id}`, null, getClientIp(req), ws.id);
|
||||
|
||||
res.json({ user_id: target.id, workspace_id: ws.id, workspace_name: ws.name, organization_name: org?.name || null, role: 'workspace_viewer' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -453,7 +453,24 @@ router.put('/me', requireAuth, (req, res) => {
|
|||
// List users - platform admins see all, admins see team members only
|
||||
router.get('/users', requireAuth, requireAdmin, (req, res) => {
|
||||
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();
|
||||
// One aggregate query (no N+1): each user carries workspace_count, and for
|
||||
// an exactly-one membership the single workspace id/name + org name (used by
|
||||
// the admin Users page Workspace column). MAX() over a single grouped row
|
||||
// yields that row's values; the CASE blanks them when count != 1 so we never
|
||||
// surface a single workspace name for a multi-membership user.
|
||||
const users = db.prepare(`
|
||||
SELECT u.id, u.email, u.name, u.role, u.auth_provider, u.avatar_url, u.plan_id, u.created_at, u.last_login,
|
||||
COUNT(wm.workspace_id) AS workspace_count,
|
||||
CASE WHEN COUNT(wm.workspace_id) = 1 THEN MAX(w.id) END AS workspace_id,
|
||||
CASE WHEN COUNT(wm.workspace_id) = 1 THEN MAX(w.name) END AS workspace_name,
|
||||
CASE WHEN COUNT(wm.workspace_id) = 1 THEN MAX(o.name) END AS organization_name
|
||||
FROM users u
|
||||
LEFT JOIN workspace_members wm ON wm.user_id = u.id
|
||||
LEFT JOIN workspaces w ON w.id = wm.workspace_id
|
||||
LEFT JOIN organizations o ON o.id = w.organization_id
|
||||
GROUP BY u.id
|
||||
ORDER BY u.created_at ASC
|
||||
`).all();
|
||||
res.json(users);
|
||||
} else {
|
||||
// Admin sees themselves + users in their teams
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ db.exec(`
|
|||
joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
UNIQUE(workspace_id, user_id)
|
||||
);
|
||||
CREATE TABLE organizations (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE activity_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT,
|
||||
|
|
@ -99,6 +102,7 @@ const adminRouter = require('../routes/admin');
|
|||
const authRouter = require('../routes/auth');
|
||||
|
||||
// --- seed orgs/workspaces/users ---
|
||||
db.prepare("INSERT INTO organizations (id, name) VALUES ('org-a','Org A'),('org-b','Org B')").run();
|
||||
db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-a','org-a','Workspace A')").run();
|
||||
db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-b','org-b','Workspace B')").run();
|
||||
|
||||
|
|
@ -116,6 +120,14 @@ const regular = seedUser({ id: 'u-regular', email: 'regular@test.local', role: '
|
|||
// can't perturb the non-admin/operator tokens used by the deny tests above).
|
||||
seedUser({ id: 'u-role-target', email: 'role-target@test.local', role: 'user' });
|
||||
|
||||
// Workspace move/assign targets (PUT /api/admin/users/:id/workspace).
|
||||
seedUser({ id: 'u-ws-single', email: 'ws-single@test.local', role: 'user' });
|
||||
db.prepare("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('ws-a','u-ws-single','workspace_editor')").run();
|
||||
seedUser({ id: 'u-ws-zero', email: 'ws-zero@test.local', role: 'user' });
|
||||
seedUser({ id: 'u-ws-multi', email: 'ws-multi@test.local', role: 'user' });
|
||||
db.prepare("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('ws-a','u-ws-multi','workspace_viewer')").run();
|
||||
db.prepare("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('ws-b','u-ws-multi','workspace_viewer')").run();
|
||||
|
||||
const tokens = {
|
||||
admin: generateToken(adminUser, null),
|
||||
orgAdminA: generateToken(orgAdminA, 'ws-a'),
|
||||
|
|
@ -238,3 +250,46 @@ test('platform_operator is assignable via PUT /users/:id/role (regression for #1
|
|||
const dbRole = db.prepare('SELECT role FROM users WHERE id = ?').get('u-role-target').role;
|
||||
assert.equal(dbRole, 'platform_operator', 'role actually persisted as platform_operator');
|
||||
});
|
||||
|
||||
// ---- PUT /api/admin/users/:id/workspace (move / assign single workspace) ----
|
||||
function setWorkspace(userId, workspaceId, token) {
|
||||
return fetch(base + `/api/admin/users/${userId}/workspace`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
});
|
||||
}
|
||||
const wsRows = id => db.prepare('SELECT workspace_id, role FROM workspace_members WHERE user_id = ?').all(id);
|
||||
|
||||
test('workspace move: single-membership user moved to another workspace (200, membership changed)', async () => {
|
||||
const res = await setWorkspace('u-ws-single', 'ws-b', tokens.admin);
|
||||
assert.equal(res.status, 200);
|
||||
const rows = wsRows('u-ws-single');
|
||||
assert.equal(rows.length, 1, 'still exactly one membership');
|
||||
assert.equal(rows[0].workspace_id, 'ws-b', 'moved to ws-b');
|
||||
assert.equal(rows[0].role, 'workspace_viewer', 'default role on move');
|
||||
});
|
||||
|
||||
test('workspace assign: zero-membership user assigned a workspace (200)', async () => {
|
||||
const res = await setWorkspace('u-ws-zero', 'ws-a', tokens.admin);
|
||||
assert.equal(res.status, 200);
|
||||
const rows = wsRows('u-ws-zero');
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].workspace_id, 'ws-a');
|
||||
assert.equal(rows[0].role, 'workspace_viewer');
|
||||
});
|
||||
|
||||
test('workspace move REFUSED for a multi-membership user (400, untouched)', async () => {
|
||||
const res = await setWorkspace('u-ws-multi', 'ws-a', tokens.admin);
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(wsRows('u-ws-multi').length, 2, 'both memberships preserved');
|
||||
});
|
||||
|
||||
test('workspace move denied for a non-platform-admin (403)', async () => {
|
||||
const reg = await setWorkspace('u-ws-zero', 'ws-b', tokens.regular);
|
||||
assert.equal(reg.status, 403);
|
||||
// platform_operator is also denied (platform user-mgmt is owner-only)
|
||||
const op = await setWorkspace('u-ws-zero', 'ws-b', tokens.operator);
|
||||
assert.equal(op.status, 403);
|
||||
assert.equal(wsRows('u-ws-zero')[0].workspace_id, 'ws-a', 'unchanged by denied calls');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue