Commit graph

325 commits

Author SHA1 Message Date
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
ScreenTinker 7615eabdd5 feat(admin): Workspace column + inline move/assign on the Users page
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>
2026-06-08 10:34:47 -05:00
ScreenTinker 65691e26da chore(admin-sw): bump cache to v3 to evict stale clients
Force returning browsers to drop the old service-worker cache bucket so the
new platform Users "Add user" button lands. The SW is already network-first;
bumping CACHE (rd-admin-v2 -> v3) changes the SW bytes, which makes the browser
detect a new worker and run activate(), deleting every cache key != CACHE.
Also rescues any client still stuck on the pre-v2 cache-first worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:34:47 -05:00
ScreenTinker 400872f8ea feat(admin): Add User from the platform Users page (workspace picker)
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>
2026-06-08 10:34:47 -05:00
screentinker 9aae64c47a
Merge pull request #20 from screentinker/docs/surface-disable-registration
docs: surface DISABLE_REGISTRATION self-hosting flag (#11)
2026-06-08 10:30:13 -05:00
ScreenTinker 406f481a57 docs: surface DISABLE_REGISTRATION self-hosting flag (#11)
DISABLE_REGISTRATION already closes public self-service signup (first-user
setup on an empty DB still allowed) and the login page already hides its
"Create account" button when it's set - but the flag was easy to miss: it was
in the README env-var table yet absent from .env.example (the file
self-hosters actually copy) and from the README systemd unit example.

- .env.example: document DISABLE_REGISTRATION + DISABLE_HOMEPAGE under the
  Self-hosting section.
- README: add commented Environment= lines for both to the systemd example,
  noting the login UI hides the signup button to match.

Docs only - no code change. Backend gate (routes/auth.js canRegister +
/auth/config registration_enabled) and the login.js hiding already behave
correctly; verified registration_enabled flips to false under the flag.

Closes #11.
2026-06-08 10:29:08 -05:00
screentinker 212170eb88
Merge pull request #17 from screentinker/feat/role-model-and-admin-users
Role model + MSP user provisioning (#14, #13, #10, #12)
2026-06-05 13:44:46 -05:00
ScreenTinker 5502a3eaa8 fix(roles): make platform_operator assignable + add deny/assign regression tests
The bug: #13 added 'platform_operator' to the frontend role dropdown
(PLATFORM_ROLE_OPTIONS) but #14's PUT /api/auth/users/:id/role whitelist
(ASSIGNABLE_PLATFORM_ROLES) only listed ['user','platform_admin'], so
selecting "Platform operator" returned 400 "Invalid role" - the role was
unassignable via the UI.

Fix: add 'platform_operator' to ASSIGNABLE_PLATFORM_ROLES. One line; the
self-demote guard is intentionally left untouched (a platform_admin still
cannot self-assign the non-owner operator role and lock themselves out).

Tests (node:test, isolated in-memory DB injection - no DB_PATH change):
- admin-users.test.js: platform_admin can PUT role=platform_operator on a
  target user -> 200 and the row persists as platform_operator (regression
  guard for the whitelist gap).
- operator-permissions.test.js (new): verify-then-test of the highest-blast
  -radius deny. Operator CAN update/delete a workspace-scoped content row
  (cross-org write works) but is denied (403) updating or deleting a shared
  (workspace_id IS NULL) row - proving the separate PLATFORM_ROLES gate in
  content.js's checkContentWrite still holds after canWrite was broadened to
  isPlatformStaff.

Verified read-only (no leak): the other shared-asset write sites keep their
PLATFORM_ROLES gate that excludes operator - kiosk.js:57, widgets.js:110,
folders.js:31, layouts.js:59/117/133.

cd server && npm test -> 12 pass / 0 fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:44:39 -05:00
ScreenTinker 7674f6dc9f test(admin): node:test coverage for Add User + role gating
Adds server/test/admin-users.test.js and a `npm test` (node --test) script.
No DB_PATH override: the suite mounts the real routers against an isolated
in-memory better-sqlite3 instance injected into the require cache, seeded by
the test itself. Node v20 built-ins only (node:test, node:assert, fetch).

Covers: Add User success (response omits password/hash, hash stored not
plaintext, membership written, hosted lifecycle sentinels stamped, audit row
without the password), duplicate-email 409 (no overwrite), non-admin 403,
platform_operator denied (403), org_admin scoped to their own org only,
input validation, and the must_change_password lifecycle (set on create,
surfaced on login, cleared on PUT /api/auth/me).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:23:06 -05:00
ScreenTinker 54549420e7 feat(signup): optional org-on-create for self-service signups (#12)
MSP-style deployments want self-service signups created WITHOUT a personal
org, so an admin/operator can assign them into an existing customer org
afterward.

- config.autoCreateOrgOnSignup (AUTO_CREATE_ORG_ON_SIGNUP env), default
  true - single-tenant and the hosted self-service flow are unchanged.
- ensureDefaultOrgForUser gains { allowCreate }: an existing membership is
  always returned (idempotent); the MINT path is gated. allowCreate=false +
  no membership -> returns null (user created org-less).
- register accepts a per-request createOrg flag overriding the deployment
  default; the first-ever user is always given an org (never headless).
  login / Google / Microsoft pass allowCreate from the global config, so an
  org-less user is not silently given an org on next sign-in.

Edge case: a non-platform user with zero workspaces now lands on a "no
workspaces yet" empty state (new no-workspace view) instead of being bounced
into onboarding (whose pairing step needs a workspace). route() redirects
them there, and refreshCurrentUser() redirects once /me reveals zero
accessible_workspaces (covers the first-load race). The workspace switcher
already rendered an empty placeholder and resource routes already return []
for a null workspace, so nothing crashes in between.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:16:27 -05:00
ScreenTinker 6e31770cee feat(admin): admin-provisioned user creation + first-login gate (#10)
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>
2026-06-05 11:03:56 -05:00
ScreenTinker 48902f6807 feat(roles): add cross-org platform_operator staff role (#13)
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>
2026-06-05 10:30:21 -05:00
ScreenTinker 797eab7c8d refactor(roles): normalize the platform-role model (#14)
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>
2026-06-05 09:58:46 -05:00
ScreenTinker 0fec335e75 docs: add Android player troubleshooting & recovery guide
Covers the "Connecting to server" / xhr-poll-error hang (stale server URL,
fixed via Clear data + re-provision), and adb-over-Wi-Fi setup including the
gotchas: must be on the same subnet, and never `adb root` over a wireless
connection (it wedges adbd until reboot). Linked from the README Device Setup
section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:58:09 -05:00
ScreenTinker 890ec5790f fix(proof-of-play): throttle play_logs writes to prevent runaway bloat
A player stuck in a tight loop (playlist with 0-second item durations)
fires device:play-event 'play_start' ~3x/sec, inserting a play_logs row
each time. Three web players doing this generated ~909k rows (99.9% with
duration_sec=0) and grew the prod DB to 265 MB.

Throttle proof-of-play inserts to at most one per device per 2s (in-memory
lastPlayLogAt map). Skipped cycles create no row; the live dashboard
progress event still fires every time, so the UI is unaffected. The
play_end UPDATE only closes open rows, so throttling play_start is safe.

(Junk rows already pruned in prod: 909k deleted, DB 265 MB -> 9.8 MB.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:52:22 -05:00
ScreenTinker cbe00d6c85 feat(signup): T+3 activation nudge for users with zero paired screens
Daily sweep (15:00 UTC) emails a warm, personal "checking in" message
to users who signed up 3-14 days ago and still have no paired screen,
nudging them toward activation. Once per user, reuses the Graph
transport (services/email.js) via the existing fromName/rawSubject
options.

- New service services/activationNudge.js, started from server.js.
  Self-correcting daily scheduler (recompute next 15:00 UTC each run;
  no node-cron dependency).
- Eligibility (Option B, workspace-aware): created 3-14 days ago,
  activation_nudge_sent_at IS NULL, COALESCE(email_alerts,1)=1 (only
  an explicit opt-out of 0 is excluded; NULL/unset still qualify), and
  ZERO devices owned by the user OR present in any workspace they
  belong to. The workspace check avoids nudging engaged team members.
- Idempotency: activation_nudge_sent_at, stamped after send; paired
  sentinel-1 backfill so the first sweep can't blast the dormant
  legacy base. Only genuinely-new signups become eligible.
- GATE: HOSTED_INSTANCE=true (positive hosted signal, NOT !selfHosted).
  A daily bulk sweep would be far worse to leak than a single email, so
  a self-hoster who configured Graph but missed SELF_HOSTED won't blast
  their user base. Unset -> neither scheduled nor sent. Documented in
  .env.example.
2026-05-30 20:28:24 -05:00
ScreenTinker 2f78fa1106 chore: track .env.example (un-ignore from .env.* rule)
The prior commit's .env.example was silently dropped by the .env.*
gitignore rule. Add a "!.env.example" negation so the documented
template (placeholders only, no secrets) is tracked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:16:52 -05:00
ScreenTinker c0b220836a fix(signup): make admin-notify recipient env-driven, not hardcoded
The admin signup-notify recipient was hardcoded to
support@screentinker.com and shipped in the open-source code. Combined
with the opt-out SELF_HOSTED gate, any self-hoster who configured their
own Graph credentials but forgot SELF_HOSTED=true would fire their
users' signup PII (email, IP, country) into our support inbox.

Source the recipient from ADMIN_NOTIFY_EMAIL instead, defaulting to
null. When unset, the admin notification is skipped entirely and logged
("[SIGNUP-EMAIL] admin notify skipped (ADMIN_NOTIFY_EMAIL unset)"); the
user's welcome email is unaffected. Hosted prod sets the env var so its
notifications continue; self-hosters send nothing to us by default, and
the .com address no longer ships in code.

Document ADMIN_NOTIFY_EMAIL (and the related mail/self-host vars) in a
new .env.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:16:33 -05:00
ScreenTinker a0abdc01ed fix(signup): route admin signup notification to support@ not personal inbox
Admin signup notifications were going to dw5304@gmail.com. Route them
to the monitored support@screentinker.com queue instead, so signups
land in the shared inbox rather than a personal account.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:52:59 -05:00
ScreenTinker b67fbaa1b6 feat(signup): welcome email + admin signup notification (slice 1)
Every new user now gets a personal welcome email from
"Dan at ScreenTinker" <support@screentinker.com>, and Dan gets an
admin notification, immediately after signup. Fired from all three
signup paths (local /register, Google, Microsoft) via a shared
helper (services/signupEmails.js) at the new-user branch only, so
OAuth logins of existing users don't re-trigger.

- Reuses the single Microsoft Graph transport (services/email.js).
  Adds two optional, backward-compatible params: fromName (custom
  From display name; address stays support@ so replies route there)
  and rawSubject (skip the "[ScreenTinker] " prefix for clean
  subjects "Welcome to ScreenTinker" / "New signup: <email>").
- Idempotency: users.welcome_email_sent_at, stamped after the send
  block; non-null short-circuits so a user is only emailed once.
  Paired backfill stamps all pre-existing users with sentinel 1 so
  a future "IS NULL" sweep can't mistake the legacy base for
  un-welcomed and blast them.
- Production-only: gated on !config.selfHosted so self-host
  operators never emit mail from our domain or CC Dan.
- No retry logic by design (no re-trigger path on existing users);
  per-email {sent, reason} is logged so a Graph hiccup is visible.

Admin notification includes workspace org name, email, UTC + Central
timestamp, client IP (CF-aware), CF-IPCountry, and user agent.
2026-05-30 14:50:27 -05:00
ScreenTinker d7e3ae6076 security(widgets): tighten webpage widget inner sandbox
The webpage widget's inner iframe previously declared
sandbox="allow-scripts allow-same-origin", which was
functionally stripped to "allow-scripts" by the outer
iframe sandbox added in fe36c8c. This commit makes the
declared sandbox match the actual effective behavior.

Closes the remaining piece of issue #8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:14:31 -05:00
ScreenTinker fe36c8c4b9 security(widgets): add sandbox="allow-scripts" to widget iframes
Addresses the primary finding from the May 27 security report (issue #8):
the admin widget preview modal (frontend/js/views/widgets.js) and the web
player widget renderer (server/player/index.html, 2 sites) loaded
user-authored widget HTML into unsandboxed iframes. Same-origin scripts
in the widget content could access window.parent.localStorage and
exfiltrate the JWT.

sandbox="allow-scripts" without allow-same-origin sandboxes the widget
into a unique origin: inline scripts (clock, RSS, weather widgets)
continue to work, but parent-origin access and same-origin requests are
blocked. Verified via Playwright probe against all 6 widget types in the
dev DB (clock, rss, social, text, weather, webpage): each renders
correctly under the new sandbox and contentDocument access from the
parent is blocked (opaque-origin enforcement working). Admin preview
unchanged in appearance; player display unchanged.

Webpage widget (server/routes/widgets.js) sandbox tightening (drop
allow-same-origin) is a separate forthcoming commit - needs test against
real embed URLs since some sites rely on same-origin behavior. The
sandbox-attribute intersection rule means today's outer-iframe sandbox
will cascade and strip allow-same-origin from the webpage widget's inner
iframe too; accepted as a narrow cosmetic regression (cookies/localStorage
stripped for embedded sites) until the deliberate inner-iframe handling
ships.

SECURITY.md added with reporting process (GitHub Security Advisories
primary, support@bytetinker.net fallback) and scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:28:34 -05:00
ScreenTinker 159a36ed99 fix(workspaces): use APP_URL env var for invite-accept URL generation
Slice 1+3 (c4fbd2b) introduced PUBLIC_URL as the env var name for the
public-facing origin used to construct invite-accept URLs. The README
has long documented APP_URL as the canonical name for this concept
(used for Stripe callbacks in the existing codebase). The new code
should have read APP_URL from the start; PUBLIC_URL was unintentional
naming drift.

Caught during prod-deploy survey on 2026-05-17: APP_URL was set on the
production systemd unit and documented in the README, but read by no
code path on origin/main. PUBLIC_URL was read by slice-1 code but set
nowhere. The bug was masked in 99% of cases by the request-derived
fallback (${req.protocol}://${req.get('host')}) which produces the
correct URL when invites are triggered from browsers behind Cloudflare.
It would have manifested for any future non-browser-triggered invite
path.

README updated to note APP_URL covers both Stripe callbacks and
invite-accept URL generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:26:07 -05:00
ScreenTinker caa9fd0f40 feat(workspaces): mutation UI for members (slice 2B)
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>
2026-05-17 14:45:34 -05:00
ScreenTinker 399af54839 feat(workspaces): accept-invite URL handler (slice 2C) + email URL path fix
Slice 2C: hash route #/accept-invite/{id} with full flow support across
all six auth entry points (login/register/Google/Microsoft/support/setup)
via app-boot consumer pattern rather than per-handler hooks. Stash
mechanism uses localStorage with timestamp + staleness check
(INVITE_EXPIRY_DAYS_FRONTEND = 7, mirrors backend default). On success:
switch workspace, reload, show toast post-reload via scoped
pending_invite_toast key. On error: showToast directly, no reload.
Non-reentrant guard prevents double-consume across the synthetic
hashchange that fires before reload completes.

Two bugs surfaced during Playwright-driven verification (slice 1 left
two latent issues that only manifested when the full accept-invite
flow ran end-to-end):

1. Email URL path: workspaces.js constructed
   ${publicBase}/#/accept-invite/X which lands on the marketing landing
   page (the SPA is at /app). Fixed to use
   ${publicBase}/app#/accept-invite/X. Any invite email sent before
   this fix would have produced an unfollowable link.

2. Synchronous hashchange race: location.hash = '#/' followed by
   reload() fires hashchange BEFORE the reload unloads the page. The
   intermediate route() call would consume the toast key against a DOM
   about to be destroyed, so the post-reload page had no toast. Fixed
   with history.replaceState which mutates hash without firing
   hashchange.

Files:
- server/routes/workspaces.js (+4/-1, /app path fix + comment)
- frontend/js/api.js (+3 LOC, acceptInvite helper)
- frontend/js/app.js (+154 LOC, accept-invite plumbing)
- frontend/js/i18n/en.js (+9 LOC, accept.* keys)

Browser verification: 11/11 assertions PASS via Playwright suite
covering all 5 D-cases (unauthed flow, authed direct, wrong account,
stale stash, already-member). Script stashed at
~/Documents/screentinker-2c-playwright-2026-05.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:50:23 -05:00
ScreenTinker 8db171d979 feat(workspaces): members page read-only view (slice 2A)
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>
2026-05-16 13:00:51 -05:00