Skip to content

feat(web): auth-protected routes via React Router v7 middleware + Zustand store#31168

Merged
ashleeradka merged 5 commits into
mainfrom
devin/1779213131-auth-middleware-implementation
May 19, 2026
Merged

feat(web): auth-protected routes via React Router v7 middleware + Zustand store#31168
ashleeradka merged 5 commits into
mainfrom
devin/1779213131-auth-middleware-implementation

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka commented May 19, 2026

Prompt / plan

Replace the React Context-based AuthProvider with a Zustand auth store + React Router v7 middleware for auth-protected routes. Unauthenticated users hitting /assistant/* are redirected to /account/login with a returnTo parameter.

Why this change is needed

  • No route protection exists today — unauthenticated users hitting /assistant see a broken/loading page instead of being redirected to login
  • React Context can't be read outside React — middleware, loaders, and API interceptors need auth state but Context requires a component tree. Zustand's .getState() works anywhere
  • Full parity with platform — the platform's AppOrganizationGate always enforces auth; this middleware does the same using React Router v7's recommended pattern

What changed

New files:

  • src/utils/create-selectors.tsZustand auto-generating selectors utility. Wraps a store to auto-generate .use.field() hooks for every state key — fully typed, with autocomplete
  • src/stores/auth-store.ts — Zustand store replacing AuthProvider. Same session probing, tab-sync (BroadcastChannel), focus/visibility refresh logic. Wrapped with createSelectors for .use.field() API. Readable from middleware via .getState()
  • src/lib/auth/auth-middleware.ts — React Router v7 middleware (v8_middleware future flag). Runs before route components render. Uses request.url from middleware args (not window.location) per React Router docs. Redirects unauthenticated users to /account/login. Passes user data via React Router's typed context

Modified files:

  • src/routes.tsxv8_middleware: true future flag enabled, authMiddleware wired to /assistant/* routes. Account routes remain public
  • src/main.tsxAuthProvider removed, replaced with useAuthStore.getState().initSession() + setupAuthListeners() at module level
  • src/components/providers.tsx — Migrated to useAuthStore.use.field() selectors. Added comment: only third-party library providers (React Query) belong here; app state uses Zustand stores
  • src/domains/chat/chat-page.tsxuseAuth()useAuthStore.use.field()
  • src/domains/account/pages/*.tsxuseAuth()useAuthStore.use.field()
  • src/domains/organization/organization-provider.tsxuseAuth()useAuthStore.use.field()
  • CONVENTIONS.md — Added createSelectors section documenting the auto-generating selectors pattern

Deleted files:

  • src/lib/auth/auth-provider.tsx — Replaced by Zustand store
  • src/lib/providers/app-providers.tsx — Dead code (duplicate of src/components/providers.tsx, not imported anywhere)
  • src/lib/organization/organization-provider.tsx — Dead code (duplicate of src/domains/organization/organization-provider.tsx, not imported anywhere)

Why Zustand store at stores/auth-store.ts

Auth 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 in lib/auth/ since they're configured third-party wrappers and route infrastructure.

createSelectors pattern

All stores should be wrapped with createSelectors() to auto-generate per-field selector hooks. This is the official Zustand pattern — consumers use useAuthStore.use.isLoggedIn() instead of useAuthStore((s) => s.isLoggedIn). Fully typed with autocomplete. Documented in CONVENTIONS.md.

Alternatives not taken

  1. Layout gate component (like the platform's AppOrganizationGate) — renders the component tree first, checks auth in useEffect, causes a flash of protected content. Middleware runs before rendering — no flash. React Router middleware docs
  2. Loader-based auth — pre-middleware pattern (React Router 6.4+). Middleware is the evolution: cascades automatically, has typed context, separates auth from data loading
  3. Keep React Context — can't be read from middleware or loaders without hacks. Zustand's getState() works everywhere. Zustand docs
  4. Conditional auth via env var — deferred to follow-up LUM-1642. Current implementation always requires auth (full platform parity). Optional auth for self-hosted/Electron/open-source will be added when those use cases are actively developed
  5. Manual selector hooks (useIsLoggedIn, useIsLoading) — replaced by createSelectors auto-generation which provides the same per-field re-render optimization without manual maintenance
  6. useAuth shorthand (exporting useAuthStore.use as useAuth) — rejected because the store name prefix provides context about which store you're accessing, important when components pull from multiple stores

Follow-up tickets

  • LUM-1642 — Add conditional auth for self-hosted / Electron / open-source local dev
  • LUM-1643 — Migrate OrganizationProvider from React Context to Zustand store

Test plan

  • Typecheck passes (bunx tsc --noEmit)
  • Lint passes (bun run lint)
  • Build passes (bun run build)
  • CI green (3/3 checks)

Link to Devin session: https://app.devin.ai/sessions/5c400920d2b745bc84401cd020820f22
Requested by: @ashleeradka


Open in Devin Review

…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-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

devin-ai-integration Bot and others added 4 commits May 19, 2026 18:32
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>
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ✅
  • initSession always sets isLoading: false in both the success path and the catch — so waitForAuthReady() always resolves ✅
  • BroadcastChannel guard (typeof BroadcastChannel !== "undefined") is correct for environments that don't support it ✅
  • syncOrganizationState resets 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.locationrequest.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.

@ashleeradka ashleeradka merged commit b5b8666 into main May 19, 2026
3 checks passed
@ashleeradka ashleeradka deleted the devin/1779213131-auth-middleware-implementation branch May 19, 2026 19:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant