Skip to content

[UX] playout — Admin registration ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch: develop @ 998e706d · Files reviewed: 16 Patterns: authentication/signup, forms/code-confirmation

Summary ​

RegisterAdmin.vue is the only route in the app that turns a URL into tenant admin rights, and it is the least finished auth surface in the repo: it decides whether to show a sign-in form by reading auth.currentUser synchronously at mount, which is always null on a cold load, so an invitee who is already signed in — and every user returning from the BCC Signon round-trip — is shown "Sign in or create an account" again. The invite code is never checked before the form it gates is rendered; it is only spent after a Firebase account has been created, so a dead or already-used link leaves the user with a real account, an invalidAttempts record and a terminal "contact your tenant administrator" screen with no way forward. The whole view is hardcoded English in a repo with CI-enforced en/fr/no parity.

Out of scope but worth passing on: functions/src/repositories/index.ts:45 re-exports getInviteCode from ./codes.repository, which does not export it (the identifier appears exactly once in the whole repo). That is a pnpm build:functions type error on the module that owns this feature's backend. I did not run the build to confirm.

Findings ​

1. Signed-in invitees are shown the sign-in form; the BCC Signon path cannot complete — Blocker · NAV-06 (proposed) ​

Where: src/views/Tenant/RegisterAdmin.vue:211, :283-287What: needsLogin = ref(!auth.currentUser) and onMounted(() => { if (auth.currentUser) … }) read Firebase's user synchronously. Firebase restores the persisted session asynchronously (setPersistence in src/main.ts:57 is not awaited), so auth.currentUser is null on every cold page load. Nothing upstream covers for it: register-admin is meta: { unprotected: true } (src/router/routes.ts:70) and the guard returns immediately for unprotected routes without awaiting authReady — it only awaits it for to.name === "login" (src/router/index.ts:47-71) — and App.vue:10 renders the RouterView as soon as layout.loading flips false. The repo already knows this: the comment at src/router/index.ts:50-52 says "auth.currentUser is null until Firebase finishes restoring the session", and Login.vue:12-17 correctly waits via onAuthStateChanged. The BCC path makes it terminal: loginWithAuth0 (:278-281) stores returnUrl and leaves; Callback.vue:47 returns with location.href = origin + returnUrl, i.e. a full page reload — so RegisterAdmin mounts cold again, auth.currentUser is null again, and the freshly authenticated user is shown the same "Sign in or create an account" card. Round-tripping again produces the same result. Why it matters: An admin who is already signed in must re-enter credentials, and is one click away from creating a duplicate account on the "Create account" tab (the authentication/signup corpus lists "No Duplicate Account Detection" as a named anti-pattern). A BCC Signon invitee cannot accept the invite at all. Fix: Resolve auth state before branching — await auth.authStateReady() (or the onAuthStateChanged-once pattern already used in Login.vue) and hold a resolvingAuth state until it settles; only then set needsLogin and call runValidation(). Better still, export the router's existing authReady promise from src/router/index.ts and await that, so there is one source of truth.

2. The invite code is only validated after an account has been created — Blocker · SEC-02 ​

Where: src/views/Tenant/RegisterAdmin.vue:224-231, :262-276; functions/src/handlers/https/validateCode.ts:7-17What: runValidation() runs only from onMounted (authenticated users) or after a successful signInWithEmailAndPassword / createUserWithEmailAndPassword / signInWithPopup. The gate is never checked before the form it gates is rendered. The backend has no read-only pre-check: POST register/:tenant/:code requires jwtCheck, and deleteCodeIfExists (functions/src/repositories/codes.repository.ts:4-10) deletes the code as it reads it, so validating and consuming are the same call. Why it matters: A user following an expired, mistyped or already-redeemed link is walked all the way through account creation — createUserWithEmailAndPassword + updateProfile — before being told the code is dead. They are left holding a real Firebase account with no tenant, plus an invalidAttempts document written against their brand-new uid (recordInvalidAttempt, same file, :12-16). It also means an unauthenticated visitor can force account creation on any tenant slug by guessing a URL, and the invalid-attempt counter is trivially polluted. Fix: Add an unauthenticated (or lightly rate-limited) GET register/:tenant/:code that reads the code without deleting it, call it in onMounted before rendering anything, and render "This invite is no longer valid" instead of the form when it fails. The repository already intends this — getInviteCode is re-exported at functions/src/repositories/index.ts:45 but never defined; defining it as a non-destructive read is exactly the missing piece. Keep the destructive POST as the final step after authentication.

3. Invite-code URL has no noindex and no referrer policy — High · NAV-04 ​

Where: src/router/routes.ts:70; index.html:1-30; firebase.json (hosting headers) What: The secret is the URL — /:tenant/register/:code grants tenant admin to whoever loads it. index.html sets no robots meta and no Referrer-Policy; the Firebase Hosting headers block only sets Cache-Control for index.html, sw.js, registerSW.js, workbox-*.js, manifest.webmanifest and /assets/**. A repo-wide grep for Referrer-Policy, X-Robots-Tag, noindex and referrerpolicy returns nothing. Why it matters: The full invite URL is sent as Referer to every cross-origin request the page makes — the Umami analytics endpoint injected at build time, the Google sign-in popup origin, the API host — and any paste of the link into a crawled surface can get it indexed. The forms/code-confirmation corpus makes the same point ("code input does not persist in browser history"), and authentication/signup recommends noindex for account-flow pages. Fix: Add a Referrer-Policy: no-referrer + X-Robots-Tag: noindex, nofollow response-header rule in firebase.json scoped to the register path (and to /index.html, since the SPA rewrite serves it for every route). A runtime <meta> tag alone does not satisfy this rule and does not stop the Referer header on requests that fire before Vue mounts.

4. Failure state is a dead end that also mislabels network errors and second visits — High · MSG-04, MSG-03 ​

Where: src/views/Tenant/RegisterAdmin.vue:188-198, :224-231What: The failure card shows "The code could not be validated. Please contact your tenant administrator." with no button, no link and no retry. runValidation maps everything to that screen: .then(r => registrationSuccess = r.data.valid) and .catch(() => registrationSuccess = false) — a dropped connection, a 500, and a genuinely invalid code are indistinguishable. Because the valid path deletes the code, a reload of the success page, or a second click on the emailed link, also lands here — telling a user who does now have admin access that registration failed. Why it matters: The user is stranded on a page with no controls at all, and the copy sends them to a human for what is often a transient error or an already-completed registration. There is no sign-out either, so a user who created an account on this page is left signed in as an account with no tenant, with no visible navigation. Fix: Distinguish the cases: on a network/5xx failure show "Something went wrong — try again" with a retry button; on valid: false say the invite is no longer valid, name the reason ("it may already have been used"), and offer both "Go to sign in" and a link to request a new invite (the forms/code-confirmation corpus's expired-code copy: "This code has expired. Please request a new one."). If the user already has access to the tenant, route them to events instead of showing a failure.

5. Every string in the view is hardcoded English — Medium · CONTENT-01 ​

Where: src/views/Tenant/RegisterAdmin.vue:1-202 (every string in the template — headings :13/:16, tab labels :28/:36, all six field labels in :56-67 and :116-141, button labels :73/:93/:103/:147/:184, the validation messages :133/:140, the waiting copy :157, and both result cards :173-197), plus the error fallbacks at :242, :256, :272What: The file contains no $t/t() call. src/locales/{en,fr,no}.yml exist and already carry a login: section (en.yml:785-787) and a common: section (en.yml:821+); this view uses none of it. scripts/check-locales.mjs compares keys across the three files and flags $t('x') calls whose key is missing from en.yml — it has no way to detect copy that never goes through i18n, so pnpm check:locales passes while the page is English-only. Why it matters: French and Norwegian admins accept invites in English, on the one screen where getting it wrong means creating a duplicate account. It is also the only view in src/views/Tenant/ outside Settings/ with zero i18n. Fix: Move the copy into a register-admin: block in en.yml and mirror it into fr.yml/no.yml. Consider extending check-locales.mjs with a heuristic for literal text in <template> in src/views/** so this class of regression is caught in CI.

6. Credential fields set no autocomplete, so password managers cannot fill or save — Medium · FORM-02 ​

Where: src/views/Tenant/RegisterAdmin.vue:56-67 (sign-in), :116-141 (create account) What: All six FormKit fields are declared with label/name/type/validation only. src/formkit.config.ts registers custom inputs and the auto-animate plugin but sets no default attributes, so nothing supplies autocomplete. Required values: username + current-password on the sign-in tab, name + email + new-password (both password fields) on the create-account tab. Why it matters: A password manager cannot offer to generate a password on the tab whose whole purpose is creating one, and cannot reliably save the result — so the new admin invents a weak password or loses it. authentication/signup's reference markup sets autocomplete="new-password" explicitly for this reason. Fix: Pass the attributes through FormKit (autocomplete="new-password" etc.), or set them as defaults for type="password"/type="email" in formkit.config.ts so Login.vue — which has the same gap at :86-97 — is fixed at the same time.

7. No password reveal toggle — Medium · FORM-03 ​

Where: src/views/Tenant/RegisterAdmin.vue:128-141What: Both password fields are plain FormKit type="password". There is no PasswordField in @playout/ui (packages/ui/src/components/ has no password component), and SecretField (src/components/common/SecretField.vue) is the API-secret control used in Admin/TenantDashboard.vue, not a credential input. Why it matters: A user creating and confirming a password they cannot see gets a mismatch error with no way to find the typo — the exact case the reveal toggle exists for, and the reason the baseline pairs FORM-03 with FORM-07. Fix: Add a PasswordField to @playout/ui (a type="button" toggle with aria-pressed and an accessible name, per the package's no-i18n contract: label via prop) and register it as a FormKit input so Login.vue and any future reset screen get it too.

8. Submit and social buttons are disabled during submission — Medium · FORM-06 ​

Where: src/views/Tenant/RegisterAdmin.vue:68-74, :86-104, :142-148What: Every button is :disabled="loginLoading". Disabling the button the user just activated removes it from the accessibility tree and drops focus to <body>; the label swap ("Creating account…") is the only signal, and it is not announced. Why it matters: Keyboard and screen-reader users lose their place mid-flow and get no announcement that anything is happening, on a submit that can take several seconds (account creation + updateProfile + validateCode). Fix: Keep the buttons enabled, set :aria-busy="loginLoading", and guard re-entry at the top of each handler (if (loginLoading.value) return;). The corpus advises disabling here, but the baseline rule is deliberate and takes precedence.

9. Raw Firebase SDK error strings are shown to the user — Medium · MSG-02, MSG-03 ​

Where: src/views/Tenant/RegisterAdmin.vue:242, :256, :272What: All three handlers do loginError.value = e?.message ?? "…", so the UI renders strings like Firebase: Error (auth/email-already-in-use). or Firebase: Password should be at least 6 characters (auth/weak-password). verbatim. No code mapping exists. Why it matters: The most likely error on this screen — an invitee who already has an account clicking "Create account" — produces a raw SDK string that names a technical error code and does not tell them to switch to the "Sign in" tab. It also leaks the 6-character Firebase minimum, contradicting the 8-character rule the form itself enforces. Fix: Map the codes that matter (auth/email-already-in-use → "You already have an account — switch to Sign in", auth/wrong-password / auth/invalid-credential, auth/too-many-requests, auth/popup-closed-by-user) and fall back to one generic sentence. For email-already-in-use, switch activeTab to signin and carry the email over.

10. Error and result states are not announced — Medium · MSG-01 ​

Where: src/views/Tenant/RegisterAdmin.vue:40-45, :169-198What: The login/registration error <p> has no role="alert", and the success/failure result card (which replaces the entire form via v-if/Transition) has no role="status" or aria-live. Why it matters: A screen-reader user submits the form and hears nothing — neither the rejection nor the fact that registration succeeded and the page is now a different page (WCAG 4.1.3). Fix: role="alert" on the error block; role="status" on the result card, and move focus to its heading when it appears.

11. The result screen has no <h1> and jumps to <h3> — Medium · A11Y-04 ​

Where: src/views/Tenant/RegisterAdmin.vue:12-14, :173-175, :192-194What: The only <h1> ("Accept your invite") lives inside the v-if="needsLogin" card. Once the user authenticates, that branch unmounts and the page's sole heading is an <h3> ("Registration successful" / "Registration failed"). No <main> or other landmark wraps any of it. Why it matters: After submission the page has no h1 at all and its heading level skips from nothing to 3, so heading navigation and page-structure cues are broken precisely on the screen carrying the outcome. Fix: Promote both result headings to <h1> (they are the page title at that point) and wrap the card in <main>.

12. BCC Signon started from this page leaves a live IdP session after logout — Medium · SEC-06 (proposed) ​

Where: src/views/Tenant/RegisterAdmin.vue:278-281What: loginWithAuth0 here stores only returnUrl. Login.vue:60-63 additionally sets localStorage.setItem("authProvider", "bcc"), which is what NavbarUser.vue:53-59 reads to send the user through the Auth0 /v2/logout endpoint on sign-out. A user whose first authentication was through this invite page never gets that flag. Why it matters: Their "Logout" clears the Firebase session but leaves the Auth0 session alive, so the next person on a shared production machine clicking "Sign in with BCC Signon" is silently signed straight back in as them. Fix: Set authProvider in this handler too — better, extract the whole BCC redirect (returnUrl + authProvider + location.href) into one shared helper used by both views so the pair cannot drift again.

13. Password rule is invisible until it is broken, and a correct confirmation says nothing — Low · FORM-04, FORM-07 ​

Where: src/views/Tenant/RegisterAdmin.vue:128-141What: The 8-character minimum exists only as a validation-messages.length string shown after a failed blur; there is no help text before typing and no strength meter. The confirm field uses FormKit's confirm rule, which reports a mismatch but never confirms a match. Why it matters: The user learns the constraint by violating it, and gets no positive signal that the two passwords agree — with no reveal toggle (finding 7) there is nothing else to go on. Fix: Add persistent help text ("At least 8 characters") via FormKit's help prop, and an affirmative match indicator on the confirm field.

14. First field is not focused, and the email is lost when switching tabs — Low · FORM-11, FORM-09 ​

Where: src/views/Tenant/RegisterAdmin.vue:22-38, :56, :109, :215, :219What: Nothing is focused on mount. loginForm and registerForm are separate refs, so a user who types an email on "Create account", hits the already-registered error and switches to "Sign in" retypes it. Why it matters: Two extra interactions on a single-purpose form, and the retype lands exactly where the user is already frustrated. Fix: Autofocus the first field of the active tab; hold one shared email ref across both tabs.

15. Tab switcher exposes its selected state only through colour — Low · A11Y-07 (proposed) ​

Where: src/views/Tenant/RegisterAdmin.vue:21-38What: The two <button type="button"> controls change classes on selection but carry no aria-pressed (or role="tab"/aria-selected in a role="tablist"). Note @playout/ui's TabSwitch.vue has the same gap, so adopting it would not fix this. Why it matters: A screen-reader user cannot tell which of "Sign in" / "Create account" is active, and after a failed submit cannot tell which form they are in. Fix: Add :aria-pressed="activeTab === 'signin'" to each button — and fix TabSwitch.vue upstream the same way (it is also missing type="button").

16. The route sets no document title — Low · NAV-03 ​

Where: src/router/index.ts:94-97; index.html:20What: afterEach only persists the tenant to localStorage; no route sets document.title, so every page in the app is "Playout". Why it matters: Tab, history and bookmark entries for the invite page are indistinguishable from every other page. This is app-wide rather than specific to this feature — worth raising once at project level rather than in each feature issue. Fix: Add meta.title to routes and set document.title in afterEach, through i18n.

Unverified ​

  • A11Y-01 (contrast). text-faint on bg-panel is used for the subtitle (:15-17) and for both result-card body copy (:176, :195) and the "or" divider label (:81-83). These are the likeliest failures on the page but the tokens are OKLCH variables in packages/ui/src/styles/tokens.css and require computed values — not settled by reading code.
  • A11Y-06 (short viewport / mobile keyboard). The create-account tab renders four fields plus the tab switcher inside a centred max-w-sm card with the logo absolutely positioned at mt-30; whether the submit button stays reachable at ~700px height with a keyboard open needs a rendered viewport.
  • A11Y-02 (target size). The tab buttons are py-1.5 text-sm, which computes to roughly 30px tall — probably passing, but not measured.
  • Runtime confirmation of finding 1. The auth.currentUser-null-at-mount conclusion is drawn from the Firebase persistence contract, the un-awaited setPersistence in main.ts:57, the guard's own comment at router/index.ts:50-52 and App.vue:10. I did not run the app to observe it.
  • The getInviteCode dangling re-export (functions/src/repositories/index.ts:45) is reported from grep; I did not run pnpm build:functions.

Baseline additions ​

  • NAV-06 — A view that branches on authentication state must wait for the auth SDK's resolved state (authStateReady() / a one-shot onAuthStateChanged) before rendering either branch. A synchronous auth.currentUser read at mount is null on every cold load and shows signed-in users the signed-out UI. (Seen here as a Blocker; Login.vue already implements the correct form, so the rule captures a real intra-repo inconsistency.)
  • SEC-06 — Where sign-out must also terminate a federated IdP session, every entry point into that IdP records the provider; a second entry point that forgets to leaves the IdP session alive after logout.
  • A11Y-07 — A segmented/tab control exposes its selected state programmatically (aria-pressed, or role="tab" + aria-selected), not only through colour or weight.

Cross-project note ​

  • NAV-06 (auth-state race) is a Firebase/Supabase-shaped defect. members and customer-portal both have their own auth bootstraps — any view that reads a synchronous currentUser/session at mount instead of awaiting the SDK will have it. Worth grepping all three for currentUser outside a listener.
  • NAV-04 (noindex + no-referrer headers) almost certainly fails in all four: it requires hosting-level configuration (firebase.json, vercel.json, nginx) that a Vue app cannot express, and it was already open in playout's password-reset audit. This is the strongest candidate for a single cross-project alignment issue.
  • MSG-02 (raw SDK error strings) — e?.message passthrough is the default shape in playout (Login.vue:41, Callback.vue) and is likely mirrored wherever the other projects surface auth errors.
  • FORM-02/FORM-03 — the baseline notes both customer-portal and tt-time-tracker get a toggle for free from PrimeVue's Password; playout has no equivalent component at all, so this pair is playout-specific until @playout/ui grows a PasswordField.