Skip to content

[UX] playout — Admin management ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch: develop @ 998e706d · Files reviewed: 22 Patterns: authentication/user-profile

Summary ​

settings-admins is the screen that grants and revokes tenant admin rights, and it is the least defended write surface in the app. Nothing stops an admin from removing or demoting themselves, and nothing counts the remaining admins — a single-admin tenant can be permanently locked out of its own settings by two clicks, recoverable only by a platform super-admin. Underneath that, every write on the page is fire-and-forget: no await, no .catch, no toast, so a failed invite, a failed role change and a failed removal are all indistinguishable from success or from nothing happening. The repo already ships the two helpers that would fix this (Confirm.vue and useAsyncAction) and this screen uses neither.

The confirmation dialog that MSG-05 asks for does exist, which is more than most destructive surfaces manage — but its copy says only "Are you sure?", never names what access is lost, and its destructive button is labelled $t('settings.admins.title'), i.e. the word "Admins".

Findings ​

1. An admin can remove or demote themselves, and nothing protects the last admin — High · MSG-05, LASTADMIN-01 (proposed) ​

Where: src/components/tenant/settings/Admins.vue:79-93 (role select + delete button rendered for every row, including the current user), src/stores/admins.store.ts:70-73, firestore.rules:125-128What: The admin list is rendered with v-for="admin in admins.list" and every row gets both a role VSelect and a danger delete button. There is no comparison against session.uid anywhere in the component or the store, so the signed-in admin's own row is fully interactive; the list does not even mark which row is you. remove() is a plain deleteDoc and updateRole() a plain updateDoc, with no count of remaining admins. The Firestore rules authorise both — match /admins/{adminId} { allow write: if isTenantAdministrator() } evaluates the actor's role at write time, which is still admin for the write that revokes it. There is no Cloud Function in front of either path, so there is no server-side guard either. Two consequences follow. (a) Lockout. The last admin of a tenant removes themselves → invite-codes requires isTenantAdministrator() to write (firestore.rules:220-222), match /tenants/{tenantId} { allow write: if isTenantAdministrator() } gates every settings surface, and the whole /settings route tree is meta: { admin: true } (src/router/routes.ts:75). Nobody remaining can invite anybody. The tenant's operators keep working but the tenant can never be administered again without a platform super-admin. (b) A confusing half-state on self-demotion. checkAdminRole only re-fetches the role when session.role == undefined or the tenant changed (src/router/index.ts:18-24), so after demoting yourself to operator the client keeps isAdmin === true until a reload: the settings UI stays fully rendered while every write it issues is now rejected by the rules — and, per finding 2, rejected silently. Why it matters: This is the one screen where a mis-click is not recoverable by the person who made it. MSG-05's confirmation exists but tells the user nothing about either risk: removing yourself and removing a colleague produce the identical sentence. Severity call: placed at High, not Blocker. The feature does work as designed for the normal path and the damage requires the admin's own deliberate action on their own row — but the end state is an unrecoverable dead end for the whole tenant, which is squarely the High definition. Fix: Three layers, cheapest first. (1) In the component, compare admin.id against useSession().uid; label that row "You" and either hide its delete button or route it through a distinct "Leave this tenant" confirmation that spells out the consequence. (2) Refuse the action client-side when admins.list.filter(a => a.role === 'admin').length <= 1, with an explanation rather than a disabled control (FORM-05). (3) Enforce it server-side — a callable/HTTPS function that transactionally counts remaining admins, so the rule cannot be bypassed with a direct SDK call. Only (3) actually closes it.

2. Every write is fire-and-forget; a failed invite clears the form as if it had been sent — High · MSG-01, MSG-06 (proposed family) ​

Where: src/components/tenant/settings/Admins.vue:36 (admins.invite(newAdmin).then(resetNewAdmin)), :85, :90, :56; src/stores/admins.store.ts:50-75What: None of the four store actions is awaited or caught by a caller, and none of them reports anything. Specifically:

  • invite() is async and returns early with undefined when !admin.email || !tenant.ref || !tenant.data (admins.store.ts:51). That resolved promise still triggers .then(resetNewAdmin), so the email field empties and the row resets — the exact visual signature of success — while no invite-code document and no mails document were written. tenant.data is a live Firestore binding, so this fires on any click made before the tenant document has resolved, and on any tenant read failure.
  • If batch.commit() rejects (offline, rules denial), the rejection is unhandled: no toast, no console path in prod, and an unhandled promise rejection.
  • updateRole, remove and removeInvite return the raw updateDoc/deleteDoc promise and every call site drops it. A rejected removal leaves the row exactly where it was with no message; a rejected role change lets vuefire snap the select back to the old value with no explanation.
  • On success there is likewise no confirmation: the invite disappears from the form and the admin has no statement that an email went anywhere. Why it matters: On a permissions screen the user's model of "did that work?" is the whole product. Here the failure modes are silent and one of them is worse than silent — it is affirmatively misleading. An admin can believe they invited a colleague, or revoked a departing employee's access, when neither write landed. Fix: src/composables/useAsyncAction.ts exists for precisely this ("Wrap a user-initiated async action so failures never disappear silently") and is unused here. Wrap all four calls in run(...) with successKey/errorKey, use its pending flag for aria-busy (not disabled, per FORM-06), and make invite() throw rather than return when its preconditions are unmet so the guard clause cannot masquerade as a successful write.

3. Removing an admin or changing their role notifies nobody and leaves no record — High · SEC-04 ​

Where: src/stores/admins.store.ts:55-66 (invite writes a mails document), vs :70-75 (updateRole, remove, removeInvite write nothing but the mutation) What: Inviting an admin sends mail through the mails collection with the admin-invite template. The three inverse operations — revoking a pending invite, removing an existing admin, and changing someone's role — send nothing. The affected person discovers the change when the app stops working. There is also no durable record of who did it: the audit store is scoped to a single event (src/views/Events/Audit.vue, src/stores/audit.store.ts) and covers on-air actions, not tenant membership, and the admins documents carry no updatedBy/updatedAt (packages/schemas/src/admin.schema.ts:22-27 is settings + role + user). Why it matters: A privilege revocation is a security-relevant account change, and a privilege grant even more so: an attacker who obtains one admin session can promote a second account they control and neither the promoted user nor the other admins are told, with nothing in any log to find afterwards. Removal is the same story from the other side — an admin locked out mid-broadcast has no idea whether it was intentional. Severity call: High per the brief's normalisation — a missing out-of-band notification is a hardening gap, not something an outsider can act on directly. Fix: Write a mails document on removal and on role change, mirroring the invite path, and notify the other admins on any promotion to admin. Stamp updatedAt / updatedBy onto the admin document and surface tenant membership changes in a tenant-level audit view (the per-event log already has the UI vocabulary to reuse).

4. The confirmation says nothing about what is lost, and its destructive button is labelled "Admins" — Medium · MSG-05, CONTENT-02 ​

Where: src/components/tenant/settings/Admins.vue:98-123, :151-163; src/locales/en.yml:397-398What: The dialog is hand-rolled rather than using the repo's own Confirm.vue (src/components/common/Confirm.vue), and three things went wrong in the copy:

  • The destructive footer button renders {{ $t('settings.admins.title') }} — the section heading key. The confirm button on a deletion dialog therefore reads "Admins" (fr: "Administrateurs", no: "Admins"). common.delete and common.confirm both exist (en.yml:826, and Confirm.vue:5 already defaults to common.confirm).
  • The cancel button borrows settings.productionUnits.close (:113) — a key owned by an unrelated section, while common.close exists at en.yml:829. Renaming or removing the production-units key silently breaks this dialog's cancel label.
  • The body is settings.admins.confirm-remove = "Are you sure you want to remove this admin?" It does not name the person (only the dialog title carries admin.user?.displayName, and falls back to a raw document id when the user profile has not loaded, :152), does not say what access is lost, does not say whether it can be undone, and does not distinguish "this is you" (finding 1). Why it matters: MSG-05 asks a confirmation to state what will be lost; this one states only that something will happen. And the user's last read before committing is a red button that says "Admins", which describes the section, not the action. Severity call: Medium rather than High — the danger intent and trash icon carry the meaning that the label fails to, so misclick risk is real but not acute. The copy defect is unambiguous. Fix: Replace the block with <Confirm> (src/components/common/Confirm.vue), passing :confirm-label="$t('common.delete')" and a subtitle that names the person and the consequence — e.g. "{name} will lose access to {tenant} immediately. They can be invited again." Add a separate string for the self-removal case.

5. The invite row has no labels, no email validation and no duplicate check — Medium · FORM-01, FORM-04, FORM-05 ​

Where: src/components/tenant/settings/Admins.vue:17-39What: Four distinct gaps in one 20-line form:

  • FORM-01. The email field is declared with :placeholder="$t('settings.admins.email')" and no label, so the placeholder is the only label and disappears on input. The role VSelect is given no label at all — and a settings.admins.role key exists, unused, in all three locale files (en.yml:393).
  • FORM-04. type="email" is passed with no validation prop, so FormKit applies no email rule. bob@exmaple is accepted, an invite-code document is created, and a mail is queued to an address that will never arrive. Nothing tells the inviting admin.
  • FORM-05. :disabled="newAdmin.email?.trim() === ''" disables the submit instead of explaining the block; VButton adds disabled:pointer-events-none (packages/ui/src/components/VButton.vue:19), so the control is inert rather than informative.
  • No duplicate check: inviting someone who is already an admin, or who already has an open invite, creates a second code silently. There is no keyboard submit either — the FormKit type="group" renders no <form> and no @keyup.enter is bound, so pressing Enter after typing an email does nothing and the user must reach for the + button. Why it matters: The one field on the screen is unlabelled for screen readers, unvalidated for typos, and unreachable by the keyboard shortcut everyone expects. A typo'd invite fails completely silently — combined with finding 2, the admin has no way to learn it went nowhere. Fix: Add :label on both inputs (using the existing email / role keys, with label-class if the visual design wants them hidden — but then use aria-label, not nothing), add validation="required|email", keep the button enabled and let validation explain, and check admins.list / admins.pendingInvites for the address before writing.

6. The delete buttons have no accessible name — Medium · A11Y-05 ​

Where: src/components/tenant/settings/Admins.vue:53-59 (revoke invite), :87-93 (remove admin) What: Both are <VButton intent="danger" round><MdiDelete /></VButton> — icon only, no aria-label, no visually-hidden text, no title. MdiDelete resolves to ~icons/mdi/delete via unplugin-icons (components.d.ts:41, vite.config.ts:142), an inline SVG with no <title>, so the button's accessible name is empty either way. The invite button three lines above gets this right (:32, :aria-label="$t('settings.admins.invite')"), which makes the omission an inconsistency inside a single component. Why it matters: A screen-reader user hears "button" twice per row, with no indication of which one removes a pending invite and which one removes a live admin — on rows whose only other content is a person's name. Row context does not supply it: the buttons sit in a sibling div, not inside the name element. Fix: :aria-label="$t('settings.admins.remove', { name: admin.user?.displayName })" and a matching revoke-invite key. Both keys need adding to en/fr/no.yml.

7. Loading, empty and failed states all render as the same empty list — Medium · CONTENT-04, MSG-06 (proposed family) ​

Where: src/components/tenant/settings/Admins.vue:42-96; src/stores/admins.store.ts:33-48What: list and pendingInvites both deliberately return [] while rawList.pending.value is true (a correct cross-tenant staleness guard — see the comment at admins.store.ts:20-32), but the component never reads pending and never renders a loading state. It also never renders an empty state, and neither computed exposes an error branch, so a rules denial or a dropped connection produces the same output as "still loading" and as "no admins": a bare <ul> with nothing in it, under the "Admins" heading. @playout/ui ships both PageSkeleton and EmptyState (packages/ui/src/components/), neither used here. The authentication/user-profile corpus lists "No Empty State" as a named anti-pattern for exactly this shape. Why it matters: The most alarming possible reading — "this tenant has no administrators" — is what the screen shows during the ordinary first second of every visit, and permanently if the read fails. An admin's rational response to an apparently empty admin list is to invite someone, which is the wrong action in two of the three cases. Fix: Expose pending (and an error ref) from the store, render PageSkeleton while pending, EmptyState with an invite prompt when genuinely empty, and a retryable error surface on failure. CONTENT-04 wants the waiting state to name what it is waiting for.

8. Role names render as untranslated English, and pending invites show the raw stored value — Medium · CONTENT-01 ​

Where: src/components/tenant/settings/Admins.vue:137-140 (roles array), :52 ({{ invite.role }}) What: The role options are a hardcoded literal array — [{ value: "operator", name: "Operator" }, { value: "admin", name: "Admin" }] — inside a component that otherwise uses $t throughout, and the keys it should be using (settings.admins.operator, settings.admins.admin) already exist and are already translated in fr.yml:419-420. The pending-invite row is worse: it interpolates invite.role directly, so it renders the raw Firestore value, lowercase and unlocalised — a French admin sees "operator - En attente". Why it matters: This is not the project-level "keys present but untranslated" problem (see PROJECT-LEVEL.md — playout's CONTENT-01 architectural finding); the translations for these two words exist and are simply not wired up. So a French admin sees a correctly translated page with English role names in the one control that decides what people can do. Fix: Build roles inside setup from t('settings.admins.operator') / t('settings.admins.admin'), and map invite.role through the same lookup rather than printing it. Note that no.yml:144-153 carries the whole admins: block as # TODO: translate English — worth flagging into the project-level CONTENT-01 issue rather than re-filing here.

9. Invites are permanent, untraceable, and cannot be resent or copied — Medium · MSG-04, SEC-EXPIRY (proposed) ​

Where: src/stores/admins.store.ts:50-68; src/stores/tenant.store.ts:12-15 (InviteCode = { role, email }); src/components/tenant/settings/Admins.vue:43-61What: An invite is a generateGUID() document holding only role and email. There is no createdAt, no expiresAt, no invitedBy, and no expiry check anywhere on the consuming side (functions/src/repositories/codes.repository.ts:4-10 reads the document and deletes it; the only question asked is "does it exist"). The pending list therefore shows an email, a role and the word "Pending" with no date, and the admin has no way to tell an invite sent this morning from one sent last year. There is also no "resend" and no "copy link": if the mail fails or is lost, the only recovery is revoke-and-reinvite, and the URL itself is never shown in the UI. Cross-reference — this is the admin-side face of the registration audit's finding 2 (playout--admin-registration.md, SEC-02): because deleteCodeIfExists deletes the code before addAdmin runs, a failed registration attempt consumes the invite. From this screen that looks like the invite spontaneously vanishing from the pending list, with no admin appearing in the list above it and nothing anywhere explaining why. The admin's only available diagnosis is "they must have accepted it", which is wrong. Why it matters: A permanent URL that grants tenant admin to whoever loads it is a standing liability — forwarded mail, a shared inbox, an old backup — and the screen that owns those URLs gives the admin neither the age of each one nor a reliable way to reason about their fate. MSG-04's dead-end logic applies to the admin here too: the pending row offers exactly one action (revoke) and no route to "send it again". Fix: Add createdAt, expiresAt and invitedBy to the invite document; enforce expiry in deleteCodeIfExists (and move the delete to after addAdmin succeeds, per the registration audit); show relative age and expiry in the pending row with a <time datetime> element; add "Resend" and "Copy invite link" actions.

Cross-references — not re-filed here ​

  • @playout/ui VDialog hides closed dialogs with opacity only (PROJECT-LEVEL.md). This view's VDialog is mounted unconditionally (:98), so its "Close" and its destructive button stay in the tab order at all times on the admins page. confirmAction is a no-op while pendingAction is null, so the invisible-activation hazard does not bite here — but two unlabelled, invisible tab stops sit at the end of the page.
  • VDialog binds Escape globally via Mousetrap and never unbinds it (VDialog.vue:123-126, PROJECT-LEVEL.md). Harmless on this screen (Escape closes a dialog that is already closed) but the binding leaks on route change like everywhere else.
  • VTitle hardcodes <h1> (PROJECT-LEVEL.md, A11Y-04). Feature-specific consequence: this page renders two <h1>s in its resting state — "Settings" (src/views/Tenant/Settings/Index.vue:3) and "Admins" (Admins.vue:3) — and three once any confirmation has been opened, because VDialog renders confirmTitle through VTitle (VDialog.vue:55-61) and confirmTitle is never cleared on close (Admins.vue:165-169). The third <h1> is a colleague's display name, and it persists invisibly for the rest of the session.
  • VButton loses focus when disabled (PROJECT-LEVEL.md, FORM-06) — hit by the invite button's :disabled at :34; see finding 5.
  • NAV-03 — no per-route document title mechanism exists (PROJECT-LEVEL.md). Fails project-wide; nothing feature-specific to add.
  • CONTENT-01 architectural finding (PROJECT-LEVEL.md) — no.yml:144-153 carries the whole settings.admins block as # TODO: translate. Finding 8 above is a different, feature-specific defect: translations that exist and are not used.

Unverified ​

  • A11Y-01 (contrast). text-faint carries the pending-invite role/status line (:52) and the admin's email (:75), and text-muted carries the confirmation body (:104). These are the likely failures but the tokens are OKLCH variables in packages/ui/src/styles/tokens.css and need computed values.
  • A11Y-06 (short viewport). The confirmation dialog is a bottom-anchored drawer below sm (VDialog.vue:19) capped at 88dvh; whether the footer buttons stay reachable at ~700px with the list behind it needs a rendered viewport.
  • A11Y-02 (target size). The round icon buttons compute to roughly 34–36px (p-2! aspect-square on a 1.2em icon at text-sm) — probably passing, not measured.
  • VSelect internals. vue3-select-component is not installed (node_modules absent repo-wide, per the standing caveat), so I cannot say what the rendered combobox exposes for keyboard operation or aria-*. What is certain is that the app passes it no label and no aria-label at :79-86, so whatever accessible name it produces is not one the app chose.
  • unplugin-icons output. I could not read the generated SVG for mdi:delete to confirm whether it carries aria-hidden. Finding 6 does not depend on it: with no <title> in the source icon, the button has no accessible name either way.
  • Firestore rules behaviour under self-demotion is read from the rules file, not exercised against the emulator.

Baseline additions ​

  • LASTADMIN-01 — A surface that can revoke a privilege must not allow the current actor to revoke their own last-remaining path back, and must not allow the final holder of a required role to be removed. Enforced server-side, and surfaced as an explanation rather than a disabled control. (Distinct from MSG-05, which governs the confirmation; this governs whether the action is offered at all.)
  • SEC-EXPIRY — Credentials embodied in a URL (invite links, magic links, share links) carry an explicit expiry that is enforced at redemption, and the surface that issues them shows each one's age and expiry.
  • Supports the existing MSG-06 family (PROJECT-LEVEL.md reconciliation list): this feature is another instance of both "failed fetch renders as empty state" (finding 7) and "non-throwing result discarded, failure shown as success" (finding 2, where a guard-clause early return drives a success-shaped UI reset).

Cross-project note ​

  • LASTADMIN-01 is the strongest cross-project candidate here. customer-portal, members and tt-time-tracker all have org/account-scoped roles; any of them with an admin-management screen almost certainly renders the current user's own row with the same controls. tt-time-tracker's organization admin surface and customer-portal's account links are the two to check first — grep each for a session.uid / currentUser.id comparison in the member-list component and report explicitly.
  • SEC-04 (no notification on privilege change) — tt-time-tracker has an accept-invite flow (queue row audited separately) and customer-portal has account linking; both send mail on invite. Whether either sends mail on revocation is worth one grep per repo.
  • MSG-06 (fire-and-forget writes) is confirmed in all four projects already; this is playout's admin-surface instance and reinforces the reconciliation rather than adding to it.
  • Placeholder-as-label (FORM-01) — worth checking the other three's inline "add a row" forms specifically. Compact table-row forms are where the placeholder-only pattern survives longest, because a visible label breaks the row layout.