The switcher's "manage members" + "rename/slug" affordances lived only in the
multi-workspace (>1) dropdown. A user with exactly one accessible workspace got
a plain static name with no way to reach org settings - so a fresh user with a
fresh workspace couldn't invite users, set permissions, or rename their slug.
Fix: the single-workspace view now renders the workspace name plus inline
manage-members + rename icons when the user can administer it (can_admin). No
dropdown for a single item.
Refactored the icon markup into adminIconsHtml(w) and the click wiring into
wireAdminIcons(scope, list), shared by the single-workspace view and the
dropdown items so the two can't drift again.
Frontend only. Verified headless: a fresh single-workspace admin now sees both
icons; clicking members navigates to #/workspace/:id/members and the members
view renders. Server suite unaffected (33/33).
Closes#19.
At MSP scale (100+ orgs) the org/workspace switcher dropdown was an
un-scrollable wall. Add a type-to-filter search box.
- Sticky search input at the top of the switcher menu, shown once the list
reaches a threshold (>= 8 workspaces); below that the plain list is fine.
- Live client-side filter: case-insensitive substring match on
"organization name + workspace name" (data-search haystack per row). The
full list is already loaded from /me, so no extra requests.
- Keyboard nav: search is auto-focused on open; type filters, ArrowUp/Down
move a highlight among visible rows, Enter selects (switches), Esc closes.
- "No matches" state when nothing matches; opening resets the filter.
- Refactored the switch action into a shared switchTo() used by both click
and Enter.
Frontend only. Verified headless: filter narrows live, no-match state,
clear restores, arrow-key highlight. EN i18n added.
Closes#16.
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).
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>
Adds POST /api/admin/users so an admin can create a user directly with a
known password and assign them to a workspace + role - for self-hosted
instances with no outbound email, where invites never deliver.
Server (routes/admin.js, mounted /api/admin with requireAuth + activityLogger):
- Gated by canAdminWorkspace(db, req.user, targetWorkspace): 404 if the
workspace is missing, 403 if not an admin of it. This scopes org_admins
to their own org and excludes platform_operator (no user/role mgmt, #13).
- Validates email (invite-create regex), role in WORKSPACE_ROLES, password
min-8 (the /me rule). 409 on duplicate email - never overwrites.
- One transaction: global users row (auth_provider 'local',
bcrypt.hashSync(pw,10), must_change_password from the flag) + a
workspace_members row written inline (same footprint as an accepted
invite; accept-invite left untouched).
- Explicit audit row admin_create_user; never logs the password; response
excludes password/hash.
- HOSTED_INSTANCE: never calls sendSignupEmails and stamps both
welcome_email_sent_at / activation_nudge_sent_at, so an admin-created
user gets no welcome email and never enters the activation-nudge sweep.
must_change_password (frontend-first enforcement, per spec):
- Migration adds users.must_change_password INTEGER NOT NULL DEFAULT 0;
surfaced via requireAuth + /me + login responses.
- route() in app.js forces users with the flag to a #/change-password
screen (new force-password-change view, reuses PUT /api/auth/me) and
blocks every other view until set. The /me update clears the flag.
Frontend: "Add User" button beside "Invite member" in the members view
(admin-only) opening a modal (email, name, password + generate, role,
must-change checkbox); invite and Add User coexist. api.adminCreateUser;
EN i18n only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes P2 user-management. Adds the full admin surface for managing
workspace membership: invite modal, role change, member remove, cancel
pending invite. All admin-gated client-side via can_admin from /me,
server-gated via canAdminWorkspace.
Component additions:
- NEW workspace-members-invite-modal.js (~115 LOC). Mirrors
workspace-rename-modal.js pattern (imperative open + listeners + close
+ esc/click-outside/enter). Two key differences: onSuccess callback
instead of window.location.reload (allows targeted re-render of
pending-invites section), and mapError callback so the parent's
mapMutationError is the single regex-to-i18n source of truth (instead
of duplicating in the modal).
- workspace-members.js: header invite button (can_admin gated), per-row
affordances (role select + remove on direct members, cancel on invited
rows, none on via_org rows), exported mapMutationError mapper,
re-render on both success AND error for role-select to resync state
when the server rejects.
- 4 api.js helpers (inviteWorkspaceMember, cancelWorkspaceInvite,
updateWorkspaceMemberRole, removeWorkspaceMember).
- 24 i18n keys under members.modal.*, members.button.*,
members.confirm.*, members.error.*, members.success.*
- CSS for .member-actions family (action buttons + role select + hover
states).
UX decisions:
- Direct-member rows: role <select> replaces role text in same column;
remove button right of detail
- via_org rows: no actions cell (server would 403; UI respects boundary)
- Invited rows: cancel button only (handoff rule was over-broad -
cancel-invite IS a valid mutation on invited rows, refined during 2B
survey)
- Role select fires on change, no Save button (matches teams.js pattern;
mitigations for accidental clicks noted in handoff if reports come in)
- Mutations re-fetch + re-render rather than optimistic updates -
simpler, no state-drift bugs, endpoints respond fast
- /invites endpoint skipped entirely when !can_admin (saves a request;
server still enforces)
Verification: 21/21 Playwright assertions PASS across 6 cases (invite
happy path, invite collision, role change, remove member, last-admin
block, cancel invite). Test infrastructure stashed at
~/Documents/screentinker-2b-playwright-2026-05.py.
Closes P2 (user-management feature). Slice 1+3 backend landed c4fbd2b,
2A read-only view landed 8db171d, 2C accept-invite handler landed
399af54, 2B mutation UI landed here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the workspace members page at #/workspace/:id/members.
Read-only listing only - mutations land in slice 2B,
accept-invite URL handler lands in slice 2C.
Three sections render based on access path:
- Members: direct workspace_members rows with role + join date
- Organization access: org_owner/org_admin who reach this
workspace via org-level access (via_org=true). 75% opacity
+ italic "via organization" label to distinguish from direct
membership. Section hidden if empty.
- Pending invites: workspace_invites rows (admin-only -
section silently absent for non-admins via 403-suppress)
Switcher dropdown adds a "members" icon next to the rename
pencil, gated on can_admin (same predicate). Icon visible on
hover, mirrors the existing pencil pattern.
24 i18n keys added under members.* (read-only set; mutation
keys land in 2B).
Backend coverage from c4fbd2b unchanged; pre-flight curl
verification (13/13 cases) confirmed all 7 endpoints work as
documented before slice 2 first-exercised the four previously
untested ones (GET /invites, DELETE /invites/:id, PUT
/members/:userId, DELETE /members/:userId).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/me's accessible_workspaces query gains a device_count field via a
correlated subquery on workspaces.id - WHERE workspace_id = w.id
strictly excludes the unclaimed pair-pool (workspace_id IS NULL fails
equality). Added to both query branches (platform_admin LEFT JOIN and
regular INNER JOIN); microseconds per row at current scale (~37 rows
worst case), not optimizing.
Frontend appends the count to the muted org-name line with a middle-dot
separator: 'Acme Studios . 2 devices'. Singular/plural respected via the
existing tn() helper convention; 'No devices' for empty workspaces. New
formatResourceCount(n, keyBase, zeroKey) helper is generic so the same
shape can wire users/playlists/schedules counts later without refactor.
New i18n keys: switcher.devices_count_one, switcher.devices_count_other,
switcher.no_devices. Added to en.js only; other locales fall back to en
via the existing lookup chain (verified in i18n.js:19).
API smoke verified: switcher-test sees Studio A=2, Field Crew=2;
dw5304 (platform_admin) sees all 37 workspaces with their device counts
varying 0-4; single-workspace zero-device user (geoff.case) sees 0.
- Toast now announces via role="status"/aria-live="polite" by default,
and role="alert"/aria-live="assertive" for errors. Screen readers
previously got nothing when notifications appeared.
- Move playlist-item flex-wrap:wrap from inline style into the
@media (max-width: 768px) block so desktop rows don't wrap controls
when the viewport is intermediate-narrow.
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>