Appearance
[UX] playout — Profile
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch:
develop@998e706d· Files reviewed: 12 Patterns:authentication/account-settings
Summary
Profile is the only account-settings surface in playout, and it is where a user links and unlinks sign-in methods. Those are the highest-value writes in the application and they are the least protected ones: linking a new email/password credential needs no re-authentication, sets no minimum password length, sends no out-of-band notification, and — because no password-change or password-reset path exists anywhere in the repo — produces a credential the user can never change or recover. Everything else (unlabelled inputs, a hand-rolled cropper modal, swallowed save failures) is ordinary settings-page hygiene by comparison.
Sign-out is reachable from this route: /profile is a child of Layout (src/router/routes.ts:54), so NavbarUser.vue renders it. It is not, however, keyboard-operable — see finding 9.
Project-level defects that apply here and are not re-filed: NAV-03 (no per-route title mechanism), the architectural CONTENT-01 key-parity-only problem, VButton's native disabled focus drop, VTitle hardcoding <h1>, and the unbound Mousetrap binding in Layout.vue:97. See PROJECT-LEVEL.md.
Findings
1. A password can be created here but can never be changed or reset — High · ACCT-PWCHANGE (proposed)
Where: src/views/Profile.vue:150-166 (creates the credential); absent everywhere else What: "Link Email / Password" calls EmailAuthProvider.credential(...) + linkWithCredential(...), adding a password sign-in method to the account. There is no counterpart. grep -rn "sendPasswordResetEmail\|updatePassword\|updateEmail\|verifyBeforeUpdateEmail" src/ packages/ returns zero hits repo-wide, and src/router/routes.ts has no forgot-password / reset-password route on develop (queue row #2 confirms that work is unmerged, PR #437). The pattern's Anatomy section makes "Password — Change password" a required Security-section control; playout has neither the control nor the recovery flow. Why it matters: a user who sets a password here and later forgets it, or who suspects it is compromised, has no self-service path at all — not from the profile page, not from the login screen. Their only options are to sign in with a different linked provider (if they still have one) or to contact an administrator. On accounts whose only providers are BCC Signon plus this password, a compromised password cannot be rotated by its owner. Fix: add a Security section to this page with "Change password" (updatePassword behind a reauthenticateWithCredential step) and surface sendPasswordResetEmail from the login screen. If PR #437 is the intended home for the reset half, this page still needs the change half.
2. Linking or unlinking a sign-in method needs no re-authentication and notifies nobody — High · SEC-04
Where: src/views/Profile.vue:132-144 (Google), :150-166 (email/password), :168-180 (unlink) What: all three handlers call the Firebase SDK directly on the live session. No current-password or recent-login challenge is issued first (auth/requires-recent-login is only handled reactively at :125, as an error message telling the user to sign out and back in), and nothing emails the account owner afterwards. The success signal is the same generic showSuccess("profile") toast used for changing a display name. Why it matters: anyone who reaches a live session — an unlocked laptop, a shared browser profile, a stolen session token — can attach their own email + password at :154-155 and retain permanent independent access to a broadcast-control account, or strip the owner's own provider at :172. The real owner is never told. This is the pattern's named anti-pattern "No Re-Authentication for Sensitive Changes" verbatim. Why High and not Blocker: exploitation requires prior session access, so it is a hardening gap rather than something an outsider can act on directly — exactly the case the severity note assigns to High. Fix: require reauthenticateWithPopup / reauthenticateWithCredential immediately before any link or unlink, and send a "a new sign-in method was added to your account" / "a sign-in method was removed" mail from a Cloud Function on both events.
3. The password field for a new credential has no minimum length and no strength guidance — High · SEC-05 (also FORM-04)
Where: src/views/Profile.vue:483-487What: <FormKit v-model="emailLinkPassword" :label="$t('profile.password')" type="password" /> carries no validation prop, no minlength, no strength meter and no requirements text. The only floor is Firebase Auth's project default of six characters, which is below the NIST 800-63B minimum of eight. The submit gate at :496 checks truthiness only (!emailLinkPassword), so a one-character value reaches the SDK. Why it matters: combined with finding 1, a six-character password becomes a permanent, unrotatable sign-in method on an account that controls live on-air output. The user is not told the constraint before typing (FORM-04) and only learns it from a raw Firebase error string after submitting (finding 7). Fix: state the requirement above the field, add validation="required|length:8", and add a strength meter rather than composition rules. Raise the Firebase project's minimum-password-length policy server-side too, so the client rule is not the only enforcement.
4. Unlinking a sign-in method is destructive and never confirmed — Medium · MSG-05
Where: src/views/Profile.vue:434-442What: the per-provider "Unlink" button calls unlinkProvider(...) on a single click. There is no confirmation step and no statement of consequence. The canUnlink guard at :111 only prevents removing the last provider on a non-BCC account; for BCC accounts it is unconditionally true (:110-111). Why it matters: an accidental click silently removes a login method. Because of finding 1 the email/password method cannot simply be recreated with the same password, and a user who unlinks Google on a shared machine loses that route back in with no warning. The repo already has the fix on the shelf: src/components/common/Confirm.vue wraps @playout/ui's ConfirmDialog and is used in Events/Dashboard.vue. Fix: route both unlink paths through Confirm.vue with a subtitle naming which provider is being removed and which methods will remain.
5. Every save path swallows its own failure — Medium · MSG-06 (proposed; see PROJECT-LEVEL.md reconciliation)
Where: src/views/Profile.vue:31-39 (savePhoneNumber), :88-98 (saveDisplayName) What: both use try { … } finally { saving = false } with no catch. If the Firestore updateDoc or the Auth updateProfile rejects — offline, rules denial, quota — the success toast is skipped (it sits after the await inside try), the spinner clears, and the rejection escapes as an unhandled promise. Nothing is rendered. The photo path is the only one with a catch (:81-83). Why it matters: the user presses Save, the button un-busies, and the page looks exactly as it did. There is no toast, no inline error, no retry prompt — they cannot distinguish a completed save from a rejected one, and their edit is still sitting in the field as if it had been persisted. Fix: add catch blocks that call layout.showError(...) with a mapped message, matching the treatment confirmCrop already gives uploads.
6. Saving a display name does not update the name shown on the page — Medium · MSG-07 (proposed)
Where: src/views/Profile.vue:88-98, compare :75-77What: saveDisplayName calls updateProfile(auth.currentUser!, …) and then user.saveInfo(), but never session.refreshUser(). The photo path two blocks above does call it (:76) before saveInfo(), for exactly this reason. session.displayName is computed(() => currentUser.value?.displayName ?? "") over a ref(auth.currentUser) (src/stores/session.store.ts:10,29); updateProfile mutates the underlying Firebase User object in place, which does not go through the reactive proxy's setter, so the computed is not invalidated. Why it matters: the identity block at :264-266 and the navbar avatar keep showing the previous name after a successful save. The user sees a green "Your profile has been updated!" toast next to a name that did not change, and the natural conclusion is that the save failed. The asymmetry between the two handlers is plainly in the source; the exact re-render behaviour should be confirmed in a browser before the fix is sized. Fix: await session.refreshUser() after updateProfile in saveDisplayName, as the photo path does — and consider making refreshUser reassign a fresh object so the ref actually changes identity.
7. Error copy is hardcoded English and falls through to raw SDK strings — Medium · MSG-02, CONTENT-01
Where: src/views/Profile.vue:116-130, :82, :182-186, :214-219What: authErrorMessage holds eleven English literals in a repo with CI-enforced en/fr/no parity, and its fallback is err?.message — the raw firebase/auth string, e.g. "Firebase: Error (auth/network-request-failed).". uploadError at :82 does the same for Storage errors. providerLabel (:182-186) and roleLabel (:214-219) also return English literals; the role badges "Admin" / "Operator" / "Super Admin" are user-facing data labels, not proper nouns. Why it matters: a French or Norwegian operator hitting any link/unlink failure gets English, and for any unmapped code gets untranslated Firebase internals with a auth/ code in them. pnpm check:locales cannot catch this because the strings never enter a locale file at all — this is a different defect from the project-level # TODO: translate problem. Fix: move all eleven messages plus the role and provider labels into en.yml and translate; replace the err?.message fallback with a single generic $t('alert.error.generic') sentence. Locale note (not re-filed): of the 22 profile.* keys, fr.yml leaves 5 (tenant, tenants, tenantsHint, noTenants, goToTenant) marked # TODO: translate; no.yml leaves all 22. Instance of the project-level CONTENT-01 finding.
8. The two main inputs have no label; the credential form has no autocomplete or reveal toggle — Medium · FORM-01, FORM-02, FORM-03
Where: src/views/Profile.vue:327-332 (display name), :349-354 (phone), :478-487 (link form) What: the display-name and phone FormKit inputs pass :placeholder only — no label prop, so no <label for> is generated. The visible <p class="text-xs uppercase"> above each (:323-325, :345-347) is decorative text with no programmatic association. The link form's fields do have labels but set no autocomplete (should be username and new-password) and the password field has no reveal toggle. Why it matters: a screen-reader user tabbing into the display-name field hears only "Your name" — and the placeholder disappears the moment they type, so a low-vision or cognitively-loaded user loses the field's identity mid-edit. Without autocomplete="new-password" a password manager will not offer to generate or save the credential being created, pushing users toward a memorable (short) one — compounding finding 3. Fix: give both fields :label, drop the decorative <p>s; add autocomplete="username" / autocomplete="new-password" and a reveal toggle (type="button", aria-pressed, accessible name) to the link form. Note also that none of the four fields sits in a <form>, so Enter never submits — wrapping each section in <FormKit type="form"> fixes labels and Enter together.
9. Sign-out and the other user-menu items are mouse-only — Medium · A11Y-03
Where: src/components/layout/navbar/NavbarUser.vue:21-42What: PROJECT-LEVEL.md records that MenuDropdown's trigger is a bare <div @click>. The menu items are too: "Profile", "Admin" and "Logout" are each <div role="menuitem" @click="…"> with no tabindex, no @keydown.enter and no focus styling. role="menuitem" without focusability is worse than no role — it promises a menu the keyboard cannot drive. Why it matters: this is the only sign-out control in the application, and /profile is one of the routes where a user is most likely to want it (after unlinking a provider, or after hitting the auth/requires-recent-login message at :125 that explicitly instructs them to "sign out and sign in again"). A keyboard-only user cannot follow that instruction. Filed here rather than deferred because it is the item-level defect, not the trigger-level one already recorded. Fix: make the items <button role="menuitem" tabindex="-1"> inside a role="menu" container with roving focus, Escape-to-close and Enter/Space activation — in @playout/ui's MenuDropdown so every consumer benefits.
10. The photo cropper is a hand-rolled overlay, not a dialog — Medium · A11Y-03
Where: src/views/Profile.vue:289-318What: a <div class="fixed inset-0 z-50"> with no role="dialog", no aria-modal="true", no accessible name, no focus move on open, no focus trap, no focus restore on close, no Escape handler and no backdrop click-to-dismiss. The page behind stays in the tab order. Why it matters: a keyboard user who opens the cropper can tab straight out of it into the page underneath while the overlay still covers the screen, activating controls they cannot see; a screen-reader user is given no indication that a modal opened at all. Escape — the one key every user tries — does nothing, so the only exit is finding and clicking Cancel. Fix: use @playout/ui's VDialog once its own defects are fixed (see PROJECT-LEVEL.md — the closed-dialog tab-reachability and the global unguarded Escape binding must be resolved first, or this page inherits them). Failing that, add the ARIA, an @keydown.esc scoped to the overlay, and focus management here.
11. Five section headings are paragraphs, so the page has no heading structure — Medium · A11Y-04
Where: src/views/Profile.vue:323, :345, :367, :508 (and the identity card at :236 has none at all) What: each VSection is titled with <p class="text-xs font-semibold uppercase tracking-widest text-faint">. VTitle at :231 supplies the only real heading on the page (an <h1> — correctly one here, since neither Navbar nor the sidebars render a competing heading). Why it matters: the page is a long single-column settings surface, exactly the shape that heading navigation exists for. A screen-reader user pressing H or opening a headings list gets one entry, "Profile", and must arrow through the whole document to find "Connected accounts". The pattern's accessibility checklist requires <h2> per settings section. Fix: render each section title as <h2> with the same utility classes, and add one for the identity card.
12. Save buttons are disabled while empty and while submitting — Medium · FORM-05, FORM-06
Where: src/views/Profile.vue:335, :356, :311, :435, :496What: :disabled="savingName || !displayName.trim()" and friends. VButton binds the native attribute and adds disabled:pointer-events-none (packages/ui/src/components/VButton.vue:6,15) — asserted from source, since packages/ui is in-repo. Why it matters: two distinct failures. (a) FORM-05 — clearing the display name greys out Save with no message; the user is told nothing about why, and cannot discover that an empty name is rejected rather than merely unsaved. Likewise the unlink button at :435 is disabled when canUnlink is false with no explanation that the last sign-in method cannot be removed. (b) FORM-06 — the button the user just activated is disabled mid-submit, so focus drops to <body> and a keyboard user's next Tab restarts from the top of the document. Fix: keep buttons enabled, guard inside the handlers, use aria-busy for the in-flight state, and render the block reason as text next to the disabled unlink control.
13. Object URLs from the file picker are never revoked — Low · NAV-02
Where: src/views/Profile.vue:50; cancelCrop :54-58, confirmCrop :78-80What: cropSrc.value = URL.createObjectURL(file) is called on every file selection; both exit paths set cropSrc = null without a matching URL.revokeObjectURL. Nothing runs on unmount either. Why it matters: each selected image is pinned in memory for the lifetime of the tab. On a long-lived studio session where an operator tries several crops it is a slow leak of full-resolution bitmaps. Fix: revoke the previous URL in onFileSelect before creating a new one, and in both cancelCrop and confirmCrop.
14. Two icon-only controls are named only by title, one below the minimum target size — Low · A11Y-05, A11Y-02
Where: src/views/Profile.vue:572-578 (go-to-tenant), :246-254 (camera) What: the go-to-tenant router-link wraps a bare h-4 w-4 icon (16 × 16 CSS px) with no padding — below the 24 × 24 floor of WCAG 2.5.8 — and both controls take their accessible name from :title alone. title does supply a fallback accessible name per the accname spec, so this is a weakness rather than a hard naming failure; it is not exposed on touch and is not shown on keyboard focus. Why it matters: on a phone, the tenant link is a 16px tap target in a row of list items, so mis-taps land on the row instead; and neither control's purpose is discoverable without a mouse hover. Fix: add p-2 to the link (24 × 24 minimum) and give both controls an explicit aria-label alongside the title.
15. Error text is rendered without an alert role — Medium · MSG-01
Where: src/views/Profile.vue:271-276 (uploadError), :374-379 (linkError) What: both are plain <p class="text-sm text-red"> toggled by v-if. No role="alert", no aria-live. The shared success toast has the same gap — src/components/common/Success.vue:11-36 has no role="status" (app-wide, noted not re-filed as a per-feature defect). Why it matters: the link/unlink errors from finding 2's handlers are the most important text on the page — "This sign-in method already belongs to a different account" tells the user their action did nothing. A screen-reader user gets silence, and because the error appears above the button they pressed (:374-379, buttons at :446-464), it is not in their reading path either (WCAG 4.1.3). Fix: role="alert" on both paragraphs; role="status" on the success toast.
Unverified
- A11Y-01 (contrast) — cannot be settled from source.
text-faintis used for the section headings (:323et al.), the tenant id sub-label (:547), the primary-sign-in badge (:402) and thetext-xsupload error (:273); several are small text onbg-panel. Needs a contrast tool against the resolved Tailwind theme values. - A11Y-06 (short viewport / mobile keyboard) — the cropper overlay is
fixed inset-0with anh-72canvas plus header and buttons inside amax-w-mdcard and no internal scroll (:293-316); on a ~700px-tall viewport with a keyboard open the Save/Cancel row may be pushed off-screen. Plausible from the markup but must be confirmed in a rendered viewport. - Whether Firebase enforces
requires-recent-loginonlinkWithCredential— the code handles the error code at:125but never triggers a challenge itself. Whether Firebase happens to demand recent login for this specific operation depends on token age and project config; finding 2 stands regardless, since the app does not require it. - Money surfaces — PROJECT-LEVEL.md asks every playout audit touching money to grep for
maximumFractionDigits. Not applicable: this feature displays no monetary value, and no currency formatting appears in any file reviewed.
Baseline additions
ACCT-PWCHANGE— If an application lets a user create a password credential, it must also let that user change it, and must offer a self-service reset path for a forgotten one. (finding 1)MSG-07— After a write succeeds, every surface displaying the written value reflects the new value without a page reload. A success message beside a stale value reads as a failed save. (finding 6)- Reinforces the existing
MSG-06family (a) — a failure must never render as nothing — with a fourth instance shape:try/finallywith nocatch(finding 5). Suggest the reconciled wording cover "handler discards the rejection" as well as "result object ignored". - Suggest extending
SEC-04wording to name linking and unlinking a sign-in method explicitly alongside password and email change — three audits in this batch now hit credential-linking surfaces and the current wording ("security-relevant account changes") leaves it to the auditor's judgement.
Cross-project note
- SEC-04 / no re-authentication on credential changes (finding 2) — likely in customer-portal and tt-time-tracker, both of which have account-settings surfaces and neither of which has yet been audited for re-auth. Worth a targeted check of any
link*/updatePassword/updateEmailcall in those two. members has no self-service credential management to check. - MSG-01 missing
role="alert"on hand-rolled error paragraphs — already confirmed in customer-portal and members; this is the playout instance. - FORM-01 placeholder-as-label — playout is the first confirmed instance in this programme; the other three use component-library inputs that take a
labelprop by default, so it may be playout-specific toFormKitcall sites that omit it. A grep for<FormKitwith:placeholderand no:labelacross playout would size it. - ACCT-PWCHANGE (finding 1) — playout is the extreme case (no reset flow on
developat all). customer-portal and tt-time-tracker both have reset flows per PROJECT-LEVEL.md's SEC-02 row, so the reset half exists there; whether either offers an in-app change is unchecked.