diff --git a/frontend/js/api.js b/frontend/js/api.js index 52cec21..8aad3ab 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -174,6 +174,10 @@ export const api = { // Slice 2C - accept a workspace invite by id (post-auth flow) acceptInvite: (inviteId) => request(`/auth/accept-invite/${inviteId}`, { method: 'POST' }), + // Admin-provisioned user creation (#10). data: { email, name, password, + // workspaceId, role, mustChangePassword } + adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }), + // Admin - Users getUsers: () => request('/auth/users'), deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }), diff --git a/frontend/js/app.js b/frontend/js/app.js index 1c60a72..12e2152 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -20,6 +20,8 @@ import * as adminPlayerDebug from './views/admin-player-debug.js'; import * as designer from './views/designer.js'; import * as playlists from './views/playlists.js'; import * as workspaceMembers from './views/workspace-members.js'; +import * as forcePasswordChange from './views/force-password-change.js'; +import * as noWorkspace from './views/no-workspace.js'; import { applyBranding } from './branding.js'; import { t } from './i18n.js'; import { isPlatformAdmin } from './utils.js'; @@ -210,6 +212,18 @@ function getCurrentUser() { } catch { return null; } } +// #12: true when a signed-in user provably has zero accessible workspaces and +// no platform-level reach. Requires accessible_workspaces to be present (only +// /me populates it) - undefined means "not loaded yet", so we DON'T trigger and +// fall through to the normal (workspace-empty-safe) views until /me resolves. +function hasNoAccessibleWorkspace(u) { + return !!u + && Array.isArray(u.accessible_workspaces) + && u.accessible_workspaces.length === 0 + && !u.current_workspace_id + && !isPlatformAdmin(u); +} + // Refresh the cached user from the server. The server reads plan_id fresh // from the DB on every request, but the frontend only wrote `user` into // localStorage at login — so plan/role changes made by an admin weren't @@ -226,6 +240,16 @@ async function refreshCurrentUser() { // the dropdown in sync if a workspace was added/removed in another tab. renderWorkspaceSwitcher(fresh); window.dispatchEvent(new CustomEvent('user-refreshed', { detail: fresh })); + // #12: /me is the first place accessible_workspaces is known. If it resolves + // to zero (org-less user), send them to the empty state now - on a fresh + // load route() may have already rendered the dashboard before /me returned. + // Guard against the login / change-password / already-there screens to avoid + // a redirect loop. + const hash = window.location.hash || '#/'; + if (hasNoAccessibleWorkspace(fresh) + && hash !== '#/no-workspace' && hash !== '#/login' && hash !== '#/change-password') { + window.location.hash = '#/no-workspace'; + } } catch {} } @@ -276,6 +300,56 @@ function route() { } } + // #10: forced first-login password change. An admin-provisioned user carries + // must_change_password until they set their own password. Block every other + // authenticated view and force them to the change-password screen; the server + // clears the flag on a successful PUT /api/auth/me. The screen itself is the + // one exception (so they can actually change it). + if (isAuthenticated()) { + const u = getCurrentUser(); + if (u && u.must_change_password && hash !== '#/change-password') { + window.location.hash = '#/change-password'; + return; + } + if (hash === '#/change-password') { + if (!u || !u.must_change_password) { + // Not (or no longer) required - don't strand the user on a dead screen. + window.location.hash = '#/'; + return; + } + sidebar.style.display = 'none'; + app.style.marginLeft = '0'; + const mb = document.getElementById('mobileMenuBtn'); + if (mb) mb.style.display = 'none'; + currentView = forcePasswordChange; + forcePasswordChange.render(app); + return; + } + } + + // #12: a signed-in user with zero accessible workspaces (org-less self-signup + // on an AUTO_CREATE_ORG_ON_SIGNUP=false deployment) lands on a "no workspaces + // yet" empty state instead of being bounced into onboarding (whose pairing + // step needs a workspace). Only fires once /me has populated + // accessible_workspaces; until then the workspace-empty-safe dashboard shows. + if (isAuthenticated()) { + const u = getCurrentUser(); + if (hasNoAccessibleWorkspace(u) && hash !== '#/no-workspace') { + window.location.hash = '#/no-workspace'; + return; + } + if (hash === '#/no-workspace') { + if (!hasNoAccessibleWorkspace(u)) { window.location.hash = '#/'; return; } + sidebar.style.display = 'none'; + app.style.marginLeft = '0'; + const mb = document.getElementById('mobileMenuBtn'); + if (mb) mb.style.display = 'none'; + currentView = noWorkspace; + noWorkspace.render(app); + return; + } + } + // Onboarding for new users if (hash === '#/onboarding' && isAuthenticated()) { sidebar.style.display = 'none'; diff --git a/frontend/js/components/workspace-members-add-user-modal.js b/frontend/js/components/workspace-members-add-user-modal.js new file mode 100644 index 0000000..af88004 --- /dev/null +++ b/frontend/js/components/workspace-members-add-user-modal.js @@ -0,0 +1,133 @@ +// Add-User modal (#10). Sits next to the invite modal in the members view, but +// instead of emailing an invite it creates a user account directly with an +// admin-set password and assigns them to this workspace + role. For +// admin-provisioning on instances with no outbound email (where invites never +// deliver). Mirrors workspace-members-invite-modal.js's structure. +// +// workspace: { id, name } +// opts.onSuccess: (result) => void - fires on 201 (server response body) +// opts.mapError: (err) => string - translates server error to display text +import { api } from '../api.js'; +import { t } from '../i18n.js'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +// Crockford-ish readable random password: avoids ambiguous chars (0/O, 1/l/I). +function generatePassword(len = 16) { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; + const arr = new Uint32Array(len); + crypto.getRandomValues(arr); + let out = ''; + for (let i = 0; i < len; i++) out += alphabet[arr[i] % alphabet.length]; + return out; +} + +export function openAddUserModal(workspace, opts = {}) { + const { onSuccess, mapError } = opts; + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + + const emailInput = overlay.querySelector('#addUserEmail'); + const nameInput = overlay.querySelector('#addUserName'); + const pwInput = overlay.querySelector('#addUserPassword'); + const genBtn = overlay.querySelector('#addUserGenerate'); + const roleSelect = overlay.querySelector('#addUserRole'); + const mustChange = overlay.querySelector('#addUserMustChange'); + const errorEl = overlay.querySelector('#addUserError'); + const submitBtn = overlay.querySelector('#addUserSubmit'); + emailInput.focus(); + + function close() { overlay.remove(); document.removeEventListener('keydown', onKey); } + function onKey(e) { if (e.key === 'Escape') close(); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + overlay.querySelectorAll('[data-add-close]').forEach(b => b.addEventListener('click', close)); + genBtn.addEventListener('click', () => { pwInput.value = generatePassword(); pwInput.type = 'text'; }); + + async function submit() { + errorEl.style.display = 'none'; + const email = emailInput.value.trim().toLowerCase(); + const name = nameInput.value.trim(); + const password = pwInput.value; + const role = roleSelect.value; + if (!email || !EMAIL_RE.test(email)) { showError(t('members.error.invalid_email')); emailInput.focus(); return; } + if (!password || password.length < 8) { showError(t('members.error.password_min_8')); pwInput.focus(); return; } + + submitBtn.disabled = true; + submitBtn.textContent = t('members.modal.creating'); + try { + const result = await api.adminCreateUser({ + email, name, password, role, + workspaceId: workspace.id, + mustChangePassword: mustChange.checked, + }); + close(); + if (typeof onSuccess === 'function') { + try { onSuccess(result); } + catch (e) { console.error('add-user modal onSuccess threw:', e); } + } + } catch (err) { + submitBtn.disabled = false; + submitBtn.textContent = t('members.modal.create'); + const msg = (typeof mapError === 'function') + ? mapError(err) + : (err?.message || t('members.error.mutation_generic', { error: '' })); + showError(msg); + } + } + + function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; } + + submitBtn.addEventListener('click', submit); +} + +function esc(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); +} diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index ea00789..c1c1223 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -799,6 +799,10 @@ export default { 'admin.col.monthly': 'Monthly', 'admin.col.yearly': 'Yearly', 'admin.role.user': 'User', + 'admin.role.platform_operator': 'Platform operator', + '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', @@ -1155,9 +1159,21 @@ export default { 'members.modal.send': 'Send invite', 'members.modal.sending': 'Sending...', + // Modal — Add User form (#10, admin-provisioned account) + 'members.modal.add_user_title': 'Add user to {workspace}', + 'members.modal.name_label': 'Name', + 'members.modal.name_placeholder': 'Full name (optional)', + 'members.modal.password_label': 'Password', + 'members.modal.password_placeholder': 'Set a password', + 'members.modal.generate': 'Generate', + 'members.modal.must_change_label': 'Require a password change on first login', + 'members.modal.create': 'Create user', + 'members.modal.creating': 'Creating...', + // Buttons — page header + per-row action affordances (titles double as // ARIA labels for the icon-only buttons). 'members.button.invite': 'Invite member', + 'members.button.add_user': 'Add user', 'members.button.remove': 'Remove member', 'members.button.cancel_invite': 'Cancel invite', @@ -1174,6 +1190,8 @@ export default { 'members.error.invalid_email': 'Please enter a valid email address.', 'members.error.org_owner_remove': 'Cannot remove the organization owner.', 'members.error.email_send_failed': 'Email send failed. Try again.', + 'members.error.user_exists': 'A user with that email already exists.', + 'members.error.password_min_8': 'Password must be at least 8 characters.', 'members.error.mutation_generic': 'Action failed: {error}', // Success toasts fired post-mutation. @@ -1181,6 +1199,28 @@ export default { 'members.success.invite_cancelled': 'Invite cancelled', 'members.success.role_changed': 'Role updated', 'members.success.member_removed': '{name} removed', + 'members.success.user_created': 'User {email} created', + + // Forced first-login password change (#10). Shown when an admin-provisioned + // user (must_change_password) is routed to #/change-password. + 'forcepw.title': 'Set a new password', + 'forcepw.subtitle': 'Your account was set up by an administrator. Choose your own password to continue.', + 'forcepw.current': 'Current password', + 'forcepw.new': 'New password', + 'forcepw.confirm': 'Confirm new password', + 'forcepw.hint': 'At least 8 characters.', + 'forcepw.submit': 'Set password', + 'forcepw.submitting': 'Saving...', + 'forcepw.success': 'Password updated', + 'forcepw.error_required': 'Enter your current and new password.', + 'forcepw.error_min8': 'Password must be at least 8 characters.', + 'forcepw.error_mismatch': "Passwords don't match.", + 'forcepw.error_generic': 'Could not update password. Try again.', + + // No-workspace empty state (#12): shown to an org-less signed-in user. + 'noworkspace.title': 'No workspaces yet', + 'noworkspace.body': "Your account isn't part of any workspace yet. Ask your administrator to add you to one, then sign in again.", + 'noworkspace.sign_out': 'Sign out', // Accept-invite flow (Slice 2C). Toasts that fire post-accept on the // dashboard. Error variants share one helper in app.js's mapAcceptError(). diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js index 307eac2..5307e5b 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' (cross-org staff). +const PLATFORM_ROLE_OPTIONS = ['user', 'platform_operator', '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/force-password-change.js b/frontend/js/views/force-password-change.js new file mode 100644 index 0000000..c15307d --- /dev/null +++ b/frontend/js/views/force-password-change.js @@ -0,0 +1,81 @@ +// #10: forced first-login password change. When an admin provisions a user +// with must_change_password=1, route() in app.js redirects them here and blocks +// every other view until they set a new password. Reuses the same PUT /api/auth/me +// path as the Settings change-password form; on success the server clears +// must_change_password, we refresh the cached user, and return to the app. +import { api } from '../api.js'; +import { t } from '../i18n.js'; +import { showToast } from '../components/toast.js'; + +export async function render(container) { + container.innerHTML = ` +
+
+
+

${t('forcepw.title')}

+

${t('forcepw.subtitle')}

+
+
+
+ + +
+
+ + +
+
+ + +
+

${t('forcepw.hint')}

+ + +
+
+
+ `; + + const current = container.querySelector('#fpwCurrent'); + const next = container.querySelector('#fpwNew'); + const confirm = container.querySelector('#fpwConfirm'); + const submit = container.querySelector('#fpwSubmit'); + const errorEl = container.querySelector('#fpwError'); + current.focus(); + + const showError = (msg) => { errorEl.textContent = msg; errorEl.style.display = 'block'; }; + + async function doChange() { + errorEl.style.display = 'none'; + const cur = current.value; + const nw = next.value; + const cf = confirm.value; + if (!cur || !nw) { showError(t('forcepw.error_required')); return; } + if (nw.length < 8) { showError(t('forcepw.error_min8')); return; } + if (nw !== cf) { showError(t('forcepw.error_mismatch')); return; } + + submit.disabled = true; + submit.textContent = t('forcepw.submitting'); + try { + await api.updateMe({ password: nw, current_password: cur }); + // Refresh the cached user so the (now-cleared) must_change_password flag + // is reflected, then return to the app. + try { + const fresh = await api.getMe(); + localStorage.setItem('user', JSON.stringify(fresh)); + } catch { /* fall through; reload re-fetches */ } + showToast(t('forcepw.success'), 'success'); + window.location.hash = '#/'; + window.location.reload(); + } catch (err) { + submit.disabled = false; + submit.textContent = t('forcepw.submit'); + showError(err?.message || t('forcepw.error_generic')); + } + } + + submit.addEventListener('click', doChange); + [current, next, confirm].forEach(el => el.addEventListener('keydown', (e) => { if (e.key === 'Enter') doChange(); })); +} + +export function cleanup() {} diff --git a/frontend/js/views/no-workspace.js b/frontend/js/views/no-workspace.js new file mode 100644 index 0000000..29076fb --- /dev/null +++ b/frontend/js/views/no-workspace.js @@ -0,0 +1,31 @@ +// #12: empty state for a signed-in user who belongs to zero workspaces. Happens +// on deployments with AUTO_CREATE_ORG_ON_SIGNUP=false, where a self-service +// signup is created org-less and an admin/operator assigns them to a workspace +// afterward. Without this, such a user would be bounced into onboarding (whose +// device-pairing step needs a workspace) - a broken flow. Here they get a clear +// "ask your admin" message instead. +import { t } from '../i18n.js'; + +export function render(container) { + container.innerHTML = ` +
+
+ + + + +

${t('noworkspace.title')}

+

${t('noworkspace.body')}

+ +
+
+ `; + container.querySelector('#noWsSignOut').addEventListener('click', () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.hash = '#/login'; + window.location.reload(); + }); +} + +export function cleanup() {} 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 = `