1. “caveman”不是远古人是 React 开发者自嘲式技术隐喻最近在几个前端技术群和 React 相关的 PR 评论区里“caveman”这个词突然高频出现——它既不是某个新出的 UI 库名也不是某家初创公司的代号更不是某个 npm 包的正式发布名。它是一个带着黑色幽默色彩的内部梗专指一类特定的 React 开发实践状态在复杂状态管理、Token 生命周期处理、身份认证链路调试中退化到“只靠 console.log 刷新页面 硬重启”三板斧解决问题的原始阶段。你可能刚遇到sign-in could not be completed token exchange failed: error sending request翻了 7 个 Stack Overflow 页面试了 3 种npx playwright install的变体最后发现是.env文件里少了个换行符也可能在写useMemo时反复纠结依赖数组该不该加token结果绕了一大圈才发现问题根本不在 memo而在authContext的初始值没兜住空 token 场景——这种“靠直觉、凭运气、拼手速”的开发方式就被同行戏称为“caveman mode”。这个词精准戳中了当前 React 生态中一个普遍却少被系统讨论的痛点当项目规模越过临界点身份认证尤其是基于 JWT 的 Token 流程与 React 的声明式、组件化范式之间开始产生结构性摩擦。token不再是简单的字符串而是牵一发而动全身的状态核心useMemo不再只是性能优化工具而成了防止 Token 无效时触发无限重渲染的救命稻草npx命令失败背后往往暴露的是本地环境、CI/CD 配置、网络策略与认证服务端响应逻辑的多重耦合。而所谓“caveman”本质上是在这套复杂系统失序时开发者被迫退回最基础、最不可靠但短期最“有效”的调试本能。它不指向技术能力缺陷而指向工程设计断层——当React的抽象层无法平滑承接token exchange failed: token endpoint returned status 403 forbidden: country这类真实世界错误时人就只能赤手空拳上阵。这篇文章要做的不是教你如何优雅地写一个useAuthHook而是带你完整复盘一次典型的“caveman 事件”从一条报错日志出发逆向拆解整个 Token 认证链路在 React 应用中的落地细节解释为什么npx playwright install失败会和token endpoint returned 403产生隐性关联为什么useMemo的依赖项漏掉一个token就会让画布canvas/flowork白屏数分钟以及如何把这种“原始调试”过程系统性地升级为可复现、可测试、可监控的现代前端工程实践。适合正在维护中大型 React 项目、频繁遭遇登录态异常、Token 刷新失败、跨域认证问题的工程师也适合刚从 Vue/Angular 转来、对 React 生态中“状态即一切”哲学尚不适应的开发者。你不需要精通 OAuth2.0 RFC但得愿意跟着一行console.log深挖到底。2. 项目整体设计思路从“报错即崩溃”到“Token 即状态”2.1 为什么“caveman”模式在 React 中如此顽固很多团队在初期用create-react-app搭建登录页时会写一个非常干净的login()函数const login async (credentials) { const res await fetch(/api/login, { method: POST, body: JSON.stringify(credentials) }); const { token } await res.json(); localStorage.setItem(auth_token, token); setUser({ token }); };这看起来毫无问题。但当项目加入React Router v6的受保护路由、集成Playwright做 E2E 测试、接入Claude MCP servers类 AI 服务、或需要支持多地域用户触发country级别的 403时这个简单模型立刻崩塌。根本原因在于React 的核心范式是“状态驱动 UI”而传统 Token 管理方案如直接操作 localStorage是“命令式副作用”二者天然存在范式冲突。localStorage.setItem是一个不可回溯、不可撤销、无生命周期感知的副作用useMemo依赖数组若漏掉token会导致缓存失效或无限循环因为token变化本应触发整个认证上下文重计算npx playwright install失败常因网络策略拦截了https://registry.npmjs.org的请求而这恰好也是token exchange请求被 403 的同一网络出口——它们共享底层 HTTP 客户端配置却在代码层面完全隔离。因此“caveman”模式的本质是开发者在范式冲突面前选择了放弃抽象、回归原始不再信任 Hook 的依赖追踪改用window.location.reload()强刷不再信任fetch的错误边界改用try/catch alert()不再信任 CI 环境的npx改用本地npm install -g playwright后手动拷贝二进制。这不是懒惰而是对现有抽象层失效的务实响应。2.2 我们的设计原则让 Token 成为一级公民状态要终结“caveman”必须将 Token 从“存储介质中的字符串”升格为“React 应用中的一等状态”。这意味着Token 必须有明确的生命周期状态机idle→loading→authenticated→refreshing→expired→unauthorized每个状态对应清晰的 UI 行为和副作用所有 Token 相关操作必须可中断、可重试、可审计npx playwright install失败时应能定位到具体哪个 registry URL 被拦截token exchange failed时应能区分是网络超时、403 Forbidden 还是 400 Bad Request状态变更必须严格遵循 React 的更新规则useMemo的依赖数组必须包含所有影响其输出的变量包括token、userRole、permissions等衍生状态环境隔离必须显式声明开发、测试、生产环境的token endpoint、playwright browserPath、AI service base URL必须通过process.env显式注入而非硬编码或动态拼接。这套设计不追求“最优雅”而追求“最可调试”。当sign-in could not be completed时你打开 DevTools看到的不是一个模糊的Network Error而是一个结构化的AuthState对象其中error.code TOKEN_EXCHANGE_403error.context.country CNerror.timestamp精确到毫秒——这才是现代前端工程该有的样子而不是靠console.log(token?, token)猜测。2.3 架构选型轻量级但绝不妥协的组合我们拒绝引入Redux Toolkit Query或SWR这类重型数据流库原因很实际Token 认证是应用的基石不应依赖可能被误配或误用的通用数据层。我们的技术栈选择基于三个硬性标准零运行时开销、最小 API 表面、与 React 18 并发渲染兼容。状态管理原生useReduceruseContext。useReducer提供可预测的状态变迁useContext解决跨层级传递二者组合比useState更易追踪 Token 状态流转。我们定义了一个AuthReducer其action.type严格对应 RFC 6749 的授权码流程Authorization Code Flow各环节AUTH_INIT、AUTH_CODE_RECEIVED、TOKEN_EXCHANGE_STARTED、TOKEN_EXCHANGE_SUCCESS、TOKEN_EXCHANGE_FAILED、TOKEN_REFRESHED、LOGOUT。HTTP 客户端fetch封装而非axios。fetch是浏览器原生 API无额外包体积且其AbortController天然支持请求取消——这对token exchange这种敏感操作至关重要。我们封装的authFetch函数会自动注入Authorization: Bearer ${token}并在401时触发刷新逻辑在403时记录国家上下文。持久化localStorage 内存缓存双写。localStorage保证页面刷新后 Token 不丢失内存缓存一个RefObject保证组件内快速读取避免重复解析 JWT。关键点在于所有写入localStorage的操作都必须伴随dispatch({ type: TOKEN_PERSISTED, payload: token })确保状态同步。测试与调试PlaywrightmswMock Service Worker。npx playwright install失败我们用msw模拟token endpoint的各种响应200、403、timeout在 CI 中跳过真实安装直接运行测试。这样npx playwright install的失败就从“阻塞构建”降级为“本地开发提示”不影响主干交付。这个架构没有炫技但它让每一个token相关的错误都能被精确定位到 reducer 的某一行case被msw的某一个handler捕获被useMemo的某一个依赖项触发——这才是对抗“caveman”的真正武器。3. 核心细节解析Token 生命周期与 React 状态的精密咬合3.1 Token 状态机详解从idle到unauthorized的七步演进一个健壮的 Token 管理系统其核心是一个明确定义的状态机。我们定义的AuthState接口如下type AuthState { status: idle | loading | authenticated | refreshing | expired | unauthorized | error; token: string | null; user: User | null; error: { code: string; // 如 TOKEN_EXCHANGE_403, REFRESH_TOKEN_EMPTY message: string; context: Recordstring, any; // 如 { country: CN, url: https://auth.openai.co } timestamp: number; } | null; expiresAt: number | null; // JWT exp 时间戳 };这个状态机不是理论模型而是每一帧都在 React 中真实运行的实体。它的变迁由AuthReducer驱动而AuthReducer的dispatch调用全部来自AuthContext提供的login()、logout()、refreshToken()等方法。关键细节在于idle状态的陷阱很多项目在 App 初始化时直接dispatch({ type: AUTH_INIT })然后检查localStorage.getItem(auth_token)。但这忽略了idle状态下UI 可能已开始渲染如导航栏的userAvatar组件而token还未加载完成。正确做法是idle状态下token和user必须为null且status明确为idleUI 组件据此显示加载骨架屏而非尝试渲染空用户信息。loading与refreshing的严格区分loading仅用于首次登录或页面刷新后的 Token 恢复refreshing专用于后台静默刷新Silent Refresh。二者 UI 表现不同loading时全屏遮罩refreshing时仅禁用相关按钮。混淆二者会导致用户点击“提交”后因 Token 正在刷新而被意外拦截。expired与unauthorized的语义分离expired指 Token 的 JWTexp字段已过期但用户仍处于登录态refresh_token有效unauthorized指refresh_token也已失效或被吊销必须强制登出。很多项目将二者合并为loggedOut导致无法区分“用户只是离开太久”和“账号已被管理员禁用”这两种完全不同场景。提示expiresAt字段必须从 JWT 的exp字段解析而非依赖服务端返回的expires_in秒数。因为expires_in是相对时间而exp是绝对时间戳后者能规避客户端时钟偏差问题。我们用jwt-decode库解析但只取exp不验证签名——签名验证是服务端责任前端只需信任token的exp字段。3.2useMemo在 Token 场景下的致命陷阱与安全写法useMemo是 React 中最常被误用的 Hook 之一尤其在涉及 Token 的场景。一个典型错误是// ❌ 危险依赖数组漏掉了 token const canvasConfig useMemo(() { return { width: 800, height: 600, backgroundColor: user?.theme || white, // ... 其他配置 }; }, [user?.theme]); // 错user.theme 可能不变但 token 变了user 对象引用却没变问题在于user对象通常由useContext(AuthContext)获取其引用在token更新时并不一定改变如果user数据来自缓存或未重新解析。useMemo的依赖数组只看引用是否变化不深比较对象内容。结果就是token已刷新user角色权限已变但canvasConfig缓存未更新导致画布flowork渲染旧权限下的内容甚至白屏。正确写法必须将所有影响输出的变量显式列出// ✅ 安全token 和 user 都是独立依赖 const canvasConfig useMemo(() { return { width: 800, height: 600, backgroundColor: user?.theme || white, permissions: user?.permissions || [], // ... 其他配置 }; }, [token, user?.theme, user?.permissions]); // token 是关键即使 user 没变token 变了也要重算 // ✅ 更优用一个稳定引用封装所有依赖 const configDeps useMemo(() ({ token, theme: user?.theme, permissions: user?.permissions, }), [token, user?.theme, user?.permissions]); const canvasConfig useMemo(() ({ width: 800, height: 600, backgroundColor: configDeps.theme || white, permissions: configDeps.permissions || [], }), [configDeps]);第二个方案的优势在于configDeps是一个稳定的对象引用只要其内部属性变化configDeps就会是新对象canvasConfig的依赖数组[configDeps]就能可靠触发重计算。这避免了在多个useMemo中重复写长依赖数组。注意token本身是一个字符串其引用变化即表示 Token 已更新。不要试图用JSON.stringify(token)或其他方式“标准化”它——那只会增加不必要的计算开销且string的引用比较是最高效的。3.3npx失败与token exchange failed的深层关联npx playwright install失败和token exchange failed: error sending request看似风马牛不相及但在企业级网络环境中它们共享同一个底层约束出站 HTTP 请求的代理与防火墙策略。npx playwright install的本质是npx会从https://registry.npmjs.org下载playwright包元数据然后根据PLAYWRIGHT_DOWNLOAD_HOST环境变量默认https://npmmirror.com/mirrors/playwright下载浏览器二进制文件。整个过程涉及多次 HTTPS 请求。token exchange请求则发送到https://auth.openai.co或其他厂商的 auth endpoint同样是一次 HTTPS POST 请求。当公司网络策略启用以下任一措施时二者会同时失败策略类型影响npx影响token exchange调试线索域名白名单registry.npmjs.org未在白名单auth.openai.co未在白名单npx报ENOTFOUNDfetch报TypeError: Failed to fetchHTTPS 拦截SSL Inspectionnpx证书校验失败fetch证书校验失败npx报CERT_HAS_EXPIREDfetch报net::ERR_CERT_INVALID国家/地区封锁npmmirror.com的 CDN 节点被限auth.openai.co返回403 Forbidden: countrynpx下载缓慢或超时fetch返回403且响应头含X-Country: CN因此当npx playwright install失败时第一反应不应该是“重装 Node.js”而应执行# 检查网络连通性 curl -v https://registry.npmjs.org curl -v https://auth.openai.co # 检查 DNS 解析 nslookup registry.npmjs.org nslookup auth.openai.co # 检查代理设置关键 echo $HTTP_PROXY $HTTPS_PROXY npm config get proxy npm config get https-proxy如果发现curl也失败说明是网络策略问题需联系 IT 部门开通白名单如果curl成功而npx失败则是npx的缓存或权限问题如果curl对auth.openai.co返回403则token exchange失败原因已锁定——此时前端代码无需修改只需后端配置CORS或调整地域策略。实操心得我在某金融客户项目中曾花 3 天排查token exchange failed最终发现是HTTPS_PROXY环境变量被 CI 系统全局注入而该代理服务器不支持auth.openai.co的 SNI 扩展。解决方案是在 CI 脚本中unset HTTPS_PROXY并为playwright单独配置PLAYWRIGHT_DOWNLOAD_HOSThttps://npmmirror.com/mirrors/playwright。这比改任何一行 React 代码都有效。4. 实操过程从零搭建可调试的 Token 认证系统4.1 环境准备与依赖安装避开npx陷阱的务实方案第一步不是写代码而是确保你的本地和 CI 环境能稳定运行。npx playwright install失败是常见拦路虎但我们有更可靠的替代路径跳过npx直接使用npm全局安装 Playwright# 全局安装避免 npx 的路径解析问题 npm install -g playwright # 手动安装浏览器指定国内镜像 PLAYWRIGHT_DOWNLOAD_HOSThttps://npmmirror.com/mirrors/playwright npx playwright install chromium firefox webkit为 CI/CD 环境预设环境变量以 GitHub Actions 为例# .github/workflows/test.yml jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Setup Node.js uses: actions/setup-nodev4 with: node-version: 18 - name: Cache Playwright browsers uses: actions/cachev4 with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles(**/package-lock.json) }} - name: Install dependencies run: npm ci - name: Install Playwright browsers # 关键跳过 npx直接调用 playwright CLI run: npx playwright install --with-deps chromium - name: Run tests run: npx playwright test配置.npmrc统一 registry避免npx和npm install使用不同源registryhttps://registry.npmjs.org/ scope:registryhttps://registry.npmjs.org/ //registry.npmjs.org/:_authToken${NPM_TOKEN} # 国内加速可选 # registryhttps://registry.npmmirror.com/注意NPM_TOKEN是私有包发布的凭证与auth token无关。不要混淆二者。git 设置代码库token用于 Git 操作hass g10s token用于 Home Assistant 集成它们都是独立的认证体系共用“token”一词只是语义巧合。4.2AuthContext实现一个可调试、可测试的认证上下文以下是AuthContext的核心实现重点在于其可调试性// src/context/AuthContext.tsx import { createContext, useContext, useReducer, useEffect, useRef } from react; import { jwtDecode } from jwt-decode; export interface User { id: string; email: string; role: string; permissions: string[]; } export interface AuthState { status: idle | loading | authenticated | refreshing | expired | unauthorized | error; token: string | null; user: User | null; error: { code: string; message: string; context: Recordstring, any; timestamp: number; } | null; expiresAt: number | null; } type AuthAction | { type: AUTH_INIT } | { type: AUTH_LOADING } | { type: AUTH_SUCCESS; payload: { token: string; user: User } } | { type: AUTH_ERROR; payload: { code: string; message: string; context: Recordstring, any } } | { type: TOKEN_REFRESHING } | { type: TOKEN_REFRESHED; payload: { token: string; user: User } } | { type: LOGOUT }; const initialState: AuthState { status: idle, token: null, user: null, error: null, expiresAt: null, }; // 一个 Ref用于在 reducer 外部访问最新 state调试用 const authStateRef { current: initialState }; const AuthReducer (state: AuthState, action: AuthAction): AuthState { let newState: AuthState; switch (action.type) { case AUTH_INIT: newState { ...state, status: loading }; break; case AUTH_LOADING: newState { ...state, status: loading, error: null }; break; case AUTH_SUCCESS: const decoded jwtDecode{ exp: number }(action.payload.token); newState { ...state, status: authenticated, token: action.payload.token, user: action.payload.user, error: null, expiresAt: decoded.exp * 1000, // 转为毫秒 }; break; case AUTH_ERROR: newState { ...state, status: error, error: { code: action.payload.code, message: action.payload.message, context: action.payload.context, timestamp: Date.now(), }, }; break; case TOKEN_REFRESHING: newState { ...state, status: refreshing, error: null }; break; case TOKEN_REFRESHED: const refreshedDecoded jwtDecode{ exp: number }(action.payload.token); newState { ...state, status: authenticated, token: action.payload.token, user: action.payload.user, error: null, expiresAt: refreshedDecoded.exp * 1000, }; break; case LOGOUT: newState { ...initialState, status: idle, }; break; default: newState state; } // 更新 ref便于外部调试 authStateRef.current newState; return newState; }; const AuthContext createContext{ state: AuthState; login: (credentials: { email: string; password: string }) Promisevoid; logout: () void; refreshToken: () Promisevoid; }({ state: initialState, login: async () {}, logout: () {}, refreshToken: async () {}, }); export const AuthProvider ({ children }: { children: React.ReactNode }) { const [state, dispatch] useReducer(AuthReducer, initialState); // 初始化从 localStorage 恢复 token useEffect(() { const savedToken localStorage.getItem(auth_token); if (savedToken) { try { const decoded jwtDecode{ exp: number }(savedToken); if (decoded.exp * 1000 Date.now()) { // Token 未过期但需验证有效性调用 /me 或类似 endpoint dispatch({ type: AUTH_LOADING }); // 这里应调用 authFetch(/api/me, { method: GET }) } else { dispatch({ type: AUTH_ERROR, payload: { code: TOKEN_EXPIRED, message: Token expired, context: {} } }); } } catch (e) { dispatch({ type: AUTH_ERROR, payload: { code: TOKEN_INVALID, message: Invalid token format, context: { error: (e as Error).message } } }); } } else { dispatch({ type: AUTH_INIT }); } }, []); const login async (credentials: { email: string; password: string }) { try { dispatch({ type: AUTH_LOADING }); const response await fetch(/api/login, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(credentials), }); if (!response.ok) { const errorData await response.json(); throw new Error(Login failed: ${errorData.message || response.status}); } const data await response.json(); const { token } data; // 解析用户信息 const decoded jwtDecode{ sub: string; email: string; role: string; permissions: string[] }(token); localStorage.setItem(auth_token, token); dispatch({ type: AUTH_SUCCESS, payload: { token, user: { id: decoded.sub, email: decoded.email, role: decoded.role, permissions: decoded.permissions || [], }, }, }); } catch (error) { dispatch({ type: AUTH_ERROR, payload: { code: LOGIN_FAILED, message: (error as Error).message, context: { credentials: { email: *** } }, }, }); } }; const logout () { localStorage.removeItem(auth_token); dispatch({ type: LOGOUT }); }; const refreshToken async () { const refreshToken localStorage.getItem(refresh_token); // 假设有 refresh_token if (!refreshToken) { dispatch({ type: AUTH_ERROR, payload: { code: REFRESH_TOKEN_EMPTY, message: Refresh token is empty, context: {}, }, }); return; } try { dispatch({ type: TOKEN_REFRESHING }); const response await fetch(/api/token/refresh, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!response.ok) { const errorData await response.json(); throw new Error(Refresh failed: ${errorData.message || response.status}); } const data await response.json(); const { token } data; const decoded jwtDecode{ sub: string; email: string; role: string; permissions: string[] }(token); localStorage.setItem(auth_token, token); dispatch({ type: TOKEN_REFRESHED, payload: { token, user: { id: decoded.sub, email: decoded.email, role: decoded.role, permissions: decoded.permissions || [], }, }, }); } catch (error) { dispatch({ type: AUTH_ERROR, payload: { code: TOKEN_REFRESH_FAILED, message: (error as Error).message, context: {}, }, }); } }; return ( AuthContext.Provider value{{ state, login, logout, refreshToken }} {children} /AuthContext.Provider ); }; export const useAuth () { const context useContext(AuthContext); if (!context) { throw new Error(useAuth must be used within an AuthProvider); } return context; }; // 调试用导出当前状态 ref export const getAuthState () authStateRef.current;这个实现的关键优势authStateRef允许你在任意地方如浏览器控制台调用getAuthState()查看最新状态无需依赖 React DevToolsuseEffect初始化逻辑明确区分idle和loading避免 UI 闪动错误context字段{ credentials: { email: *** } }这样的脱敏记录既保留调试信息又符合安全规范jwtDecode的轻量使用只解析exp和必要字段不进行签名验证符合前端职责边界。4.3authFetch封装让每一次 Token 请求都可审计fetch是基础但裸fetch无法满足 Token 场景的特殊需求。我们封装authFetch使其具备自动注入Authorizationheader自动处理401触发刷新和403记录国家上下文请求取消支持AbortController响应时间监控错误分类网络错误 vs HTTP 错误。// src/utils/authFetch.ts import { getAuthState, useAuth } from ../context/AuthContext; interface AuthFetchOptions extends RequestInit { timeout?: number; // ms } export const authFetch async ( input: string | URL, options: AuthFetchOptions {} ): PromiseResponse { const state getAuthState(); // 使用 ref避免闭包 stale state const controller new AbortController(); const timeoutId setTimeout(() controller.abort(), options.timeout || 10000); // 自动添加 Authorization header const headers new Headers(options.headers); if (state.token !headers.has(Authorization)) { headers.set(Authorization, Bearer ${state.token}); } try { const response await fetch(input, { ...options, headers, signal: controller.signal, }); clearTimeout(timeoutId); // 特殊 HTTP 状态处理 if (response.status 401) { // 触发刷新逻辑此处应调用 useAuth().refreshToken()但需注意 Hook 规则 // 实际项目中这里会抛出一个自定义错误由上层组件捕获并调用 refreshToken throw new Error(Unauthorized: token expired or invalid); } if (response.status 403) { // 记录 403 上下文如国家信息 const country response.headers.get(X-Country) || UNKNOWN; throw new Error(Forbidden: access denied for country ${country}); } return response; } catch (error) { clearTimeout(timeoutId); if (error instanceof DOMException error.name AbortError) { throw new Error(Request timeout); } throw error; } }; // 使用示例 const loadData async () { try { const response await authFetch(/api/data, { timeout: 5000 }); return await response.json(); } catch (error) { console.error(Failed to load data:, error); // 这里可以 dispatch 一个全局错误或显示 toast } };实操心得authFetch的timeout参数必须显式设置。默认fetch无超时当token endpoint因网络策略被拦截时请求会永远挂起导致 UI “假死”。5 秒超时是经验阈值——足够完成大多数认证请求又能在异常时快速失败。4.4ProtectedRoute与useAuth的实战整合最后将认证状态与路由、组件深度整合// src/components/ProtectedRoute.tsx import { Navigate, Outlet } from react-router-dom; import { useAuth } from ../context/AuthContext; export const ProtectedRoute () { const { state } useAuth(); // 状态映射到 UI 行为 switch (state.status) { case idle: // 初始加载显示骨架屏 return divLoading.../div; case loading: // 正在恢复 Token显示加载指示器 return divAuthenticating.../div; case authenticated: // 正常渲染 return Outlet /; case refreshing: // 静默刷新中允许继续浏览但禁用敏感操作 return Outlet /; case expired: // Token 过期尝试刷新 // 实际中这里会触发 refreshToken return divRefreshing session.../div; case unauthorized: // 强制登出 return Navigate to/login replace /; case error: // 显示结构化错误 return ( div h2Authentication Error/h2 pCode: {state.error?.code}/p pMessage: {state.error?.message}/p {state.error?.context.country ( pCountry: {state.error.context.country}/p )} button onClick{() window.location.reload()} Retry /button /div ); default: return Navigate to/login replace /; } }; // src/App.tsx import { BrowserRouter, Routes, Route } from react-router-dom; import { AuthProvider } from ./context/AuthContext; import { ProtectedRoute } from ./components/ProtectedRoute