From 797eab7c8d5eecc0ad6fd7a53b07551b90f5df65 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 5 Jun 2026 09:58:46 -0500 Subject: [PATCH] refactor(roles): normalize the platform-role model (#14) The legacy /api/auth/users dropdown could write 'superadmin' and 'admin' role strings that not every code path recognized. Some checks matched only 'platform_admin' (tenancy accessContext/resolveTenancy), so a 'superadmin' user could list orgs but not act-as into them. Normalize to the current two-tier platform model (users.role holds the PLATFORM role only; org/workspace roles live in the membership tables): - Migration (idempotent, exact-string): superadmin -> platform_admin, admin -> user. No-ops on rows already in the current model. - Add isPlatformRole() helper in middleware/auth.js; route the two superadmin-excluding checks in tenancy.js through it so a stray 'superadmin' is never treated as lower-privileged (fixes act-as). - Remove the dead/stricter requirePlatformAdmin in permissions.js (bare === 'platform_admin'); the single guard is the one in middleware/auth.js. - Recovery-token default role admin -> platform_admin so emergency recovery keeps full access once 'admin' no longer implies elevation. - PUT /api/auth/users/:id/role whitelist -> ['user','platform_admin']; self-demote guard retargeted via isPlatformRole. - Frontend: platform user-management dropdown now offers User / Platform admin only; owner-delete guard and settings highlight use isPlatformAdmin. EN i18n: add admin.role.platform_admin. Behaviour is identical under HOSTED_INSTANCE set or unset; the migration only touches exact legacy strings. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/i18n/en.js | 3 +++ frontend/js/views/admin.js | 12 ++++++++---- frontend/js/views/settings.js | 7 +++++-- server/db/database.js | 11 +++++++++++ server/lib/permissions.js | 12 +++++------- server/lib/tenancy.js | 11 ++++++++--- server/middleware/auth.js | 19 +++++++++++++++++-- server/routes/auth.js | 15 +++++++++++---- 8 files changed, 68 insertions(+), 22 deletions(-) diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index ea00789..ad0aa90 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -799,6 +799,9 @@ export default { 'admin.col.monthly': 'Monthly', 'admin.col.yearly': 'Yearly', 'admin.role.user': 'User', + 'admin.role.platform_admin': 'Platform admin', + // Legacy labels kept for back-compat with any not-yet-normalized data; the + // role dropdown no longer offers these (#14 normalization). 'admin.role.admin': 'Admin', 'admin.role.superadmin': 'Superadmin', 'admin.remove': 'Remove', diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js index 307eac2..18fcf29 100644 --- a/frontend/js/views/admin.js +++ b/frontend/js/views/admin.js @@ -6,6 +6,12 @@ import { t } from '../i18n.js'; const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' }); const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json()); +// #14: the platform user-management dropdown manages users.role (the +// PLATFORM-level role) only - workspace/org roles are managed in the members +// views. Options are the current model; the legacy 'admin'/'superadmin' strings +// were normalized away. (#13 adds 'platform_operator' to this list.) +const PLATFORM_ROLE_OPTIONS = ['user', 'platform_admin']; + export async function render(container) { const user = JSON.parse(localStorage.getItem('user') || '{}'); if (!isPlatformAdmin(user)) { @@ -65,9 +71,7 @@ async function loadUsers() { ${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : t('common.never')} @@ -77,7 +81,7 @@ async function loadUsers() { ${u.auth_provider === 'local' && u.id !== currentUser.id ? `` : ''} - ${u.role !== 'superadmin' ? `` : `${t('admin.owner')}`} + ${!isPlatformAdmin(u) ? `` : `${t('admin.owner')}`} `).join('')} diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index 84e370f..28693c2 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -12,7 +12,10 @@ export async function render(container) { try { user = await api.getMe(); localStorage.setItem('user', JSON.stringify(user)); } catch { user = JSON.parse(localStorage.getItem('user') || '{}'); } const isSuperAdmin = isPlatformAdmin(user); - const isAdmin = user.role === 'admin' || isSuperAdmin; + // #14: the legacy 'admin' platform role was normalized away; platform-level + // admin is now just isPlatformAdmin. (Elevated capability otherwise comes from + // org/workspace membership, gated in the members views, not users.role.) + const isAdmin = isSuperAdmin; container.innerHTML = `