Frontend UX: session-expiry handling loses in-progress work; auth feedback gaps #48

Closed
opened 2026-06-06 18:51:19 +00:00 by logaritmisk · 1 comment
Owner

Severity: High. From a frontend UX audit. The session-expiry path is the most damaging daily failure mode for a long-session tool.

Problems

  • 401 → hard reload that destroys unsaved work. Any 401 calls redirectToLogin() (web/src/api/client.ts:6-13) → window.location.assign("/login") (auth-redirect.ts:3-6), a full page reload. If a session expires mid-edit (e.g. during useUpdateObject/useSetFields), the form state is destroyed and the login page gives no reason ("session expired"). After re-login the user lands on /objects, not where they were. (The code comment even notes it could be a router navigation.)
  • Login ignores intended destination. login-page.tsx:21 hardcodes navigate("/objects"); require-auth.tsx:10 redirects without capturing the attempted location. Shared deep links always require manual re-navigation after login.
  • Auth loading is a blank screen. require-auth.tsx:8 renders an empty <div role="status"> during the useMe fetch — a blank white page on first load reads as "broken/hung", plus layout shift when the shell appears.
  • Login & logout feedback gaps. Login submits with empty fields (button only disabled on isPending); logout (app-shell.tsx:13-16) navigates only on success with no pending/error feedback.

Suggested fixes

  • Use router navigation for the 401 redirect; pass ?reason=expired&from=<path> so login shows "Your session expired — please sign in again" and returns the user afterward. Preserve SPA state where possible.
  • Capture location in RequireAuth; honor it on login success.
  • Render a centered spinner / shell skeleton during the auth check; give Sign out a pending/disabled state and surface failures; disable login submit on empty fields.

Source: frontend UX/design audit, 2026-06-06.

**Severity: High.** _From a frontend UX audit. The session-expiry path is the most damaging daily failure mode for a long-session tool._ ## Problems - **401 → hard reload that destroys unsaved work.** Any 401 calls `redirectToLogin()` (`web/src/api/client.ts:6-13`) → `window.location.assign("/login")` (`auth-redirect.ts:3-6`), a full page reload. If a session expires mid-edit (e.g. during `useUpdateObject`/`useSetFields`), the form state is destroyed and the login page gives **no reason** ("session expired"). After re-login the user lands on `/objects`, not where they were. (The code comment even notes it could be a router navigation.) - **Login ignores intended destination.** `login-page.tsx:21` hardcodes `navigate("/objects")`; `require-auth.tsx:10` redirects without capturing the attempted location. Shared deep links always require manual re-navigation after login. - **Auth loading is a blank screen.** `require-auth.tsx:8` renders an empty `<div role="status">` during the `useMe` fetch — a blank white page on first load reads as "broken/hung", plus layout shift when the shell appears. - **Login & logout feedback gaps.** Login submits with empty fields (button only disabled on `isPending`); logout (`app-shell.tsx:13-16`) navigates only on success with no pending/error feedback. ## Suggested fixes - Use router navigation for the 401 redirect; pass `?reason=expired&from=<path>` so login shows "Your session expired — please sign in again" and returns the user afterward. Preserve SPA state where possible. - Capture `location` in `RequireAuth`; honor it on login success. - Render a centered spinner / shell skeleton during the auth check; give Sign out a pending/disabled state and surface failures; disable login submit on empty fields. _Source: frontend UX/design audit, 2026-06-06._
Author
Owner

Fixed in merge 7a43f79.

401 → soft redirect (no full reload): the openapi-fetch 401 middleware can't use useNavigate, so a small navigate-bridge module (api/auth-redirect.ts: setNavigate/redirectToLogin) holds a settable navigate ref; a one-line NavigationBridge component (mounted via a pathless RootLayout route covering /login + the authed area) registers React Router's navigate. A 401 now router-navigates to /login?reason=expired&from=<path> — the React app + query cache survive (no window.location.assign teardown). Hard-nav fallback only if a 401 fires before first paint; no-op when already on /login.

Return-to-destination: login reads reason/from, shows a "session expired" notice when reason=expired, and on success returns to a validated from (open-redirect guard safeFrom — single-leading-slash local paths only; //host/absolute → /objects). RequireAuth now redirects unauthenticated users to /login?from=<attempted path> so deep links return after login.

Feedback gaps: login submit is disabled while either field is empty; the Sign out item shows a pending state (auth.signingOut, disabled, menu kept open via closeOnClick={false}) while logging out — logout errors already surface via the global toast.

Note: the audit's "blank screen during auth check" was already fixed earlier — RequireAuth renders <AppShellSkeleton/>.

Gate: 237 tests pass; typecheck/lint/build clean; check:size 215.9 KB gz; check:colors clean; en/sv parity (2 new keys: auth.sessionExpired, auth.signingOut); no new dependency; client.ts untouched.

Deferred follow-up: an in-place re-auth modal that keeps the edit form mounted for full field-level preservation of in-progress work (this milestone ships the soft redirect; typed values in an unsaved form are still lost on expiry, but you land back on the same record without a reload).

Fixed in merge `7a43f79`. **401 → soft redirect (no full reload):** the openapi-fetch 401 middleware can't use `useNavigate`, so a small **navigate-bridge** module (`api/auth-redirect.ts`: `setNavigate`/`redirectToLogin`) holds a settable navigate ref; a one-line `NavigationBridge` component (mounted via a pathless `RootLayout` route covering `/login` + the authed area) registers React Router's `navigate`. A 401 now router-navigates to `/login?reason=expired&from=<path>` — the React app + query cache survive (no `window.location.assign` teardown). Hard-nav fallback only if a 401 fires before first paint; no-op when already on `/login`. **Return-to-destination:** login reads `reason`/`from`, shows a "session expired" notice when `reason=expired`, and on success returns to a **validated** `from` (open-redirect guard `safeFrom` — single-leading-slash local paths only; `//host`/absolute → `/objects`). `RequireAuth` now redirects unauthenticated users to `/login?from=<attempted path>` so deep links return after login. **Feedback gaps:** login submit is disabled while either field is empty; the Sign out item shows a pending state (`auth.signingOut`, disabled, menu kept open via `closeOnClick={false}`) while logging out — logout *errors* already surface via the global toast. Note: the audit's "blank screen during auth check" was already fixed earlier — `RequireAuth` renders `<AppShellSkeleton/>`. Gate: 237 tests pass; typecheck/lint/build clean; check:size 215.9 KB gz; check:colors clean; en/sv parity (2 new keys: `auth.sessionExpired`, `auth.signingOut`); no new dependency; `client.ts` untouched. **Deferred follow-up:** an in-place re-auth modal that keeps the edit form mounted for full field-level preservation of in-progress work (this milestone ships the soft redirect; typed values in an unsaved form are still lost on expiry, but you land back on the same record without a reload).
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logaritmisk/biggus-dickus#48