Appearance
[UX] playout — Platform admin
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch:
develop@998e706d· Files reviewed: 38 Patterns:data-display/table(attempted — the MCP returned 59k chars of mangled compiled MDX with the prose stripped and no usabletoc, so it contributed nothing; findings below are baseline-derived only)
Summary
Eight routes (admin, admin-tenant, overview, languages, tickets, mails, syncs, persons) share one shell, one Table, one Confirm and one toast pair, so almost every defect here is common to all eight rather than route-specific. The console is visually the most polished surface in the repo — Overview.vue and TenantDashboard.vue are genuinely well built — but the plumbing underneath is unguarded: creating a tenant signs the operator out of their own account and in as the new tenant's service account, six of the eight routes render "nothing here yet, create one" while data is still loading or when Firestore denied the read, and every platform-wide destructive action confirms with a bare "Are you sure?" that never names what is about to be destroyed. Only Overview.vue has a real loading and error state; it is the model the other seven should copy.
Findings
1. Creating a tenant replaces the admin's session with the new tenant's service account — Blocker · proposed SEC-SESSION-INTEGRITY
Where: src/stores/tenants.store.ts:104 (called from src/views/Admin/Tenants.vue:134-137) What: create() ends with
ts
return createUserWithEmailAndPassword(auth, `${tenantData.id}@playout.studio`, serviceAccountPassword);auth is the app's single default-app Auth instance (src/firebase.ts:23) — the same one the signed-in superadmin is using. The Firebase Web SDK signs the newly created user in on that instance, so the moment the batch commits, the superadmin's session is silently swapped for the tenant service account. session.store.ts:11 onIdTokenChanged fires, session.uid changes, and every open useCollection/useDocument re-subscribes as the service account. Tenants.vue:136 then shows a green "Tenant created" toast on top of the hijacked session. Why it matters: the primary action of the platform-admin console logs the operator out of their own account without saying so. The tenant list empties (superadmin rules no longer match), every subsequent write fails, and the next route change re-resolves the role as "" and drops them onto the 403 surface. The only recovery is to sign out and back in — and until they do, a live service-account session is sitting in their browser. Rated Blocker under the normalisation's first clause: the feature does not work as intended for a normal user, independently of any security reading. Fix: create the service-account user server-side (the Admin SDK path already exists in functions/, and delete/:tenantId is already superAdminCheck-gated there), or at minimum mint it on a initializeApp(config, "secondary") instance and deleteApp() it, so the primary session is never touched. Secondary, on the same line: serviceAccountPassword is generated in the browser and persisted in cleartext at tenants/{t}/settings/general, which firestore.rules:243 lets any tenant operator read.
2. Six of eight routes render loading and permission-denied as "nothing here yet" — High · MSG-06 (proposed variant (b): a failed or pending fetch must not render as an empty state)
Where: src/views/Admin/Tenants.vue:30-44; Languages.vue:20-31; Tickets.vue:8-16; Mails.vue:8-12; Syncs.vue:14; Persons.vue:23-50; Overview.vue:229-306 and :353-406 (the analytics/AI blocks only — the release-safety block above them is correct) What: every one of these gates purely on list.length === 0. vuefire's useCollection exposes .pending and .error, and tenants.store.ts:16 even publishes a loading computed — no view reads either. Table.vue accepts a loading prop and renders a skeleton, and Mails.vue:8 never passes it. useCountQuery.ts:9 initialises count to 0 and has no error branch, so TenantDashboard.vue:93/117 show a confident "0 events / 0 admins" while the aggregate query is still in flight and forever if it rejects. Why it matters: two different wrong answers from the same code path. During the (Firestore-cold) first paint, Tenants.vue says "No tenants yet — Create a tenant to get started" and offers a "Create first tenant" button on a platform that has customers. And when Firestore denies the read — which is the normal outcome for anyone who is not a superadmin (firestore.rules:62,69,73,85,92 gate syncs, analytics, ai-usage, tickets and mails to superadmins) — the user gets the identical "nothing here yet" screen plus a create CTA that cannot succeed. An operator can reasonably conclude the platform is empty and start creating duplicates. Fix: thread pending/error from the stores into each view; use Table's existing loading prop; give useCountQuery a null initial value and an error state so "not loaded" is distinguishable from "zero"; and render a distinct denied state rather than the empty state when error is permission-denied.
3. Platform-wide destructive actions confirm with an unlabelled "Are you sure?", and one has no confirmation at all — High · MSG-05
Where: src/views/Admin/Languages.vue:108-112 (empty the whole songs database) and :113-117 (delete a bible); Persons.vue:71-75 (empty the whole persons database); src/components/syncs/SyncTable.vue:95 (delete a sync run — no confirm at all); Languages.vue:57-63 and Persons.vue:12 (re-import / re-index fire immediately) What: Confirm.vue:8 defaults title to $t('common.areYouSure') and confirmLabel to $t('common.confirm'), and passes subtitle straight through. None of the three calls above supply a subtitle or a confirmLabel, so all three render the identical dialog: "Are you sure?" / Cancel / Confirm, with no mention of what is being destroyed. Languages.vue mounts two such dialogs side by side (clear-all-songs and delete-one-bible) that are visually indistinguishable — the one that fires depends only on which boolean is set. The delete-bible dialog also never names the language or translation being removed. The good copy already exists and is not used: en.yml:863empty-songs-database-hint: "Remove all songs from the database. This cannot be undone." is rendered as static grey text next to the button but not inside the dialog. Why it matters: these are irreversible, cross-tenant operations — emptying the songs corpus takes down song search for every tenant at once. Two adjacent dialogs that say exactly the same thing are the classic setup for confirming the wrong one. SyncTable.vue:95 deletes a sync record on a single menu click. Why High not Blocker: the confirmation step does exist and is reachable; the copy is wrong, not the control. TenantDashboard.vue:404-410 shows the right shape — it passes both a confirm-label and a subtitle — so this is a consistency fix, not new machinery. Fix: pass :subtitle and :confirm-label at all three call sites (reuse the *-hint keys that already exist), interpolate the bible/language name into the delete-bible subtitle, and add a confirm to SyncTable.vue:95.
4. The /admin/* route tree is never checked for superadmin — High · proposed SEC-ROLE-SCOPE (a route guard must verify the privilege level the view actually requires, not merely that some role resolved)
Where: src/router/routes.ts:57-65 (no meta), src/router/index.ts:15-30What: checkAdminRole caches the resolved role on the session and re-resolves only when session.role == undefined || (tenant && session.tenant != tenant). None of admin, overview, languages, tickets, mails, syncs, persons carries a :tenant param, so for those routes tenant is undefined and the cached value is reused; and because the parent record only sets meta: { theme: "red" }, to.meta.admin is undefined, so the function returns true for any non-empty role. A user who is an admin or operator of tenant A (role cached during their normal post-login landing on /{A}/events) satisfies the guard for the entire platform-admin tree on any in-app navigation to it. The admin-tenant route is the same shape: it resolves the role for that tenant, so a tenant admin passes the guard for /admin/{their-own-tenant} and is shown the platform tenant dashboard, Mux credential fields and Delete-tenant danger zone. Why it matters: firestore.rules:122 grants allow read: if isAuthenticated() on the whole /tenants collection, so if that surface is reached the entire customer list — id, name and contact email address — renders. The server holds elsewhere (Mux is superadmin-only at firestore.rules:248, delete/:tenantId is superAdminCheck at functions/src/handlers/https/delete.ts:21), so nothing is deleted and no secret leaks. Why High not Blocker: I could not demonstrate immediate exploitation. The nav entry is correctly hidden (NavbarUser.vue:29, MobileTabBar.vue:125 both gate on session.isSuperAdmin), and typing /admin in the address bar forces a full page load, which resets session.role to undefined and re-resolves it with no tenant — resolveUserRole (functions/src/services/permission.ts:190) returns '' and the 403 shows. So the gap is latent: it needs one in-app router.push({ name: 'admin' }) from anywhere to become live. That matches the brief's "needs another condition to exploit" definition of High. Fix: add meta: { superadmin: true } to the /admin parent and have checkAdminRole return session.isSuperAdmin for it; drop the tenant-keyed cache when the target route has no tenant param. Separately, /tenants should not expose contact and churches to every authenticated user — the tenant chooser only needs id and name.
5. Church scope on the tenant dashboard writes immediately and is invisible to the save bar — High · MSG-05
Where: src/views/Admin/TenantDashboard.vue:462-465, :489-502, :200-209What: tenantChurches is a writable computed whose setter is tenants.updateChurches(...) — a direct updateDoc. Every addChurch, removeChurch, setAllChurches and clearAllChurches hits Firestore on click. Meanwhile features and Mux are staged in local models behind the dirty/save bar at :356-381. isDirty (:584) watches only featureSettingsModel and muxSettingsModel, so church changes never light the bar and discardChanges (:600) cannot undo them. The starkest case is clearAllChurches at :500: the tiny × on the "All churches" chip (:203-209) instantly rewrites the tenant from "sees every church" to [] — an empty list, not a hand-picked one — with no confirmation and no undo. Why it matters: on a page that visibly promises "you have unsaved changes / Discard / Save", a class of change is silently already committed. An operator who removes the wrong church and hits Discard believes they reverted it. Church membership is the tenant's data-access scope, so [] is a live scoping change to a customer's production tenant, made by one click on a 16px control. Fix: stage churches in the same local model as the other two sections so the dirty bar and Discard cover them, and confirm the all-churches → specific-list transition (the code comment at :195-196 already knows it is a mode change).
6. Roughly thirty user-visible strings bypass i18n entirely — Medium · CONTENT-01
Where: Tenants.vue:22,32,34,42,48; Tickets.vue:14,31; Mails.vue:14,20,26,32,38 (all five column headers); Syncs.vue:10; Persons.vue:14,20,27,30,40,55,68,69; SyncTable.vue:12,18,24,30,36,42,48 (all seven column headers); Table.vue:137,145,193; ContextMenu.vue:11; DangerZone.vue:4What: these are English literals in templates, not keys. This is a different defect from the project-level one: pnpm check:locales compares key sets, so a string that never becomes a key is invisible to CI and untranslatable in every locale, including by a translator who does the outstanding # TODO: translate work. The routes are inconsistent about it within a single screen — Persons.vue translates its $t('menu.persons') title and then hardcodes "Database", "Core API", "Indexed", "Empty database" and "Remove all persons from the database. This cannot be undone." directly underneath. Why it matters: a Norwegian superadmin gets a half-Norwegian console, and the strings that stay English include every table column header and the copy on the destructive controls. Fix: move them into en.yml under an admin.* namespace; check:locales will then flag the fr/no gaps. Separately, Table.vue and ContextMenu.vue live in @playout/ui-adjacent shared code and need the text as props (the package contract in packages/ui/CLAUDE.md forbids $t inside the package, so these belong as defaulted props). Project-level: the fr/no # TODO: translate value-parity problem — no.yml:470-472 (overview) and :863-866 (languages) are affected — fails project-wide, see PROJECT-LEVEL.md. Not re-filed here.
7. Icon-only controls with no accessible name, several below the 24px target floor — Medium · A11Y-05, A11Y-02
Where: TenantDashboard.vue:45-52 (the ⋯ MenuDropdown trigger), :221-227 (the × that removes a church), :5-11 (the back link); src/components/admin/Ticket.vue:16-23 and :25-32 (archive / restore) What: each of these renders an icon component inside a VButton/button with no aria-label, no title and no sr-only text. Two sibling controls get it right and show the omission is accidental: TenantDashboard.vue:204 gives the "All churches" × an aria-label, and ContextMenu.vue:11 ships an sr-only "Open options" — the ⋯ trigger next to it does not. On size: the church-removal × is p-0.5 (2px) around a text-xs (12px) glyph ≈ 16×16 px, and the back link is bare text-xs with no padding — both under WCAG 2.5.8's 24×24. Why it matters: a screen-reader user hears "button" five times on the tenant dashboard with no way to tell the destructive one (remove a church from a tenant's access scope) from the benign one. The archive control is a ticket's only action. Fix: aria-label on all five, and pad the × controls to a 24px hit area.
8. Toasts and the Overview error block are not announced — Medium · MSG-01
Where: src/components/common/Success.vue:11-14, src/components/common/Error.vue:11-14, src/views/Admin/Overview.vue:36-43What: the two global toasts are plain <div v-if> inside a <transition> — no role="status", no role="alert", no aria-live region wrapper. Every outcome in this feature reports through them: tenant created, tenant deleted, id copied, settings saved, sync triggered, songs cleared, persons cleared, and every layout.showError("default"). Overview.vue:36 likewise renders its load failure in a bare <div>. Success.vue:50 auto-dismisses after 5s. Why it matters: a screen-reader user gets no feedback at all after emptying the songs database or deleting a tenant, and the message is gone before they could find it manually (WCAG 4.1.3). Fix: role="status" on the success toast, role="alert" on the error toast and on Overview.vue:36, with the live region present in the DOM before the message so the insertion is announced. Shared components — worth promoting to the project-level list alongside the known VAlert gap.
9. Overview.vue has three <h1>s and no heading structure beneath them — Medium · A11Y-04
Where: src/views/Admin/Overview.vue:6, :221, :313; section labels at :87, :133, :166, :189, :232, :240, :251, :324, :337, :343, :354What: VTitle hardcodes <h1> (packages/ui/src/components/VTitle.vue:2 — asserted from source; packages/ui is in-repo). Overview.vue uses it three times, so the page announces three level-1 headings. Every one of the eleven actual section labels below them is a styled <p>, so the longest page in the console has zero navigable structure between its top and its data. Why it matters: heading navigation is the primary way a screen-reader user skims a dashboard; here it yields three identically-ranked landmarks and nothing else. The two hand-rolled analytics tables (:258, :361) compound it — neither has a <caption> and neither <th> carries scope="col", which Mails.vue and SyncTable.vue both do correctly. Fix: give VTitle an as/level prop (upstream, in @playout/ui), use it for the two sub-sections, promote the section labels to <h3>, and add scope="col" + <caption class="sr-only"> to both tables. Project-level: the VTitle hardcoded-<h1> root cause and the missing per-route <title> (NAV-03) both fail project-wide — see PROJECT-LEVEL.md.
10. Failures in the New-tenant wizard are silent, and the entered data is gone — Medium · MSG-06, FORM-05, FORM-06
Where: src/components/tenant/NewTenant.vue:173-178, :251-268; src/views/Admin/Tenants.vue:134-137What: three problems on one path. (a) NewTenant.vue:267 emits closeimmediately after create, and :283-285 resets the whole form on the next open — so the wizard is torn down before the write is attempted. (b) Tenants.vue:135 is await tenants.create(tenant) with no try/catch; tenants.store.ts:41 validate(TenantSchema, …) throws synchronously on a bad id and batch.commit() can reject, and either becomes an unhandled promise rejection — no toast, no error, nothing. (c) NewTenant.vue:174:disabled="!canContinue" is a disabled submit with no message explaining the block (FORM-05), and disabling a just-clicked button drops focus to <body> (FORM-06 — the known VButton native-disabled defect, see PROJECT-LEVEL.md). The same missing-catch shape appears at Languages.vue:149-153 and :155-160, where api.importSongs(...) / importBibleFromSQL(...) are fired and not awaited at all, and at Persons.vue:106-113, where getDataFromApi has no catch, so a failed countPersons() leaves both counters permanently blank and skips the second call. Why it matters: the tenant id is a URL slug and half of a service-account email, its constraints are never stated (FORM-04), and a rejected value produces a closed modal, an empty form and no message. The user has no way to know whether the tenant was created. Fix: keep the modal open until the promise settles, catch and surface the error, replace the disabled button with an always-enabled one plus a validation message, and state the id format before the user types.
11. SecretField and CopyField labels are not associated with their inputs — Medium · FORM-01
Where: src/components/common/SecretField.vue:3-6 and src/components/common/CopyField.vue:3-6, used five times at TenantDashboard.vue:307-343What: both render <label>{{ label }}</label> with no for, and the <input> has no id. The label is adjacent text, not a programmatic label. Why it matters: all five Mux credential fields ("Token ID", "Token secret", "Signing key ID", "Private key", "Signing secret") are announced as unlabelled text boxes containing masked content — indistinguishable from each other, on a form where pasting the wrong secret into the wrong field breaks a tenant's streaming. Fix: useId() and wire for/id. FORM-03 is correctly not applicable here — per BASELINE.md's playout note these are API secrets, and the -webkit-text-security choice is deliberate and documented at SecretField.vue:8-12. The reveal toggle does get an accessible name from title, but its state is not exposed programmatically (aria-pressed) — worth folding into the pending A11Y-07(d) reconciliation rather than filing separately.
12. Copy that reports failure without a next step — Low · MSG-03, CONTENT-04
Where: en.yml:135 overview.load-error: "Could not load the overview."; Overview.vue:209-214 (loading state) What: the error names the failure and stops. The loading state is a bare spinning icon with no text at all, so nothing tells a user — or a screen reader — what is being waited on. Fix: "Could not load the overview. Retry, or check the Firestore status page." and a "Loading activity overview…" label on the spinner.
Unverified
- A11Y-01 (contrast). Not settleable from source. Several patterns need a meter:
text-faintonbg-panel(used for every table cell and section label across all eight routes), the amber drift banner atPersons.vue:51-56(text-amberonbg-amber/15), thetext-rederror atOverview.vue:40onbg-red/5, and thetext-green/text-redbanner text atOverview.vue:68-75on 10%-opacity backgrounds. - A11Y-06 (short viewport / responsive). Needs a rendered page. Two specific things to check: the sticky save bar at
TenantDashboard.vue:356-381, which is positionedbottom-[calc(var(--tabbar-h)+1rem)]and could overlap the danger zone at ~700px height; andTable.vue:73min-w-160, which forces horizontal scroll forMailsandSyncTablebecause neither provides the#cardslot thatTable.vue:13uses to switch to a mobile card list. - MenuDropdown ARIA.
MenuDropdown.vue:31setsrole="menu"on the panel while the slotted children are plain<button>s with norole="menuitem". That is an invalid ARIA composition and I would expect items not to be enumerated in menu-browse mode, but the actual AT behaviour needs testing. The keyboard-reachability half of the project-levelMenuDropdownfinding does not apply at these two call sites (TenantDashboard.vue:44-52and:238-243both put a real<button>inside the trigger slot, so click bubbles from a focusable element) — butaria-expanded,aria-haspopupand Escape handling are still absent.
Baseline additions
SEC-SESSION-INTEGRITY— an administrative action must never alter the acting user's own authentication state. Provisioning a credential for another principal has to happen server-side or on an isolated client instance. (Finding- Probably belongs in SEC even though the symptom is functional.)
SEC-ROLE-SCOPE— a route guard must verify the specific privilege the view requires, not merely that some role resolved non-empty; a cached authorisation decision must be invalidated when the scope it was resolved for changes. (Finding 4. This is the router-side twin of the "persisted scope never revalidated" theme already tracked in PROJECT-LEVEL.md for three projects.)STATE-PENDING(new name proposed deliberately —MSG-06is already carrying four different meanings per PROJECT-LEVEL.md) — a list must distinguish loading, empty, no results for this filter and denied, and must never offer a create CTA on any of the last three. (Finding 2.)FORM-STAGED-WRITES— on a screen with an explicit Save/Discard control, every editable field on that screen must be covered by it; no control may write immediately while its neighbours are staged. (Finding 5. Related to, but narrower than, theFORM-12(d)"warn before discarding entered form data" proposal.)- Finding 6 is a genuine second sense of
CONTENT-01: copy that never enters the i18n layer at all is invisible to a key-parity check and so is not caught by the existing project-level finding. Worth splitting the rule into CONTENT-01a (all copy is keyed) and CONTENT-01b (all keys are translated).
Cross-project note
- Finding 2 (pending/denied rendered as empty) is the single most portable defect here. PROJECT-LEVEL.md already records it for members (
DonsProgress.vue,objectifs.store.ts,MyDecharges.vue) and tt-time-tracker (worker screens have no error surface at all), and customer-portal's DataTable pages have the related "(status: error)" bug. All four projects. - Finding 3 (generic confirm copy) — worth checking
customer-portalandtt-time-trackeradmin tables, both of which have delete affordances. - Finding 4 (guard verifies a role, not the role) is the same root cause as the "persisted scope never revalidated" row in PROJECT-LEVEL.md, which already lists playout, customer-portal and tt-time-tracker. This is a fourth instance in playout and strengthens the case for one shared rule.
- Finding 8 (toasts with no live region) — check every project's toast component; none of the four has been audited at that layer yet.
- Currency precision — playout answered, and it PASSES. PROJECT-LEVEL.md asks for an explicit answer.
src/utils/format.ts:28-37is playout's onlyIntl.NumberFormatmoney formatter and it setsminimumFractionDigits: 2withmaximumFractionDigitswidened to 4 for sub-cent amounts; the source value goes throughmicrosToCurrency(packages/schemas/src/aiUsage.schema.ts:27,micros / 1_000_000) so no precision is lost. It is display-only (AI cost,Overview.vue:328,394andAiUsagePanel.vue:23) — playout has no money input anywhere. Mark playout ✓ in the CONTENT-05 table.