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..dc31b78 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -20,6 +20,7 @@ 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 { applyBranding } from './branding.js'; import { t } from './i18n.js'; import { isPlatformAdmin } from './utils.js'; @@ -276,6 +277,33 @@ 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; + } + } + // 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 db04ae4..0d2b5ca 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -1159,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', @@ -1178,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. @@ -1185,6 +1199,23 @@ 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.', // 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/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/workspace-members.js b/frontend/js/views/workspace-members.js index 2cca907..9422ee0 100644 --- a/frontend/js/views/workspace-members.js +++ b/frontend/js/views/workspace-members.js @@ -12,6 +12,7 @@ import { api } from '../api.js'; import { t } from '../i18n.js'; import { showToast } from '../components/toast.js'; import { openInviteMemberModal } from '../components/workspace-members-invite-modal.js'; +import { openAddUserModal } from '../components/workspace-members-add-user-modal.js'; export async function render(container, workspaceId) { container.innerHTML = ` @@ -62,10 +63,15 @@ export async function render(container, workspaceId) { } } - // Invite button - admin only, opens modal. + // Invite + Add User buttons - admin only. Invite is self-service (emails a + // link); Add User (#10) provisions an account directly with an admin-set + // password (for instances with no outbound email). They coexist. if (canAdmin) { headerActions.innerHTML = ` - +
+ + +
`; document.getElementById('inviteMemberBtn').addEventListener('click', () => { openInviteMemberModal({ id: workspaceId, name: workspaceName }, { @@ -76,6 +82,15 @@ export async function render(container, workspaceId) { mapError: mapMutationError, }); }); + document.getElementById('addUserBtn').addEventListener('click', () => { + openAddUserModal({ id: workspaceId, name: workspaceName }, { + onSuccess: (result) => { + showToast(t('members.success.user_created', { email: result.email }), 'success'); + render(container, workspaceId); + }, + mapError: mapMutationError, + }); + }); } const direct = members.filter(m => !m.via_org); @@ -264,6 +279,9 @@ export function mapMutationError(err) { if (/Cannot demote the last admin/i.test(msg)) return t('members.error.last_admin_demote'); if (/Cannot remove the last admin/i.test(msg)) return t('members.error.last_admin_remove'); if (/already a member/i.test(msg)) return t('members.error.already_member'); + // #10 Add User: duplicate email + weak password. + if (/user with that email already exists/i.test(msg)) return t('members.error.user_exists'); + if (/at least 8 characters/i.test(msg)) return t('members.error.password_min_8'); if (/Valid email required/i.test(msg)) return t('members.error.invalid_email'); if (/Cannot remove the organization owner/i.test(msg)) return t('members.error.org_owner_remove'); if (/Email send failed/i.test(msg)) return t('members.error.email_send_failed'); diff --git a/server/db/database.js b/server/db/database.js index 7104238..852c296 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -173,6 +173,10 @@ const migrations = [ // already in the current model ('user'/'platform_admin'/'platform_operator'). "UPDATE users SET role = 'platform_admin' WHERE role = 'superadmin'", "UPDATE users SET role = 'user' WHERE role = 'admin'", + // Issue #10: admin-provisioned users. When an admin creates a user with a + // known password, must_change_password=1 forces a password change on first + // login. Default 0 so all existing users are unaffected. + "ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0", ]; for (const sql of migrations) { try { db.exec(sql); } catch (e) { /* already exists */ } diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 52660bb..04e3334 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -48,7 +48,7 @@ function requireAuth(req, res, next) { req.jwtWorkspaceId = null; return next(); } - const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts FROM users WHERE id = ?').get(decoded.id); + const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?').get(decoded.id); if (!user) return res.status(401).json({ error: 'User not found' }); req.user = user; // Tenancy middleware reads this on the resolver step. diff --git a/server/routes/admin.js b/server/routes/admin.js new file mode 100644 index 0000000..f20c45e --- /dev/null +++ b/server/routes/admin.js @@ -0,0 +1,102 @@ +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcryptjs'); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); +const { canAdminWorkspace } = require('../lib/permissions'); +const { logActivity, getClientIp } = require('../services/activity'); + +// Admin-provisioned user creation (#10). Operates on a target workspace +// specified in the body, NOT the caller's active workspace - so this router is +// mounted with requireAuth only (no resolveTenancy), mirroring routes/workspaces.js. +// Permission is gated per-handler via canAdminWorkspace() against the TARGET +// workspace, which: +// - lets a platform_admin create users anywhere, +// - scopes an org_admin / org_owner to workspaces in orgs they administer, +// - and excludes platform_operator (isPlatformRole owner-only) - operators +// have no user/role-management power (#13). + +// Same email shape the invite-create endpoint validates against (workspaces.js). +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const WORKSPACE_ROLES = ['workspace_admin', 'workspace_editor', 'workspace_viewer']; +// Mirror the server-side minimum enforced by PUT /api/auth/me and register. +const MIN_PASSWORD_LENGTH = 8; + +// POST /api/admin/users - create a user with an admin-set password and assign +// them to a workspace + role. The result is indistinguishable from an +// invite-accepted user (a global users row + a workspace_members row). +router.post('/users', (req, res) => { + const email = String(req.body?.email || '').trim().toLowerCase(); + const name = String(req.body?.name || '').trim(); + const password = String(req.body?.password || ''); + // Accept workspaceId (preferred) or orgId as an alias for the target field. + const workspaceId = String(req.body?.workspaceId || req.body?.orgId || '').trim(); + const role = String(req.body?.role || '').trim(); + const mustChangePassword = !!req.body?.mustChangePassword; + + if (!email || !EMAIL_RE.test(email)) { + return res.status(400).json({ error: 'Valid email required' }); + } + if (!WORKSPACE_ROLES.includes(role)) { + return res.status(400).json({ error: 'Role must be workspace_admin, workspace_editor, or workspace_viewer' }); + } + if (password.length < MIN_PASSWORD_LENGTH) { + return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }); + } + if (!workspaceId) { + return res.status(400).json({ error: 'workspaceId required' }); + } + + const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspaceId); + 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 so the activityLogger middleware (and our + // explicit audit row) attribute to the right tenant. + req.workspaceId = ws.id; + + // Email uniqueness: clean 409, never overwrite an existing account. + const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); + if (existing) { + return res.status(409).json({ error: 'A user with that email already exists' }); + } + + const id = uuidv4(); + const passwordHash = bcrypt.hashSync(password, 10); + + // HOSTED_INSTANCE: an admin-provisioned user is already set up with a + // password, so they must NOT receive the welcome email or enter the + // activation-nudge lifecycle. We never call sendSignupEmails here, and the + // nudge sweep already excludes them (they have a workspace_members row); we + // additionally stamp both *_sent_at sentinels so any future sweep treats them + // as already-handled. See services/signupEmails.js + services/activationNudge.js. + const txn = db.transaction(() => { + db.prepare(` + INSERT INTO users ( + id, email, name, password_hash, auth_provider, role, plan_id, + must_change_password, welcome_email_sent_at, activation_nudge_sent_at + ) VALUES (?, ?, ?, ?, 'local', 'user', 'free', ?, strftime('%s','now'), strftime('%s','now')) + `).run(id, email, name || email.split('@')[0], passwordHash, mustChangePassword ? 1 : 0); + + // Same membership footprint as an accepted invite: one workspace_members + // row, invited_by = the admin who created them. + db.prepare(` + INSERT INTO workspace_members (workspace_id, user_id, role, invited_by) + VALUES (?, ?, ?, ?) + `).run(ws.id, id, role, req.user.id); + }); + txn(); + + // Explicit audit row - who created whom, where, with what role. Never the + // plaintext password (and the generic activityLogger only summarizes name). + logActivity(req.user.id, 'admin_create_user', `target: ${email}, role: ${role}`, null, getClientIp(req), ws.id); + + // Response never includes password or hash. + const created = db.prepare( + 'SELECT id, email, name, role, auth_provider, plan_id, must_change_password, created_at FROM users WHERE id = ?' + ).get(id); + res.status(201).json({ ...created, workspace_id: ws.id, workspace_role: role }); +}); + +module.exports = router; diff --git a/server/routes/auth.js b/server/routes/auth.js index 196db92..ce7ed6b 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -429,10 +429,12 @@ router.put('/me', requireAuth, (req, res) => { } } const hash = bcrypt.hashSync(password, 10); - db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') + // #10: a successful password change clears must_change_password, releasing + // the first-login change-password gate. + db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') .run(hash, req.user.id); } - const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts FROM users WHERE id = ?').get(req.user.id); + const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?').get(req.user.id); res.json(user); }); diff --git a/server/server.js b/server/server.js index fb3eeae..84bb4bc 100644 --- a/server/server.js +++ b/server/server.js @@ -365,6 +365,12 @@ app.use(activityLogger); // no resolveTenancy. Permission gated per-handler via canAdminWorkspace(). app.use('/api/workspaces', requireAuth, require('./routes/workspaces')); +// /api/admin: admin-provisioned user creation (#10). Like /api/workspaces it +// targets a workspace by body param (not the caller's active one), so +// requireAuth only - per-handler canAdminWorkspace() gates it. Mounted after +// activityLogger so creations are auto-logged. +app.use('/api/admin', requireAuth, require('./routes/admin')); + 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'));