Commit graph

350 commits

Author SHA1 Message Date
screentinker 36d1578794
Merge pull request #49 from screentinker/feat/admin-create-org
feat(admin): Create Organization for platform admins (#35)
2026-06-09 09:10:20 -05:00
ScreenTinker ae595a208d feat(admin): Create Organization for platform admins (#35)
MSPs onboarding customers as separate orgs had no way to create one with
AUTO_CREATE_ORG_ON_SIGNUP=false (the only path was signup auto-org). Add a
platform-admin 'Create organization' action.

POST /api/admin/orgs (requirePlatformAdmin) creates the org + its first 'Default'
workspace. organizations.owner_user_id is NOT NULL, so an org can't be ownerless;
the creating admin becomes org_owner + workspace_admin (mirrors the signup
bootstrap in routes/auth.js) - which also surfaces the org in their switcher.
Customer users are then added via the existing Add User / manage-memberships flow.

UI: 'Create organization' button + single-field modal in the Admin area (gated).
Tests: create (201 + memberships + audit), empty-name 400, non-admin/operator 403.
2026-06-09 09:10:15 -05:00
screentinker 69b46647c5
Merge pull request #48 from screentinker/feat/zone-fit-mode
feat(layouts): per-zone fit mode (fix cropped multi-zone video)
2026-06-09 08:55:20 -05:00
ScreenTinker 8fd971405e feat(layouts): per-zone fit mode + default to 'contain'
Multi-zone videos/images were cropped: every template zone inherited fit_mode
'cover' (fill+crop) and the layout editor had no control to change it, so a
landscape video in a tall split zone showed only a center strip. The player
already honors fit_mode (web object-fit, Android scaleType) - the gap was the UI
and the default. Add a per-zone Fit selector (Contain/Cover/Stretch) to the layout
editor, and make 'contain' (show the whole frame) the default for new zones, the
schema column, and the save fallbacks. Existing built-in templates are migrated
separately.
2026-06-09 08:55:15 -05:00
screentinker 7af9f7a057
Merge pull request #47 from screentinker/fix/player-coldstart-layout
fix(player-web): no fullscreen flash on true cold start (unknown layout)
2026-06-09 08:31:03 -05:00
ScreenTinker 397aedf2d8 fix(player-web): don't optimistic-render fullscreen when layout is unknown
Follow-up to the layout cache. On a cold start with a cached playlist but no cached
layout yet (first run after shipping, or cleared cache), the player still rendered
fullscreen and flashed before the payload arrived. Now gate the optimistic cached
render on the layout being KNOWN (cache key present — null=fullscreen vs object=
zoned, both fine); if unknown, wait ~1s for the payload to drive the first render.
Eliminates the fullscreen flash on the very first pass too.
2026-06-09 08:30:58 -05:00
screentinker 8de15465ad
Merge pull request #46 from screentinker/fix/player-cache-layout
fix(player-web): cache layout — cold start renders zones on first pass
2026-06-09 08:27:46 -05:00
ScreenTinker 00964e90a8 fix(player-web): cache layout so cold start renders zones on first pass
The player cached only the playlist, not the layout. On cold start it restored the
playlist and rendered immediately with layout=null -> fullscreen, then re-rendered
into zones once the server payload arrived (the 'fullscreen first, then split'
flash). Cache the layout alongside the playlist and restore it before the first
render; cleared on reset.
2026-06-09 08:27:41 -05:00
screentinker ccee032740
Merge pull request #45 from screentinker/fix/zone-widget-content-type
fix(player-web): render widgets in content zones (black-zone bug)
2026-06-09 08:22:10 -05:00
ScreenTinker 4fe8e87416 fix(player-web): render widgets in any zone, not just zone_type=widget
A widget (e.g. directory board) assigned to a 'content' zone rendered as a black
zone: showZoneItem gated the widget branch on zone.zone_type==='widget', so the
widget was skipped and (mime_type null) nothing else matched either. Key off the
assignment's widget_id instead - matching the Android ZoneManager, which is why
the same layout worked on the APK but not the web player.
2026-06-09 08:22:05 -05:00
screentinker 67d2eae2cf
Merge pull request #44 from screentinker/fix/widget-render-nostore
fix(widgets): no-store on widget/kiosk render
2026-06-08 23:46:46 -05:00
ScreenTinker 8e7d599170 fix(widgets): no-store on widget/kiosk render
The render had no Cache-Control. A copy cached before the X-Frame-Options fix keeps
showing blank, and widget data (clock/weather/rss/directory) is dynamic anyway, so
mark the render no-store. Pairs with the X-Frame-Options removal.
2026-06-08 23:46:42 -05:00
screentinker 8dce93d4dc
Merge pull request #43 from screentinker/fix/widget-render-frameable
fix(widgets): make widget/kiosk render frameable (X-Frame-Options)
2026-06-08 23:37:52 -05:00
ScreenTinker 827b1c4c87 fix(widgets): make widget/kiosk render frameable (X-Frame-Options)
The web player embeds widget/kiosk renders in a sandboxed (allow-scripts, no
allow-same-origin) iframe = a null origin. The global helmet X-Frame-Options:
SAMEORIGIN refuses that (null != same-origin), so every widget rendered blank in
the web player (video worked since it isn't an iframe). Drop X-Frame-Options on
just the /render endpoints - the sandbox, not X-Frame-Options, is what isolates
the widget from the dashboard (it still can't read the JWT). Dashboard keeps its
clickjacking protection. Verified: directory board now renders in a sandboxed
iframe with no refusal.
2026-06-08 23:36:53 -05:00
screentinker d13ac58e74
Merge pull request #30 from screentinker/fix/widget-render-xss
fix(security): sanitize public widget render (stored XSS)
2026-06-08 23:20:38 -05:00
screentinker ac1b24fe43
Merge pull request #42 from screentinker/fix/sw-video-passthrough
fix(web): service worker video passthrough + independent per-zone rotation
2026-06-08 23:17:08 -05:00
ScreenTinker 68fb6a985e Merge remote-tracking branch 'origin/main' into fix/sw-video-passthrough
# Conflicts:
#	server/player/index.html
2026-06-08 23:15:32 -05:00
ScreenTinker 546fcdc105 fix(player-web): independent per-zone rotation in multi-zone layouts
Mirror of the Android fix. The web player showed only the FIRST assignment per
zone (playlist.find) and an image zone set the GLOBAL advanceTimer->nextItem, so
the whole layout re-rendered on one global tick instead of each zone cycling its
own content. Now each zone groups its assignments (by zone_id, sorted), renders
the first, and advances on its OWN timer (images/widgets/youtube: duration;
videos: on end; single-item zones loop). Cleared in teardown. Also render zones
before the single-item 'renderable?' bail so an empty current item can't blank
the screen.
2026-06-08 23:12:29 -05:00
ScreenTinker d4f71bbf3a fix(sw): stop the admin service worker from breaking video playback
sw-admin.js (scope '/') intercepted every non-API GET with clone+cache+respond.
Video requests are Range requests -> 206 Partial Content, which can't be cached;
cache.put threw and the handler errored ('ServiceWorker encountered an unexpected
error'), so .mp4s never loaded on any page this SW controls - including the web
player at /player, which then thrashed between items.

Now bypass (network-only) non-GET, Range requests, and /uploads//player/api/
socket.io; only cache same-origin 200s. CACHE bumped to v4 so clients pick up the
new SW + drop the stale bucket.
2026-06-08 23:08:13 -05:00
screentinker 6ef2cb548c
Merge pull request #33 from screentinker/fix/fullscreen-widgets
fix(android): widgets not rendering in fullscreen / single-zone layouts
2026-06-08 22:54:11 -05:00
ScreenTinker 5c0721b77f Merge branch 'main' into fix/fullscreen-widgets 2026-06-08 22:42:59 -05:00
ScreenTinker 3510670ce1 fix(android): YouTube Error 153 + visible web-frame errors
- YouTube: load the embed via loadDataWithBaseURL with a youtube.com base URL so
  the iframe has a valid origin/referer (a bare loadUrl of /embed/ID gives
  'player misconfigured, Error 153'). Applies to zone + fullscreen YouTube.
- Web frames: shared WebViewSupport.configure() enables mixed-content (self-hosted
  http LAN servers) and pipes WebView load/HTTP/JS-console errors to DebugLog, so a
  failing web frame surfaces the real error in the live panel instead of a black
  broken-page view.
2026-06-08 22:42:59 -05:00
ScreenTinker c184b94602 fix(android): log per-zone content switches (live debug)
After stopping the fullscreen controller in multi-zone, the only switch logs went
away - each zone now logs every item it renders (initial + each rotation) so the
live debug panel shows each zone advancing on its own interval.
2026-06-08 22:36:07 -05:00
ScreenTinker c94757fc97 fix(android): per-zone rotation + stop fullscreen controller in multi-zone
From Chris's live debug logs on the L-Bar layout:
- ZoneManager only rendered the FIRST assignment per zone -> the Main zone (3
  images) never rotated ('says it's switching but it's not'). Now each zone
  cycles its own assignments: images/widgets on a duration timer, videos on
  end (single-item zones still loop).
- The fullscreen PlaylistController kept running BEHIND the zones (playItem every
  10s, would leak audio for a zone video) because startIfNeeded() ran after every
  playlist update. Now only start it when not in multi-zone (zoneManager.hasZones).
- renderAssignments still called container.removeAllViews() (the same static-view
  nuke the cleanup() fix addressed) -> now removes only its own zone views.
2026-06-08 22:19:25 -05:00
ScreenTinker 73912d5f58 feat(debug): live per-device debug logging toggle on the device screen
Checkbox on the device-detail page streams the Android player's player/zone logs
live (no adb). Transient (off on reconnect), not persisted.

- Android: DebugLog util (logcat + optional socket emit); 'set_debug' command wires
  the sink + flag; key player/zone decisions (layout mode, playItem, per-zone
  render) emit through it.
- Server: relay device:log -> dashboard workspace room as dashboard:device-log.
- Dashboard: 'Debug logging' checkbox sends set_debug; live log panel streams lines
  (rendered via textContent; capped at 500).
2026-06-08 21:49:03 -05:00
ScreenTinker 50d7dbe222 fix(player): zone reset on multi->single layout switch + don't blank multi-zone
- Server (deviceSocket buildPlaylistPayload): when a device's layout has <2 zones
  (single or none), strip leftover zone_id from assignments. After switching a
  device from multi-zone back to fullscreen, content was stuck bound to a gone
  left/right zone_id and never played; nulling it lets both players fall back to
  the default fullscreen renderer.
- Web player: render multi-zone zones BEFORE the single-item 'renderable?' bail,
  so an empty/placeholder current rotation item can't blank the whole screen.
2026-06-08 21:31:27 -05:00
screentinker c1fbe165e7
Merge pull request #34 from screentinker/fix/sidebar-scroll
fix(ui): sidebar nav unscrollable on short screens
2026-06-08 21:21:00 -05:00
ScreenTinker 2e14de2069 fix(ui): make sidebar nav scrollable on short screens
On a short viewport (e.g. 1366x768) the sidebar nav was taller than the screen
with no scroll, so items below the fold (Settings) were unreachable. Add
overflow-y:auto + min-height:0 to .nav-links (the min-height:0 lets the flex
child shrink and scroll instead of overflowing).
2026-06-08 20:41:15 -05:00
ScreenTinker c7bbc4f815 fix(android): ZoneManager.cleanup must not remove the activity's static views
The black-screen on fullscreen widgets (and any single-zone playback after using
a multi-zone layout) was here: cleanup() called container.removeAllViews(), but
`container` is the activity root that also holds the static playerView/imageView/
youtubeWebView/statusOverlay. Removing them detached the WebView that the
fullscreen widget path reuses -> black. Remove only the zone views we added.
2026-06-08 20:34:30 -05:00
screentinker 171b69233c
Merge pull request #32 from screentinker/fix/android-device-fixes
fix(android): OTA APK signature verification (Critical) + pairing-code visibility
2026-06-08 20:08:58 -05:00
ScreenTinker 911cd07951 fix(android): render widgets in fullscreen / single-zone layouts
Widgets worked in multi-zone layouts (ZoneManager renders them in a WebView) but
were broken in "default fullscreen" (no layout) and the fullscreen template (a
single-zone layout) - both take the single-zone PlaylistController path, which:
  1) called getString("content_id"), throwing on a widget assignment (no
     content_id) - in both the playlist builder AND the pre-download loop, which
     could break the whole fullscreen playlist; and
  2) had no widget render case in playItem (so a widget never displayed).

Fix:
- PlaylistItem gains widgetId/widgetType + isWidget; the builder reads them and
  tolerates a missing content_id.
- playItem renders a widget fullscreen via MediaPlayerManager.showWidget() (loads
  /api/widgets/:id/render in the full-screen WebView, mirroring ZoneManager).
- Widgets auto-advance on their duration like images.
- Pre-download loop skips widget assignments (no file to fetch).

Compile-checked; signed APK builds. Needs on-device check: a widget plays in
default-fullscreen and the fullscreen template, and mixed widget+media playlists
advance correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:07:23 -05:00
ScreenTinker 60cda97b1d fix(android): stop pairing-code glyph clip + remove duplicate instruction
- The code's bottom was still clipped: the autosize TextView used wrap_content
  height, which clips glyph bottoms. Give it a fixed 96dp box (autosize 24-64sp,
  gravity center) so the text is centered inside a bounded box and never clipped.
- The "Enter this code…" line appeared twice (static label + statusText). Clear
  statusText when paired so it shows only once, with the code.
2026-06-08 19:53:44 -05:00
ScreenTinker 86340caf9d fix(android): keep pairing code fully on-screen (was clipped at bottom)
Follow-up to the provisioning layout fix - on a Pixel the code's bottom half was
cut off. Tightened the screen so the whole block fits:
- "RemoteDisplay" title 36sp -> 22sp, smaller subtitle + margins.
- Anchor content to the top (gravity center_horizontal|top) so the code sits
  high instead of being pushed below the fold by vertical centering.
- Pairing code autosize cap 96sp -> 56sp + vertical padding, so tall digits
  aren't clipped and the block stays on screen on short/landscape phones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:46:30 -05:00
ScreenTinker 06c6c3214b fix(android): make pairing code fit/visible on all screen sizes
Reported on a Pixel 10: the pairing code wasn't visible. The provisioning screen
was a non-scrolling vertical stack, and when the pairing section appeared below
the server-URL + Connect controls, the fixed 64sp code got pushed off-screen on
short/landscape phones (and could clip horizontally on narrow widths).

- Wrap the screen in a ScrollView (fillViewport) so content is always reachable.
- pairingCodeText now auto-sizes (autoSizeTextType=uniform, 24-96sp, single line,
  match_parent width) so it fills the width and never clips - phones, TVs, sticks.
- Hide the server-URL section + Connect button once paired so the code gets the
  full screen.

Compile-checked + signed APK builds. Needs on-device confirmation (Pixel 10 /
onn stick) that the code is now visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:19:55 -05:00
ScreenTinker d41bd1f27d fix(android): verify OTA APK signature before install + disable backup (Critical)
The updater fetched download_url from the server JSON and installed it via
PackageInstaller with NO verification, over cleartext (usesCleartextTraffic,
no pinning). A network MITM or compromised server could return a malicious APK
and have it silently installed (REQUEST_INSTALL_PACKAGES) → full device RCE.

Fix: before install, verify the downloaded APK (a) is our own package and
(b) shares a current signing certificate with the installed app
(GET_SIGNING_CERTIFICATES on P+, GET_SIGNATURES below). An attacker can't forge
our signing key, so this holds even over an untrusted/cleartext transport.
Fail-closed on any parse/verify error; the APK is deleted on mismatch. Gates
both the session-install and intent-fallback paths.

Also set android:allowBackup="false" so adb backup can't exfiltrate the
device token / config.

Compile-checked + signed debug APK builds. NOT verified on-device - needs a
real update cycle on a device (valid update installs; a wrong-signed APK is
rejected) before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:19:55 -05:00
ScreenTinker 401c4b00b5 fix(security): sanitize public widget render to close stored XSS
The public, CSP-exempt widget render (GET /api/widgets/:id/render) inlined
config values straight into <style>/CSS and (for the text widget) raw into the
same-origin document. A workspace editor could store `}</style><script>...` in a
color/background/size field (bypassing the UI pickers via the API) → stored XSS
executing in the app origin for anyone who opens the render URL (JWT theft).

- safeCss(): allow colors/gradients but reject CSS breakout / url() / @import /
  expression / javascript:. Applied to background/color across clock, weather,
  rss, social renders.
- safeNumber(): coerce font_size / scroll_speed / max_items to a finite number
  so they can't smuggle markup.
- Text widget keeps its intentional raw HTML/CSS feature, but it now renders
  inside an <iframe sandbox="allow-scripts"> (NO allow-same-origin) - scripts run
  in a null origin that can't reach the dashboard's localStorage/JWT.

Tests: test/widget-render-xss.test.js (breakout rejected, numbers coerced, text
isolated, legit colors/gradients preserved). Full suite green.
2026-06-08 19:11:14 -05:00
screentinker 50ad1f670b
Merge pull request #28 from screentinker/fix/security-quick-wins
fix(security): quick-win fixes from the codebase security review
2026-06-08 19:04:14 -05:00
ScreenTinker ba3e2cc785 fix(security): patch quick-win findings from the codebase review
Five low-risk, high-value fixes surfaced by the security review:

#3 Branding lockdown — `custom_domain`/`custom_css` (which feed the PUBLIC,
   pre-auth branding resolver and the login-page <style>) are now settable only
   by platform admins; a workspace_admin can no longer hijack the platform login
   page by claiming its domain. The public /api/branding (+ /domain) now return
   only presentational fields via publicBranding() (no id/user_id/workspace_id/
   custom_domain/timestamps leak).

#6 Strip device_token — the device WS auth secret (validated with
   timingSafeEqual) was returned in device list/get/update + pairing responses
   (SELECT d.* / *). New lib/device-sanitize.js strips it everywhere; prevents
   device impersonation by any workspace user.

#7 must_change_password enforced server-side — was a frontend-only redirect, so
   a provisioned temp password worked indefinitely via the API. requireAuth now
   403s every route except GET/PUT /api/auth/me (the password change, which
   clears the flag) and logout while the flag is set.

#8 XSS — escape user data interpolated into innerHTML in teams.js, kiosk.js,
   layout-editor.js (team/page/layout/zone names, member name/email, kiosk
   config fields). scriptSrcAttr 'unsafe-inline' made these exploitable via
   injected event handlers, not just markup.

#9 Thumbnail IDOR — /api/content/:id/thumbnail had no auth/scope gate (any UUID
   served any tenant's thumbnail). Now mirrors the /file route's playlist/widget
   workspace-scoped reference check.

Tests: new test/security-fixes.test.js (device strip, publicBranding field
allowlist, must_change_password gate). Full suite 41/41. Verified live against a
prod-data copy: device_token absent from /api/devices, /api/branding trimmed.

Not addressed here (tracked for follow-up): Android OTA signature verification
(Critical), public widget-render XSS, token revocation/logout, pairing-code
strength, validateRemoteUrl hardening, import quota.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:02:19 -05:00
ScreenTinker 66ef47239f fix(android): Android 14+ MediaProjection / foreground-service compliance (#5)
On Android 14+ (targetSdk 34) the app could fail to run at all on newer devices
(Pixel 10, onn HD stick). Root cause: the always-on WebSocketService called the
2-arg startForeground(), which claims EVERY foreground-service type declared in
the manifest - including mediaProjection. Android 14 rejects starting a
mediaProjection-typed FGS without a MediaProjection consent token, so the core
service threw on launch and the player never came up. Matches the reporter's
"screen recording policy" hunch - via the FGS type, not the capture trigger.

Fixes:
- WebSocketService now claims ONLY mediaPlayback (explicit
  startForeground(..., FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK), API>=29 guarded;
  2-arg on older). Manifest type narrowed to mediaPlayback.
- New MediaProjectionService (manifest type mediaProjection), started only AFTER
  the user grants consent. It enters the foreground with the mediaProjection type
  BEFORE getMediaProjection() (required on 14+), then drives ScreenCaptureService.
  The consent Activity now hands the result to this service instead of calling
  getMediaProjection() directly (an Activity can't hold that FGS type).
- ScreenCaptureService: register the MediaProjection.Callback BEFORE
  createVirtualDisplay() (Android 14 throws IllegalStateException otherwise).

Verified: Kotlin compiles, manifest merges (WebSocketService=mediaPlayback,
MediaProjectionService=mediaProjection), signed debug APK builds. NOT yet
verified on-device - needs a Pixel 10 / onn-stick run + logcat to confirm the
exact crash is resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:19:56 -05:00
screentinker d6e85b1745
Merge pull request #26 from screentinker/feat/global-default-branding-15
feat: instance-level default white-label branding (#15)
2026-06-08 17:03:39 -05:00
ScreenTinker eb13f716d0 feat(branding): instance-level default white-label branding (#15)
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.
2026-06-08 16:55:22 -05:00
screentinker 5433a97bc9
Merge pull request #25 from screentinker/fix/single-workspace-settings-19
fix(switcher): workspace settings inaccessible with a single workspace (#19)
2026-06-08 16:40:44 -05:00
ScreenTinker 3bf108d0fb fix(switcher): expose workspace settings for single-workspace users (#19)
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.
2026-06-08 16:39:42 -05:00
screentinker c1f2f0a637
Merge pull request #24 from screentinker/feat/searchable-org-switcher-16
feat(switcher): searchable / filterable org switcher (#16)
2026-06-08 16:32:45 -05:00
ScreenTinker 1f62ffbc3b feat(switcher): searchable/filterable org switcher (#16)
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.
2026-06-08 16:31:46 -05:00
screentinker 0f84cac440
Merge pull request #23 from screentinker/feat/admin-user-workspace-mgmt
feat(admin): manage a user's workspace memberships (multi + per-workspace role)
2026-06-08 16:26:07 -05:00
ScreenTinker 2872b883c7 feat(admin): manage a user's workspace memberships (multi + per-workspace role)
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).
2026-06-08 16:24:52 -05:00
ScreenTinker 66c95bb331 fix(db): cascade tenant resources on workspace/org delete (#18 follow-up)
The #18 user-delete bug was the first symptom of a broader gap: 13 tables
reference workspaces(id) (and activity_log also organizations(id)) with NO
ACTION, so deleting a workspace or organization fails the same FK wall once it
holds any content. SQLite can't ALTER an FK action, so this migration rebuilds
each table (the create-copy-rename pattern the assignments/schedules migrations
already use), changing only the tenant FK clause:
  workspace_id -> ON DELETE CASCADE   (resources belong to the workspace)
  activity_log.workspace_id / organization_id -> ON DELETE SET NULL (keep audit)

user_id FKs are intentionally left as-is - user deletion stays handled app-side
by lib/user-deletion.js (the #18 fix).

- lib/tenant-cascade-migration.js: pure, idempotent core (table-existence
  guarded; transforms the stored CREATE text, copies rows verbatim, recreates
  indexes; fixes activity_log's AUTOINCREMENT sequence; baseline-vs-after
  foreign_key_check so pre-existing orphan rows don't abort it but a botched
  rebuild does).
- db/database.js: boot wrapper owns the pre-migration snapshot + process.exit
  on failure, matching the other heavy migrations.

Tests (node:test): reproduces the workspace-delete FK failure, applies the
migration, verifies FK actions (CASCADE / SET NULL), index recreation, data
preserved, and that workspace/org delete now cascades (activity_log preserved).
Full suite 27/27. Verified on a copy of a real DB: 13 tables rebuilt,
integrity_check ok, workspace delete cascades, no new FK violations.
2026-06-08 16:01:52 -05:00
screentinker ec44cb785a
Merge pull request #21 from screentinker/fix/delete-user-fk-cascade-18
fix: user deletion fails with FOREIGN KEY constraint (#18)
2026-06-08 10:52:50 -05:00
ScreenTinker 05f9c20ecf fix(admin): user deletion failed with FOREIGN KEY constraint (#18)
DELETE /api/auth/users/:id ran a bare `DELETE FROM users`, but 23 columns
reference users(id) and only 4 cascade, so with foreign_keys=ON the delete
fails the moment the user is referenced anywhere - and a real user always is
(owns an org, created a workspace, has login activity). Reproduces on a fresh
DB, exactly as reported.

The schema also lacks cascades from workspaces -> tenant resources, so the DB
can't clean up on its own. New lib/user-deletion.js resolves every reference in
one transaction (defer_foreign_keys=ON for forgiving order; table-existence
guard for resilience):
  - Refuse (409) if the user OWNS an organization that has other members -
    don't nuke a shared tenant; transfer ownership first.
  - Hard-delete the organizations they SOLELY own (workspaces + all contents).
  - In orgs they don't own, PRESERVE resources: SET NULL the nullable
    creator/inviter columns, and reassign the NOT NULL legacy creator user_id to
    the resource's org owner (fallback: the acting admin).
  - Memberships (organization_members/workspace_members/team_members/
    content_folders) cascade on the user delete; pending invites they sent and
    legacy teams they own are removed.

The handler now 404s an unknown id and 409s the shared-org case.

Tests (node:test): reproduces the FK failure, then verifies provisioned-member
delete (resources preserved + unlinked/reassigned), solo-org-owner cascade,
shared-org refusal (409), self-delete 400, non-superadmin 403, unknown 404.
Full suite 22/22. Verified end-to-end on a copy of a real DB: deleted a user
owning 2 solo orgs, foreign_key_check clean.

Closes #18.
2026-06-08 10:51:32 -05:00