feat(web): auth-protected routes via React Router v7 middleware + Zustand store#31168
Conversation
…tand store Replace the React Context-based AuthProvider with: - Zustand auth store (stores/auth-store.ts) — readable from middleware, loaders, and API interceptors via .getState() - React Router v7 middleware (lib/auth/auth-middleware.ts) — runs before route components render, redirects unauthenticated users to login - Environment-config auth requirement (lib/auth/require-auth.ts) — VITE_AUTH_REQUIRED env var controls enforcement. Defaults false so local dev and self-hosting work without login. Route tree updated: v8_middleware future flag enabled, authMiddleware wired to /assistant/* routes. Account routes remain public. All useAuth() consumers migrated to useAuthStore() selectors. Dead code removed: lib/auth/auth-provider.tsx, lib/providers/, lib/organization/ (duplicates of files already in components/ and domains/). References: - https://reactrouter.com/how-to/middleware - https://reactrouter.com/upgrading/future#futurev8_middleware - https://zustand.docs.pmnd.rs/guides/reading-and-writing-state-outside-components - https://vite.dev/guide/env-and-mode.html Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 39bc0a4273
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } | ||
|
|
||
| if (!isLoggedIn || !user) { | ||
| const returnTo = encodeURIComponent(window.location.pathname + window.location.search); |
There was a problem hiding this comment.
Build returnTo from middleware request URL
Use the navigation request URL here instead of window.location when constructing returnTo. React Router middleware receives the destination request for the in-flight navigation, and middleware runs before navigation completes; using window.location.pathname + window.location.search can capture the previous page during client-side transitions or fetcher-driven redirects, sending users back to the wrong route after login. Derive returnTo from request.url in middleware args so the redirect target is always the protected URL the user actually requested.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — fixed in d2dc865. Now destructures request from middleware args and uses new URL(request.url) to extract pathname + search, matching the documented middleware pattern. window.location timing relative to middleware execution is not API-contracted (see remix-run/react-router#12790).
React Router middleware receives a Request object with the navigation destination URL. Using window.location is unreliable during client-side transitions — its timing relative to middleware execution is not guaranteed by the API contract (see remix-run/react-router#12790). Parse request.url via new URL() to extract pathname + search for the returnTo parameter, matching the documented middleware pattern from https://reactrouter.com/how-to/middleware. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Documents the env var, its default behavior (auth optional), and how to test auth-required mode locally. Vite-specific conventions (VITE_ prefix requirement, .env.local for overrides) are included for open-source contributors unfamiliar with the stack. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Remove conditional auth logic (VITE_AUTH_REQUIRED env var, requiresAuth() config function, anonymous user passthrough, .env.example). The middleware now always redirects unauthenticated users to /account/login, matching the platform's current behavior exactly. Conditional auth for self-hosted/Electron/open-source will be added as a follow-up when those use cases are actively developed. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
- Add createSelectors utility (Zustand auto-generating selectors pattern) - Wrap auth store with createSelectors for .use.field() API - Migrate all useAuthStore((s) => s.field) to useAuthStore.use.field() - Add createSelectors docs to CONVENTIONS.md - Add third-party-only comment to providers.tsx Reference: https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
There was a problem hiding this comment.
✦ APPROVE
Value: Unauthenticated users hitting /assistant/* previously landed on a broken loading state with no redirect — this PR closes that gap with a correct React Router v7 middleware guard and replaces the React Context auth layer with a Zustand store accessible everywhere (middleware, interceptors, loaders).
What this does: Deletes the Context-based AuthProvider (−539 lines across three files), adds a Zustand auth-store with session init + window-focus refresh + cross-tab BroadcastChannel sync, introduces createSelectors to auto-generate atomic per-field selector hooks (store.use.field()), and wires authMiddleware onto the /assistant route tree via the v8_middleware: true future flag.
create-selectors.ts — excellent addition
This is the official Zustand auto-generating selectors pattern. store.use.field() generates one selector per key, so each consumer subscribes to exactly the field it needs — strictly more granular than useShallow for grouped reads. The CONVENTIONS.md update documents it clearly and sets the right precedent for new stores going forward.
One thing to note: Object.keys(store.getState()) generates .use hooks for action keys too (useAuthStore.use.logout()), not just state. This works because function references in a create() closure are stable, so the selector never triggers a re-render. It's consistent with the Zustand docs example, so this is fine — just worth knowing if someone wonders why use.initSession exists.
auth-store.ts — clean
- Module-level
create<AuthStore>()((set) => ...)curried form ✅ initSessionalways setsisLoading: falsein both the success path and the catch — sowaitForAuthReady()always resolves ✅BroadcastChannelguard (typeof BroadcastChannel !== "undefined") is correct for environments that don't support it ✅syncOrganizationStateresets org when user changes or logs out — preserves the existing cache-invalidation invariant ✅
auth-middleware.ts — mostly good, one style note
The waitForAuthReady() implementation (subscribe first, then check current state) is correct — Zustand notifies synchronously on setState, and there's no async gap between the subscribe() and the getState() check, so the "miss the transition" race can't happen.
The recursive call pattern is worth noting:
if (isLoading) {
await waitForAuthReady();
return authMiddleware({ request, context } as Parameters<MiddlewareFunction>[0], next);
}This works — after waitForAuthReady() resolves, isLoading is false, so the recursive call runs the redirect or next() branch immediately. But it's a non-obvious read. An equivalent and slightly clearer alternative would be re-reading state inline after the await rather than recursing. Non-blocking — the current form is correct.
The Codex P2 (window.location → request.url) is already fixed in d2dc865 per Devin's confirmation ✅.
main.tsx — init pattern is correct
useAuthStore.getState().initSession() called before createRoot() — the async promise is intentionally not awaited, which is right. The isLoading: true initial state ensures the middleware waits before routing. setupAuthListeners() return value (cleanup fn) is ignored — fine for an SPA that never unmounts, though a window.__vexCleanup hook in dev mode could be handy if HMR ever makes module-level side effects weird.
No tests
The store has async session init, BroadcastChannel cross-tab sync, and window focus listeners — meaningful behavior worth unit-testing against the Zustand .getState() / .setState() API directly. The middleware's redirect logic would also be testable with createMemoryRouter. Not a blocker for landing this now given the migration pace, but flagging for a follow-on.
Bot review status: Both Devin ("No Issues Found") and Codex reviewed at 39bc0a4273. HEAD is a7a07db8ee (4 commits later — the createSelectors utility + consumer migration). That latest commit is mechanical (official Zustand pattern, no logic changes), so the stale bot reviews don't leave meaningful blind spots. Devin's approval counts toward merge criteria.
Vellum Constitution — Trust-seeking: replacing a silent broken-loading-state with an explicit login redirect makes the app's auth boundary predictable and auditable.
Prompt / plan
Replace the React Context-based
AuthProviderwith a Zustand auth store + React Router v7 middleware for auth-protected routes. Unauthenticated users hitting/assistant/*are redirected to/account/loginwith areturnToparameter.Why this change is needed
/assistantsee a broken/loading page instead of being redirected to login.getState()works anywhereAppOrganizationGatealways enforces auth; this middleware does the same using React Router v7's recommended patternWhat changed
New files:
src/utils/create-selectors.ts— Zustand auto-generating selectors utility. Wraps a store to auto-generate.use.field()hooks for every state key — fully typed, with autocompletesrc/stores/auth-store.ts— Zustand store replacingAuthProvider. Same session probing, tab-sync (BroadcastChannel), focus/visibility refresh logic. Wrapped withcreateSelectorsfor.use.field()API. Readable from middleware via.getState()src/lib/auth/auth-middleware.ts— React Router v7 middleware (v8_middlewarefuture flag). Runs before route components render. Usesrequest.urlfrom middleware args (notwindow.location) per React Router docs. Redirects unauthenticated users to/account/login. Passes user data via React Router's typedcontextModified files:
src/routes.tsx—v8_middleware: truefuture flag enabled,authMiddlewarewired to/assistant/*routes. Account routes remain publicsrc/main.tsx—AuthProviderremoved, replaced withuseAuthStore.getState().initSession()+setupAuthListeners()at module levelsrc/components/providers.tsx— Migrated touseAuthStore.use.field()selectors. Added comment: only third-party library providers (React Query) belong here; app state uses Zustand storessrc/domains/chat/chat-page.tsx—useAuth()→useAuthStore.use.field()src/domains/account/pages/*.tsx—useAuth()→useAuthStore.use.field()src/domains/organization/organization-provider.tsx—useAuth()→useAuthStore.use.field()CONVENTIONS.md— AddedcreateSelectorssection documenting the auto-generating selectors patternDeleted files:
src/lib/auth/auth-provider.tsx— Replaced by Zustand storesrc/lib/providers/app-providers.tsx— Dead code (duplicate ofsrc/components/providers.tsx, not imported anywhere)src/lib/organization/organization-provider.tsx— Dead code (duplicate ofsrc/domains/organization/organization-provider.tsx, not imported anywhere)Why Zustand store at
stores/auth-store.tsAuth state is cross-domain shared state consumed by middleware, API interceptors, providers, and multiple domains (chat, account, organization). Per CONVENTIONS.md, app-level cross-domain stores belong in
src/stores/. Auth logic files (middleware, allauth API client, CSRF) stay inlib/auth/since they're configured third-party wrappers and route infrastructure.createSelectorspatternAll stores should be wrapped with
createSelectors()to auto-generate per-field selector hooks. This is the official Zustand pattern — consumers useuseAuthStore.use.isLoggedIn()instead ofuseAuthStore((s) => s.isLoggedIn). Fully typed with autocomplete. Documented in CONVENTIONS.md.Alternatives not taken
AppOrganizationGate) — renders the component tree first, checks auth inuseEffect, causes a flash of protected content. Middleware runs before rendering — no flash. React Router middleware docsgetState()works everywhere. Zustand docsuseIsLoggedIn,useIsLoading) — replaced bycreateSelectorsauto-generation which provides the same per-field re-render optimization without manual maintenanceuseAuthshorthand (exportinguseAuthStore.useasuseAuth) — rejected because the store name prefix provides context about which store you're accessing, important when components pull from multiple storesFollow-up tickets
Test plan
bunx tsc --noEmit)bun run lint)bun run build)Link to Devin session: https://app.devin.ai/sessions/5c400920d2b745bc84401cd020820f22
Requested by: @ashleeradka