Appearance
[UX] members — Auth callback (admin SPA)
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch:
develop@2c22f5a· Files reviewed: 12 Patterns:user-feedback/loading-indicator
Summary
The callback view itself is three lines of code, and almost every defect in this feature lives in the two things it delegates to: App.vue's unconditional session.initialize() and the router's busy-wait guard. The most important finding is a genuine infinite sign-in loop: any error inside initialize() — including a 500 from /api/me/orgs or /api/me/capabilities, or a browser that declines the session cookie — calls login(), which redirects to the IdP, which (session still live) redirects straight back to /auth/callback, where the same failure recurs. Callback.vue passes retry: false specifically to break that loop, but that call can never be the first initialize(), so the guard is dead code. Throughout the whole flow the user sees a blank white page: no spinner, no status text, no error, no retry.
On the queue's two flagged questions: (a) the busy-wait does not hang on ordinary failures — initialized is set in a finally (session.store.ts:38), so any settled fetch releases the guard; it hangs only on a request that never settles, and none of the three bootstrap requests has a timeout or AbortController. (b) The consumed OAuth code is stripped from the SPA address bar — the server consumes it at /api/auth/oauth/callback and 302s to a clean /auth/callback — so NAV-04's main clause passes; what fails is the error path, where the browser stays parked on the code-bearing URL with no Referrer-Policy and no X-Robots-Tag anywhere in the app.
Findings
1. Any backend error puts sign-in into an unbounded IdP redirect loop — Blocker · MSG-04
Where: admin/src/client/stores/session.store.ts:35-37, admin/src/client/App.vue:2,14-17, admin/src/client/views/Callback.vue:11
What: initialize() wraps four awaits — /api/me, orgs.fetch() (/api/me/orgs), caps.fetch() (/api/me/capabilities) — in one try, and its catch is if (retry) login(). login() is a full-page navigation to /api/auth/login. Every one of those calls throws on any non-2xx (throw new Error("HTTP " + status) in orgs.store.ts:31 and capabilities.store.ts:26), and a network failure throws too. So a 500 on the capabilities endpoint, a transient network drop, or a browser that refuses the SameSite=Lax session cookie all produce the same behaviour: bounce to the IdP. The IdP session is still valid, so it issues a fresh code immediately, the API mints a fresh session cookie, and the browser lands back on /auth/callback — where App.vue:15 runs initialize() again, with retry defaulting to true. Nothing counts attempts and nothing distinguishes "not signed in" from "signed in, backend broken".
Callback.vue:11 calls initialize(false) — clearly intended as the loop-breaker. It cannot work: App.vue:2 gates <RouterView> on v-if="loaded", and loaded is only set after App.vue:15's own initialize() resolves. The retry-suppressed call is therefore always the second one, after the redirect has already fired.
Why it matters: during an API incident, or for a single user on a browser that drops the cookie, the admin SPA is not merely broken — it is a redirect loop the user cannot exit. The screen stays blank, the URL flickers between the app and the IdP, there is no error message, no retry control, and no way back except knowing to clear cookies or force-quit the tab. It also hammers the IdP with a fresh authorization code per iteration.
Severity call: Blocker rather than High because the feature's entire purpose is to get a signed-in user into the app, and under a realistic and non-exotic condition it instead produces an unrecoverable state with no user-visible exit. The code shows the author already recognised the loop and tried to prevent it.
Fix: distinguish unauthenticated from failed. Have initialize() return a discriminated result — { status: "ok" | "unauthenticated" | "error" } — and call login() only for unauthenticated (i.e. a 401 from /api/me), never for a 5xx or a network error. Bound it as well: record an attempt marker in sessionStorage before login() and, if a second attempt arrives within a short window, render a terminal error screen ("Connexion impossible — réessayer / se déconnecter") instead of redirecting. Related: router.beforeEach (router.ts:95) never checks session.isAuthenticated at all, only capabilities — once the redirect is made conditional, the guard needs to own the unauthenticated case explicitly.
2. Unbounded bootstrap: no timeout on any of the three requests, and the guard busy-waits on them — High · NAV-06(b) (proposed — see PROJECT-LEVEL.md collisions)
Where: admin/src/client/router.ts:98, admin/src/client/stores/session.store.ts:27, admin/src/client/stores/orgs.store.ts:30, admin/src/client/stores/capabilities.store.ts:25
What: while (!session.initialized) await new Promise(resolve => setTimeout(resolve, 100)). Verified against the failure path as the queue asked: initialized is assigned in initialize()'s finally block (session.store.ts:38-40), so a rejected request does release the guard — the busy-wait is not the whole story the inventory note suggested. What it does not survive is a request that never settles. None of the three fetch() calls sets a timeout or an AbortController, and fetch has no default timeout, so a hung gateway, a captive-portal intercept, or a laptop resuming from sleep mid-request leaves initialized permanently false. The guard then polls every 100 ms forever, the navigation never resolves, and App.vue's v-if="loaded" keeps the tree empty. Secondarily, the loop is not tied to any navigation lifecycle, so it keeps polling after a navigation is superseded.
Why it matters: a blank white page, indefinitely, with no spinner, no message, no retry and no timeout. The user's only recourse is a manual reload, which restarts the identical unbounded wait.
Fix: give the three bootstrap fetches a shared AbortSignal.timeout(10_000) and replace the busy-wait with await session.ready — a promise the store resolves once, exposing { ok, error }. On timeout or error, resolve it and let the guard route to a real error state carrying a retry button, rather than looping.
3. Server-side auth errors are unstyled English plain text with no way out — High · MSG-03, MSG-04
Where: api/src/auth/auth.controller.ts:54, api/src/auth/auth.controller.ts:75
What: res.status(400).send("Invalid authentication state") and res.status(401).send("Authentication failed"). These are the two most likely outcomes of a normal-user mistake: the state/verifier/nonce cookies expire after 10 minutes (authStateCookieOptions, auth.controller.ts:94), so leaving the IdP tab open through a meeting, or opening a stale callback link from history, produces the 400. The user gets a bare browser default page, in English, in an application that is French throughout, with no styling, no link home, and no "réessayer".
Why it matters: for a non-tech-savvy volunteer administrator (the documented user of this app, per CLAUDE.md), "Invalid authentication state" is indistinguishable from the site being broken. It is a terminal dead end: MSG-04 requires a route out, and there is none — not even a link to /.
Fix: redirect both branches to a real SPA route (e.g. /auth/callback?erreur=etat_expire) and render a French error view with the cause in plain language and a « Se reconnecter » button that restarts /api/auth/login. If a server-rendered response must stay, make it a styled HTML page with a French message and a link back to /.
4. The sign-in flow always lands on Home, discarding the URL the user asked for — Medium · proposed NAV-RETURN-TO-ORIGIN
Where: admin/src/client/stores/session.store.ts:44, admin/src/client/views/Callback.vue:12
What: login() hardcodes redirect=${encodeURIComponent("/auth/callback")}, ignoring window.location, and Callback.vue:12 then does router.push("/") unconditionally. The backend already supports this properly — it stores any same-origin path in REDIRECT_COOKIE and honours it after the exchange (auth.controller.ts:38 and :62), with an open-redirect guard already in place. The capability is built and unused.
Why it matters: a colleague shares a link to /bactive/facturation; the recipient's session has expired; they sign in and arrive at the dashboard with no indication of where they were headed, and must re-navigate from memory. Same for every session expiry mid-task.
Fix: login() should pass the current path (redirect=${encodeURIComponent(location.pathname + location.search)}, excluding /auth/callback itself), and Callback.vue should push that target rather than /. The server-side same-origin check already prevents open redirects.
5. The callback page is blank — no indicator, status text, or completion handoff — Medium · CONTENT-04, MSG-01, A11Y-04
Where: admin/src/client/views/Callback.vue:2, admin/src/client/App.vue:2
What: the entire template is <span> </span>, and App.vue renders nothing at all until loaded flips. Across the callback the user waits on three sequential round trips (/api/me, /api/me/orgs, /api/me/capabilities) — doubled, see finding 6 — with an empty white viewport. The user-feedback/loading-indicator anatomy names five parts (indicator graphic, status text, busy target, cancellation-or-retry path, completion handoff); this page has none of them. No role="status" or aria-live region, so a screen reader announces nothing between the IdP page and the dashboard (MSG-01 / WCAG 4.1.3); no heading or landmark on the route (A11Y-04); and the pattern's "skipping announcement strategy" anti-pattern applies verbatim.
Why it matters: during a slow bootstrap the app is indistinguishable from a crash, which invites a reload — and a reload during a redirect chain is exactly how users end up on the expired-state error of finding 3.
Fix: render a centred spinner with French status text naming what is being waited on (« Connexion en cours… », then « Chargement de vos autorisations… ») inside a role="status" container, with an <h1 class="sr-only">Connexion</h1>. Add a delayed escape hatch: after ~10 s, swap in « La connexion prend plus de temps que prévu » plus a retry link.
6. The callback re-runs the entire session bootstrap that just completed — Low · MSG-06-adjacent (redundant work, not a failure)
Where: admin/src/client/views/Callback.vue:11 vs admin/src/client/App.vue:15
What: App.vue's onMounted runs initialize() to completion before <RouterView> renders; Callback.vue's onMounted then runs initialize(false) again, issuing a second /api/me, /api/me/orgs and /api/me/capabilities. There is no guard on the store against re-entry.
Why it matters: doubles the blank-screen time on the one route where the user is already waiting the longest, and triples the load the IdP-return path puts on the API.
Fix: make initialize() idempotent (return the in-flight promise if one exists, or no-op when initialized is already true), and reduce Callback.vue to the redirect.
7. NAV-04 — no Referrer-Policy or X-Robots-Tag, and the error path parks the browser on the code-bearing URL — Medium · NAV-04
Where: api/src/main.ts:8-27, api/src/auth/auth.controller.ts:48-77
What: the happy path passes: the code is consumed server-side at /api/auth/oauth/callback and the browser is 302'd to a query-free /auth/callback, so no secret lingers in the SPA address bar or in history.pushState. What fails is the header half of the rule. api/src/main.ts registers CORS, cookie-parser and a body parser but no helmet; a repo-wide grep for Referrer-Policy, X-Robots-Tag, helmet and noindex returns zero hits outside two unrelated rel="noreferrer" link attributes. On the 400/401 branches the browser stays on /api/auth/oauth/callback?code=…&state=… while rendering the error body, so the code and state sit in the address bar, in browser history, and in any screenshot the user sends to support.
Why it matters: codes are single-use and PKCE-bound, and by the time the error page renders the code has been consumed or rejected — so this is defence-in-depth, not a live hole. But history and screenshots are long-lived, and the absence of a default Referrer-Policy applies to the whole app, not just this route.
Severity call: Medium, not Blocker. Per the normalisation note, a Blocker needs something an outsider can act on directly; exploiting a consumed, PKCE-bound code requires further conditions, and the error page loads no subresources so there is no immediate Referer leak.
Fix: add helmet in api/src/main.ts with referrerPolicy: { policy: "no-referrer" } and an X-Robots-Tag: noindex, nofollow header on /api/auth/*, and (per finding 3) redirect the error branches to a clean SPA URL so the code leaves the address bar.
8. NAV-03 — no per-route document title anywhere in the admin SPA — Low · NAV-03 (project-wide; belongs in PROJECT-LEVEL.md)
Where: admin/src/client/index.html:14, admin/src/client/router.ts:54-92
What: <title>BCC Nancy Admin</title> is the title for all 24 routes; no route sets meta.title and a grep for document.title / useTitle returns nothing. Notably LayoutBreadcrumb.vue:74 already reads route.meta.title — a read that resolves to undefined for every route, since nothing ever sets it.
Why it matters: browser history and tab switching are unusable for distinguishing screens, and screen-reader users get no page announcement on navigation.
Fix: one finding for the project, not 24 — add meta.title to each record and a router.afterEach that writes document.title. This also makes LayoutBreadcrumb's existing meta.title read functional.
Unverified
- A11Y-01 (contrast) — nothing to measure on this route (the view renders a single non-breaking space), and the error pages of finding 3 use browser defaults. Requires a rendered page.
- A11Y-06 (short viewport / responsive) — requires a rendered viewport.
@bcc-code/component-library-vueinternals —node_modulesis not installed in this checkout (standing caveat in PROJECT-LEVEL.md), so I could not check whether the library ships a spinner/BccLoaderwith a live region that finding 5 should reuse rather than hand-roll. The fix should check the library first, per the members note in BASELINE.md.- Real-world reachability of the loop in finding 1 — reasoned from source (
initialize's catch,App.vue's ordering,verifySessionToken'sstatus !== ACTIVErejection). Confirming it end to end needs a running API that can be made to return 500 on/api/me/capabilities.
Baseline additions
Descriptive IDs, for the orchestrator to renumber:
AUTH-NO-REDIRECT-LOOP— an authentication failure must never automatically re-enter the sign-in redirect it has just returned from. Distinguish unauthenticated (redirect once) from error (show a terminal message), and bound automatic re-entry with an attempt counter.NAV-RETURN-TO-ORIGIN— after authentication the user returns to the URL they requested, not a fixed landing page. Same-origin paths only.- Already proposed elsewhere, cited rather than duplicated:
NAV-06(b)("async guard gates bounded by timeout + retry", per the collision list in PROJECT-LEVEL.md) covers finding 2, andMSG-06(c)("a router-enforced gate must expose the escape hatch that clears it") covers findings 1 and 2 from the other direction. I have not minted new numbers for either.
CONTENT-01 — not-applicable, see project-level i18n finding. (The English strings in finding 3 are raised as copy quality under MSG-03, not as an i18n violation.)
Cross-project note
- Findings 1 and 4 (redirect loop; no return-to-origin) — likely in customer-portal and playout, both of which have hosted-IdP sign-in and a post-auth landing route. The specific shape to look for is a catch-all
catcharound session bootstrap that calls the login redirect for any error rather than for 401 only. - Finding 2 (unbounded bootstrap / guard busy-wait) — the guard busy-wait is members-specific, but "no timeout on the session bootstrap fetch" is worth checking in all three others; PROJECT-LEVEL.md already carries an
MSG-06-family theme about failed loads rendering as permanent loading. - Finding 7 (no
Referrer-Policy/helmet) — a whole-service header gap, so any project whose API is a separate Nest/Express service is a candidate: check customer-portal and tt-time-tracker (NestJS). - Finding 8 (NAV-03) — already confirmed failing in playout, customer-portal and tt-time-tracker. This closes the row: all four projects fail NAV-03.