Commit graph

74 commits

Author SHA1 Message Date
ScreenTinker ce332ead67 feat(switcher): per-workspace device count in dropdown rows
/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.
2026-05-12 14:04:21 -05:00
ScreenTinker 42966da973 feat(teams): temporarily disable Teams API while feature is redesigned
Teams in its pre-Workspaces form is being paused while the feature is
redesigned as a user-grouping primitive within the new Workspaces
architecture. The original Teams data model had no workspace-awareness
and was effectively non-functional after Phase 2.2 (every route migrated
away from team_id), but the UI remained reachable and allowed users to
accumulate orphan data while believing they were configuring access
control.

Hide the Teams sidebar nav entry to prevent new entries to the UI.
/api/teams now returns 503 Service Unavailable with a 'feature
redesign in progress' message. Existing teams/team_members/team_invites
table data is preserved indefinitely for forward migration to the
future teams design.

Bonus: requireAuth middleware fires before the catch-all so unauthenticated
callers see the standard 401 instead of the 503 redesign message - avoids
exposing the 'feature being redesigned' signal to unauthenticated probes
or fingerprint scanners.
2026-05-12 13:30:55 -05:00
ScreenTinker 1e142d9644 fix(frontend): playlists.js thumbnail path uses API endpoint instead of legacy direct path
2 line substitutions in frontend/js/views/playlists.js: switches
/uploads/thumbnails/{filename} -> /api/content/{id}/thumbnail at both
the playlist editor render (line 293) and the Add-to-playlist content
picker (line 543). Brings playlist view inline with widgets.js,
content-library.js, and device-detail.js which already use the API
path.

Side benefit: thumbnails now go through the workspace-aware permission
check in content.js's /api/content/:id/thumbnail handler (checkContentRead)
instead of unauthenticated static file serve at /uploads/thumbnails/.

Reported by semetra22 in Discord ('All images retrieved via the API
display correctly, but in the playlists, the images are fetched
directly from /uploads/thumbnails/filename and do not display properly').
2026-05-12 11:44:30 -05:00
ScreenTinker 56da64d0cd feat(workspaces): rename via switcher dropdown - new PATCH /api/workspaces/:id route, per-row pencil affordance in switcher (visible only when caller can_admin), small rename modal with name + slug fields, validation (name <=80 chars, slug ^[a-z0-9]+(?:-[a-z0-9]+)*$ <=60 chars, blank slug -> NULL), 409 on per-org slug collision. Permission gating via new canAdminWorkspace(db, user, ws) helper in lib/permissions.js - reused-ready for future Phase 3 admin actions. /me query now joins organization_members to compute can_admin per accessible_workspaces entry. Drive-by fixes surfaced: (1) activityLogger method filter was missing PATCH, added; (2) routes that operate on a target workspace by URL param need to stamp req.workspaceId from the param so activityLogger captures the right tenant attribution - documented in the route. Smoke fixture: switcher-test@local.test is workspace_admin of Studio A and workspace_editor of Field Crew (no org_owner) so the can_admin true/false split is exercised in one login. 2026-05-12 11:06:55 -05:00
ScreenTinker 0c91390e56 fix(frontend): workspace switcher (Phase 3 MVP) + SW network-first migration + platform_admin accessible_workspaces expansion + static render CSS cleanup. The switcher adds a sidebar dropdown for users who are members of multiple workspaces, renders as static text with a 'Workspace' label for single-workspace users, and muted 'No workspace' for zero. Uses existing /api/auth/me's accessible_workspaces and POST /api/auth/switch-workspace endpoints. Platform admin / superadmin users now see all workspaces in accessible_workspaces (closing the known regression from 88d91b1) via a LEFT JOIN that preserves workspace_role semantics (null = acting-as, role string = direct member). No cap on the list - deliberate for now, revisit at 50+ workspaces. SW fix bumps rd-admin-v1 -> rd-admin-v2 and switches fetch strategy from cache-first to network-first so the server's existing Cache-Control: no-cache + ETag headers actually get respected; preserves offline fallback. Static render CSS drops the bordered-box chrome that was making single-workspace users think the static text was clickable. Includes test fixture user switcher-test@local.test (credentials in fixture SQL header). Surfaced by semetra22 / Discord report about 'screens jumbled up' post-migration; root cause was the missing workspace switcher UI making devices in non-active workspaces appear missing. 2026-05-12 10:55:09 -05:00
ScreenTinker f88805f36d fix(schedule): add delete button to schedule edit modal so schedules can be removed from the UI (DELETE /api/schedules/:id already existed) 2026-05-11 23:05:03 -05:00
ScreenTinker a77ab365dd fix(dashboard): selection bar surfaces 'pick 1 more' hint when 1 display selected so the disabled Create Video Wall button isn't silently unresponsive 2026-05-11 22:56:15 -05:00
ScreenTinker ac3eb74122 i18n: register Italian locale in language registry (followup to PR #2) 2026-05-11 20:05:09 -05:00
ScreenTinker 2954fd1a84 Phase 2.1: tenancy middleware, permission helpers, JWT workspace context, frontend + backend role-rename compat 2026-05-11 20:02:00 -05:00
albanobattistella 9f1ca2e177
Add Italian Translation 2026-05-09 15:58:48 +02:00
ScreenTinker 2068bc8833 Video walls: free-form canvas editor, leader-driven sync, group dissolve, progress bars
Wall editor: replaces the small grid with a Figma-style pan/zoom canvas. Each
display is a rectangle that can be dragged/resized to match its physical
arrangement; a separate semi-transparent player rect overlays the screens and
defines what content plays where. Drag empty space to pan, wheel to zoom,
"Center" button auto-fits content. Per-rect numeric x/y/w/h panel; arrow keys
nudge by 1px (10px with shift). Negative coordinates supported for screens
offset above/left of the origin. Coords rounded to integers on save.

Wall rendering: each device receives screen_rect + player_rect, maps the
player into its viewport with vw/vh and object-fit:fill so vertical position
of every source pixel is identical across devices that share viewport height.
Leader emits wall:sync at 4Hz with sent_at timestamp; followers apply
latency-adjusted target and use playbackRate ±3% for sub-300ms drift,
hard-seek for >300ms. Followers stay muted; leader unmutes via gesture with
AudioContext priming and pause+play retry to bypass Firefox autoplay.
"Tap to enable audio" overlay as a final fallback.

Reconnect handling: server re-evaluates leader on device:register so the
top-left tile reclaims leadership when it returns. Followers emit
wall:sync-request on entering wall mode (incl. reconnect) so they snap to
position immediately instead of drifting until the next periodic tick.

Group dissolve: removing a device from its last group clears its playlist
to mirror wall-leave semantics. Leaving a group with playlists on remaining
groups inherits the next group's playlist.

Dashboard: walls render as their own card section (hidden the device cards
they contain). Multi-select checkboxes on cards + "Create Video Wall" toolbar
action that creates the wall, removes devices from groups, and opens the
editor. dashboard:wall-changed broadcast triggers live re-render. Per-card
playback progress bar driven by play_start events forwarded from devices.

Security: PUT /walls/:id/devices verifies caller owns each device (or has
team-owner access via the widgets pattern), preventing cross-tenant device
takeover. wall:sync and wall:sync-request validate that the sending device
is a member of the named wall; relay re-stamps device_id with currentDeviceId
so clients can't spoof or shadow-exclude peers.

Schema: video_walls += player_x/y/width/height, playlist_id;
video_wall_devices += canvas_x/y/width/height. All idempotent migrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:11:16 -05:00
ScreenTinker 388e9e6ab8 Admin password reset + widget visibility fix
Password reset for other users:
- New PUT /api/auth/users/:id/password endpoint
- Superadmin can reset any local user; admin can reset role=user
  members of teams they own only (cannot reset other admins or
  superadmins, cannot self-reset — that goes through PUT /me with
  current_password)
- OAuth users are excluded (no password to reset)
- Rate-limited 20 req/min/IP to cap blast radius if an admin session
  is compromised
- Explicit audit log entry "password_reset_for_user / target: <email>"
  on every reset; activity logger's summarizeAction never reads the
  password field, so the password value is not stored anywhere

Frontend: Reset Password button in the Admin user table and Settings
> User Management table. Shown only for local-auth users that aren't
the current user; prompts for an 8+ char password.

Widgets visibility fix:
- routes/widgets.js had `const isAdmin = req.user.role === 'superadmin'`
  which mislabeled superadmin as admin and silently restricted real
  admins (role=admin) to seeing only their own widgets. Now matches
  /auth/users behavior: superadmin sees all, admin sees own + public
  + widgets owned by members of teams they own, user sees own + public.

7 new i18n keys (admin.reset_password, admin.prompt_reset_password,
admin.toast.password_min_8, admin.toast.password_reset, and the
matching settings.user.* / settings.toast.* trio). 1024 keys total,
parity 100% across en/es/fr/de/pt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:45:25 -05:00
ScreenTinker aebaacf2c1 i18n batch 7: index.html modal + player overlay
- Add-Display modal in index.html: marked translatable elements with
  data-i18n / data-i18n-placeholder / data-i18n-html attributes
- app.js: translateStaticDom() walks data-i18n* on init and on every
  language-changed event so static HTML stays in sync
- server/player/index.html: standalone player gets its own inline
  PLAYER_I18N table (en/es/fr/de/pt) with a tiny _t() helper. Reads
  rd_lang from localStorage (set by dashboard) so the player picks up
  the same language. Translates info overlay, setup screen, and
  status messages.
- 1018 keys total in dashboard locales, parity 100%.

This completes the wiring; Android resources are next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:19:06 -05:00
ScreenTinker 6d6f901ef4 i18n batch 6: wire teams + activity + help (~62 keys)
- teams.js: list, detail with members + shared devices, invite/role
  controls, all toasts
- activity.js: page chrome, action verb/noun mapping translated through
  t() so the audit log reads naturally in each language
- help.js: page chrome translated; guides and FAQ body content kept
  in English with a comment explaining why (machine-translated docs
  read worse than English source)
- 1008 keys total, parity 100% across en/es/fr/de/pt

All 16 dashboard views now use t(). index.html modal, player overlay,
and Android resources still pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:16:21 -05:00
ScreenTinker 7a17bb5079 i18n batch 5: wire layout-editor + video-wall + billing (~85 keys)
- layout-editor.js: list with templates + custom, zone editor with
  drag/resize and properties panel
- video-wall.js: list with grid preview, editor with grid config,
  bezel inputs, drag-and-drop device placement
- billing.js: current plan card, plans grid with checkout buttons,
  Stripe portal integration
- 943 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:13:38 -05:00
ScreenTinker f4a81d7be2 i18n batch 4: wire schedule + reports + kiosk (~95 keys)
- schedule.js: weekly calendar, add/edit modal with target/recurrence,
  hour labels, day-of-week headers
- reports.js: filters, summary cards, top-content + by-device tables,
  daily/hourly charts
- kiosk.js: list + editor, page settings, style controls, button list
  with action types
- 838 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:09:32 -05:00
ScreenTinker 457a2e4dd4 i18n batch 3b: wire onboarding.js + admin.js (~84 keys)
- Onboarding: 5-step wizard (welcome, get player, pair, upload, done)
  with translated step titles, content, prompts, error messages
- Admin: superadmin user table, plans, system info, role/plan
  selectors, delete confirms
- 750 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:04:23 -05:00
ScreenTinker 04891bccee i18n batch 3a: wire playlists.js (~65 keys)
- List view: tags, item/display pluralization, empty state, load errors
- Detail view: draft banner, inline rename/description, items list
- Drag-reorder + up/down buttons, duration editor
- Add-item modal with content/widgets tabs and search
- 671 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:00:52 -05:00
ScreenTinker 103803fb92 i18n batch 2b: wire designer.js (~80 keys)
- All 12 element types (text, heading, image, video, clock, date,
  weather, ticker, shape, qr, countdown, webpage)
- Background swatches, properties panel, layers list
- Translated prompts for video/weather/RSS/QR/countdown/webpage URLs
- Toasts for publish, export, load, invalid file
- 612 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:57:12 -05:00
ScreenTinker 0743901e48 i18n batch 2a: wire widgets.js (~107 keys)
- All widget types (clock/weather/rss/text/webpage/social/directory-board)
  with localized names + descriptions
- Full Directory Board editor (categories, entries, logo, backgrounds)
- Content picker overlay
- Confirms, toasts, empty states
- 532 keys total, 100% parity across en/es/fr/de/pt

Designer.js follows in batch 2b.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:52:31 -05:00
ScreenTinker eccf4b7af1 i18n batch 1/6: wire device-detail + settings (~242 keys)
- device-detail.js: tabs, draft banner, layout selector, info cards,
  uptime timeline, controls, remote tab, playlist items, copy/assign
  modals, all toasts and confirms
- settings.js: account, change password, license, user management,
  white-label, server info, setup guide, your data export/import,
  language selector, about
- es/fr/de/pt all at 425/425 key parity; hi skeleton untouched
- Native review still recommended before publicizing as fully supported

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:47:17 -05:00
ScreenTinker 8e7a093150 i18n: extract all strings, add 6 language translations, restructure i18n module
Session 1 of 2 of the i18n rollout.

- Split i18n module into per-language files under frontend/js/i18n/ so a
  translator can edit one language without touching the others.
- Add Portuguese (pt) and seed Hindi (hi). Hindi is intentionally a skeleton
  -- 0 keys, full English fallback -- because we have an active Indian user
  and would rather ship "no Hindi" than ship machine-quality Hindi that
  could read as unprofessional or get formality/gender register wrong.
- 183 keys, 100% parity across en/es/fr/de/pt; native review still
  recommended before publicizing as "fully supported".
- Add t(key, vars) variable substitution and tn(keyBase, n, vars) plural
  helper for _one/_other key pairs.
- setLanguage() now triggers a CustomEvent + HashChangeEvent so the
  existing hash router naturally re-renders the current view, plus a
  subscriber pattern for nav labels rendered once outside the router.
- Wire t() into 3 high-traffic views end-to-end: dashboard, login,
  content-library. Sidebar nav labels in app.js update on language change.
- The remaining 16 views still ship with hardcoded English; they will be
  wired in session 2. The t() lookup is robust against unwired views, so
  the dashboard works in 5 languages while clicking into e.g. Schedule
  still shows English. No regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:25:22 -05:00
ScreenTinker a2c8ab4336 Match YouTube oEmbed embed format (revert nocookie, add referrerpolicy) to fix Error 153 2026-04-29 11:32:57 -05:00
ScreenTinker a273e5b2b6 Switch YouTube embed to youtube-nocookie.com to avoid Error 153 from tracking blockers 2026-04-29 11:28:49 -05:00
ScreenTinker a27738120a Add YouTube video embed to landing page 2026-04-29 11:25:29 -05:00
ScreenTinker 19b62fdc1b Fix landing-page comparison: ScreenTinker 15-device price is \$1,188 not \$989
The Pro plan is \$99/mo flat, so 15 devices for a year = \$1,188. The
landing page's compare table mistakenly showed \$989, which would imply
\$82.42/mo and contradicts every other place the price is quoted (the
comparison pages, the demo video, the pricing cards).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:23:58 -05:00
ScreenTinker 25ab1c485b SEO: add meta tags, sitemap, robots.txt, comparison pages, guides, internal linking
Landing page (frontend/landing.html):
- Title now includes "Self-Hosted" for that keyword
- Description appended "MIT licensed."
- Keywords aligned to spec (digital signage raspberry pi, digital
  signage android tv, video wall software, kiosk software, etc.)
- SoftwareApplication JSON-LD: added applicationSubCategory
  "DigitalSignage", license URL, refreshed description
- Image alt text + og:image:alt + twitter:image:alt now include
  "open-source digital signage"
- New Resources section above the CTA with 6 cards linking to all
  new guides and comparison pages
- Footer rewritten as a 5-column grid (Brand / Guides / Compare /
  Project / Legal) with the new internal links

New SEO pages, all dark-themed, mobile-responsive, ASCII-only:
- frontend/css/seo-page.css (shared nav/footer/article/table styles)
- frontend/compare/yodeck-alternative.html
- frontend/compare/screencloud-alternative.html
- frontend/compare/optisigns-alternative.html
- frontend/guides/raspberry-pi-digital-signage.html
- frontend/guides/digital-signage-android-tv.html
- frontend/guides/self-hosted-digital-signage.html

Each new page has unique title/description/canonical, OG and Twitter
card tags, BreadcrumbList JSON-LD, single h1, proper h2/h3 nesting,
visible breadcrumb, comparison table or step-by-step ordered list,
"Related guides" cross-link block, and a CTA.

Sitemap (frontend/sitemap.xml): added all 6 new URLs with appropriate
priority (0.8 for compare pages, 0.9 for guides). Existing landing
(1.0) and legal pages preserved.

Robots (frontend/robots.txt): allow /compare/ and /guides/, disallow
/player (was previously allowed by mistake).

Server (server/server.js): added explicit GET /sitemap.xml and
GET /robots.txt routes ahead of the static middleware so the
Content-Type is guaranteed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:54:32 -05:00
ScreenTinker 63dcc2b656 Drag-and-drop devices into groups on the dashboard
Device cards are now draggable. Group sections accept drops to add
membership (mirroring the Manage modal — same confirmation if the
device is already in another group). The Ungrouped section also
accepts drops to remove the device from every group it's in.

The existing Manage modal still works for bulk add/remove and for
finding devices not currently visible. Click-to-open on a card still
works; drag is only triggered on actual mouse movement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:54:33 -05:00
ScreenTinker 9b26b4930b Make breadcrumb a drop target for moving content out of folders
Once inside a folder, the only drop targets shown were that folder's
own subfolders — no way to drag a file back up to root or to a parent
without opening the edit modal. Breadcrumb segments now accept content
drops: drop on 'All Content' to move to root, or onto a parent folder
name to move there. The edit modal still works for cross-branch moves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:51:02 -05:00
ScreenTinker 8ec33721f7 Security: sanitize notes, add CSP headers, tighten CORS
LOW 1 (notes XSS): device.notes textarea content now goes through
esc(). Notes weren't in the sanitizeBody allow-list at write time, so
HTML in the field would render unescaped on the device-detail page.

LOW 2 (CSP): enabled Helmet contentSecurityPolicy with default-src
'self', script-src 'self', style-src 'self' 'unsafe-inline', plus the
data:/blob:/https: image and media sources the player needs. Strict
script-src blocks <script> injection; script-src-attr 'unsafe-inline'
keeps existing inline onclick handlers working until they can be
refactored to addEventListener (TODO comment in code).

  CSP applies to /app and most other paths. Skipped on the public
  widget and kiosk render endpoints, the landing page, and /player —
  those legitimately need inline scripts/styles. upgrade-insecure-
  requests is explicitly disabled so HTTP-only self-hosted LAN
  deployments aren't broken.

  Refactored two inline onclick handlers in index.html to data-close-
  modal attributes wired by a delegated listener in app.js. Was the
  only blocker for /app under strict script-src.

LOW 3 (CORS): Express CORS now only allows screentinker.com (and
subdomains) + localhost in production. SELF_HOSTED=true bypasses the
allowlist (operator owns their deployment). Development mode stays
open. Same policy applied to the Socket.IO CORS config which was
previously origin: '*'. Native clients (Android, server-to-server,
kiosk iframes) send no Origin and pass through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:37:31 -05:00
ScreenTinker c105a5941e Security: fix IDORs, XSS, rate limits, SSRF validation
HIGH 1 (teams IDOR): POST/DELETE /api/teams/:id/devices now require the
caller to own the device before assigning or detaching it. Without this
check, any team member could pull any device into their team via UUID
guess and gain remote-control access.

HIGH 2 (schedules IDOR): PUT /api/schedules/:id now re-verifies
ownership of every changed target field — device_id, group_id,
content_id, widget_id, layout_id, playlist_id. Previously only the
schedule owner was checked, letting users fire arbitrary content on
victim devices via update.

HIGH 3 (filename XSS): file.originalname captured by multer bypassed
sanitizeBody. New safeFilename() wraps every INSERT path (multipart
upload, remote URL, YouTube). Frontend sinks now go through esc() in
content-library.js, device-detail.js, video-wall.js. Web player gets
an inline escHtml helper for its info overlay where filenames, device
name, and serverUrl land in innerHTML.

HIGH 4 (kiosk public XSS): config.idleTimeout is now coerced via the
existing safeNumber() helper at both interpolation sites. A crafted
value with a newline can no longer escape the JS line comment to
inject arbitrary code into the public render endpoint.

HIGH 5 (folder DoS): POST /api/folders enforces a per-user cap of 100
folders (429 on overflow). Superadmin exempt.

MED 1 (SSRF): ImageLoader.decodeUrl rejects any URL scheme other than
http(s) so a malicious remote_url can't read local files via file://.
On the server, validateRemoteUrl() is extracted and now also runs on
PUT /api/content/:id remote_url updates — previously the SSRF check
only fired on POST.

MED 2 (fingerprint takeover): the WS device:register fingerprint
reclaim path now rejects takeover while the target device is online or
within 24h of its last heartbeat. A leaked fingerprint can no longer
hijack an active display.

MED 3 (npm audit): bumped uuid 9.x -> 14.0.0 (v3/v5/v6 buffer bounds
CVE; we only use v4 so not exploitable, but clears the audit). path-
to-regexp resolved to 0.1.13 via npm audit fix. 0 vulns remaining.

MED 4 (folder admin consistency): ownedFolder() and the content.js
folder_id move check now both treat only superadmin as privileged,
matching GET /api/folders. Previously a plain "admin" could rename
or delete folders they couldn't see, and could move content into
folders they couldn't list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:37:18 -05:00
ScreenTinker fcecf805ed Add media folder organization to content library
New content_folders table with hierarchical parent_id and per-user
scoping. content.folder_id added (ON DELETE SET NULL so deleting a
folder drops items back to root). New /api/folders route exposes
list/create/rename/move/delete with cycle detection on move.

Content library UI: breadcrumb navigation, subfolder grid, "+ New
Folder" creates inside the current folder, drag-and-drop content
items onto folder cards to move them, and the edit modal has a
folder dropdown showing each folder's full path.

Per-user scoping is enforced server-side: every folder query
filters by user_id, and folder ownership is checked on both folder
mutations and content.folder_id updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:36 -05:00
ScreenTinker ee6888e737 Fix display duplication on WebSocket reconnect
Server-side: when a device reconnects on a fresh socket while the old
TCP zombie is still around, the old socket's eventual disconnect handler
flipped the device offline and removed the new heartbeat entry. Now we
proactively evict any prior socket on register and ignore disconnects
from sockets that are no longer the registered one for that device_id.

Frontend: dedupe devices by id from the API response and only render
each device in the first group it belongs to (multi-group membership
is still tracked for the Manage modal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:00 -05:00
ScreenTinker 05f70b7910 Update ToS: add CSAM policy, fix MIT license conflict, add governing law
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:26:32 -05:00
ScreenTinker 846d61a1b0 Add Discord link and refresh feature copy
- README + landing page footer now link to the community Discord
- Landing page feature grid gains Playlists, Directory Board,
  Offline Resilience, and Mobile Dashboard cards; Scheduling and
  Self-Hosted copy updated to mention group-level schedules and
  the DISABLE_REGISTRATION env var
- Structured data featureList expanded to match; Organization
  sameAs now includes Discord
- README feature list clarifies scheduling precedence, mobile
  responsiveness scope, and the auth/IDOR/XSS audit work

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 17:47:00 -05:00
ScreenTinker 2959eaa149 Refresh cached user so admin plan/role changes propagate
The JWT only carries { id, email, role } and the server reads plan_id
fresh from the DB per request, but the frontend cached the user object
in localStorage at login and never refreshed it. After an admin changed
a user's plan, the dashboard kept rendering the old plan until the
user logged out and back in.

Added api.getMe() and a refreshCurrentUser() helper that runs at
startup and on every hashchange. Settings page now fetches the user
fresh via api.getMe() on render, with localStorage as fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:38:46 -05:00
ScreenTinker 281a735e84 Fix white-label settings not applying on page load
Root cause: the Settings page loaded /api/white-label into the form
inputs but never applied the saved values (primary_color, bg_color,
brand_name, favicon, custom_css) to the actual document. Nothing in
app.js bootstrap touched branding. So the save hit the DB correctly,
reload kept the DB value correctly, but the page always rendered the
hardcoded defaults from css/variables.css and the static "ScreenTinker"
label in index.html — which looked like the save had reverted.

Fix: new frontend/js/branding.js module that fetches /api/white-label
once at startup (app.js) and applies values to:
  - --accent and --bg-primary CSS vars
  - document.title and the .sidebar-header .logo span text
  - all <link rel="icon">/apple-touch-icon hrefs
  - a <style id="wl-custom-css"> tag for custom_css
  - the theme-color meta tag

Settings save now calls resetBranding() after POST so changes apply
immediately without a reload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:36:20 -05:00
ScreenTinker 4392bb460a Add DISABLE_REGISTRATION env var to block public sign-ups
When DISABLE_REGISTRATION=true (or 1), POST /api/auth/register returns
403 with a clear error. OAuth endpoints (/google, /microsoft) also
refuse to auto-create new accounts — existing OAuth users can still
sign in. First-user setup (empty users table) is always allowed so a
fresh install can still be initialized.

GET /api/auth/config now returns registration_enabled so the login
view can hide the "Create Account" button and the trial banner when
registration is off. Absence of the flag is treated as enabled for
back-compat with older servers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:35:32 -05:00
ScreenTinker 4e4664b603 Add directory board editor UI with content picker, category/entry management
Inline editor with:
- Collapsible categories, reorder up/down, delete
- Entries with identifier, name, subtitle, available toggle
- Add/remove with auto-focus on new row
- Empty state prompts first category
- Theme, scroll speed, column count selectors
- Reusable content picker (single/multi-select) against user's image library
- Logo picker + background image picker (multi) via that picker
- Preview button posts unsaved config to /widgets/preview and shows the
  returned HTML in a modal iframe (srcdoc + injected <base> so relative
  content URLs resolve against our origin)
- Delete confirms with widget name

Also escapes w.name / typeMeta.name / w.id in the widget grid to prevent
stored XSS against admins viewing other users' widgets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:28:47 -05:00
ScreenTinker a981171c94 SEO: open-source positioning, GitHub links, OG image, semantic <main>
- Retarget primary keywords ("open-source", "CMS") in title, description,
  OG/Twitter tags and hero h1
- Swap OG/Twitter image from icon-512 to dashboard-preview.png with
  width/height/alt metadata
- Add GitHub link in nav (icon), hero (secondary btn), footer, and a
  new Open Source callout section
- Wrap content in <main> landmark; add width/height on screenshot for
  CLS; add third-party license page to sitemap; Organization schema
  sameAs now points to the GitHub repo

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:56:22 -05:00
ScreenTinker ea80d3aca5 Landing: replace iframe mock with dashboard screenshot
Swaps the live-app iframe for a static PNG of the Displays view.
Faster load, no auth flash, looks sharp.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:47:13 -05:00
ScreenTinker 3476f2b7e7 Landing: group Sign In next to Start Free Trial on the right
Removes the far-right floating position; Sign In sits in the nav
cluster alongside the CTA instead of pinned to the viewport edge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:37:41 -05:00
ScreenTinker e0bfa76545 Landing: float Sign In to far top-right, separate from Start Free Trial
Sign In now lives outside the nav-links cluster with margin-left:auto,
pinning it to the top-right corner with visible separation from the
primary CTA.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:30:33 -05:00
ScreenTinker 87a935cb74 Landing: fix mobile nav overflow so Sign In stays visible
Pixel 8 Pro portrait (~412px) was clipping Sign In because logo + both
buttons overflowed. Hide logo text below 420px, shorten 'Start Free Trial'
to 'Try Free' on mobile, nowrap nav-links with tighter padding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:27:19 -05:00
ScreenTinker 25f3870472 Landing: keep Sign In button visible on mobile nav
Previously hidden behind the primary CTA; now shows alongside it with
tighter padding on small screens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:24:57 -05:00
ScreenTinker 52297ec618 Settings: add account profile + password change UI
Adds a per-user Account section in Settings with name edit and password
change. Password change requires current password; local auth only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:13:20 -05:00
ScreenTinker 8da0e60c20 Mobile: public-facing pages (landing + login)
Login view:
- Remove `margin-left: calc(-1 * var(--sidebar-width))` from the
  centering wrapper. It was a hack to compensate for the sidebar
  offset, but app.js already zeros the app margin on the login
  route. On mobile this was pushing the login card ~240px off
  the left edge of the viewport.
- Use min-height + padding so the card breathes on short screens.
- Drop inline font-size:11px on the support-token input so the
  global .input 16px mobile rule applies (iOS focus-zoom prevention).

app.js:
- Hide the mobile hamburger button on the login route; it has no
  function there since the sidebar is already hidden.

Landing page:
- Scope the old blanket `.nav-links { display: none }` to hide only
  the section anchors + secondary Sign In button, so the primary
  "Start Free Trial" CTA stays visible on mobile.
- Wrap the 5-column Compare table in a horizontal-scroll container
  and set min-width:560px so it scrolls instead of overflowing
  the page.
- Add min-height:44px to .btn on mobile, tighten section padding
  to 16px (from 24px) so content doesn't feel cramped against
  the viewport edge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 18:52:53 -05:00
ScreenTinker 481ae0209a Mobile: fix modal and form control overflow
Inline width:NNNpx beats the .modal { width: 95vw } mobile rule due to
specificity. Convert to max-width:NNNpx;width:95vw on the three affected
modals so they cap at their desktop size but still shrink on mobile:
  - playlists.js add-item modal (560px)
  - device-detail.js assign-playlist modal (650px)
  - content-library.js edit-content modal (500px)

Same fix pattern for fixed-width form controls flagged in QA — selects
and inputs change to max-width:NNNpx;width:100% so they keep their
desktop size but shrink to container on mobile:
  - admin.js role/plan selects (120/130px)
  - teams.js member role + add-device selects (100/200px)
  - content-library.js search input + folder filter (250/180px)
  - onboarding.js pairing code + display name inputs (240px)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 18:48:51 -05:00
ScreenTinker 0bd34544e5 QA fixes: toast aria-live + scope playlist flex-wrap to mobile
- 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>
2026-04-21 16:00:41 -05:00
ScreenTinker 8cd5dd518a Playlist: add up/down reorder buttons
Adds accessible up/down arrow buttons alongside the existing drag-to-
reorder handle on each playlist item. Touch users (and keyboard users)
now have a reliable way to re-order without relying on HTML5 drag-drop,
which is effectively unusable on mobile. First/last items have the
respective arrow disabled.

Uses the same /reorder API the drag handler uses, so behavior stays
consistent. flex-wrap on the item container prevents control overflow
on narrow screens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:57:40 -05:00