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._
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 validatedfrom (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).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Severity: High. From a frontend UX audit. The session-expiry path is the most damaging daily failure mode for a long-session tool.
Problems
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. duringuseUpdateObject/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-page.tsx:21hardcodesnavigate("/objects");require-auth.tsx:10redirects without capturing the attempted location. Shared deep links always require manual re-navigation after login.require-auth.tsx:8renders an empty<div role="status">during theuseMefetch — a blank white page on first load reads as "broken/hung", plus layout shift when the shell appears.isPending); logout (app-shell.tsx:13-16) navigates only on success with no pending/error feedback.Suggested fixes
?reason=expired&from=<path>so login shows "Your session expired — please sign in again" and returns the user afterward. Preserve SPA state where possible.locationinRequireAuth; honor it on login success.Source: frontend UX/design audit, 2026-06-06.
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-lineNavigationBridgecomponent (mounted via a pathlessRootLayoutroute covering/login+ the authed area) registers React Router'snavigate. A 401 now router-navigates to/login?reason=expired&from=<path>— the React app + query cache survive (nowindow.location.assignteardown). 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 whenreason=expired, and on success returns to a validatedfrom(open-redirect guardsafeFrom— single-leading-slash local paths only;//host/absolute →/objects).RequireAuthnow 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 viacloseOnClick={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 —
RequireAuthrenders<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.tsuntouched.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).