|
| 1 | +# 401 Unauthenticated → Login Modal Pattern |
| 2 | + |
| 3 | +**Type**: Architecture Guide |
| 4 | +**Date**: 2026-04-09 |
| 5 | +**Status**: Pattern defined in `.claude/rules/error-handling.md` — pending implementation |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## 1. Overview — Why Modal Instead of Redirect or Toast |
| 10 | + |
| 11 | +When a 401 response occurs (session expired or unauthenticated action), the current implementation redirects the user via `window.location.replace()`. This loses page context: any in-progress form state, scroll position, and the URL the user was on are all discarded. |
| 12 | + |
| 13 | +The target pattern shows a login modal **in place**: |
| 14 | + |
| 15 | +| Approach | UX Impact | When to use | |
| 16 | +|----------|-----------|-------------| |
| 17 | +| Redirect (`window.location.replace`) | Destroys page context, forces full navigation | ❌ Legacy pattern — being replaced | |
| 18 | +| Toast (error message only) | Confusing — no clear recovery action | ❌ Wrong for auth failures | |
| 19 | +| Login Modal (in-place) | Page stays intact, user logs back in and continues | ✅ Target pattern | |
| 20 | + |
| 21 | +The modal approach is especially important for flows where the user has filled out a form or navigated deep into the app. A redirect would restart that journey from scratch. |
| 22 | + |
| 23 | +--- |
| 24 | + |
| 25 | +## 2. Core Concepts |
| 26 | + |
| 27 | +### Two Interception Points |
| 28 | + |
| 29 | +401 errors can originate from two distinct paths. Each has its own interception point: |
| 30 | + |
| 31 | +#### Point 1 — Token Refresh Failure (`auth-session-recovery.ts`) |
| 32 | + |
| 33 | +Triggered when the access token has expired and the silent refresh also fails (e.g., refresh token expired or invalid). Currently calls `window.location.replace()`. |
| 34 | + |
| 35 | +**Target behavior**: Call `useLoginModalStore.getState().open()` instead. |
| 36 | + |
| 37 | +```typescript |
| 38 | +// src/api/client/auth-session-recovery.ts |
| 39 | +// Current (loses page context) |
| 40 | +window.location.replace(nextUrl); |
| 41 | + |
| 42 | +// Target |
| 43 | +import { useLoginModalStore } from '@/stores/use-login-modal-store'; |
| 44 | +useLoginModalStore.getState().open(); |
| 45 | +``` |
| 46 | + |
| 47 | +`hasPendingDocumentAuthRecovery` acts as an idempotency flag — prevents multiple concurrent failing requests from each triggering a separate modal open. Reset this flag when the modal closes. |
| 48 | + |
| 49 | +#### Point 2 — Unauthenticated Mutation (`query-client.ts`) |
| 50 | + |
| 51 | +Triggered when a guest user (not logged in) attempts an action that requires auth. Currently all `MutationCache.onError` failures show a generic error toast. |
| 52 | + |
| 53 | +**Target behavior**: Check `statusCode === 401` before the toast path. |
| 54 | + |
| 55 | +```typescript |
| 56 | +// src/config/query-client.ts (target) |
| 57 | +mutationCache: new MutationCache({ |
| 58 | + onError: (error, _variables, _context, mutation) => { |
| 59 | + if (mutation.options.onError) return; |
| 60 | + if (isServer) return; |
| 61 | + |
| 62 | + const errorInfo = analyzeError(error); |
| 63 | + |
| 64 | + if (errorInfo.statusCode === 401) { |
| 65 | + useLoginModalStore.getState().open(); |
| 66 | + return; // no Sentry — this is expected flow, same as AUTH001 |
| 67 | + } |
| 68 | + |
| 69 | + useToastStore.getState().showToast(errorInfo.userMessage, 'error'); |
| 70 | + sendErrorToSentry(errorInfo, { source: 'MutationCache.onError' }); |
| 71 | + }, |
| 72 | +}), |
| 73 | +``` |
| 74 | + |
| 75 | +### Zustand Store Pattern |
| 76 | + |
| 77 | +Model this after `use-toast-store.ts`, which demonstrates the key technique: calling `.getState()` to trigger state changes from **outside React** (inside axios interceptors, module-level functions). |
| 78 | + |
| 79 | +```typescript |
| 80 | +// src/stores/use-login-modal-store.ts (to be created) |
| 81 | +import { create } from 'zustand'; |
| 82 | + |
| 83 | +interface LoginModalState { |
| 84 | + isOpen: boolean; |
| 85 | + open: () => void; |
| 86 | + close: () => void; |
| 87 | +} |
| 88 | + |
| 89 | +export const useLoginModalStore = create<LoginModalState>((set) => ({ |
| 90 | + isOpen: false, |
| 91 | + open: () => set({ isOpen: true }), |
| 92 | + close: () => set({ isOpen: false }), |
| 93 | +})); |
| 94 | +``` |
| 95 | + |
| 96 | +Usage outside React (interceptors, module-level callbacks): |
| 97 | +```typescript |
| 98 | +useLoginModalStore.getState().open(); |
| 99 | +``` |
| 100 | + |
| 101 | +Usage inside React components: |
| 102 | +```typescript |
| 103 | +const { isOpen, close } = useLoginModalStore(); |
| 104 | +``` |
| 105 | + |
| 106 | +### LoginModal Controlled Mode |
| 107 | + |
| 108 | +The current `LoginModal` component manages `isOpen` internally with `useState` and requires `openTrigger`. To allow Zustand to drive it, add optional controlled props while keeping the existing `openTrigger` usage intact: |
| 109 | + |
| 110 | +```typescript |
| 111 | +// src/components/auth/modals/login-modal.tsx |
| 112 | +export default function LoginModal({ |
| 113 | + openTrigger, |
| 114 | + open, // new: optional controlled open |
| 115 | + onOpenChange, // new: optional controlled setter |
| 116 | +}: { |
| 117 | + openTrigger?: ReactNode; // made optional — not needed in controlled mode |
| 118 | + open?: boolean; |
| 119 | + onOpenChange?: (open: boolean) => void; |
| 120 | +}) { |
| 121 | + const [internalOpen, setInternalOpen] = useState(false); |
| 122 | + |
| 123 | + // Use controlled props if provided, otherwise fall back to internal state |
| 124 | + const isOpen = open ?? internalOpen; |
| 125 | + const setIsOpen = onOpenChange ?? setInternalOpen; |
| 126 | + |
| 127 | + // ... rest of component unchanged |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +### Global Mount Point |
| 132 | + |
| 133 | +Mount `<GlobalLoginModal />` in `(service)/layout.tsx` alongside `<GlobalToast />`: |
| 134 | + |
| 135 | +```tsx |
| 136 | +// src/app/(service)/layout.tsx — inside <MainProvider> |
| 137 | +<GlobalToast /> |
| 138 | +<GlobalLoginModal /> // reads isOpen from useLoginModalStore |
| 139 | +``` |
| 140 | + |
| 141 | +```tsx |
| 142 | +// src/components/auth/modals/global-login-modal.tsx |
| 143 | +'use client'; |
| 144 | + |
| 145 | +import { useLoginModalStore } from '@/stores/use-login-modal-store'; |
| 146 | +import LoginModal from './login-modal'; |
| 147 | + |
| 148 | +export function GlobalLoginModal() { |
| 149 | + const { isOpen, close } = useLoginModalStore(); |
| 150 | + return <LoginModal open={isOpen} onOpenChange={(v) => !v && close()} />; |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +### AUTH Error Code Classification |
| 155 | + |
| 156 | +Understanding which error codes map to which behavior prevents both over- and under-triggering the modal: |
| 157 | + |
| 158 | +| Code | HTTP | Cause | Frontend Action | |
| 159 | +|------|------|-------|----------------| |
| 160 | +| AUTH001 | 401 | Missing / expired / invalid access token | Login modal | |
| 161 | +| AUTH002 | 403 | Authenticated but insufficient role | Error toast | |
| 162 | +| AUTH003 | 401 | Unsupported OAuth code (OAuth flow) | Login modal | |
| 163 | +| AUTH004 | **400** | Refresh token invalid/expired | `auth-session-recovery.ts` only — never reaches `MutationCache` | |
| 164 | + |
| 165 | +AUTH004 returns **400**, not 401. This means it bypasses the `statusCode === 401` gate in `query-client.ts` automatically. It is handled exclusively by `auth-session-recovery.ts`. |
| 166 | + |
| 167 | +--- |
| 168 | + |
| 169 | +## 3. Patterns in This Project |
| 170 | + |
| 171 | +### Idempotency: `hasPendingDocumentAuthRecovery` |
| 172 | + |
| 173 | +In `auth-session-recovery.ts`, the module-level flag prevents concurrent requests from each opening a new modal: |
| 174 | + |
| 175 | +```typescript |
| 176 | +let hasPendingDocumentAuthRecovery = false; |
| 177 | + |
| 178 | +export const requestDocumentAuthRecovery = (): boolean => { |
| 179 | + if (!currentUrl || hasPendingDocumentAuthRecovery) { |
| 180 | + return false; // already opening — skip |
| 181 | + } |
| 182 | + |
| 183 | + hasPendingDocumentAuthRecovery = true; |
| 184 | + useLoginModalStore.getState().open(); |
| 185 | + |
| 186 | + return true; |
| 187 | +}; |
| 188 | +``` |
| 189 | + |
| 190 | +Reset when the modal closes — wire it to the store's `close()` action or to `GlobalLoginModal`'s `onOpenChange`: |
| 191 | + |
| 192 | +```typescript |
| 193 | +onOpenChange={(v) => { |
| 194 | + if (!v) { |
| 195 | + close(); |
| 196 | + hasPendingDocumentAuthRecovery = false; |
| 197 | + } |
| 198 | +}} |
| 199 | +``` |
| 200 | + |
| 201 | +### Why No Sentry on 401 |
| 202 | + |
| 203 | +AUTH001 is explicitly excluded from Sentry in `sentry.client.config.ts` via `beforeSend`. The same reasoning applies to 401s in `MutationCache.onError`: token expiry is expected, not an error to alert on. Only unexpected errors should fire Sentry. |
| 204 | + |
| 205 | +### `getState()` vs Hook |
| 206 | + |
| 207 | +| Context | Pattern | |
| 208 | +|---------|---------| |
| 209 | +| Inside React component | `const { isOpen, close } = useLoginModalStore()` | |
| 210 | +| Outside React (interceptor, module function) | `useLoginModalStore.getState().open()` | |
| 211 | + |
| 212 | +This is the same pattern used by `useToastStore` today — `use-toast-store.ts:13` shows the `create()` call that enables both usages. |
| 213 | + |
| 214 | +--- |
| 215 | + |
| 216 | +## 4. Decision Guide |
| 217 | + |
| 218 | +Use this flowchart when deciding how to handle an auth-related error: |
| 219 | + |
| 220 | +``` |
| 221 | +Error received |
| 222 | +│ |
| 223 | +├── Is it 401? |
| 224 | +│ ├── YES → Show login modal (in-place, preserves context) |
| 225 | +│ │ Don't show toast. Don't report to Sentry. |
| 226 | +│ └── NO ↓ |
| 227 | +│ |
| 228 | +├── Is it 403? |
| 229 | +│ ├── YES → Show error toast (user is logged in, just lacks permission) |
| 230 | +│ │ Report to Sentry if unexpected. |
| 231 | +│ └── NO ↓ |
| 232 | +│ |
| 233 | +├── Is it 400 with AUTH004? |
| 234 | +│ ├── YES → Handled only in auth-session-recovery.ts. |
| 235 | +│ │ Never reaches MutationCache.onError. |
| 236 | +│ └── NO ↓ |
| 237 | +│ |
| 238 | +└── Other error → Show error toast + report to Sentry |
| 239 | +``` |
| 240 | + |
| 241 | +**Never** show the login modal for 403 — the user is authenticated, they just lack the right role. Showing a login modal would incorrectly imply they need to log in again. |
| 242 | + |
| 243 | +**Never** redirect using `window.location.replace()` on 401 — this is the legacy pattern being replaced. The modal preserves page context, which is the whole point. |
| 244 | + |
| 245 | +--- |
| 246 | + |
| 247 | +## Implementation Checklist |
| 248 | + |
| 249 | +When implementing this pattern: |
| 250 | + |
| 251 | +- [ ] Create `src/stores/use-login-modal-store.ts` |
| 252 | +- [ ] Update `src/components/auth/modals/login-modal.tsx` to accept optional `open?`/`onOpenChange?` |
| 253 | +- [ ] Create `src/components/auth/modals/global-login-modal.tsx` |
| 254 | +- [ ] Mount `<GlobalLoginModal />` in `src/app/(service)/layout.tsx` |
| 255 | +- [ ] Update `src/api/client/auth-session-recovery.ts`: replace `window.location.replace()` with `useLoginModalStore.getState().open()`, reset `hasPendingDocumentAuthRecovery` on modal close |
| 256 | +- [ ] Update `src/config/query-client.ts`: add `statusCode === 401` gate before toast in `MutationCache.onError` |
0 commit comments