Appearance
[UX] playout — Sign in
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch:
develop@998e706d· Files reviewed: 18 Patterns:authentication/login,authentication/social-login(forms/passwordnot re-fetched — its rubric was mined in full during the 2026-07-30 password-reset audit and is already encoded in FORM-02/03/05/06/10.)
Summary
Correction to the assignment brief first: none of the password-reset audit's fixes to Login.vue are on develop. They live only on the unmerged branch atlas/issue-436 (PR #437). git log -- src/views/Authentication/Login.vue ends at 82e576ae (#416), routes.ts has no forgot-password/reset-password routes, and src/components/common/PasswordField.vue does not exist on develop. So the rules the brief expected to pass all fail on the audited tree. Findings 1, 4 (partly), 5, 7 (partly), 10 and 13 are already fixed on that branch and are recorded here only because they are live on develop today — each is tagged in its Fix.
The findings that no open PR addresses are 2, 3, 6, 8, 9, 11, 12 and the social-button half of 4 and 7. Of those, the most important is finding 2: after a successful BCC Signon sign-in the consumed ?code= is left in the URL, so pressing Back re-runs the exchange against a code the server has already deleted and shows the user "Authentication failed" — a false failure on a routine action. The same missing router.replace is also the root of finding 3.
Findings
1. Raw Firebase error strings are shown to the user, and they say whether the account exists — Blocker · SEC-01, MSG-02, MSG-03
Where: src/views/Authentication/Login.vue:41 and :54What: Both handlers assign the SDK's own message straight to the visible error: error.value = e?.message ?? "Login failed". Firebase renders these as Firebase: Error (auth/user-not-found). and Firebase: Error (auth/wrong-password). — two distinguishable strings for "no such account" and "wrong password". Why it matters: An attacker can enumerate which addresses have Playout accounts by watching which of the two strings comes back — the exact "Revealing Account Existence" anti-pattern in authentication/login. And a legitimate user who mistypes their password is shown an internal error code with no advice in it. Fix: Map auth/invalid-credential, auth/invalid-email, auth/wrong-password and auth/user-not-found to one shared message, keep auth/too-many-requests and auth/user-disabled distinct, and fall back to a single generic sentence. Already implemented on atlas/issue-436 as friendlyError() — no new work if PR #437 merges.
2. Pressing Back after signing in with BCC Signon shows "Authentication failed" — High · NAV-06 (proposed)
Where: src/views/Authentication/Callback.vue:32-58; server side functions/src/handlers/https/firebaseToken.ts:156-188What: On success Callback.vue navigates away with router.push (line 51/53) or location.href (line 47) — never replace — so /callback?code=<uuid> stays in the history stack. The exchange endpoint deletes the code inside a Firestore transaction (tx.delete(docRef), line 172), so it is strictly single-use. One Back press therefore re-mounts Callback.vue, re-posts the consumed code, gets 400 Invalid or expired code, and lands on the full-screen red-flag state "Authentication failed / Something went wrong during sign-in. Please try again." Why it matters: The user is signed in and everything is fine, but the app tells them authentication failed. Back is not an exotic gesture — it is the first thing people do after a redirect chain drops them somewhere unexpected. Recovery works (the "Back to login" button is bounced onward by the router guard) but the user has no way to know the error was spurious. Fix: After a successful exchange, strip the query before navigating — await router.replace({ path: route.path, query: {} }), then navigate — and use router.replace / location.replace for the onward hop so /callback is not a history entry at all. Guard the re-entry case too: if auth.currentUser is already set when Callback mounts, redirect instead of exchanging.
3. The single-use auth code is left in the URL, the history and the analytics payload — High · NAV-04
Where: src/views/Authentication/Callback.vue:33; firebase.json:16-70 (hosting headers); vite.config.ts:41-51 (Umami injection); index.htmlWhat: /callback?code=<uuid> carries a credential that mints a Firebase custom token. Nothing in the repo sets X-Robots-Tag/noindex or a Referrer-Policy — grep for noindex|Referrer-Policy|robots across src/, functions/src/, index.html, public/ and firebase.json returns nothing. The hosting headers block sets only Cache-Control. Meanwhile vite.config.ts:41 injects https://cloud.umami.is/script.js into every production page, and main.ts:28-37 initialises Sentry with browserTracingIntegration — both of which record page URLs. Why it matters: The code has a 60-second TTL and is deleted on first use (AUTH_CODE_TTL_MS = 60_000), which bounds the exposure but does not remove it: inside that window the URL sits in browser history, in any synced-history profile, and in whatever third-party record of the URL the trackers keep. NAV-04 requires the protection to be a response header, not an assumption about tracker defaults. Fix: Two changes. (a) Strip the query on load — the same router.replace as finding 2 — so the code never survives past the exchange. (b) Add to firebase.json's app hosting target a header rule for /callback (and /) with Referrer-Policy: no-referrer and X-Robots-Tag: noindex, nofollow. Consider also staticPaths-scoping the Umami script away from /callback.
4. Every user-facing string on both screens is a hardcoded English literal — Medium · CONTENT-01
Where: src/views/Authentication/Login.vue:87, 93, 104, 113, 124, 134; src/views/Authentication/Callback.vue:7, 10, 13, 19, 35, 57What: Login.vue hardcodes label="Email", label="Password", 'Signing in…', 'Sign in', the or divider, Sign in with Google and Sign in with BCC Signon — even though login.email and login.password already exist in all three locale files (src/locales/en.yml:785-787, fr.yml:31-33, no.yml:792-794). Callback.vue hardcodes the heading "Authentication failed", both error sentences, the "Back to login" button and name="Playout". Why it matters: playout ships en/fr/no and enforces parity in CI, but scripts/check-locales.mjs only compares keys referenced by $t('…') calls against en.yml — a bare literal is invisible to all three of its checks. CI stays green while French and Norwegian users read English on the first screen of the product and on the screen that appears when sign-in breaks. Fix: Move all of it under a login.* / callback.* namespace in en.yml and mirror into fr.yml/no.yml. PR #437 covers the email/password labels, submit text and error messages only — the divider, both social-button labels and the whole of Callback.vue remain hardcoded on that branch too (verified against git show atlas/issue-436:src/views/Authentication/Login.vue).
5. Credential fields carry no autocomplete, so password managers cannot fill them — Medium · FORM-02
Where: src/views/Authentication/Login.vue:86-97What: Neither FormKit field sets autocomplete. FormKit does not infer it from type. Why it matters: Browser and 1Password/Bitwarden autofill degrade to heuristic guessing on the app's primary sign-in form; on iOS the credential sheet often does not offer at all. Named as "Missing Autocomplete Attributes" in authentication/login. Fix: autocomplete="username" on the email field, autocomplete="current-password" on the password field. Already on atlas/issue-436 — no new work if PR #437 merges.
6. The password field has no reveal toggle — Medium · FORM-03
Where: src/views/Authentication/Login.vue:92-97What: Plain <FormKit type="password">, which renders a bare masked input. Why it matters: "No Password Visibility Toggle" is a named anti-pattern — users cannot check what they typed, which drives repeat failures on mobile keyboards, and Playout is operated from tablets in production galleries. Fix: Use the PasswordField component. Note this rule is not fixed by PR #437: that branch adds src/components/common/PasswordField.vue but wires it only into ForgotPassword.vue/ResetPassword.vue — Login.vue still uses the raw FormKit password input on atlas/issue-436. Swapping it in is a one-line follow-up once #437 lands.
7. Loading and error states are never announced, and all three buttons are disabled mid-submit — Medium · MSG-01, FORM-06
Where: src/views/Authentication/Login.vue:72-77 (error <p>), :100, :119, :129 (:disabled="loading"); src/views/Authentication/Callback.vue:2-15What: The login error paragraph has no role="alert"; the Callback error block has neither role="alert" nor role="status". The label swap to "Signing in…" is a silent DOM change. All three buttons take :disabled="loading", so activating one removes it from the accessibility tree and drops focus to <body> for the duration of the request. Why it matters: A screen-reader user activates Sign in, loses focus, and is told nothing — not that it is working, not that it failed (WCAG 4.1.3). A keyboard user has to re-tab from the top of the page to retry. Fix: role="alert" on both error blocks; replace :disabled="loading" with :aria-busy="loading" and an early return in each handler. PR #437 does this for the login error block and the submit button only — the Google and BCC Signon buttons keep :disabled="loading" on that branch, and Callback.vue is untouched.
8. Neither screen has an <h1> or a landmark — Medium · A11Y-04
Where: src/views/Authentication/Login.vue:66-71; src/views/Authentication/Callback.vue:6-8; src/App.vue:2-9What: The login route renders outside Layout.vue — App.vue puts <RouterView> straight inside <div id="app">. Login.vue then renders a logo image and a card with no heading element at all. Callback.vue styles its failure heading as <p class="text-3xl font-semibold">. Why it matters: A screen-reader user landing on the app's front door gets no page title from the heading structure and no main to skip to; the heading list is empty. The authentication/login and authentication/social-login reference HTML both open with <h1>Sign in</h1>. Fix: Wrap the card in <main>, add a visible <h1>{{ $t('login.heading') }}</h1> ("Sign in") above the form, and promote Callback's "Authentication failed" <p> to <h1>.
9. Google sign-in uses a popup, which is blocked on mobile and in embedded browsers — Medium · NAV-07 (proposed)
Where: src/views/Authentication/Login.vue:51What: signInWithPopup(auth, new GoogleAuthProvider()). The BCC Signon path right below it (line 62) correctly uses a full-page redirect. Why it matters: "Popup OAuth Flow" is a named anti-pattern in authentication/social-login: popups are blocked by default in several mobile browsers and inside in-app webviews (the Teams/Slack/Instagram browsers people open shared links in), and Safari ITP interferes with the popup's storage access. The user gets auth/popup-blocked — which today surfaces as a raw Firebase string (finding 1) and even after PR #437 falls into the generic "something went wrong" bucket, so they are never told to allow popups. Fix: Use signInWithRedirect + getRedirectResult for Google so both providers behave identically, or at minimum branch on auth/popup-blocked and auth/popup-closed-by-user with actionable copy.
10. There is no route to password recovery from the login form — Medium · MSG-04
Where: src/views/Authentication/Login.vue:98-106; src/router/routes.ts:49-53What: No "Forgot password?" link, and no forgot-password/reset-password route exists on develop at all. Why it matters: A user who has forgotten their Playout password has no in-product way out — their only options are a different provider or contacting an administrator. Fix: The whole flow plus the link lands with PR #437 (the link is placed after the submit button, correctly satisfying FORM-08). Listed here so it is not lost if that PR stalls — it is the single largest gap in the audited tree.
11. The email field is not focused on mount — Low · FORM-11
Where: src/views/Authentication/Login.vue:86-91What: No autofocus and no programmatic focus in onMounted (which only registers the auth-state listener, lines 12-17). Why it matters: Every visitor to a single-purpose form starts with a mandatory click or Tab. Not fixed by PR #437. Fix: Add autofocus to the email FormKit, or focus its input in onMounted.
12. Neither route sets a document title — Low · NAV-03
Where: index.html:20; src/router/index.ts:94-97What: The title is the static <title>Playout</title>. The afterEach hook only persists the tenant to localStorage. useTitle is used in exactly one place in the app (src/components/output/LiveScreen.vue:20). Why it matters: Tabs, history entries and browser back-menus for the sign-in page, the OAuth callback and every other route are indistinguishable; a screen reader announces the same title on every navigation. Fix: Add meta.title to the route records and set document.title in the existing afterEach. This is app-wide, not sign-in-specific — worth one issue against the router rather than repeating it per feature.
13. Waiting states do not say what is being waited on — Low · CONTENT-04
Where: src/views/Authentication/Login.vue:60-63; src/views/Authentication/Callback.vue:16-20What: loginWithAuth0 writes localStorage and assigns location.href without ever setting loading, so the BCC Signon button looks inert for the whole of a full-page navigation. When the user comes back, Callback.vue renders AppLoader whose only text is the product name "Playout" (packages/ui/src/components/AppLoader.vue:11-13). Why it matters: On a slow connection the click appears to do nothing, and the return leg looks like a cold app boot rather than "we are finishing your sign-in". The social-login pattern prescribes "Connecting to <provider>…" from click until redirect. Fix: Set loading.value = true in loginWithAuth0 and swap the label to $t('login.connecting', { provider: 'BCC Signon' }); pass a caption prop to AppLoader on the callback route ("Completing sign-in…") inside a role="status" region.
Unverified
- A11Y-01 (contrast). The card uses
text-faintonbg-panelfor the "or" divider (Login.vue:112) andtext-faintfor the callback error sentence (Callback.vue:9) — both are the palette's lowest-emphasis token on a low-contrast surface, and both are exactly where a ratio failure would be expected. Needs computed OKLCH values from a rendered page. - A11Y-06 (short viewport).
Login.vue:67-70pins the logo withabsolute top-0 mt-48inside amin-h-dvhflex-centre container. That is a plausible overlap at ~700px height and with a mobile keyboard open, but it cannot be settled from the classes alone. - Analytics payload contents (finding 3). That the code is left in the URL, that no
Referrer-Policy/noindexheader exists, and that Umami and Sentry are loaded on/callbackare all established from the code. Whether the Umami tracker's default payload includeslocation.search, and whether the Sentry navigation breadcrumb retains the query string, needs a network-tab check against a production build. - FormKit-generated markup.
FORM-01(label/forpairing) and thearia-liveon FormKit's own validation messages are taken as passing on FormKit's documented default schema; not confirmed against rendered DOM.
Baseline additions
Two new rules, both cited above, plus one project-wide note:
NAV-06 — A single-use code or token is removed from the URL as soon as it is consumed (
router.replace, notpush), so Back or refresh cannot re-run the exchange and present a false failure. Complements NAV-04, which covers who else can see the token; NAV-06 covers what the user sees when they navigate back onto it. Directly checkable by reading code.NAV-07 — Third-party authentication uses a full-page redirect, not a popup. If a popup is used,
popup-blockedandpopup-closed-by-userare handled with copy that tells the user what to change. From the "Popup OAuth Flow" anti-pattern inauthentication/social-login.A11Y-07 (proposed, but do not file per feature) — The viewport meta tag does not disable zoom.
index.html:8setsuser-scalable=no, which is a WCAG 1.4.4 failure for every route in the app. Like CONTENT-01 in members and tt-time-tracker, this is one architectural finding per project, not a per-feature one — raise it once againstindex.html. Worth checking in the other three repos, whose viewport tags I have not read.
Cross-project note
- Findings 1 (SEC-01 raw provider errors) and 7 (MSG-01/FORM-06) are the most likely to recur. customer-portal, tt-time-tracker and members all wrap a provider SDK behind a login form; the
e.messageshortcut and the disable-on-submit reflex are stack-independent habits. Check all three. - Finding 5 (FORM-02 autocomplete) and finding 8 (no
<h1>/landmark on the login route) are equally portable — a login view rendered outside the app shell routinely loses both the heading and the landmark. - Findings 2, 3 and 9 are specific to playout's OAuth-code callback and its Firebase popup call. customer-portal is the only other project likely to have a redirect-based provider callback; worth one grep for
signInWithPopupand for a?code=route across the other three. - Finding 12 (NAV-03) is almost certainly universal — SPA routers do not set titles unless someone deliberately adds it. Treat as a candidate for the final consistency pass rather than four separate issues.