mirror of
https://github.com/zuiidea/antd-admin
synced 2026-10-30 13:23:49 +00:00
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 <zuiiidea@gmail.com>
This commit is contained in:
+10
-10
@@ -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/<resource>.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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
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 };
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
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<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!params) return undefined;
|
||||
const out: Record<string, unknown> = {};
|
||||
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<boolean> | null = null;
|
||||
|
||||
async function doRefreshSessionTokens(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<T>(opts: {
|
||||
method: HttpMethod;
|
||||
url: string;
|
||||
params?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
skipAuthRefresh?: boolean;
|
||||
}): Promise<T> {
|
||||
const res = await client.request<T>({
|
||||
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<TParams>; query: z.infer<TQuery>; body: z.infer<TBody> }
|
||||
: { params: z.infer<TParams>; query: z.infer<TQuery> }
|
||||
: TBody extends z.ZodType
|
||||
? { params: z.infer<TParams>; body: z.infer<TBody> }
|
||||
: z.infer<TParams>
|
||||
: TQuery extends z.ZodType
|
||||
? TBody extends z.ZodType
|
||||
? { query: z.infer<TQuery>; body: z.infer<TBody> }
|
||||
: z.infer<TQuery>
|
||||
: TBody extends z.ZodType
|
||||
? z.infer<TBody>
|
||||
: void;
|
||||
|
||||
function renderPath(path: string, params?: Record<string, unknown>): 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<TParams, TQuery, TBody, TOut>,
|
||||
): (
|
||||
...args: CallArgs<TParams, TQuery, TBody> extends void
|
||||
? [options?: CallOptions]
|
||||
: [args: CallArgs<TParams, TQuery, TBody>, options?: CallOptions]
|
||||
) => Promise<z.infer<TOut>> {
|
||||
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<z.infer<TOut>> => {
|
||||
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<string, unknown> | undefined;
|
||||
let query: Record<string, unknown> | undefined;
|
||||
let body: unknown;
|
||||
|
||||
if (segmentCount === 0) {
|
||||
// no input segments
|
||||
} else if (segmentCount === 1) {
|
||||
if (def.params) {
|
||||
params = def.params.parse(callArgs) as Record<string, unknown>;
|
||||
} else if (def.query) {
|
||||
query = def.query.parse(callArgs) as Record<string, unknown>;
|
||||
} 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<string, unknown>;
|
||||
}
|
||||
if (def.query) {
|
||||
query = def.query.parse(shaped.query) as Record<string, unknown>;
|
||||
}
|
||||
if (def.body) {
|
||||
body = def.body.parse(shaped.body);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await transportRequest<unknown>({
|
||||
method,
|
||||
url: renderPath(def.path, params),
|
||||
params: query,
|
||||
body: def.body ? body : undefined,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
return def.output.parse(data) as z.infer<TOut>;
|
||||
};
|
||||
}
|
||||
@@ -123,6 +123,32 @@ export type PaginatedData<T> = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */
|
||||
export function PaginatedDataSchema<T extends z.ZodTypeAny>(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<typeof ListUsersQuerySchema>;
|
||||
|
||||
export const IdParamsSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export type IdParams = z.infer<typeof IdParamsSchema>;
|
||||
|
||||
|
||||
export const SearchParamsSchema = z.object({
|
||||
page: z.number().int().positive().catch(1),
|
||||
pageSize: z.number().int().positive().catch(10),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<RecordType extends object = object> = {
|
||||
/** When `lockScrollHeight`, optional fixed outer `height` (usually same as max height) */
|
||||
frameHeight?: number;
|
||||
/** Flex column wrapping frame + optional `bottomExtra` */
|
||||
layoutRef?: Ref<HTMLDivElement | null>;
|
||||
layoutRef?: Ref<HTMLDivElement>;
|
||||
/** Bordered box around the table (e.g. for `scrollHeight` vs `clientHeight` checks) */
|
||||
frameRef?: Ref<HTMLDivElement | null>;
|
||||
frameRef?: Ref<HTMLDivElement>;
|
||||
/** Optional content below the bordered table frame */
|
||||
bottomExtra?: ReactNode;
|
||||
} & TableProps<RecordType>;
|
||||
@@ -77,14 +77,15 @@ export function DataTable<RecordType extends object = object>(
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
<div
|
||||
ref={layoutRef}
|
||||
vertical
|
||||
gap={token.marginSM}
|
||||
style={{
|
||||
flex: "1 1 0%",
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: token.marginSM,
|
||||
}}
|
||||
>
|
||||
<div ref={frameRef} style={outerStyle}>
|
||||
@@ -120,6 +121,6 @@ export function DataTable<RecordType extends object = object>(
|
||||
</div>
|
||||
</div>
|
||||
{bottomExtra}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<RequestInit, "method" | "body"> & {
|
||||
params?: Record<string, string | number | null | undefined>;
|
||||
};
|
||||
|
||||
type HttpRouter = {
|
||||
navigate: (opts: { to: string }) => void | Promise<void>;
|
||||
};
|
||||
|
||||
let httpRouter: HttpRouter | null = null;
|
||||
|
||||
export function installHttpRouter(router: HttpRouter): void {
|
||||
httpRouter = router;
|
||||
}
|
||||
|
||||
let inflightRefresh: Promise<boolean> | null = null;
|
||||
|
||||
async function doRefreshSessionTokens(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<string, string> {
|
||||
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<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: RequestOptions,
|
||||
isAfterRefresh = false,
|
||||
): Promise<T> {
|
||||
const url = buildUrl(path, options?.params);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(),
|
||||
...(options?.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
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<T>(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: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options),
|
||||
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||
request<T>("POST", path, body, options),
|
||||
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||
request<T>("PUT", path, body, options),
|
||||
delete: <T>(path: string, options?: RequestOptions) =>
|
||||
request<T>("DELETE", path, undefined, options),
|
||||
};
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user