White-label is stored per-workspace (white_labels.workspace_id); unbranded and
new workspaces - and the login page - fell back to hardcoded ScreenTinker. Add a
single platform default that everything inherits beneath the per-workspace layer.
Resolution (lib/branding.js): workspace row -> custom-domain match -> platform
default -> hardcoded ScreenTinker. Row-level override: a workspace with its own
row keeps it (current behavior); only row-less workspaces inherit the default,
so editing the default propagates instantly (no row-copying at creation).
The platform default is a white_labels row with a FIXED id ('platform-default'),
not a "workspace_id IS NULL" sentinel - legacy pre-multitenancy rows can also
have a null workspace_id, which would be ambiguous.
- routes/admin.js: GET/PUT /api/admin/branding (requirePlatformAdmin) to read/
upsert the single platform-default row; audit-logged.
- server.js: public GET /api/branding (domain match -> platform default ->
hardcoded) for pre-login/pre-workspace contexts.
- routes/white-label.js: authed GET now falls back to the platform default
(was hardcoded) for row-less workspaces.
- Frontend: login page resolves + applies branding (logo, name, colors, favicon,
custom CSS) pre-auth; Admin page gets a "Default branding" form.
Tests: resolver order incl. legacy null-ws safety; admin GET/PUT (single row,
upsert, platform-admin-only 403). Full suite 37/37. Verified end-to-end:
public + authed + login-page all inherit the platform default; per-workspace
override preserved.
Closes#15.
The Workspace column on the platform Users page could only move a 0/1-workspace
user and showed a dead "N workspaces" label for multi-membership users. Replace
it with a "Manage workspaces" modal that handles the full picture.
Backend (routes/admin.js, requirePlatformAdmin):
- GET /api/admin/users/:id/workspaces list memberships (+org/ws names, role)
- POST /api/admin/users/:id/workspaces add to a workspace (upsert role)
- PUT /api/admin/users/:id/workspaces/:wsId change role in a workspace
- DELETE /api/admin/users/:id/workspaces/:wsId remove (last one allowed -> unassigned)
Roles validated against WORKSPACE_ROLES; each mutation writes an audit row.
Frontend:
- Workspace cell is now a summary (Unassigned / <name> / N workspaces /
"Platform (all)" for staff) + a Manage button.
- New admin-user-workspaces-modal: lists every membership with an inline role
dropdown + Remove, plus a type-to-filter "Add to workspace" picker (org-grouped,
excludes current memberships) with a role select. Staff get a note that they
already have platform-wide access. Refreshes the table on close if changed.
- Removed the old single-select inline move control (superseded by the modal).
Tests: 6 added (add to multiple workspaces, per-workspace role change, upsert,
remove incl. last->unassigned, validation 400/404, non-platform-admin 403).
Full suite 33/33. Verified headless: Manage opens, lists memberships, filtered
picker, add/role-change/remove round-trips persist (throwaway user, cleaned up).
Adds a "Workspace" column (after Plan) to the platform Users admin table so a
platform_admin can see and reassign a user's workspace inline, alongside the
Role/Plan dropdowns. Single-workspace move/assign model.
Backend:
- GET /api/auth/users (platform branch): one aggregate query adds
workspace_count and, for exactly-one membership, the workspace id/name + org
name (no N+1).
- PUT /api/admin/users/:id/workspace (requirePlatformAdmin - operator excluded):
move (1 membership) or assign (0) into the chosen workspace, default role
workspace_viewer, in a transaction; no-op if already there; REFUSES (400) a
user with >1 membership (manage in the members view). logActivity
admin_set_user_workspace.
Frontend (admin.js):
- Editable <select> only for a 'user' with 0/1 membership; multi-membership ->
read-only "N workspaces", platform staff -> read-only "Platform (all)".
- Options grouped by org via <optgroup>, built ONCE from /me's
accessible_workspaces (same source as the Add User picker) and reused per row.
- Picking "Unassigned" or the same workspace is a no-op so a stray pick can't
strip a membership. Success -> toast + refresh. EN i18n only.
Tests: 4 added (single-membership move 200 + changed, zero-membership assign
200, multi-membership 400 refused, non-platform-admin/operator 403). npm test
16/16. Verified headless: column renders, selected value correct, "Platform
(all)" for staff, and a dropdown move persisted (throwaway user, cleaned up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the shared add-user modal (workspace-members-add-user-modal.js) with
an optional picker mode instead of forking a second form:
- opened with a fixed workspace (members view) -> unchanged, no picker;
- opened with null (platform Users admin page) -> shows an Org/Workspace
picker (type-to-filter over /me's accessible_workspaces, labelled
"org / workspace") plus the role select; email/name/password+generate/
must-change/error-mapping stay shared.
Role options are rendered from a single WORKSPACE_ROLES constant that mirrors
the set POST /api/admin/users accepts (routes/admin.js) - so we never offer a
value the endpoint 400s (the platform_operator mismatch we already hit).
org_admin is intentionally NOT offered: the endpoint accepts only the three
workspace roles.
admin.js: "Add user" button in the page header (page is already
platform_admin-gated; the endpoint additionally enforces canAdminWorkspace,
which platform_admin passes everywhere). On success -> toast + refresh the
user list. Reuses workspace-members.js's mapMutationError. EN i18n only.
Frontend only - no backend change. Verified headless (Playwright): button
opens the modal, picker lists all 45 workspaces with working filter, role
options = [viewer, editor, admin], and submit created + assigned a user into
the chosen workspace (test row cleaned up afterward). npm test still 12/12.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
platform_operator is cross-org STAFF: it can see and act-as into every
org and read/write workspace-scoped resources (content, playlists,
layouts, schedules, devices, widgets, kiosk) anywhere - but holds NO
owner-level power.
Design is deny-by-default: operator is NEVER added to PLATFORM_ROLES /
isPlatformRole, so every owner capability (billing, org/workspace
deletion, user/role management, shared & template asset curation,
branding, workspace member mgmt/rename) stays denied, and any NEW owner
endpoint added later inherits that denial automatically.
Operator gets power from exactly two levers:
- middleware/auth.js: new PLATFORM_STAFF set + isPlatformStaff(); owner
guards (PLATFORM_ROLES, requireAdmin, requireSuperAdmin) unchanged.
- tenancy.js: accessContext + resolveTenancy treat staff as act-as
capable; new req.isPlatformStaff / req.isPlatformOperator (req.isPlatformAdmin
stays owner-only); accessibleWorkspaceIds + switch-workspace guard use staff.
- permissions.js: canRead/canWrite + canAccessWorkspace (read) grant staff;
canAdmin / canAdminWorkspace / isOrgAdmin / isOrgOwner stay owner-gated.
Read-only edges (per review): operator may VIEW workspace member lists
(canAccessWorkspace) and the unassigned device pool (devices.js), but
cannot mutate either.
Frontend: platform role dropdown adds "Platform operator"; the user-mgmt
view stays isPlatformAdmin-gated so operators can't open it. EN i18n only.
Behaviour identical under HOSTED_INSTANCE set or unset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Password reset for other users:
- New PUT /api/auth/users/:id/password endpoint
- Superadmin can reset any local user; admin can reset role=user
members of teams they own only (cannot reset other admins or
superadmins, cannot self-reset — that goes through PUT /me with
current_password)
- OAuth users are excluded (no password to reset)
- Rate-limited 20 req/min/IP to cap blast radius if an admin session
is compromised
- Explicit audit log entry "password_reset_for_user / target: <email>"
on every reset; activity logger's summarizeAction never reads the
password field, so the password value is not stored anywhere
Frontend: Reset Password button in the Admin user table and Settings
> User Management table. Shown only for local-auth users that aren't
the current user; prompts for an 8+ char password.
Widgets visibility fix:
- routes/widgets.js had `const isAdmin = req.user.role === 'superadmin'`
which mislabeled superadmin as admin and silently restricted real
admins (role=admin) to seeing only their own widgets. Now matches
/auth/users behavior: superadmin sees all, admin sees own + public
+ widgets owned by members of teams they own, user sees own + public.
7 new i18n keys (admin.reset_password, admin.prompt_reset_password,
admin.toast.password_min_8, admin.toast.password_reset, and the
matching settings.user.* / settings.toast.* trio). 1024 keys total,
parity 100% across en/es/fr/de/pt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inline width:NNNpx beats the .modal { width: 95vw } mobile rule due to
specificity. Convert to max-width:NNNpx;width:95vw on the three affected
modals so they cap at their desktop size but still shrink on mobile:
- playlists.js add-item modal (560px)
- device-detail.js assign-playlist modal (650px)
- content-library.js edit-content modal (500px)
Same fix pattern for fixed-width form controls flagged in QA — selects
and inputs change to max-width:NNNpx;width:100% so they keep their
desktop size but shrink to container on mobile:
- admin.js role/plan selects (120/130px)
- teams.js member role + add-device selects (100/200px)
- content-library.js search input + folder filter (250/180px)
- onboarding.js pairing code + display name inputs (240px)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Wrap wide tables (admin, settings, reports) in .table-wrap with
min-width on the table so they scroll horizontally on narrow screens
instead of collapsing rows.
- Add global .table-wrap { overflow-x: auto } utility.
- Mobile: add mask-image fade on .tabs right edge to hint scrollability
when tabs overflow; flex-shrink:0 on .tab keeps labels intact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>