From 8ab7ade30b332d2a3066b3d8ca5c74a45929f6e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 03:48:28 +0000 Subject: [PATCH] refactor(api): introduce defineApi + axios request layer Replace ad-hoc fetch httpClient with api/request.ts (axios interceptors, envelope unwrap, auth refresh) and defineApi-based resource modules. Move session hydration into api/auth, sync both templates, and update docs. Co-authored-by: zuiidea --- .cursor/rules/with-lingui-api.mdc | 2 +- .../.github/instructions/api.instructions.md | 20 +- apps/basic/README.md | 4 +- apps/basic/package.json | 1 + apps/basic/src/api/auth.ts | 57 +++- apps/basic/src/api/request.ts | 306 ++++++++++++++++++ apps/basic/src/api/schemas.ts | 26 ++ apps/basic/src/api/user.ts | 52 ++- apps/basic/src/components/DataTable/index.tsx | 15 +- apps/basic/src/main.tsx | 6 +- apps/basic/src/routes/_auth.tsx | 4 +- apps/basic/src/routes/_auth/users/index.tsx | 52 ++- apps/basic/src/routes/login/index.tsx | 14 +- apps/basic/src/routes/register/index.tsx | 14 +- apps/basic/src/utils/http.ts | 172 ---------- apps/basic/src/utils/session.ts | 18 -- .../en/backend/proxying-api-requests.mdx | 18 +- .../zh/backend/proxying-api-requests.mdx | 4 +- .../zh/getting-started/folder-structure.mdx | 4 +- .../instructions/add-resource.instructions.md | 12 +- .../.github/instructions/api.instructions.md | 21 +- apps/with-lingui/README.md | 4 +- apps/with-lingui/package.json | 1 + apps/with-lingui/src/api/auth.ts | 57 +++- apps/with-lingui/src/api/request.ts | 306 ++++++++++++++++++ apps/with-lingui/src/api/schemas.ts | 26 ++ apps/with-lingui/src/api/user.ts | 52 ++- .../src/components/DataTable/index.tsx | 15 +- apps/with-lingui/src/main.tsx | 2 +- apps/with-lingui/src/routes/_auth.tsx | 4 +- .../src/routes/_auth/users/index.tsx | 52 ++- apps/with-lingui/src/routes/login/index.tsx | 14 +- .../with-lingui/src/routes/register/index.tsx | 14 +- apps/with-lingui/src/utils/http.ts | 172 ---------- apps/with-lingui/src/utils/session.ts | 17 - pnpm-lock.yaml | 196 ++++++++--- 36 files changed, 1169 insertions(+), 585 deletions(-) create mode 100644 apps/basic/src/api/request.ts delete mode 100644 apps/basic/src/utils/http.ts delete mode 100644 apps/basic/src/utils/session.ts create mode 100644 apps/with-lingui/src/api/request.ts delete mode 100644 apps/with-lingui/src/utils/http.ts delete mode 100644 apps/with-lingui/src/utils/session.ts diff --git a/.cursor/rules/with-lingui-api.mdc b/.cursor/rules/with-lingui-api.mdc index 0df0532..3f0cd2c 100644 --- a/.cursor/rules/with-lingui-api.mdc +++ b/.cursor/rules/with-lingui-api.mdc @@ -1,6 +1,6 @@ --- description: "API clients, Zod schemas, HTTP, MSW handlers (with-lingui)." -globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/utils/http.ts,apps/with-lingui/src/mocks/handlers/**/*.ts,apps/with-lingui/src/mocks/createHandler.ts +globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/mocks/handlers/**/*.ts,apps/with-lingui/src/mocks/createHandler.ts alwaysApply: false --- diff --git a/apps/basic/.github/instructions/api.instructions.md b/apps/basic/.github/instructions/api.instructions.md index 24f0c26..ec6151d 100644 --- a/apps/basic/.github/instructions/api.instructions.md +++ b/apps/basic/.github/instructions/api.instructions.md @@ -1,25 +1,25 @@ --- - -## applyTo: "src/api/**/\*.ts,src/utils/http.ts,src/mocks/handlers/**/\*.ts,src/mocks/createHandler.ts" - -description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users." +applyTo: "src/api/**/*.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts" +description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, request, defineApi, schema, zod, handler, endpoint, auth, users." +--- # API Instructions ## Purpose -Maintain strict API contracts and consistent server/client mock behavior. +Maintain strict API contracts and a single call path: **schemas → `defineApi` resource modules → UI**. Transport lives in `src/api/request.ts` (axios + interceptors). ## Do -- Keep request/response shapes schema-driven and type-safe. -- Reuse endpoint constants and shared response helpers. -- Keep handler response envelope consistent (`code`, `data`, `message`). -- Ensure mock handlers reflect real API behavior and error semantics. -- Keep naming stable for endpoint keys and mutation/query keys. +- Define APIs with `defineApi` in `src/api/.ts` (`method` defaults to `GET`; use `params` / `query` / `body` + `output`). +- Keep Zod schemas in `src/api/schemas.ts` as the single contract; parse inputs/outputs inside `defineApi`. +- Keep MSW handler envelopes consistent (`code`, `data`, `message`) and aligned with schemas. +- Co-locate React Query keys with resource modules (e.g. `userQueryKeys`). +- Use `hydrateSession` from `@/api/auth` after login/register or when restoring a session. ## Do Not +- Do not call `axios` or `@/api/request` transport helpers from routes/components — import resource functions only (`listUsers`, `login`, …). - Do not return ad-hoc response shapes for one-off convenience. - Do not bypass schema parsing in new client mutations/queries. - Do not mix unrelated domain endpoints in the same module. diff --git a/apps/basic/README.md b/apps/basic/README.md index acbe611..822450c 100644 --- a/apps/basic/README.md +++ b/apps/basic/README.md @@ -104,7 +104,7 @@ pnpm run test:e2e:ui # interactive UI mode ```text src/ -├── api/ # Zod models + endpoint contracts +├── api/ # schemas, request (axios + defineApi), resource APIs ├── components/ # Reusable UI primitives and layout shells │ ├── Layout/ # MainLayout, Header, Sidebar, UserMenu, AppFooter │ ├── DataTable/ # Shared table frame/skeleton/empty state @@ -143,7 +143,7 @@ src/ │ ├── 404/index.tsx │ └── index.tsx ├── stores/ # Persisted auth/settings stores -├── utils/ # HTTP client, constants, session/menu helpers +├── utils/ # constants, app menu helpers ├── main.tsx # App bootstrap (MSW init + React render) └── routeTree.gen.ts # Generated TanStack route tree diff --git a/apps/basic/package.json b/apps/basic/package.json index f0edb02..2053696 100644 --- a/apps/basic/package.json +++ b/apps/basic/package.json @@ -19,6 +19,7 @@ "@tanstack/react-query": "^5.104.3", "@tanstack/react-router": "^1.170.45", "antd": "^6.6.6", + "axios": "^1.20.0", "lucide-react": "^1.59.0", "react": "^19.3.1", "react-dom": "^19.3.1", diff --git a/apps/basic/src/api/auth.ts b/apps/basic/src/api/auth.ts index 2d4314e..d887cff 100644 --- a/apps/basic/src/api/auth.ts +++ b/apps/basic/src/api/auth.ts @@ -1,11 +1,22 @@ +import { z } from "zod/v4"; +import { defineApi } from "@/api/request"; +import { + AuthTokensSchema, + AuthUserResponseSchema, + LoginRequestSchema, + PermissionsListSchema, + RegisterRequestSchema, + UserSchema, +} from "@/api/schemas"; import type { AuthTokens, LoginRequest, User, - MenuItem, PermissionsList, RegisterRequest, -} from "./schemas"; +} from "@/api/schemas"; +import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu"; +import { useAuthStore } from "@/stores/auth"; export const AUTH_ENDPOINTS = { login: "/api/auth/login", @@ -16,4 +27,44 @@ export const AUTH_ENDPOINTS = { permissions: "/api/auth/permissions", } as const; -export type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList, RegisterRequest }; +export const login = defineApi({ + method: "POST", + path: AUTH_ENDPOINTS.login, + body: LoginRequestSchema, + output: AuthTokensSchema, +}); + +export const register = defineApi({ + method: "POST", + path: AUTH_ENDPOINTS.register, + body: RegisterRequestSchema, + output: AuthTokensSchema, +}); + +export const logout = defineApi({ + method: "POST", + path: AUTH_ENDPOINTS.logout, + output: z.null(), +}); + +export const getAuthUser = defineApi({ + path: AUTH_ENDPOINTS.user, + output: AuthUserResponseSchema, +}); + +export const getPermissions = defineApi({ + path: AUTH_ENDPOINTS.permissions, + output: PermissionsListSchema, +}); + +/** Fetches user + permissions, then updates auth store `user` and sidebar `menus`. */ +export async function hydrateSession(): Promise { + const [userBase, permissions] = await Promise.all([getAuthUser(), getPermissions()]); + const user = UserSchema.parse({ ...userBase, permissions }); + const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions); + const { setUser, setMenus } = useAuthStore.getState(); + setUser(user); + setMenus(menus); +} + +export type { AuthTokens, LoginRequest, User, PermissionsList, RegisterRequest }; diff --git a/apps/basic/src/api/request.ts b/apps/basic/src/api/request.ts new file mode 100644 index 0000000..0c425e1 --- /dev/null +++ b/apps/basic/src/api/request.ts @@ -0,0 +1,306 @@ +import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios"; +import { z } from "zod/v4"; +import { API_BASE_URL } from "@/utils/constants"; +import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas"; +import { getAccessToken, useAuthStore } from "@/stores/auth"; + +/** Avoid importing `@/api/auth` here (circular with `defineApi` consumers). */ +const REFRESH_PATH = "/api/auth/refresh"; + +export class ApiError extends Error { + code: number; + constructor(code: number, message: string) { + super(message); + this.name = "ApiError"; + this.code = code; + } +} + +export class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.name = "HttpError"; + this.status = status; + } +} + +type HttpRouter = { + navigate: (opts: { to: string }) => void | Promise; +}; + +let httpRouter: HttpRouter | null = null; + +export function installHttpRouter(router: HttpRouter): void { + httpRouter = router; +} + +function navigateTo403(): void { + void httpRouter?.navigate({ to: "/403" }); +} + +function navigateToLogin(): void { + void httpRouter?.navigate({ to: "/login" }); +} + +type RequestConfig = InternalAxiosRequestConfig & { + skipAuthRefresh?: boolean; + _retry?: boolean; +}; + +export type CallOptions = { + signal?: AbortSignal; +}; + +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +type Envelope = { + code?: number; + data?: unknown; + message?: string; +}; + +function cleanParams( + params?: Record, +): Record | undefined { + if (!params) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (value != null) out[key] = value; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function unwrapEnvelope(json: unknown): unknown { + if (json == null || typeof json !== "object") return json; + const envelope = json as Envelope; + if (envelope.code !== undefined && envelope.code !== 0) { + throw new ApiError(envelope.code, envelope.message ?? "Unknown error"); + } + if (envelope.code !== undefined && envelope.data !== undefined) { + return envelope.data; + } + return json; +} + +const client = axios.create({ + baseURL: API_BASE_URL, + timeout: 15_000, + headers: { "Content-Type": "application/json" }, +}); + +client.interceptors.request.use((config) => { + const token = getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +let inflightRefresh: Promise | null = null; + +async function doRefreshSessionTokens(): Promise { + const { tokens, setTokens, logout } = useAuthStore.getState(); + const refreshToken = tokens?.refreshToken; + if (!refreshToken) { + logout(); + navigateToLogin(); + return false; + } + + const body = RefreshTokenRequestSchema.parse({ refreshToken }); + + try { + const res = await client.post(REFRESH_PATH, body, { + skipAuthRefresh: true, + } as RequestConfig); + const next = AuthTokensSchema.parse(res.data); + setTokens(next); + return true; + } catch { + logout(); + navigateToLogin(); + return false; + } +} + +async function refreshSessionTokens(): Promise { + if (inflightRefresh) return inflightRefresh; + inflightRefresh = doRefreshSessionTokens().finally(() => { + inflightRefresh = null; + }); + return inflightRefresh; +} + +client.interceptors.response.use( + (response) => { + response.data = unwrapEnvelope(response.data); + return response; + }, + async (error: AxiosError) => { + const status = error.response?.status; + const config = error.config as RequestConfig | undefined; + + if ( + status === 401 && + config && + !config._retry && + !config.skipAuthRefresh && + config.url !== REFRESH_PATH && + !config.url?.endsWith(REFRESH_PATH) + ) { + config._retry = true; + const refreshed = await refreshSessionTokens(); + if (refreshed) { + return client.request(config); + } + throw new HttpError(401, "Unauthorized"); + } + + if (status === 403) { + navigateTo403(); + throw new HttpError(403, "Forbidden"); + } + + if (status != null) { + throw new HttpError( + status, + `HTTP ${status}: ${error.response?.statusText ?? "Error"}`, + ); + } + + throw error; + }, +); + +async function transportRequest(opts: { + method: HttpMethod; + url: string; + params?: Record; + body?: unknown; + signal?: AbortSignal; + skipAuthRefresh?: boolean; +}): Promise { + const res = await client.request({ + method: opts.method, + url: opts.url, + params: cleanParams(opts.params), + data: opts.body, + signal: opts.signal, + skipAuthRefresh: opts.skipAuthRefresh, + } as RequestConfig); + return res.data; +} + +type ApiDefConfig< + TParams extends z.ZodType | undefined, + TQuery extends z.ZodType | undefined, + TBody extends z.ZodType | undefined, + TOut extends z.ZodType, +> = { + method?: HttpMethod; + path: string; + params?: TParams; + query?: TQuery; + body?: TBody; + output: TOut; +}; + +type CallArgs< + TParams extends z.ZodType | undefined, + TQuery extends z.ZodType | undefined, + TBody extends z.ZodType | undefined, +> = TParams extends z.ZodType + ? TQuery extends z.ZodType + ? TBody extends z.ZodType + ? { params: z.infer; query: z.infer; body: z.infer } + : { params: z.infer; query: z.infer } + : TBody extends z.ZodType + ? { params: z.infer; body: z.infer } + : z.infer + : TQuery extends z.ZodType + ? TBody extends z.ZodType + ? { query: z.infer; body: z.infer } + : z.infer + : TBody extends z.ZodType + ? z.infer + : void; + +function renderPath(path: string, params?: Record): string { + if (!params) return path; + let url = path; + for (const [key, value] of Object.entries(params)) { + url = url.replace(`:${key}`, encodeURIComponent(String(value))); + } + return url; +} + +export function defineApi< + TParams extends z.ZodType | undefined = undefined, + TQuery extends z.ZodType | undefined = undefined, + TBody extends z.ZodType | undefined = undefined, + TOut extends z.ZodType = z.ZodType, +>( + def: ApiDefConfig, +): ( + ...args: CallArgs extends void + ? [options?: CallOptions] + : [args: CallArgs, options?: CallOptions] +) => Promise> { + const method: HttpMethod = def.method ?? "GET"; + const segmentCount = + (def.params ? 1 : 0) + (def.query ? 1 : 0) + (def.body ? 1 : 0); + + return async (...allArgs: unknown[]): Promise> => { + let callArgs: unknown; + let options: CallOptions | undefined; + + if (segmentCount === 0) { + options = allArgs[0] as CallOptions | undefined; + } else { + callArgs = allArgs[0]; + options = allArgs[1] as CallOptions | undefined; + } + + let params: Record | undefined; + let query: Record | undefined; + let body: unknown; + + if (segmentCount === 0) { + // no input segments + } else if (segmentCount === 1) { + if (def.params) { + params = def.params.parse(callArgs) as Record; + } else if (def.query) { + query = def.query.parse(callArgs) as Record; + } else if (def.body) { + body = def.body.parse(callArgs); + } + } else { + const shaped = (callArgs ?? {}) as { + params?: unknown; + query?: unknown; + body?: unknown; + }; + if (def.params) { + params = def.params.parse(shaped.params) as Record; + } + if (def.query) { + query = def.query.parse(shaped.query) as Record; + } + if (def.body) { + body = def.body.parse(shaped.body); + } + } + + const data = await transportRequest({ + method, + url: renderPath(def.path, params), + params: query, + body: def.body ? body : undefined, + signal: options?.signal, + }); + + return def.output.parse(data) as z.infer; + }; +} diff --git a/apps/basic/src/api/schemas.ts b/apps/basic/src/api/schemas.ts index 429abac..90c1978 100644 --- a/apps/basic/src/api/schemas.ts +++ b/apps/basic/src/api/schemas.ts @@ -123,6 +123,32 @@ export type PaginatedData = { total: number; }; +/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */ +export function PaginatedDataSchema(itemSchema: T) { + return z.object({ + list: z.array(itemSchema), + total: z.int(), + }); +} + +export const ListUsersQuerySchema = z.object({ + limit: z.number().int().positive().optional(), + offset: z.number().int().nonnegative().optional(), + keyword: z.string().optional(), + role: z.string().optional(), + sortField: z.string().optional(), + sortOrder: z.enum(["ascend", "descend"]).optional(), +}); + +export type ListUsersQuery = z.infer; + +export const IdParamsSchema = z.object({ + id: z.string().min(1), +}); + +export type IdParams = z.infer; + + export const SearchParamsSchema = z.object({ page: z.number().int().positive().catch(1), pageSize: z.number().int().positive().catch(10), diff --git a/apps/basic/src/api/user.ts b/apps/basic/src/api/user.ts index 74a5e93..77ccd51 100644 --- a/apps/basic/src/api/user.ts +++ b/apps/basic/src/api/user.ts @@ -1,4 +1,19 @@ -import type { CreateUserRequest, UpdateUserRequest, User } from "./schemas"; +import { z } from "zod/v4"; +import { defineApi } from "@/api/request"; +import { + CreateUserRequestSchema, + IdParamsSchema, + ListUsersQuerySchema, + PaginatedDataSchema, + UpdateUserRequestSchema, + UserSchema, +} from "@/api/schemas"; +import type { + CreateUserRequest, + ListUsersQuery, + UpdateUserRequest, + User, +} from "@/api/schemas"; export const USER_ENDPOINTS = { list: "/api/users", @@ -7,4 +22,37 @@ export const USER_ENDPOINTS = { delete: (id: string) => `/api/users/${id}`, } as const; -export type { CreateUserRequest, UpdateUserRequest, User }; +export const userQueryKeys = { + all: ["users"] as const, + list: (query: ListUsersQuery) => [...userQueryKeys.all, "list", query] as const, +}; + +export const listUsers = defineApi({ + path: USER_ENDPOINTS.list, + query: ListUsersQuerySchema, + output: PaginatedDataSchema(UserSchema), +}); + +export const createUser = defineApi({ + method: "POST", + path: USER_ENDPOINTS.list, + body: CreateUserRequestSchema, + output: UserSchema, +}); + +export const updateUser = defineApi({ + method: "PUT", + path: "/api/users/:id", + params: IdParamsSchema, + body: UpdateUserRequestSchema, + output: UserSchema, +}); + +export const deleteUser = defineApi({ + method: "DELETE", + path: "/api/users/:id", + params: IdParamsSchema, + output: z.null(), +}); + +export type { CreateUserRequest, ListUsersQuery, UpdateUserRequest, User }; diff --git a/apps/basic/src/components/DataTable/index.tsx b/apps/basic/src/components/DataTable/index.tsx index a8388bd..b6061ba 100644 --- a/apps/basic/src/components/DataTable/index.tsx +++ b/apps/basic/src/components/DataTable/index.tsx @@ -1,4 +1,4 @@ -import { Flex, Table, theme } from "antd"; +import { Table, theme } from "antd"; import type { TableProps } from "antd"; import type { CSSProperties, ReactElement, ReactNode, Ref } from "react"; import { useMemo } from "react"; @@ -20,9 +20,9 @@ export type DataTableProps = { /** When `lockScrollHeight`, optional fixed outer `height` (usually same as max height) */ frameHeight?: number; /** Flex column wrapping frame + optional `bottomExtra` */ - layoutRef?: Ref; + layoutRef?: Ref; /** Bordered box around the table (e.g. for `scrollHeight` vs `clientHeight` checks) */ - frameRef?: Ref; + frameRef?: Ref; /** Optional content below the bordered table frame */ bottomExtra?: ReactNode; } & TableProps; @@ -77,14 +77,15 @@ export function DataTable( ); return ( -
@@ -120,6 +121,6 @@ export function DataTable(
{bottomExtra} -
+ ); } diff --git a/apps/basic/src/main.tsx b/apps/basic/src/main.tsx index 15e368d..2941295 100644 --- a/apps/basic/src/main.tsx +++ b/apps/basic/src/main.tsx @@ -4,8 +4,8 @@ import { RouterProvider, createRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; import { useAuthStore } from "./stores/auth"; import { useSettingsStore } from "./stores/settings"; -import { fetchSessionAndApplyToStore } from "./utils/session"; -import { installHttpRouter } from "./utils/http"; +import { hydrateSession } from "./api/auth"; +import { installHttpRouter } from "./api/request"; const router = createRouter({ routeTree }); installHttpRouter(router); @@ -29,7 +29,7 @@ enableMocking() const { isAuthenticated, tokens } = useAuthStore.getState(); if (isAuthenticated && tokens) { try { - await fetchSessionAndApplyToStore(); + await hydrateSession(); } catch { useAuthStore.getState().logout(); } diff --git a/apps/basic/src/routes/_auth.tsx b/apps/basic/src/routes/_auth.tsx index 3417be4..59ed32f 100644 --- a/apps/basic/src/routes/_auth.tsx +++ b/apps/basic/src/routes/_auth.tsx @@ -2,7 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { useAuthStore } from "@/stores/auth"; import { MainLayout } from "@/components/Layout/MainLayout"; import { canAccessPath, normalizeAppPath } from "@/utils/appMenu"; -import { fetchSessionAndApplyToStore } from "@/utils/session"; +import { hydrateSession } from "@/api/auth"; export const Route = createFileRoute("/_auth")({ beforeLoad: async ({ location }) => { @@ -13,7 +13,7 @@ export const Route = createFileRoute("/_auth")({ if (tokens && !user) { try { - await fetchSessionAndApplyToStore(); + await hydrateSession(); } catch { useAuthStore.getState().logout(); throw redirect({ to: "/login" }); diff --git a/apps/basic/src/routes/_auth/users/index.tsx b/apps/basic/src/routes/_auth/users/index.tsx index 11f8895..1cd95b1 100644 --- a/apps/basic/src/routes/_auth/users/index.tsx +++ b/apps/basic/src/routes/_auth/users/index.tsx @@ -2,9 +2,13 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd"; import type { TablePaginationConfig } from "antd/es/table/interface"; import { useMemo, useRef, useState } from "react"; -import { httpClient } from "@/utils/http"; -import { USER_ENDPOINTS } from "@/api/user"; -import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas"; +import { + createUser, + deleteUser, + listUsers, + updateUser, + userQueryKeys, +} from "@/api/user"; import type { User, CreateUserRequest } from "@/api/schemas"; import { z } from "zod/v4"; import { MoreVertical, Pencil, Trash2 } from "lucide-react"; @@ -32,8 +36,6 @@ export const Route = createFileRoute("/_auth/users/")({ component: UsersPage, }); -const paginatedUserSchema = PaginatedResponseSchema(UserSchema); - function UsersPage() { const search = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); @@ -62,36 +64,26 @@ function UsersPage() { resourceKey: "users", }); + const listQuery = { + limit: search.limit, + offset: search.offset, + keyword: search.keyword || undefined, + role: search.role || undefined, + sortField: search.sortField ?? undefined, + sortOrder: search.sortOrder ?? undefined, + }; + const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD< { list: User[]; total: number }, CreateUserRequest, CreateUserRequest & { id: string } >({ - queryKey: [ - "users", - search.limit, - search.offset, - search.keyword, - search.role, - search.sortField, - search.sortOrder, - ], - queryFn: () => - httpClient.get(USER_ENDPOINTS.list, { - params: { - limit: search.limit, - offset: search.offset, - keyword: search.keyword || undefined, - role: search.role || undefined, - sortField: search.sortField ?? undefined, - sortOrder: search.sortOrder ?? undefined, - }, - }), - select: (raw) => paginatedUserSchema.shape.data.parse(raw), - createFn: (values) => - httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)), - updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values), - deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)), + queryKey: userQueryKeys.list(listQuery), + queryFn: () => listUsers(listQuery), + select: (raw) => raw as { list: User[]; total: number }, + createFn: (values) => createUser(values), + updateFn: ({ id, ...values }) => updateUser({ params: { id }, body: values }), + deleteFn: (id) => deleteUser({ id }), optimistic: { update: true, delete: true }, createLifecycle: { onSuccess: (values) => { diff --git a/apps/basic/src/routes/login/index.tsx b/apps/basic/src/routes/login/index.tsx index 1b20199..17bff27 100644 --- a/apps/basic/src/routes/login/index.tsx +++ b/apps/basic/src/routes/login/index.tsx @@ -2,12 +2,10 @@ import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-ro import { Form, Input, Button, Card, App, theme, Typography, Flex, Checkbox, Space } from "antd"; import type { CSSProperties } from "react"; import { useMutation } from "@tanstack/react-query"; -import { httpClient } from "@/utils/http"; import { useAuthStore } from "@/stores/auth"; import { useSettingsStore } from "@/stores/settings"; -import { AUTH_ENDPOINTS } from "@/api/auth"; -import { LoginRequestSchema, AuthTokensSchema } from "@/api/schemas"; -import { fetchSessionAndApplyToStore } from "@/utils/session"; +import { hydrateSession, login } from "@/api/auth"; +import { LoginRequestSchema } from "@/api/schemas"; import type { LoginRequest } from "@/api/schemas"; import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants"; import { Theme } from "@/components/Icon"; @@ -35,11 +33,9 @@ function LoginPage() { const loginMutation = useMutation({ mutationFn: async (values: LoginRequest) => { - const parsed = LoginRequestSchema.parse(values); - const tokens = await httpClient.post(AUTH_ENDPOINTS.login, parsed); - const validTokens = AuthTokensSchema.parse(tokens); - setTokens(validTokens); - await fetchSessionAndApplyToStore(); + const tokens = await login(values); + setTokens(tokens); + await hydrateSession(); }, onSuccess: () => { message.success("Login successful"); diff --git a/apps/basic/src/routes/register/index.tsx b/apps/basic/src/routes/register/index.tsx index 62c8256..8bdfe34 100644 --- a/apps/basic/src/routes/register/index.tsx +++ b/apps/basic/src/routes/register/index.tsx @@ -2,11 +2,9 @@ import { Link, createFileRoute, redirect, useNavigate } from "@tanstack/react-ro import { Form, Input, Button, Card, App, theme, Typography, Flex } from "antd"; import type { CSSProperties } from "react"; import { useMutation } from "@tanstack/react-query"; -import { httpClient } from "@/utils/http"; import { useAuthStore } from "@/stores/auth"; -import { AUTH_ENDPOINTS } from "@/api/auth"; -import { AuthTokensSchema, RegisterRequestSchema } from "@/api/schemas"; -import { fetchSessionAndApplyToStore } from "@/utils/session"; +import { hydrateSession, register } from "@/api/auth"; +import { RegisterRequestSchema } from "@/api/schemas"; import type { RegisterRequest } from "@/api/auth"; import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants"; import { Aurora } from "@/components/Aurora"; @@ -31,11 +29,9 @@ function RegisterPage() { const registerMutation = useMutation({ mutationFn: async (values: RegisterRequest) => { - const parsed = RegisterRequestSchema.parse(values); - const tokens = await httpClient.post(AUTH_ENDPOINTS.register, parsed); - const validTokens = AuthTokensSchema.parse(tokens); - setTokens(validTokens); - await fetchSessionAndApplyToStore(); + const tokens = await register(values); + setTokens(tokens); + await hydrateSession(); }, onSuccess: () => { message.success("Account created successfully"); diff --git a/apps/basic/src/utils/http.ts b/apps/basic/src/utils/http.ts deleted file mode 100644 index 4ab53dc..0000000 --- a/apps/basic/src/utils/http.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { API_BASE_URL } from "./constants"; -import { AUTH_ENDPOINTS } from "@/api/auth"; -import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas"; -import { getAccessToken, useAuthStore } from "@/stores/auth"; - -export class ApiError extends Error { - code: number; - constructor(code: number, message: string) { - super(message); - this.name = "ApiError"; - this.code = code; - } -} - -export class HttpError extends Error { - status: number; - constructor(status: number, message: string) { - super(message); - this.name = "HttpError"; - this.status = status; - } -} - -type RequestOptions = Omit & { - params?: Record; -}; - -type HttpRouter = { - navigate: (opts: { to: string }) => void | Promise; -}; - -let httpRouter: HttpRouter | null = null; - -export function installHttpRouter(router: HttpRouter): void { - httpRouter = router; -} - -let inflightRefresh: Promise | null = null; - -async function doRefreshSessionTokens(): Promise { - const { tokens, setTokens, logout } = useAuthStore.getState(); - const refreshToken = tokens?.refreshToken; - if (!refreshToken) { - logout(); - navigateToLogin(); - return false; - } - - const body = RefreshTokenRequestSchema.parse({ refreshToken }); - const url = buildUrl(AUTH_ENDPOINTS.refresh); - - try { - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - logout(); - navigateToLogin(); - return false; - } - - const json: unknown = await res.json(); - const envelope = json as { code?: number; data?: unknown; message?: string }; - if (envelope.code !== undefined && envelope.code !== 0) { - logout(); - navigateToLogin(); - return false; - } - - const next = AuthTokensSchema.parse(envelope.data ?? envelope); - setTokens(next); - return true; - } catch { - logout(); - navigateToLogin(); - return false; - } -} - -async function refreshSessionTokens(): Promise { - if (inflightRefresh) return inflightRefresh; - - inflightRefresh = doRefreshSessionTokens().finally(() => { - inflightRefresh = null; - }); - return inflightRefresh; -} - -function buildUrl(path: string, params?: RequestOptions["params"]): string { - const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`; - if (!params) return base; - const url = new URL(base, window.location.origin); - for (const [key, value] of Object.entries(params)) { - if (value != null) url.searchParams.set(key, String(value)); - } - return url.toString(); -} - -function getAuthHeaders(): Record { - const token = getAccessToken(); - if (token) return { Authorization: `Bearer ${token}` }; - return {}; -} - -function navigateTo403(): void { - void httpRouter?.navigate({ to: "/403" }); -} - -function navigateToLogin(): void { - void httpRouter?.navigate({ to: "/login" }); -} - -async function request( - method: string, - path: string, - body?: unknown, - options?: RequestOptions, - isAfterRefresh = false, -): Promise { - const url = buildUrl(path, options?.params); - const headers: Record = { - "Content-Type": "application/json", - ...getAuthHeaders(), - ...(options?.headers as Record), - }; - - const res = await fetch(url, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - ...options, - }); - - if (res.status === 401 && !isAfterRefresh && path !== AUTH_ENDPOINTS.refresh) { - const refreshed = await refreshSessionTokens(); - if (refreshed) { - return request(method, path, body, options, true); - } - throw new HttpError(401, "Unauthorized"); - } - - if (res.status === 403) { - navigateTo403(); - throw new HttpError(403, "Forbidden"); - } - - if (!res.ok) { - throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`); - } - - const json: unknown = await res.json(); - const envelope = json as { code?: number; data?: unknown; message?: string }; - - if (envelope.code !== undefined && envelope.code !== 0) { - throw new ApiError(envelope.code, envelope.message ?? "Unknown error"); - } - - return (envelope.data !== undefined ? envelope.data : json) as T; -} - -export const httpClient = { - get: (path: string, options?: RequestOptions) => request("GET", path, undefined, options), - post: (path: string, body?: unknown, options?: RequestOptions) => - request("POST", path, body, options), - put: (path: string, body?: unknown, options?: RequestOptions) => - request("PUT", path, body, options), - delete: (path: string, options?: RequestOptions) => - request("DELETE", path, undefined, options), -}; diff --git a/apps/basic/src/utils/session.ts b/apps/basic/src/utils/session.ts deleted file mode 100644 index 45892f5..0000000 --- a/apps/basic/src/utils/session.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { httpClient } from "@/utils/http"; -import { AUTH_ENDPOINTS } from "@/api/auth"; -import { AuthUserResponseSchema, PermissionsListSchema, UserSchema } from "@/api/schemas"; -import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu"; -import { useAuthStore } from "@/stores/auth"; - -/** Fetches `/api/auth/user` and `/api/auth/permissions`, then updates `user` and sidebar `menus`. */ -export async function fetchSessionAndApplyToStore(): Promise { - const [userBase, permissions] = await Promise.all([ - httpClient.get(AUTH_ENDPOINTS.user).then((d) => AuthUserResponseSchema.parse(d)), - httpClient.get(AUTH_ENDPOINTS.permissions).then((d) => PermissionsListSchema.parse(d)), - ]); - const user = UserSchema.parse({ ...userBase, permissions }); - const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions); - const { setUser, setMenus } = useAuthStore.getState(); - setUser(user); - setMenus(menus); -} diff --git a/apps/docs/content/en/backend/proxying-api-requests.mdx b/apps/docs/content/en/backend/proxying-api-requests.mdx index 4adfbd7..917dc03 100644 --- a/apps/docs/content/en/backend/proxying-api-requests.mdx +++ b/apps/docs/content/en/backend/proxying-api-requests.mdx @@ -1,14 +1,20 @@ --- title: Proxying API Requests -description: Configure API proxying between frontend and backend +description: MSW, API base URL, and wiring a real backend --- # Proxying API Requests -Use a proxy layer in development to avoid CORS issues and keep API endpoints configurable. +## MSW (default in development) -Recommended approach: +Templates use **MSW** to intercept HTTP in development. Handlers live under `src/mocks/handlers`. You can demo login, menus, and user CRUD without a real backend. -- Define a consistent API prefix. -- Route requests by environment. -- Keep backend base URLs in environment variables. +The **worker directory** is configured via `package.json` **`msw.workerDirectory`** as `public`. + +## Talking to a real API + +Transport lives in **`src/api/request.ts` (axios)**. `baseURL` comes from **`VITE_API_BASE_URL`** (`src/utils/constants.ts`). Call APIs through `defineApi` functions in `src/api/*.ts` — do not call axios from pages. + +Point `VITE_API_BASE_URL` at your gateway and disable or bypass MSW per `main.tsx` / env flags when you no longer need mocks. + +For CORS, configure the gateway the browser can reach, or proxy `/api` in `vite.config.ts` `server.proxy`. diff --git a/apps/docs/content/zh/backend/proxying-api-requests.mdx b/apps/docs/content/zh/backend/proxying-api-requests.mdx index 1772bc7..e4c375b 100644 --- a/apps/docs/content/zh/backend/proxying-api-requests.mdx +++ b/apps/docs/content/zh/backend/proxying-api-requests.mdx @@ -13,6 +13,8 @@ description: MSW、API 基地址与对接真实后端 ## 对接真实 API -将 **`src/utils/http.ts`(或项目内统一 HTTP 封装)** 中的 **baseURL** 指向真实网关,并按环境关闭或绕过 MSW 初始化(具体开关以模板 `main.tsx` / 环境变量为准)。 +模板传输层为 **`src/api/request.ts`(axios)**:`baseURL` 来自环境变量 **`VITE_API_BASE_URL`**(见 `src/utils/constants.ts`)。业务调用请走 `src/api/*.ts` 中的 `defineApi` 函数,不要在页面里直连 axios。 + +将 `VITE_API_BASE_URL` 指向真实网关,并按环境关闭或绕过 MSW 初始化(具体开关以模板 `main.tsx` / 环境变量为准)。 跨域时需由**浏览器可访问**的网关配置 **CORS**;或通过开发代理将 `/api` 转发到后端(在 `vite.config.ts` 配置 `server.proxy`,按团队规范填写目标地址)。 diff --git a/apps/docs/content/zh/getting-started/folder-structure.mdx b/apps/docs/content/zh/getting-started/folder-structure.mdx index 7444f61..a331ede 100644 --- a/apps/docs/content/zh/getting-started/folder-structure.mdx +++ b/apps/docs/content/zh/getting-started/folder-structure.mdx @@ -9,14 +9,14 @@ description: 模板应用 src 目录职责说明 ``` src/ -├── api/ # Zod schema、接口常量与类型 +├── api/ # Zod schema、request(axios + defineApi)、资源 API ├── components/ # 通用 UI(表格壳、表单弹窗、布局等) ├── hooks/ # 主题、权限、CRUD 等组合逻辑 ├── locales/ # Lingui:仅 with-lingui(en/zh .po) ├── mocks/ # MSW handlers 与浏览器 worker ├── routes/ # TanStack Router 文件路由 ├── stores/ # Zustand(auth、settings 等持久化) -└── utils/ # HTTP 客户端、常量等 +└── utils/ # 常量、菜单等(传输层在 api/request.ts) ``` 更完整的树状说明见: diff --git a/apps/with-lingui/.github/instructions/add-resource.instructions.md b/apps/with-lingui/.github/instructions/add-resource.instructions.md index 0cbc3bc..bbf4ebb 100644 --- a/apps/with-lingui/.github/instructions/add-resource.instructions.md +++ b/apps/with-lingui/.github/instructions/add-resource.instructions.md @@ -19,12 +19,16 @@ pnpm run test:e2e -- e2e/.spec.ts ## 1. Zod and types (`src/api/schemas.ts`) - Define `Schema`, `CreateRequestSchema`, `UpdateRequestSchema` (`Update` = `Create.partial()`). -- Reuse `PaginatedResponseSchema(Schema)` for list responses. +- For list `output`, reuse `PaginatedDataSchema(Schema)` (unwrapped `data`; envelope stays in `request.ts`). +- Add list query schema (e.g. `ListQuerySchema`) when the table has filters/pagination. - Export `type = z.infer`. -## 2. Endpoint constants (`src/api/.ts`) +## 2. Resource API module (`src/api/.ts`) -- Export `_ENDPOINTS`: `list` / `create` / `update(id)` / `delete(id)` with paths like `/api/`. +- Export path constants if useful for MSW/docs. +- Define APIs with `defineApi` from `@/api/request`: `list` / `create` / `update` / `delete` (and `queryKeys`). +- `method` defaults to `GET`; mutations set `POST` / `PUT` / `DELETE`. +- Use segmented `params` / `query` / `body` + `output`; do not call axios from routes. ## 3. Mock data (`src/mocks/data.ts`) @@ -37,7 +41,7 @@ pnpm run test:e2e -- e2e/.spec.ts ## 5. Route page (`src/routes/_auth//`) -- `createFileRoute("/_auth//")`, wiring `useResourceCRUD` (recommended: `optimistic: { update: true, delete: true }`), `useCrudToasts`, `useUrlSearchState`, `useTableFitHeight` (see `users/` for splitting `-Toolbar` / `-FormModal`). +- `createFileRoute("/_auth//")`, wiring `useResourceCRUD` with resource functions (`listX`, `createX`, …) and `queryKeys` from `@/api/` (recommended: `optimistic: { update: true, delete: true }`), plus `useCrudToasts`, `useUrlSearchState`, `useTableFitHeight` (see `users/` for splitting `-Toolbar` / `-FormModal`). - **Lingui**: run `pnpm run i18n:extract && pnpm run i18n:compile` for new copy (or `vp exec lingui …`). ## 6. Menu and permissions (`src/utils/appMenu.ts`) diff --git a/apps/with-lingui/.github/instructions/api.instructions.md b/apps/with-lingui/.github/instructions/api.instructions.md index dbf2253..ec6151d 100644 --- a/apps/with-lingui/.github/instructions/api.instructions.md +++ b/apps/with-lingui/.github/instructions/api.instructions.md @@ -1,25 +1,25 @@ --- -applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts" -description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users." +applyTo: "src/api/**/*.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts" +description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, request, defineApi, schema, zod, handler, endpoint, auth, users." --- # API Instructions ## Purpose -Maintain strict API contracts and consistent server/client mock behavior. +Maintain strict API contracts and a single call path: **schemas → `defineApi` resource modules → UI**. Transport lives in `src/api/request.ts` (axios + interceptors). ## Do -- Keep request/response shapes schema-driven and type-safe. -- Reuse endpoint constants and shared response helpers. -- Keep handler response envelope consistent (`code`, `data`, `message`). -- MSW success payloads: use `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` from `src/mocks/createHandler.ts` so responses match Zod contracts before `HttpResponse.json`. -- Ensure mock handlers reflect real API behavior and error semantics. -- Keep naming stable for endpoint keys and mutation/query keys. +- Define APIs with `defineApi` in `src/api/.ts` (`method` defaults to `GET`; use `params` / `query` / `body` + `output`). +- Keep Zod schemas in `src/api/schemas.ts` as the single contract; parse inputs/outputs inside `defineApi`. +- Keep MSW handler envelopes consistent (`code`, `data`, `message`) and aligned with schemas. +- Co-locate React Query keys with resource modules (e.g. `userQueryKeys`). +- Use `hydrateSession` from `@/api/auth` after login/register or when restoring a session. ## Do Not +- Do not call `axios` or `@/api/request` transport helpers from routes/components — import resource functions only (`listUsers`, `login`, …). - Do not return ad-hoc response shapes for one-off convenience. - Do not bypass schema parsing in new client mutations/queries. - Do not mix unrelated domain endpoints in the same module. @@ -27,5 +27,4 @@ Maintain strict API contracts and consistent server/client mock behavior. ## Validation - Verify TypeScript and schema parsing paths compile cleanly. -- Run: `pnpm exec vp check --no-fmt` -- After changing `createHandler` or handler envelopes: `pnpm run test:unit` (includes `src/mocks/createHandler.test.ts`). +- Run: `vp check --no-fmt` diff --git a/apps/with-lingui/README.md b/apps/with-lingui/README.md index 55f37ad..399da8e 100644 --- a/apps/with-lingui/README.md +++ b/apps/with-lingui/README.md @@ -110,7 +110,7 @@ pnpm run i18n:extract && git diff --exit-code -- src/locales ``` .github/instructions/ # Scoped AI guidance (frontend/testing/api/refactor/add-resource) src/ -├── api/ # Zod models + endpoint contracts +├── api/ # schemas, request (axios + defineApi), resource APIs ├── components/ # Reusable UI primitives and layout shells │ ├── Layout/ # MainLayout, Header, Sidebar, UserMenu, AppFooter │ ├── DataTable/ # Shared table frame/skeleton/empty state @@ -153,7 +153,7 @@ src/ │ ├── 404/index.tsx │ └── index.tsx ├── stores/ # Persisted auth/settings stores -├── utils/ # HTTP client, constants, session/menu helpers +├── utils/ # constants, app menu helpers ├── main.tsx # App bootstrap (MSW init + React render) └── routeTree.gen.ts # Generated TanStack route tree diff --git a/apps/with-lingui/package.json b/apps/with-lingui/package.json index 5cfe60f..5b8527c 100644 --- a/apps/with-lingui/package.json +++ b/apps/with-lingui/package.json @@ -24,6 +24,7 @@ "@tanstack/react-query": "^5.104.3", "@tanstack/react-router": "^1.170.45", "antd": "^6.6.6", + "axios": "^1.20.0", "lucide-react": "^1.59.0", "react": "^19.3.1", "react-dom": "^19.3.1", diff --git a/apps/with-lingui/src/api/auth.ts b/apps/with-lingui/src/api/auth.ts index 2d4314e..d887cff 100644 --- a/apps/with-lingui/src/api/auth.ts +++ b/apps/with-lingui/src/api/auth.ts @@ -1,11 +1,22 @@ +import { z } from "zod/v4"; +import { defineApi } from "@/api/request"; +import { + AuthTokensSchema, + AuthUserResponseSchema, + LoginRequestSchema, + PermissionsListSchema, + RegisterRequestSchema, + UserSchema, +} from "@/api/schemas"; import type { AuthTokens, LoginRequest, User, - MenuItem, PermissionsList, RegisterRequest, -} from "./schemas"; +} from "@/api/schemas"; +import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu"; +import { useAuthStore } from "@/stores/auth"; export const AUTH_ENDPOINTS = { login: "/api/auth/login", @@ -16,4 +27,44 @@ export const AUTH_ENDPOINTS = { permissions: "/api/auth/permissions", } as const; -export type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList, RegisterRequest }; +export const login = defineApi({ + method: "POST", + path: AUTH_ENDPOINTS.login, + body: LoginRequestSchema, + output: AuthTokensSchema, +}); + +export const register = defineApi({ + method: "POST", + path: AUTH_ENDPOINTS.register, + body: RegisterRequestSchema, + output: AuthTokensSchema, +}); + +export const logout = defineApi({ + method: "POST", + path: AUTH_ENDPOINTS.logout, + output: z.null(), +}); + +export const getAuthUser = defineApi({ + path: AUTH_ENDPOINTS.user, + output: AuthUserResponseSchema, +}); + +export const getPermissions = defineApi({ + path: AUTH_ENDPOINTS.permissions, + output: PermissionsListSchema, +}); + +/** Fetches user + permissions, then updates auth store `user` and sidebar `menus`. */ +export async function hydrateSession(): Promise { + const [userBase, permissions] = await Promise.all([getAuthUser(), getPermissions()]); + const user = UserSchema.parse({ ...userBase, permissions }); + const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions); + const { setUser, setMenus } = useAuthStore.getState(); + setUser(user); + setMenus(menus); +} + +export type { AuthTokens, LoginRequest, User, PermissionsList, RegisterRequest }; diff --git a/apps/with-lingui/src/api/request.ts b/apps/with-lingui/src/api/request.ts new file mode 100644 index 0000000..0c425e1 --- /dev/null +++ b/apps/with-lingui/src/api/request.ts @@ -0,0 +1,306 @@ +import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios"; +import { z } from "zod/v4"; +import { API_BASE_URL } from "@/utils/constants"; +import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas"; +import { getAccessToken, useAuthStore } from "@/stores/auth"; + +/** Avoid importing `@/api/auth` here (circular with `defineApi` consumers). */ +const REFRESH_PATH = "/api/auth/refresh"; + +export class ApiError extends Error { + code: number; + constructor(code: number, message: string) { + super(message); + this.name = "ApiError"; + this.code = code; + } +} + +export class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.name = "HttpError"; + this.status = status; + } +} + +type HttpRouter = { + navigate: (opts: { to: string }) => void | Promise; +}; + +let httpRouter: HttpRouter | null = null; + +export function installHttpRouter(router: HttpRouter): void { + httpRouter = router; +} + +function navigateTo403(): void { + void httpRouter?.navigate({ to: "/403" }); +} + +function navigateToLogin(): void { + void httpRouter?.navigate({ to: "/login" }); +} + +type RequestConfig = InternalAxiosRequestConfig & { + skipAuthRefresh?: boolean; + _retry?: boolean; +}; + +export type CallOptions = { + signal?: AbortSignal; +}; + +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +type Envelope = { + code?: number; + data?: unknown; + message?: string; +}; + +function cleanParams( + params?: Record, +): Record | undefined { + if (!params) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (value != null) out[key] = value; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function unwrapEnvelope(json: unknown): unknown { + if (json == null || typeof json !== "object") return json; + const envelope = json as Envelope; + if (envelope.code !== undefined && envelope.code !== 0) { + throw new ApiError(envelope.code, envelope.message ?? "Unknown error"); + } + if (envelope.code !== undefined && envelope.data !== undefined) { + return envelope.data; + } + return json; +} + +const client = axios.create({ + baseURL: API_BASE_URL, + timeout: 15_000, + headers: { "Content-Type": "application/json" }, +}); + +client.interceptors.request.use((config) => { + const token = getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +let inflightRefresh: Promise | null = null; + +async function doRefreshSessionTokens(): Promise { + const { tokens, setTokens, logout } = useAuthStore.getState(); + const refreshToken = tokens?.refreshToken; + if (!refreshToken) { + logout(); + navigateToLogin(); + return false; + } + + const body = RefreshTokenRequestSchema.parse({ refreshToken }); + + try { + const res = await client.post(REFRESH_PATH, body, { + skipAuthRefresh: true, + } as RequestConfig); + const next = AuthTokensSchema.parse(res.data); + setTokens(next); + return true; + } catch { + logout(); + navigateToLogin(); + return false; + } +} + +async function refreshSessionTokens(): Promise { + if (inflightRefresh) return inflightRefresh; + inflightRefresh = doRefreshSessionTokens().finally(() => { + inflightRefresh = null; + }); + return inflightRefresh; +} + +client.interceptors.response.use( + (response) => { + response.data = unwrapEnvelope(response.data); + return response; + }, + async (error: AxiosError) => { + const status = error.response?.status; + const config = error.config as RequestConfig | undefined; + + if ( + status === 401 && + config && + !config._retry && + !config.skipAuthRefresh && + config.url !== REFRESH_PATH && + !config.url?.endsWith(REFRESH_PATH) + ) { + config._retry = true; + const refreshed = await refreshSessionTokens(); + if (refreshed) { + return client.request(config); + } + throw new HttpError(401, "Unauthorized"); + } + + if (status === 403) { + navigateTo403(); + throw new HttpError(403, "Forbidden"); + } + + if (status != null) { + throw new HttpError( + status, + `HTTP ${status}: ${error.response?.statusText ?? "Error"}`, + ); + } + + throw error; + }, +); + +async function transportRequest(opts: { + method: HttpMethod; + url: string; + params?: Record; + body?: unknown; + signal?: AbortSignal; + skipAuthRefresh?: boolean; +}): Promise { + const res = await client.request({ + method: opts.method, + url: opts.url, + params: cleanParams(opts.params), + data: opts.body, + signal: opts.signal, + skipAuthRefresh: opts.skipAuthRefresh, + } as RequestConfig); + return res.data; +} + +type ApiDefConfig< + TParams extends z.ZodType | undefined, + TQuery extends z.ZodType | undefined, + TBody extends z.ZodType | undefined, + TOut extends z.ZodType, +> = { + method?: HttpMethod; + path: string; + params?: TParams; + query?: TQuery; + body?: TBody; + output: TOut; +}; + +type CallArgs< + TParams extends z.ZodType | undefined, + TQuery extends z.ZodType | undefined, + TBody extends z.ZodType | undefined, +> = TParams extends z.ZodType + ? TQuery extends z.ZodType + ? TBody extends z.ZodType + ? { params: z.infer; query: z.infer; body: z.infer } + : { params: z.infer; query: z.infer } + : TBody extends z.ZodType + ? { params: z.infer; body: z.infer } + : z.infer + : TQuery extends z.ZodType + ? TBody extends z.ZodType + ? { query: z.infer; body: z.infer } + : z.infer + : TBody extends z.ZodType + ? z.infer + : void; + +function renderPath(path: string, params?: Record): string { + if (!params) return path; + let url = path; + for (const [key, value] of Object.entries(params)) { + url = url.replace(`:${key}`, encodeURIComponent(String(value))); + } + return url; +} + +export function defineApi< + TParams extends z.ZodType | undefined = undefined, + TQuery extends z.ZodType | undefined = undefined, + TBody extends z.ZodType | undefined = undefined, + TOut extends z.ZodType = z.ZodType, +>( + def: ApiDefConfig, +): ( + ...args: CallArgs extends void + ? [options?: CallOptions] + : [args: CallArgs, options?: CallOptions] +) => Promise> { + const method: HttpMethod = def.method ?? "GET"; + const segmentCount = + (def.params ? 1 : 0) + (def.query ? 1 : 0) + (def.body ? 1 : 0); + + return async (...allArgs: unknown[]): Promise> => { + let callArgs: unknown; + let options: CallOptions | undefined; + + if (segmentCount === 0) { + options = allArgs[0] as CallOptions | undefined; + } else { + callArgs = allArgs[0]; + options = allArgs[1] as CallOptions | undefined; + } + + let params: Record | undefined; + let query: Record | undefined; + let body: unknown; + + if (segmentCount === 0) { + // no input segments + } else if (segmentCount === 1) { + if (def.params) { + params = def.params.parse(callArgs) as Record; + } else if (def.query) { + query = def.query.parse(callArgs) as Record; + } else if (def.body) { + body = def.body.parse(callArgs); + } + } else { + const shaped = (callArgs ?? {}) as { + params?: unknown; + query?: unknown; + body?: unknown; + }; + if (def.params) { + params = def.params.parse(shaped.params) as Record; + } + if (def.query) { + query = def.query.parse(shaped.query) as Record; + } + if (def.body) { + body = def.body.parse(shaped.body); + } + } + + const data = await transportRequest({ + method, + url: renderPath(def.path, params), + params: query, + body: def.body ? body : undefined, + signal: options?.signal, + }); + + return def.output.parse(data) as z.infer; + }; +} diff --git a/apps/with-lingui/src/api/schemas.ts b/apps/with-lingui/src/api/schemas.ts index cbe72d4..c736e48 100644 --- a/apps/with-lingui/src/api/schemas.ts +++ b/apps/with-lingui/src/api/schemas.ts @@ -122,6 +122,32 @@ export type PaginatedData = { total: number; }; +/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */ +export function PaginatedDataSchema(itemSchema: T) { + return z.object({ + list: z.array(itemSchema), + total: z.int(), + }); +} + +export const ListUsersQuerySchema = z.object({ + limit: z.number().int().positive().optional(), + offset: z.number().int().nonnegative().optional(), + keyword: z.string().optional(), + role: z.string().optional(), + sortField: z.string().optional(), + sortOrder: z.enum(["ascend", "descend"]).optional(), +}); + +export type ListUsersQuery = z.infer; + +export const IdParamsSchema = z.object({ + id: z.string().min(1), +}); + +export type IdParams = z.infer; + + export const SearchParamsSchema = z.object({ page: z.number().int().positive().catch(1), pageSize: z.number().int().positive().catch(10), diff --git a/apps/with-lingui/src/api/user.ts b/apps/with-lingui/src/api/user.ts index 74a5e93..77ccd51 100644 --- a/apps/with-lingui/src/api/user.ts +++ b/apps/with-lingui/src/api/user.ts @@ -1,4 +1,19 @@ -import type { CreateUserRequest, UpdateUserRequest, User } from "./schemas"; +import { z } from "zod/v4"; +import { defineApi } from "@/api/request"; +import { + CreateUserRequestSchema, + IdParamsSchema, + ListUsersQuerySchema, + PaginatedDataSchema, + UpdateUserRequestSchema, + UserSchema, +} from "@/api/schemas"; +import type { + CreateUserRequest, + ListUsersQuery, + UpdateUserRequest, + User, +} from "@/api/schemas"; export const USER_ENDPOINTS = { list: "/api/users", @@ -7,4 +22,37 @@ export const USER_ENDPOINTS = { delete: (id: string) => `/api/users/${id}`, } as const; -export type { CreateUserRequest, UpdateUserRequest, User }; +export const userQueryKeys = { + all: ["users"] as const, + list: (query: ListUsersQuery) => [...userQueryKeys.all, "list", query] as const, +}; + +export const listUsers = defineApi({ + path: USER_ENDPOINTS.list, + query: ListUsersQuerySchema, + output: PaginatedDataSchema(UserSchema), +}); + +export const createUser = defineApi({ + method: "POST", + path: USER_ENDPOINTS.list, + body: CreateUserRequestSchema, + output: UserSchema, +}); + +export const updateUser = defineApi({ + method: "PUT", + path: "/api/users/:id", + params: IdParamsSchema, + body: UpdateUserRequestSchema, + output: UserSchema, +}); + +export const deleteUser = defineApi({ + method: "DELETE", + path: "/api/users/:id", + params: IdParamsSchema, + output: z.null(), +}); + +export type { CreateUserRequest, ListUsersQuery, UpdateUserRequest, User }; diff --git a/apps/with-lingui/src/components/DataTable/index.tsx b/apps/with-lingui/src/components/DataTable/index.tsx index 5a48327..debc7a7 100644 --- a/apps/with-lingui/src/components/DataTable/index.tsx +++ b/apps/with-lingui/src/components/DataTable/index.tsx @@ -1,4 +1,4 @@ -import { Flex, Table, theme } from "antd"; +import { Table, theme } from "antd"; import type { TableProps } from "antd"; import type { CSSProperties, ReactElement, ReactNode, Ref } from "react"; import { useMemo } from "react"; @@ -12,8 +12,8 @@ export type DataTableProps = { lockScrollHeight?: boolean; maxHeight?: number; frameHeight?: number; - layoutRef?: Ref; - frameRef?: Ref; + layoutRef?: Ref; + frameRef?: Ref; bottomExtra?: ReactNode; } & TableProps; @@ -67,14 +67,15 @@ export function DataTable( ); return ( -
@@ -110,6 +111,6 @@ export function DataTable(
{bottomExtra} -
+ ); } diff --git a/apps/with-lingui/src/main.tsx b/apps/with-lingui/src/main.tsx index 34467d8..ca0050a 100644 --- a/apps/with-lingui/src/main.tsx +++ b/apps/with-lingui/src/main.tsx @@ -6,7 +6,7 @@ import { i18n } from "@lingui/core"; import { loadLocaleCatalog } from "./locales/loadLocaleCatalog"; import { useSettingsStore } from "./stores/settings"; import { useAuthStore } from "./stores/auth"; -import { installHttpRouter } from "./utils/http"; +import { installHttpRouter } from "./api/request"; const router = createRouter({ routeTree }); installHttpRouter(router); diff --git a/apps/with-lingui/src/routes/_auth.tsx b/apps/with-lingui/src/routes/_auth.tsx index 3417be4..59ed32f 100644 --- a/apps/with-lingui/src/routes/_auth.tsx +++ b/apps/with-lingui/src/routes/_auth.tsx @@ -2,7 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { useAuthStore } from "@/stores/auth"; import { MainLayout } from "@/components/Layout/MainLayout"; import { canAccessPath, normalizeAppPath } from "@/utils/appMenu"; -import { fetchSessionAndApplyToStore } from "@/utils/session"; +import { hydrateSession } from "@/api/auth"; export const Route = createFileRoute("/_auth")({ beforeLoad: async ({ location }) => { @@ -13,7 +13,7 @@ export const Route = createFileRoute("/_auth")({ if (tokens && !user) { try { - await fetchSessionAndApplyToStore(); + await hydrateSession(); } catch { useAuthStore.getState().logout(); throw redirect({ to: "/login" }); diff --git a/apps/with-lingui/src/routes/_auth/users/index.tsx b/apps/with-lingui/src/routes/_auth/users/index.tsx index 1b01a8b..e417151 100644 --- a/apps/with-lingui/src/routes/_auth/users/index.tsx +++ b/apps/with-lingui/src/routes/_auth/users/index.tsx @@ -3,9 +3,13 @@ import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "an import type { TablePaginationConfig } from "antd/es/table/interface"; import { useLingui } from "@lingui/react/macro"; import { useMemo, useRef, useState } from "react"; -import { httpClient } from "@/utils/http"; -import { USER_ENDPOINTS } from "@/api/user"; -import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas"; +import { + createUser, + deleteUser, + listUsers, + updateUser, + userQueryKeys, +} from "@/api/user"; import type { User, CreateUserRequest } from "@/api/schemas"; import { z } from "zod/v4"; import { MoreVertical, Pencil, Trash2 } from "lucide-react"; @@ -33,8 +37,6 @@ export const Route = createFileRoute("/_auth/users/")({ component: UsersPage, }); -const paginatedUserSchema = PaginatedResponseSchema(UserSchema); - function UsersPage() { const search = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); @@ -64,36 +66,26 @@ function UsersPage() { resourceKey: "users", }); + const listQuery = { + limit: search.limit, + offset: search.offset, + keyword: search.keyword || undefined, + role: search.role || undefined, + sortField: search.sortField ?? undefined, + sortOrder: search.sortOrder ?? undefined, + }; + const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD< { list: User[]; total: number }, CreateUserRequest, CreateUserRequest & { id: string } >({ - queryKey: [ - "users", - search.limit, - search.offset, - search.keyword, - search.role, - search.sortField, - search.sortOrder, - ], - queryFn: () => - httpClient.get(USER_ENDPOINTS.list, { - params: { - limit: search.limit, - offset: search.offset, - keyword: search.keyword || undefined, - role: search.role || undefined, - sortField: search.sortField ?? undefined, - sortOrder: search.sortOrder ?? undefined, - }, - }), - select: (raw) => paginatedUserSchema.shape.data.parse(raw), - createFn: (values) => - httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)), - updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values), - deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)), + queryKey: userQueryKeys.list(listQuery), + queryFn: () => listUsers(listQuery), + select: (raw) => raw as { list: User[]; total: number }, + createFn: (values) => createUser(values), + updateFn: ({ id, ...values }) => updateUser({ params: { id }, body: values }), + deleteFn: (id) => deleteUser({ id }), optimistic: { update: true, delete: true }, createLifecycle: { onSuccess: (values) => { diff --git a/apps/with-lingui/src/routes/login/index.tsx b/apps/with-lingui/src/routes/login/index.tsx index 8d7c028..59dbace 100644 --- a/apps/with-lingui/src/routes/login/index.tsx +++ b/apps/with-lingui/src/routes/login/index.tsx @@ -3,12 +3,10 @@ import { Form, Input, Button, Card, App, theme, Typography, Flex, Checkbox } fro import type { CSSProperties } from "react"; import { useMutation } from "@tanstack/react-query"; import { useLingui } from "@lingui/react/macro"; -import { httpClient } from "@/utils/http"; import { useAuthStore } from "@/stores/auth"; import { useSettingsStore } from "@/stores/settings"; -import { AUTH_ENDPOINTS } from "@/api/auth"; -import { LoginRequestSchema, AuthTokensSchema } from "@/api/schemas"; -import { fetchSessionAndApplyToStore } from "@/utils/session"; +import { hydrateSession, login } from "@/api/auth"; +import { LoginRequestSchema } from "@/api/schemas"; import type { LoginRequest } from "@/api/schemas"; import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants"; import { AppFooter } from "@/components/Layout/AppFooter"; @@ -35,11 +33,9 @@ function LoginPage() { const loginMutation = useMutation({ mutationFn: async (values: LoginRequest) => { - const parsed = LoginRequestSchema.parse(values); - const tokens = await httpClient.post(AUTH_ENDPOINTS.login, parsed); - const validTokens = AuthTokensSchema.parse(tokens); - setTokens(validTokens); - await fetchSessionAndApplyToStore(); + const tokens = await login(values); + setTokens(tokens); + await hydrateSession(); }, onSuccess: () => { message.success(t`Login successful`); diff --git a/apps/with-lingui/src/routes/register/index.tsx b/apps/with-lingui/src/routes/register/index.tsx index 8fc985b..02d1a0a 100644 --- a/apps/with-lingui/src/routes/register/index.tsx +++ b/apps/with-lingui/src/routes/register/index.tsx @@ -3,11 +3,9 @@ import { Form, Input, Button, Card, App, theme, Typography, Flex } from "antd"; import type { CSSProperties } from "react"; import { useMutation } from "@tanstack/react-query"; import { useLingui } from "@lingui/react/macro"; -import { httpClient } from "@/utils/http"; import { useAuthStore } from "@/stores/auth"; -import { AUTH_ENDPOINTS } from "@/api/auth"; -import { AuthTokensSchema, RegisterRequestSchema } from "@/api/schemas"; -import { fetchSessionAndApplyToStore } from "@/utils/session"; +import { hydrateSession, register } from "@/api/auth"; +import { RegisterRequestSchema } from "@/api/schemas"; import type { RegisterRequest } from "@/api/auth"; import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants"; import { Aurora } from "@/components/Aurora"; @@ -33,11 +31,9 @@ function RegisterPage() { const registerMutation = useMutation({ mutationFn: async (values: RegisterRequest) => { - const parsed = RegisterRequestSchema.parse(values); - const tokens = await httpClient.post(AUTH_ENDPOINTS.register, parsed); - const validTokens = AuthTokensSchema.parse(tokens); - setTokens(validTokens); - await fetchSessionAndApplyToStore(); + const tokens = await register(values); + setTokens(tokens); + await hydrateSession(); }, onSuccess: () => { message.success(t`Account created successfully`); diff --git a/apps/with-lingui/src/utils/http.ts b/apps/with-lingui/src/utils/http.ts deleted file mode 100644 index 4ab53dc..0000000 --- a/apps/with-lingui/src/utils/http.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { API_BASE_URL } from "./constants"; -import { AUTH_ENDPOINTS } from "@/api/auth"; -import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas"; -import { getAccessToken, useAuthStore } from "@/stores/auth"; - -export class ApiError extends Error { - code: number; - constructor(code: number, message: string) { - super(message); - this.name = "ApiError"; - this.code = code; - } -} - -export class HttpError extends Error { - status: number; - constructor(status: number, message: string) { - super(message); - this.name = "HttpError"; - this.status = status; - } -} - -type RequestOptions = Omit & { - params?: Record; -}; - -type HttpRouter = { - navigate: (opts: { to: string }) => void | Promise; -}; - -let httpRouter: HttpRouter | null = null; - -export function installHttpRouter(router: HttpRouter): void { - httpRouter = router; -} - -let inflightRefresh: Promise | null = null; - -async function doRefreshSessionTokens(): Promise { - const { tokens, setTokens, logout } = useAuthStore.getState(); - const refreshToken = tokens?.refreshToken; - if (!refreshToken) { - logout(); - navigateToLogin(); - return false; - } - - const body = RefreshTokenRequestSchema.parse({ refreshToken }); - const url = buildUrl(AUTH_ENDPOINTS.refresh); - - try { - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - logout(); - navigateToLogin(); - return false; - } - - const json: unknown = await res.json(); - const envelope = json as { code?: number; data?: unknown; message?: string }; - if (envelope.code !== undefined && envelope.code !== 0) { - logout(); - navigateToLogin(); - return false; - } - - const next = AuthTokensSchema.parse(envelope.data ?? envelope); - setTokens(next); - return true; - } catch { - logout(); - navigateToLogin(); - return false; - } -} - -async function refreshSessionTokens(): Promise { - if (inflightRefresh) return inflightRefresh; - - inflightRefresh = doRefreshSessionTokens().finally(() => { - inflightRefresh = null; - }); - return inflightRefresh; -} - -function buildUrl(path: string, params?: RequestOptions["params"]): string { - const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`; - if (!params) return base; - const url = new URL(base, window.location.origin); - for (const [key, value] of Object.entries(params)) { - if (value != null) url.searchParams.set(key, String(value)); - } - return url.toString(); -} - -function getAuthHeaders(): Record { - const token = getAccessToken(); - if (token) return { Authorization: `Bearer ${token}` }; - return {}; -} - -function navigateTo403(): void { - void httpRouter?.navigate({ to: "/403" }); -} - -function navigateToLogin(): void { - void httpRouter?.navigate({ to: "/login" }); -} - -async function request( - method: string, - path: string, - body?: unknown, - options?: RequestOptions, - isAfterRefresh = false, -): Promise { - const url = buildUrl(path, options?.params); - const headers: Record = { - "Content-Type": "application/json", - ...getAuthHeaders(), - ...(options?.headers as Record), - }; - - const res = await fetch(url, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - ...options, - }); - - if (res.status === 401 && !isAfterRefresh && path !== AUTH_ENDPOINTS.refresh) { - const refreshed = await refreshSessionTokens(); - if (refreshed) { - return request(method, path, body, options, true); - } - throw new HttpError(401, "Unauthorized"); - } - - if (res.status === 403) { - navigateTo403(); - throw new HttpError(403, "Forbidden"); - } - - if (!res.ok) { - throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`); - } - - const json: unknown = await res.json(); - const envelope = json as { code?: number; data?: unknown; message?: string }; - - if (envelope.code !== undefined && envelope.code !== 0) { - throw new ApiError(envelope.code, envelope.message ?? "Unknown error"); - } - - return (envelope.data !== undefined ? envelope.data : json) as T; -} - -export const httpClient = { - get: (path: string, options?: RequestOptions) => request("GET", path, undefined, options), - post: (path: string, body?: unknown, options?: RequestOptions) => - request("POST", path, body, options), - put: (path: string, body?: unknown, options?: RequestOptions) => - request("PUT", path, body, options), - delete: (path: string, options?: RequestOptions) => - request("DELETE", path, undefined, options), -}; diff --git a/apps/with-lingui/src/utils/session.ts b/apps/with-lingui/src/utils/session.ts deleted file mode 100644 index 2ea3aa2..0000000 --- a/apps/with-lingui/src/utils/session.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { httpClient } from "@/utils/http"; -import { AUTH_ENDPOINTS } from "@/api/auth"; -import { AuthUserResponseSchema, PermissionsListSchema, UserSchema } from "@/api/schemas"; -import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu"; -import { useAuthStore } from "@/stores/auth"; - -export async function fetchSessionAndApplyToStore(): Promise { - const [userBase, permissions] = await Promise.all([ - httpClient.get(AUTH_ENDPOINTS.user).then((d) => AuthUserResponseSchema.parse(d)), - httpClient.get(AUTH_ENDPOINTS.permissions).then((d) => PermissionsListSchema.parse(d)), - ]); - const user = UserSchema.parse({ ...userBase, permissions }); - const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions); - const { setUser, setMenus } = useAuthStore.getState(); - setUser(user); - setMenus(menus); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 820f902..b99f099 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: antd: specifier: ^6.6.6 version: 6.6.6(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + axios: + specifier: ^1.20.0 + version: 1.20.0 lucide-react: specifier: ^1.59.0 version: 1.59.0(@types/react@19.3.0)(react@19.3.1) @@ -62,7 +65,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react-swc': specifier: ^4.3.3 - version: 4.3.3(@swc/helpers@0.5.21)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3)) + version: 4.3.3(@swc/helpers@0.5.21)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3)) msw: specifier: ^2.15.0 version: 2.15.0(@types/node@25.6.0)(typescript@6.0.3) @@ -120,10 +123,10 @@ importers: dependencies: '@lingui/core': specifier: ^6.10.0 - version: 6.10.0(@babel/core@7.29.0) + version: 6.10.0(@babel/core@7.29.7) '@lingui/react': specifier: ^6.10.0 - version: 6.10.0(@babel/core@7.29.0)(react@19.3.1) + version: 6.10.0(@babel/core@7.29.7)(react@19.3.1) '@tanstack/react-query': specifier: ^5.104.3 version: 5.104.3(react@19.3.1) @@ -133,6 +136,9 @@ importers: antd: specifier: ^6.6.6 version: 6.6.6(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + axios: + specifier: ^1.20.0 + version: 1.20.0 lucide-react: specifier: ^1.59.0 version: 1.59.0(@types/react@19.3.0)(react@19.3.1) @@ -157,10 +163,10 @@ importers: version: 6.10.0 '@lingui/swc-plugin': specifier: ^6.7.1 - version: 6.7.1(@lingui/core@6.10.0(@babel/core@7.29.0)) + version: 6.7.1(@lingui/core@6.10.0(@babel/core@7.29.7)) '@lingui/vite-plugin': specifier: ^6.10.0 - version: 6.10.0(@babel/core@7.29.0)(@lingui/babel-plugin-lingui-macro@6.10.0(@babel/core@7.29.0))(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3)) + version: 6.10.0(@babel/core@7.29.7)(@lingui/babel-plugin-lingui-macro@6.10.0(@babel/core@7.29.7))(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3)) '@playwright/test': specifier: ^1.64.1 version: 1.64.1 @@ -175,7 +181,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react-swc': specifier: ^4.3.3 - version: 4.3.3(@swc/helpers@0.5.21)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3)) + version: 4.3.3(@swc/helpers@0.5.21)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3)) msw: specifier: ^2.15.0 version: 2.15.0(@types/node@25.6.0)(typescript@6.0.3) @@ -2645,6 +2651,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -2938,6 +2945,7 @@ packages: '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yuku-codegen/binding-android-arm64@0.9.5': resolution: {integrity: sha512-jrOY5WM+AaAqkv51fHP1x28ifto4WgcZVzodLUyMU1jMWn5Sq+VciRdk/n8E0Ey5w4p4cWWE+m5GfXWOYh7Kzw==} @@ -3108,6 +3116,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -3207,6 +3219,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -3215,6 +3230,9 @@ packages: resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==} engines: {node: '>=4'} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -3390,6 +3408,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -3661,6 +3683,10 @@ packages: delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -4016,10 +4042,23 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + follow-redirects@1.16.1: + resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -4149,8 +4188,8 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} hast-util-from-dom@5.0.1: @@ -4210,6 +4249,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -4864,6 +4907,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -5256,6 +5307,10 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + pseudolocale@3.1.0: resolution: {integrity: sha512-jBidehQu5WO8zbFUod0ETJYoVPxZ0ixsdI9B8ilkE0eA+YTydxHA7ujNdU4YhTexD5yH5Griv9r5CuXx+nwFUA==} engines: {node: '>=24.0.0'} @@ -7043,6 +7098,13 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 + '@lingui/babel-plugin-lingui-macro@6.10.0(@babel/core@7.29.7)': + dependencies: + '@lingui/conf': 6.10.0 + '@lingui/message-utils': 6.10.0 + optionalDependencies: + '@babel/core': 7.29.7 + '@lingui/cli@6.10.0': dependencies: '@babel/core': 7.29.0 @@ -7092,6 +7154,13 @@ snapshots: transitivePeerDependencies: - '@babel/core' + '@lingui/core@6.10.0(@babel/core@7.29.7)': + dependencies: + '@lingui/babel-plugin-lingui-macro': 6.10.0(@babel/core@7.29.7) + '@lingui/message-utils': 6.10.0 + transitivePeerDependencies: + - '@babel/core' + '@lingui/format-po@6.10.0': dependencies: '@lingui/conf': 6.10.0 @@ -7146,29 +7215,29 @@ snapshots: '@lingui/native-tools-win32-arm64-msvc': 0.1.6 '@lingui/native-tools-win32-x64-msvc': 0.1.6 - '@lingui/react@6.10.0(@babel/core@7.29.0)(react@19.3.1)': + '@lingui/react@6.10.0(@babel/core@7.29.7)(react@19.3.1)': dependencies: - '@lingui/babel-plugin-lingui-macro': 6.10.0(@babel/core@7.29.0) - '@lingui/core': 6.10.0(@babel/core@7.29.0) + '@lingui/babel-plugin-lingui-macro': 6.10.0(@babel/core@7.29.7) + '@lingui/core': 6.10.0(@babel/core@7.29.7) react: 19.3.1 use-sync-external-store: 1.6.0(react@19.3.1) transitivePeerDependencies: - '@babel/core' - '@lingui/swc-plugin@6.7.1(@lingui/core@6.10.0(@babel/core@7.29.0))': + '@lingui/swc-plugin@6.7.1(@lingui/core@6.10.0(@babel/core@7.29.7))': dependencies: '@lingui/conf': 6.0.0 - '@lingui/core': 6.10.0(@babel/core@7.29.0) + '@lingui/core': 6.10.0(@babel/core@7.29.7) - '@lingui/vite-plugin@6.10.0(@babel/core@7.29.0)(@lingui/babel-plugin-lingui-macro@6.10.0(@babel/core@7.29.0))(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))': + '@lingui/vite-plugin@6.10.0(@babel/core@7.29.7)(@lingui/babel-plugin-lingui-macro@6.10.0(@babel/core@7.29.7))(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))': dependencies: '@lingui/cli': 6.10.0 '@lingui/conf': 6.10.0 '@lingui/native-tools': 0.1.6(@lingui/conf@6.10.0) vite: '@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3)' optionalDependencies: - '@babel/core': 7.29.0 - '@lingui/babel-plugin-lingui-macro': 6.10.0(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@lingui/babel-plugin-lingui-macro': 6.10.0(@babel/core@7.29.7) transitivePeerDependencies: - babel-plugin-macros - esbuild @@ -8597,7 +8666,7 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react-swc@4.3.3(@swc/helpers@0.5.21)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))': + '@vitejs/plugin-react-swc@4.3.3(@swc/helpers@0.5.21)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.1 '@swc/core': 1.16.18(@swc/helpers@0.5.21) @@ -8632,7 +8701,7 @@ snapshots: '@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vitest@4.1.11)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)) '@vitest/utils': 4.1.11 magic-string: 0.30.21 pngjs: 7.0.0 @@ -8649,7 +8718,7 @@ snapshots: '@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vitest@4.1.11)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)) '@vitest/utils': 4.1.11 magic-string: 0.30.21 pngjs: 7.0.0 @@ -8689,7 +8758,7 @@ snapshots: msw: 2.15.0(@types/node@25.6.0)(typescript@6.0.3) vite: '@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3)' - '@vitest/mocker@4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))': + '@vitest/mocker@4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 @@ -8878,6 +8947,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -9047,12 +9122,24 @@ snapshots: async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 axe-core@4.11.3: {} + axios@1.20.0: + dependencies: + follow-redirects: 1.16.1 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + axobject-query@4.1.0: {} babel-dead-code-elimination@1.0.12: @@ -9220,6 +9307,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@13.1.0: {} @@ -9500,6 +9591,8 @@ snapshots: dependencies: robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -9562,7 +9655,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -9631,11 +9724,11 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.3 + hasown: 2.0.4 es-to-primitive@1.3.0: dependencies: @@ -9698,7 +9791,7 @@ snapshots: eslint: 10.2.1(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.2.1(jiti@2.7.0)))(eslint@10.2.1(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.2.1(jiti@2.7.0)))(eslint@10.2.1(jiti@2.7.0)))(eslint@10.2.1(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.2.1(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@10.2.1(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@10.2.1(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@10.2.1(jiti@2.7.0)) @@ -9731,7 +9824,7 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.2.1(jiti@2.7.0)))(eslint@10.2.1(jiti@2.7.0)))(eslint@10.2.1(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.2.1(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -9746,7 +9839,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.2.1(jiti@2.7.0)))(eslint@10.2.1(jiti@2.7.0)))(eslint@10.2.1(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@10.2.1(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9758,7 +9851,7 @@ snapshots: eslint: 10.2.1(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.2.1(jiti@2.7.0)))(eslint@10.2.1(jiti@2.7.0)))(eslint@10.2.1(jiti@2.7.0)) - hasown: 2.0.3 + hasown: 2.0.4 is-core-module: 2.16.1 is-glob: 4.0.3 minimatch: 3.1.5 @@ -9786,7 +9879,7 @@ snapshots: damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 eslint: 10.2.1(jiti@2.7.0) - hasown: 2.0.3 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 minimatch: 3.1.5 @@ -9815,7 +9908,7 @@ snapshots: es-iterator-helpers: 1.3.2 eslint: 10.2.1(jiti@2.7.0) estraverse: 5.3.0 - hasown: 2.0.3 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 minimatch: 3.1.5 object.entries: 1.1.9 @@ -10090,10 +10183,20 @@ snapshots: flatted@3.4.2: {} + follow-redirects@1.16.1: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + format@0.2.2: {} fs.realpath@1.0.0: {} @@ -10109,7 +10212,7 @@ snapshots: call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.3 + hasown: 2.0.4 is-callable: 1.2.7 functions-have-names@1.2.3: {} @@ -10132,7 +10235,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-proto@1.0.1: @@ -10212,7 +10315,7 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.3: + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -10374,6 +10477,13 @@ snapshots: html-void-elements@3.0.0: {} + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@5.0.0: {} iconv-lite@0.6.3: @@ -10407,7 +10517,7 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.3 + hasown: 2.0.4 side-channel: 1.1.0 internmap@1.0.1: {} @@ -10452,7 +10562,7 @@ snapshots: is-core-module@2.16.1: dependencies: - hasown: 2.0.3 + hasown: 2.0.4 is-data-view@1.0.2: dependencies: @@ -10521,7 +10631,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 is-set@2.0.3: {} @@ -11279,6 +11389,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-fn@4.0.0: {} mimic-function@5.0.1: {} @@ -11803,6 +11919,8 @@ snapshots: property-information@7.1.0: {} + proxy-from-env@2.1.0: {} + pseudolocale@3.1.0: dependencies: commander: 13.1.0 @@ -12825,7 +12943,7 @@ snapshots: '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vitest@4.1.11) '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vitest@4.1.11) '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -12882,7 +13000,7 @@ snapshots: '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vitest@4.1.11) '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vitest@4.1.11) '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -12983,7 +13101,7 @@ snapshots: vitest@4.1.11(@types/node@25.6.0)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -13011,7 +13129,7 @@ snapshots: vitest@4.1.11(@types/node@25.6.0)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)) + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.3(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.8.3))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11