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 = `
+
`).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 = `
@@ -430,7 +433,7 @@ async function loadUsers() {
${u.auth_provider}