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 = ` +
${t('forcepw.subtitle')}
+${t('forcepw.hint')}
+ + +