Appearance
[UX] playout — Sharing links
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch:
develop@998e706d· Files reviewed: 22 Patterns:social/share-dialog
Summary
The sharing-links feature is the highest-stakes surface audited in playout so far — a link in a URL grants another organisation read access to the source tenant's person data — and it is currently non-functional for its intended recipient. Firestore rules permit only source-tenant admins to read a sharing link, but the claim page reads the link client-side before rendering anything; the repo's own rules test (sharing.rules.test.ts:67) asserts that an outsider is denied. Every real recipient therefore sees "Link not found". A second blocker sits behind it: every button on the claim page navigates to a route name that does not exist. Beyond the blockers, the owner has no way to revoke access once a link has been claimed, and nothing about the grant is confirmed, notified, or logged.
Findings
1. The claim page can never load for the person the link was sent to — Blocker · proposed SEC-READ-GATE, MSG-02
Where: src/views/Tenant/ClaimSharingLink.vue:235–246 · firestore.rules:286 · functions/src/__tests__/rules/sharing.rules.test.ts:67What: fetchLink() does a client-side getDoc() on tenants/{sourceTenant}/sharing-links/{linkId} before deciding which state to render. The rule guarding that path is allow read: if isTenantAdministrator() — evaluated against the source tenant. The recipient is by definition an admin of a different tenant, i.e. an outsider to the source tenant, so the read is denied. The update rule (isClaimingLink(), firestore.rules:276) is carefully written to allow exactly that outsider to claim — but the UI can never get far enough to offer the button. The repo's own test suite encodes the mismatch: denies outsider from reading sharing links passes, and allows an admin of the receiving tenant to claim an active link also passes. Compounding it, fetchLink has try { … } finally { … } with no catch, so the permission-denied rejection escapes as an unhandled promise rejection while linkData stays null and the template falls through to the "Not found" branch. Why it matters: the entire feature is dead in production for everyone except Playout super-admins. An admin who receives a share link sees "Link not found / This sharing link does not exist", which is factually wrong — the link exists and is valid. There is no path to the claim action at all. Fix: move the pre-claim read behind a callable function that returns only the non-sensitive fields the claim screen needs (label, expiry, source tenant display name, resolved status), or add a narrowly scoped read rule permitting an authenticated admin-of-any-tenant to read a link whose status == 'active'. Either way, add a catch that distinguishes permission-denied from not-found and surfaces it (see finding 8).
2. Every button on the claim page throws instead of navigating — Blocker · MSG-04
Where: src/views/Tenant/ClaimSharingLink.vue:259What: const goHome = () => router.push({ name: "select-tenant" });. There is no route named select-tenant — src/router/routes.ts:51 defines choose-tenant. grep -rn "select-tenant" src returns this one line and nothing else. goHome is wired to the only control in all six states of the page: Back (not-found, expired, revoked, claimed), Cancel and Continue (success). Why it matters: vue-router rejects with No match for {"name":"select-tenant"}. Nothing happens visually — the user clicks, the page does not move, no error is shown. Every terminal state of the claim flow is a hard dead end with a button that looks like the way out and is not. This is exactly the MSG-04 failure the rule exists for, made worse by the button appearing functional. Fix: router.push({ name: "choose-tenant" }). Add a router unit test that asserts every router.push({ name }) literal in src/ resolves against routes.ts — this class of typo is invisible to vue-tsc.
3. Access cannot be revoked once the link is claimed — High · MSG-05
Where: src/components/tenant/settings/SharingLinks.vue:112–135 · functions/src/repositories/tenant.repository.ts:64–80What: the Revoke button renders only v-if="resolveStatus(link) === 'active'" (line 121). Once a link is claimed, the only remaining control is the trash icon (line 129), titled common.delete — "Delete". But getTenantChurches grants the receiving tenant access for as long as status == 'claimed' andexpiresAt > now, so deleting the doc is the only way to withdraw a live grant. The owner is offered "Revoke" exactly when it does nothing meaningful (the link has not been used yet) and is denied it exactly when it matters. Related: expiry has a :min of tomorrow (line 39) but no maximum — a link can be created that expires in the year 3000, and after it is claimed there is no labelled control to end the grant. Why it matters: an admin who realises they shared person data with the wrong organisation has to work out that a button labelled "Delete" is the revoke button, and there is nothing on screen saying so. In the meantime the other tenant keeps reading person data. Fix: show Revoke for claimed links too (the rule already allows a tenant admin any update). Label it for the situation — "Revoke access" — and add a maximum expiry (e.g. 90 days) to the create form.
4. Revoke and delete fire on a single click, with no confirmation — High · MSG-05
Where: src/components/tenant/settings/SharingLinks.vue:124, :132, :202–204 · src/locales/en.yml (sharing.confirm-revoke) What: @click="handleRevoke(link.id)" calls sharing.revokeLink immediately; @click="sharing.deleteLink(link.id)" is called inline from the template with no handler at all. Neither is reversible from the UI: there is no un-revoke path (once status == 'revoked' the only control left is Delete), and deletion is permanent. The copy for the missing dialog already exists — en.yml carries sharing.confirm-revoke: "Are you sure you want to revoke this sharing link?", translated (as a # TODO: translate stub) into fr.yml and no.yml, and it is referenced nowhere in src/. The confirmation was designed and never wired up. Why it matters: two adjacent 26px icon buttons, one of which permanently destroys a record and cuts another organisation's live data access, with no confirm step and no undo. A misclick on a touch device is unrecoverable. Fix: wire sharing.confirm-revoke into a confirm dialog, and add a matching sharing.confirm-delete that states the consequence — per MSG-05 it must say what will be lost: "X will immediately lose access to your person data."
5. No out-of-band notification when person data is shared or claimed — High · SEC-04
Where: src/stores/sharing.store.ts:19–55 (createLink, claimLink) What: creating a link, and a third party claiming it, are both plain client-side Firestore writes. Nothing emails or otherwise notifies the source tenant's admins that (a) a link granting access to their person data was created, or (b) it was claimed, or by whom. The only record is the claimedByTenant string rendered in the settings list (SharingLinks.vue:107), which nobody sees unless they visit that page. There is no audit-log entry either — the app's audit view (src/views/Events/Audit.vue) is event-scoped and does not cover sharing. Why it matters: the whole point of the feature is granting an outside organisation access to personal data. A compromised or careless admin account can create and hand out a link and the rest of the tenant's admins will never find out. Placed at High rather than Blocker per the severity guidance: this is a missing out-of-band notification, which the brief names explicitly as High. Fix: send an email to all source-tenant admins on both create and claim, and write an entry to a tenant-level audit trail. The social/share-dialog pattern names "authorization checks, moderation, and audit trails" as part of the pattern, not an add-on.
6. The link id sits in the URL with no noindex and no referrer policy — High · NAV-04
Where: firebase.json:16–60 (headers block) · index.html:4–18 · src/router/routes.ts:71What: the claim URL is /{tenant}/share/{linkId} and the link id is the whole secret. firebase.json already has a headers array — it is used for Cache-Control on four paths — but sets no Referrer-Policy and no X-Robots-Tag, and index.html has no <meta name="robots">. A repo-wide grep for Referrer-Policy|X-Robots-Tag|noindex returns only packages/widgets/src/utils/sanitizeHtml.ts, which is unrelated (it sets rel on user-authored anchors). This matches what the sibling playout audits found: no referrer or robots header exists anywhere in the repo. Why it matters: the claim page is the one route in playout whose URL is a credential. Without Referrer-Policy: no-referrer the full URL leaks to any third-party origin the page touches, and without X-Robots-Tag: noindex a link pasted anywhere crawlable becomes indexed. High rather than Blocker: exploitation needs the URL to leak first, which is a second condition. Fix: add to firebase.json: {"source": "/*/share/**", "headers": [{"key":"Referrer-Policy","value":"no-referrer"},{"key":"X-Robots-Tag","value":"noindex, nofollow"}]}. Apply the same to /*/register/**, which has the same shape (routes.ts:70). NAV-04 requires a response header, so a runtime meta tag is not a sufficient fix.
7. Link expiry is enforced in the client and by a nightly batch job, not in the rules — Medium · SEC-02
Where: firestore.rules:276–284 (isClaimingLink) · functions/src/index.ts:53 · functions/src/handlers/scheduler/expireSharingLinks.tsWhat: isClaimingLink() gates on resource.data.status == 'active' and never compares expiresAt to request.time. Links only become expired when the expireSharingLinks scheduler runs — schedule: '5 0 * * *', i.e. once a day at 00:05. Between a link's stated expiry and the next run (up to ~24 hours), the document is still status: 'active' and the rules will accept a claim. The claim UI blocks this (resolvedStatus computes expiry client-side, ClaimSharingLink.vue:220–227) but the client is not the enforcement point — a direct SDK call succeeds. Why it matters: deliberately Medium, not Blocker. getTenantChurches independently re-checks expiresAt.toMillis() > now before granting anything (tenant.repository.ts:73–78), so claiming an expired link grants no data access. The real harm is that a single-use link is silently consumed and the owner's settings page shows "Claimed by <org>" for a link that had already expired — a misleading record on a security-relevant surface. Fix: add && resource.data.expiresAt > request.time to isClaimingLink(), and add a rules test asserting an expired-but-still-active link cannot be claimed. Keep the scheduler as cosmetic status cleanup.
8. A rejected claim shows nothing at all — Medium · MSG-01, MSG-03
Where: src/views/Tenant/ClaimSharingLink.vue:248–257What: handleClaim is try { … } finally { claiming.value = false } with no catch and no error ref. Every rejection path the rules define ends here silently: an operator (not admin) of the receiving tenant is denied (sharing.rules.test.ts:133), an already-claimed link is denied (:177), a link revoked between page load and click is denied. In all of these the spinner runs, stops, and the screen is unchanged. The view has no error state in its template at all — six states are rendered and none of them is "the claim failed". Why it matters: the user has no idea whether they now have access. The most likely real-world case — an operator rather than an admin of the receiving tenant following the link — produces a button that appears to do nothing, forever. Fix: add a claimError ref and an error block with role="alert" whose copy states the actionable cause ("Only an administrator of {tenant} can accept this link — ask an administrator to open it"), mapped from the Firestore error code rather than printed raw (MSG-02).
9. Visiting a share link silently rewrites the user's default tenant — Medium · proposed NAV-DEFAULT-TENANT
Where: src/router/index.ts (router.afterEach) · src/router/routes.ts:71What: afterEach runs localStorage.setItem("tenant", to.params.tenant) on every route with a :tenant param, including /:tenant/share/:linkId where the tenant is the source organisation — one the visiting user has no membership in. On the next sign-in the login guard reads that value and redirects to { name: "events", params: { tenant: savedTenant } }. Separately, the claim route carries neither unprotected nor noRoleCheck meta, so checkAdminRole runs against the source tenant, sets session.role = "" and layout.forbidden = true. ClaimSharingLink is a top-level route, not a Layout child, and layout.forbidden is only rendered in src/views/Layout.vue:23 — so the flag is set invisibly and carries into whichever Layout route the user opens next. Why it matters: an admin clicks a share link, claims it, closes the tab, and their next sign-in drops them into a tenant they cannot access. Two mechanisms push the same way — the stale localStorage default and the sticky forbidden flag. Fix: add meta: { noRoleCheck: true } to the claim route and skip the afterEach tenant write for routes the user is not a member of (or add a meta.transientTenant flag and check it in afterEach).
10. The share URL is never shown, and a failed copy still reports success — Medium · MSG-01, MSG-06-shape
Where: src/components/tenant/settings/SharingLinks.vue:142–150, :180–200What: three problems in one flow. (a) copy(url) from @vueuse/core is called without await and its result is discarded, and isSupported is never checked; showCopied is set unconditionally on the next line. In a non-secure context or with clipboard permission denied the user is told "Link copied to clipboard" and has nothing. (b) The URL itself is rendered nowhere — not in the list, not after creation. There is no selectable text to fall back to, and no way to see where a link points before sending it. The social/share-dialog anatomy lists a share preview alongside the copy action for exactly this reason. (c) The toast is a plain <div> with no role="status" / aria-live — a silent DOM swap (WCAG 4.1.3). Its dismissal setTimeouts (lines 188, 199) are never cleared on unmount, so navigating away within 2.5s writes to an unmounted component's ref (NAV-02). Why it matters: an admin creates a link, is told it is on the clipboard, pastes — and gets whatever was there before. Nothing in the UI lets them recover the URL by eye. Fix: await copy(url), branch on copied/isSupported, render the URL in a readonly input next to the copy button, and give the toast role="status" plus onScopeDispose(() => clearTimeout(timer)).
11. The receiving-tenant <select> has no associated label — Medium · FORM-01
Where: src/views/Tenant/ClaimSharingLink.vue:154–166What: the "Receive as" text is a bare <span class="text-faint">; the <select v-model="receivingTenantId"> has no <label for>, no aria-label and no aria-labelledby. It also has no placeholder <option>, while receivingTenantId initialises to null and is only auto-set when the user belongs to exactly one tenant (:216–218) — so a multi-tenant admin sees a select with no visible selection and a disabled Accept button, with nothing saying why. Why it matters: this control decides which organisation receives another organisation's person data. A screen-reader user reaches an unlabelled combobox at the single most consequential decision point in the flow. Fix: wrap in a real <label> (or aria-labelledby the existing span's id), and add a disabled placeholder option plus a hint explaining the disabled Accept.
12. Two <h1> elements on the settings page — Medium · A11Y-04
Where: src/views/Tenant/Settings/Index.vue (VTitle size="4xl") · src/components/tenant/settings/SharingLinks.vue:5–9 (VTitle size="lg") · packages/ui/src/components/VTitle.vue:2What: VTitle renders a literal <h1> regardless of its size prop — size maps to font classes only. The settings shell renders one ("Settings") and the sharing panel renders another ("Person Data Sharing"), so /:tenant/settings/sharing has two <h1>s and no <h2>. Readable from source: @playout/ui is a workspace package shipping raw SFCs, so this is not blocked by the missing node_modules. Why it matters: heading navigation gives a screen-reader user two competing page titles and no sub-structure. This affects every settings sub-route, not just sharing — the fix belongs in @playout/ui (add a tag/as prop) or in the call sites. Fix: give VTitle an as prop defaulting to h1, and pass as="h2" from every panel rendered inside the settings shell.
13. Accept is disabled while submitting — Medium · FORM-06
Where: src/views/Tenant/ClaimSharingLink.vue:176–184 · packages/ui/src/components/VButton.vue:4, :18What: :disabled="claiming || !receivingTenantId". VButton binds disabled straight onto the native <button> and adds disabled:pointer-events-none, so the just-activated button is removed from the accessibility tree mid-action and focus falls to <body>. Verified from the workspace source, not inferred. Why it matters: a keyboard or screen-reader user loses their place at the moment the result arrives, and — given finding 8 — there is nothing announced to return to. Fix: keep the button enabled, set aria-busy="true", and guard re-entry in handleClaim with if (claiming.value) return.
14. Create is disabled with no explanation — Medium · FORM-05, FORM-04
Where: src/components/tenant/settings/SharingLinks.vue:51–57, :36–41What: :disabled="!newExpires" on the Create button. The expiry field is not marked required, carries no validation message, and the only constraint shown is the native min (tomorrow), which most browsers surface only when the picker is open. A user who fills in a label and clicks a dead Create button is told nothing. Why it matters: FORM-05 exists because a disabled submit gives the user nothing to act on; here the blocked field is the second of two and is visually identical to the optional one. Fix: keep Create enabled, mark expiry required with a visible hint stating the allowed range up front (FORM-04), and show a validation message on submit.
15. Untranslated copy, plus one string outside i18n entirely — Low · CONTENT-01
Where: src/locales/fr.yml, src/locales/no.yml (sharing: block) · src/views/Tenant/ClaimSharingLink.vue:10–12What: all 34 keys under sharing: — including every string on the claim page — carry verbatim English values marked # TODO: translate in both fr.yml and no.yml. That is the project-level CONTENT-01 problem (see PROJECT-LEVEL.md); recorded here only because this feature is 100% affected. The feature-specific part is line 11: Loading sharing link… is a hardcoded English literal with no i18n key at all, so pnpm check:locales cannot see it even in principle — key parity will stay green forever. Why it matters: a French recipient reaching a page that grants access to personal data reads it entirely in English, and one string will never be translatable until it is extracted. Fix: add sharing.claim.loading and use it; translate the sharing: block.
16. The claim page identifies the sharing organisation by slug, not name — Low · CONTENT-03
Where: src/views/Tenant/ClaimSharingLink.vue:113, :135What: $t('sharing.claim.description', { tenant: sourceTenantId }) interpolates route.params.tenant — the URL slug — so the page reads "You are about to accept a person data sharing link from acme-church-2". The success message does the same. The tenant's display name is available (tenants.getFromId) but the source tenant is not in tenants.my, so it would need to come from the same server call proposed in finding 1. The page also never states what is being shared beyond "person data" — in practice it is the source tenant's church list, which decides which person records the receiving tenant can read (tenant.repository.ts:56–93). Why it matters: the social/share-dialog pattern's top anti-pattern is "treating trust as secondary UI" — this is the consent screen for a personal-data grant, and it identifies neither party in human terms nor the data in question. Fix: return the source tenant's display name and a plain-language description of the scope from the server-side link lookup, and show both above the Accept button.
17. NAV-03 — fails project-wide, see PROJECT-LEVEL.md
No per-route document title mechanism exists; both routes inherit the static title. Not re-filed.
Unverified
- A11Y-01 (contrast). The status chips use
bg-green/15 text-green,bg-accent-subtle text-accentandbg-raised text-faintattext-[10px](SharingLinks.vue:95), andtext-faintcarries most of the secondary copy on both screens. 10px uppercase text on a 15%-alpha tint is the shape that usually fails, but the tokens are OKLCH variables inpackages/ui/src/styles/tokens.cssand contrast needs computed values. Not asserted. - A11Y-02 (target size). The three icon buttons at
SharingLinks.vue:112–135arep-1.5around atext-smicon — roughly 26px, i.e. borderline against the 24px floor, and they sit adjacent withgap-x-1.5. Needs a rendered page. - A11Y-05. Those buttons take their accessible name from
:titleonly. The accname algorithm does fall back totitle, so this is likely a technical pass, buttitleis invisible to touch and keyboard users. Not asserted as a failure. - A11Y-06 (responsive). The claim card is
max-w-smcentred inmin-h-dvh; the settings header is a two-column flex that will crowd on narrow viewports. Needs a viewport. - The
sharing.titlecopy is Title Case ("Person Data Sharing") where every othermenu.*entry is sentence case. Style opinion, not a rule — noted, not filed. index.html:9setsuser-scalable=noapp-wide. That is the proposed A11Y-07(b) rule in PROJECT-LEVEL.md and belongs to a project-level draft, not this feature.
Baseline additions
SEC-READ-GATE— When the client must read a resource in order to render the control that acts on it, the read must be permitted by the same rules that permit the action. A write rule that authorises a principal the read rule excludes is a dead feature. (Finding 1. Generalises beyond Firestore to any row-level-security backend.)NAV-DEFAULT-TENANT— A persisted navigation default (last tenant, last workspace) is only written from a context the user is a member of. Transient routes — invitation, claim, public screen — must not overwrite it. (Finding 9. Note: PROJECT-LEVEL.md already flags aNAV-06collision including "persisted navigation defaults only from deliberate user choice" — this is the same rule and should merge with it.)MSG-05extension — the existing rule says destructive actions confirm first and say what will be lost. Finding 3 is a variant worth naming explicitly: the control that reverses a grant must remain available for as long as the grant is in effect, and must be labelled by its effect ("Revoke access"), not by its implementation ("Delete").- Findings 8 and 10 are both instances of the existing cross-project MSG-06 theme (non-throwing / unawaited result discarded, failure rendered as success or as nothing). No new ID needed — they should cite MSG-06 once it is reconciled per PROJECT-LEVEL.md.
Cross-project note
- NAV-04 (no
Referrer-Policy/X-Robots-Tagresponse headers) is almost certainly present in all four projects — no sibling playout audit found one anywhere in the repo, and customer-portal and tt-time-tracker both have token-in-URL routes (password reset) confirmed failing SEC-02. Worth one cross-project hardening issue covering the deploy config of each. SEC-READ-GATE(finding 1) is playout-specific in this exact form because it depends on Firestore rules, but the shape — an invite/claim screen that reads the invitation as the recipient — applies to customer-portal and tt-time-tracker wherever they have invite acceptance flows. Worth checking.- MSG-05 (destructive action with no confirmation) and FORM-06 (submit disabled during submission) have both been reported in customer-portal and tt-time-tracker; this is more evidence for the cross-project alignment table.
- SEC-04 (no out-of-band notification on a security-relevant change) is already listed as failing in customer-portal and tt-time-tracker. playout now joins them, on the feature where it matters most.