feat(web): 登录、注册功能
This commit is contained in:
@@ -2,6 +2,7 @@ import { ConfigProvider, App as AntdApp } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { router } from './router';
|
||||
import { SessionProvider } from '@/services/session';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
@@ -20,7 +21,9 @@ function App() {
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Spin } from 'antd';
|
||||
|
||||
/** 应用启动会话恢复期间的全屏占位,避免守卫闪烁跳转 */
|
||||
const SessionSplash = () => (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--bg-color)',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SessionSplash;
|
||||
@@ -5,22 +5,12 @@
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
<<<<<<< HEAD
|
||||
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||
<<<<<<< HEAD
|
||||
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
|
||||
=======
|
||||
radial-gradient(
|
||||
1000px 500px at 15% -10%,
|
||||
rgba(139, 92, 246, 0.14),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 480px at 95% 110%,
|
||||
rgba(109, 40, 217, 0.1),
|
||||
transparent 55%
|
||||
),
|
||||
>>>>>>> feat-0.2.0
|
||||
var(--bg-color);
|
||||
======= radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
|
||||
>>>>>>> feat-0.2.0 var(--bg-color);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
|
||||
@@ -96,6 +96,30 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-user {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.header-user-workspace {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.header-user-email {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.collapse-trigger,
|
||||
.mobile-menu-trigger {
|
||||
font-size: 16px;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { Breadcrumb, Drawer, Layout } from 'antd';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Drawer, Layout } from 'antd';
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||
import SidebarMenu from './SidebarMenu';
|
||||
import { getBreadcrumbItems } from './menuConfig';
|
||||
import { useSession } from '@/services/session';
|
||||
import './MainLayout.css';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
@@ -12,7 +13,10 @@ const MainLayout = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { session, logout } = useSession();
|
||||
|
||||
const breadcrumbItems = useMemo(
|
||||
() =>
|
||||
@@ -27,6 +31,16 @@ const MainLayout = () => {
|
||||
[location.pathname]
|
||||
);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
await logout();
|
||||
} finally {
|
||||
setLoggingOut(false);
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
@@ -108,6 +122,19 @@ const MainLayout = () => {
|
||||
items={breadcrumbItems}
|
||||
/>
|
||||
</div>
|
||||
{session && (
|
||||
<div className="header-right">
|
||||
<div className="header-user">
|
||||
<span className="header-user-workspace">
|
||||
{session.workspace.name}
|
||||
</span>
|
||||
<span className="header-user-email">{session.user.email}</span>
|
||||
</div>
|
||||
<Button size="small" loading={loggingOut} onClick={handleLogout}>
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
<Content className="main-content">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { App as AntdApp, Button, Form, Input } from 'antd';
|
||||
import { LockOutlined, MailOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { authToken } from '@/services/authToken';
|
||||
import { useSession } from '@/services/session';
|
||||
import { emailFormatRule } from '@/utils/validators';
|
||||
|
||||
interface LoginFormValues {
|
||||
@@ -17,7 +17,10 @@ interface LoginFormValues {
|
||||
/** 登录页,契约见 docs/api/auth.md §3 */
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { message } = AntdApp.useApp();
|
||||
const { applyAuthSession } = useSession();
|
||||
const [form] = Form.useForm<LoginFormValues>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleFinish = async (values: LoginFormValues) => {
|
||||
@@ -27,13 +30,28 @@ const LoginPage = () => {
|
||||
email: values.email.trim(),
|
||||
password: values.password,
|
||||
});
|
||||
authToken.set(session.access_token);
|
||||
applyAuthSession(session);
|
||||
message.success('登录成功');
|
||||
navigate('/online-products', { replace: true });
|
||||
// 守卫记住的来源路径优先,默认进入在线商品首页
|
||||
const from = (location.state as { from?: string } | null)?.from;
|
||||
navigate(from && from.startsWith('/') ? from : '/online-products', {
|
||||
replace: true,
|
||||
});
|
||||
} catch (error) {
|
||||
// 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
message.error('邮箱或密码不正确');
|
||||
return;
|
||||
}
|
||||
// 422 VALIDATION_FAILED 等带字段明细的错误,逐项落到对应表单项
|
||||
const details = apiErrorDetails(error);
|
||||
if (details.length > 0) {
|
||||
form.setFields(
|
||||
details.map((item) => ({
|
||||
name: item.field as keyof LoginFormValues,
|
||||
errors: [item.message],
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
message.error(apiErrorMessage(error));
|
||||
}
|
||||
@@ -52,7 +70,12 @@ const LoginPage = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form<LoginFormValues> layout="vertical" requiredMark={false} onFinish={handleFinish}>
|
||||
<Form<LoginFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={handleFinish}
|
||||
>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
|
||||
@@ -4,9 +4,9 @@ import { App as AntdApp, Button, Form, Input } from 'antd';
|
||||
import { LockOutlined, MailOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { authToken } from '@/services/authToken';
|
||||
import { useSession } from '@/services/session';
|
||||
import { emailFormatRule } from '@/utils/validators';
|
||||
|
||||
interface RegisterFormValues {
|
||||
@@ -20,6 +20,7 @@ interface RegisterFormValues {
|
||||
const RegisterPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { message } = AntdApp.useApp();
|
||||
const { applyAuthSession } = useSession();
|
||||
const [form] = Form.useForm<RegisterFormValues>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -33,13 +34,24 @@ const RegisterPage = () => {
|
||||
display_name: values.display_name?.trim() || undefined,
|
||||
});
|
||||
// 注册成功即在同一事务创建用户、个人工作空间和登录会话
|
||||
authToken.set(session.access_token);
|
||||
applyAuthSession(session);
|
||||
message.success('注册成功,已进入你的工作空间');
|
||||
navigate('/online-products', { replace: true });
|
||||
} catch (error) {
|
||||
// 重复邮箱返回 409 EMAIL_ALREADY_REGISTERED,落到邮箱字段上提示
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
form.setFields([{ name: 'email', errors: ['该邮箱已注册,请直接登录'] }]);
|
||||
return;
|
||||
}
|
||||
// 422 VALIDATION_FAILED 等带字段明细的错误,逐项落到对应表单项
|
||||
const details = apiErrorDetails(error);
|
||||
if (details.length > 0) {
|
||||
form.setFields(
|
||||
details.map((item) => ({
|
||||
name: item.field as keyof RegisterFormValues,
|
||||
errors: [item.message],
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
message.error(apiErrorMessage(error));
|
||||
}
|
||||
@@ -91,7 +103,7 @@ const RegisterPage = () => {
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
extra="长度与复杂度规则以服务端安全校验为准"
|
||||
extra="至少 12 个字符;长度与复杂度以服务端安全校验为准"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, useLocation, createBrowserRouter } from 'react-router-dom';
|
||||
import MainLayout from '@/layouts/MainLayout';
|
||||
import SessionSplash from '@/components/SessionSplash';
|
||||
import CollectionPage from '@/pages/collection/CollectionPage';
|
||||
import LoginPage from '@/pages/auth/LoginPage';
|
||||
import OnlineProductsPage from '@/pages/online-products/OnlineProductsPage';
|
||||
@@ -8,11 +10,40 @@ import ProductEditPage from '@/pages/products/ProductEditPage';
|
||||
import RegisterPage from '@/pages/auth/RegisterPage';
|
||||
import ShopsPage from '@/pages/shops/ShopsPage';
|
||||
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
||||
import { useSession } from '@/services/session';
|
||||
|
||||
/** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */
|
||||
function RequireSession({ children }: { children: ReactNode }) {
|
||||
const { status } = useSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (status === 'restoring') {
|
||||
return <SessionSplash />;
|
||||
}
|
||||
if (status === 'anonymous') {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/** 认证页互斥:已登录访问 /login、/register 时回到主布局 */
|
||||
function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
|
||||
const { status } = useSession();
|
||||
|
||||
if (status === 'authenticated') {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <MainLayout />,
|
||||
element: (
|
||||
<RequireSession>
|
||||
<MainLayout />
|
||||
</RequireSession>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/online-products" replace /> },
|
||||
{ path: 'online-products', element: <OnlineProductsPage /> },
|
||||
@@ -24,8 +55,21 @@ export const router = createBrowserRouter([
|
||||
{ path: 'products/:id', element: <ProductEditPage /> },
|
||||
],
|
||||
},
|
||||
// 认证页在主布局之外;API 侧会话体系实现后再加路由守卫
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'register', element: <RegisterPage /> },
|
||||
{
|
||||
path: 'login',
|
||||
element: (
|
||||
<RedirectIfAuthenticated>
|
||||
<LoginPage />
|
||||
</RedirectIfAuthenticated>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
element: (
|
||||
<RedirectIfAuthenticated>
|
||||
<RegisterPage />
|
||||
</RedirectIfAuthenticated>
|
||||
),
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
|
||||
import type {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { envConfig } from '@/config/env';
|
||||
import { authToken } from '@/services/authToken';
|
||||
|
||||
@@ -34,10 +39,11 @@ let refreshTask: Promise<string | null> | null = null;
|
||||
/** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */
|
||||
function refreshAccessToken(): Promise<string | null> {
|
||||
refreshTask ??= apiClient
|
||||
.post<{ access_token: string }>('/auth/refresh')
|
||||
.post('/auth/refresh')
|
||||
.then((res) => {
|
||||
authToken.set(res.data.access_token);
|
||||
return res.data.access_token;
|
||||
const token = unwrapEnvelope<{ access_token: string }>(res).access_token;
|
||||
authToken.set(token);
|
||||
return token;
|
||||
})
|
||||
.catch(() => {
|
||||
authToken.clear();
|
||||
@@ -72,18 +78,31 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 统一解包强制响应信封:成功为 { data: T },204 No Content 无响应体
|
||||
* (docs/api/conventions.md §3.1)。服务层泛型 T 指业务负载本身;
|
||||
* 未来列表接口({ data, meta })在此扩展专门的解包方法以保留 meta。
|
||||
*/
|
||||
function unwrapEnvelope<T>(response: AxiosResponse): T {
|
||||
const body = response.data as { data?: unknown } | undefined;
|
||||
if (body === null || body === undefined) {
|
||||
return undefined as T;
|
||||
}
|
||||
return body.data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.get<T>(url, config).then((res) => res.data),
|
||||
apiClient.get<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
|
||||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.post<T>(url, data, config).then((res) => res.data),
|
||||
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
|
||||
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.patch<T>(url, data, config).then((res) => res.data),
|
||||
apiClient.patch<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
|
||||
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.delete<T>(url, config).then((res) => res.data),
|
||||
apiClient.delete<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
};
|
||||
|
||||
/** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */
|
||||
@@ -99,4 +118,25 @@ export function apiErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export interface ApiErrorDetail {
|
||||
field: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 提取错误信封中的字段级明细 details: [{ field, code, message }](docs/api/conventions.md §4) */
|
||||
export function apiErrorDetails(error: unknown): ApiErrorDetail[] {
|
||||
if (!axios.isAxiosError(error)) return [];
|
||||
const details = (error.response?.data as { error?: { details?: unknown } } | undefined)?.error
|
||||
?.details;
|
||||
if (!Array.isArray(details)) return [];
|
||||
return details.filter(
|
||||
(item): item is ApiErrorDetail =>
|
||||
!!item &&
|
||||
typeof item === 'object' &&
|
||||
typeof (item as ApiErrorDetail).field === 'string' &&
|
||||
typeof (item as ApiErrorDetail).message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export default apiClient;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { AuthSessionResponse, LoginRequest, RegisterRequest } from '@atlas/contracts';
|
||||
import { api } from '@/services/api';
|
||||
import type { AuthSession, LoginPayload, MeResult, RegisterPayload } from '@/types/auth';
|
||||
import type { MeResult, RefreshResult } from '@/types/auth';
|
||||
|
||||
/** 账户与会话接口,契约见 docs/api/auth.md */
|
||||
/** 账户与会话接口,契约见 docs/api/auth.md;类型来自 @atlas/contracts(me/refresh 暂为本地临时类型) */
|
||||
export const authApi = {
|
||||
register: (payload: RegisterPayload) => api.post<AuthSession>('/auth/register', payload),
|
||||
register: (payload: RegisterRequest) =>
|
||||
api.post<AuthSessionResponse>('/auth/register', payload),
|
||||
|
||||
login: (payload: LoginPayload) => api.post<AuthSession>('/auth/login', payload),
|
||||
login: (payload: LoginRequest) => api.post<AuthSessionResponse>('/auth/login', payload),
|
||||
|
||||
refresh: () => api.post<RefreshResult>('/auth/refresh'),
|
||||
|
||||
logout: () => api.post<void>('/auth/logout'),
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import axios from 'axios';
|
||||
import type { AuthSessionResponse } from '@atlas/contracts';
|
||||
import { authApi } from '@/services/auth';
|
||||
import { authToken } from '@/services/authToken';
|
||||
import type { MeResult } from '@/types/auth';
|
||||
|
||||
export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous';
|
||||
|
||||
export interface SessionState {
|
||||
user: MeResult['user'];
|
||||
workspace: MeResult['workspace'];
|
||||
}
|
||||
|
||||
interface SessionContextValue {
|
||||
status: SessionStatus;
|
||||
session: SessionState | null;
|
||||
/** 登录 / 注册成功后写入会话 */
|
||||
applyAuthSession: (payload: AuthSessionResponse) => void;
|
||||
/** 撤销服务端会话并清除本地状态;网络失败也保证本地登出 */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
|
||||
// 模块级去重:StrictMode 双挂载或并发恢复时只发一次 refresh(refresh 会轮换会话,重复调用会互相失效)
|
||||
let restoreTask: Promise<void> | null = null;
|
||||
|
||||
/** 会话上下文:应用启动时经 HttpOnly refresh cookie 恢复会话,登录/注册/登出统一在此维护状态 */
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<SessionStatus>('restoring');
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
restoreTask ??= (async () => {
|
||||
try {
|
||||
const refreshed = await authApi.refresh();
|
||||
authToken.set(refreshed.access_token);
|
||||
const me = await authApi.me();
|
||||
if (!mountedRef.current) return;
|
||||
setSession({ user: me.user, workspace: me.workspace });
|
||||
setStatus('authenticated');
|
||||
} catch {
|
||||
if (!mountedRef.current) return;
|
||||
authToken.clear();
|
||||
setStatus('anonymous');
|
||||
} finally {
|
||||
restoreTask = null;
|
||||
}
|
||||
})();
|
||||
void restoreTask;
|
||||
}, []);
|
||||
|
||||
const applyAuthSession = useCallback((payload: AuthSessionResponse) => {
|
||||
authToken.set(payload.access_token);
|
||||
setSession({ user: payload.user, workspace: payload.workspace });
|
||||
setStatus('authenticated');
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
} catch (error) {
|
||||
// access token 已过期时先刷新一次再重试,确保服务端会话被撤销;
|
||||
// 仍失败则仅做本地登出,等待服务端会话自然过期
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
const refreshed = await authApi.refresh().catch(() => null);
|
||||
if (refreshed) {
|
||||
authToken.set(refreshed.access_token);
|
||||
await authApi.logout().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
authToken.clear();
|
||||
setSession(null);
|
||||
setStatus('anonymous');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ status, session, applyAuthSession, logout }),
|
||||
[status, session, applyAuthSession, logout]
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSession(): SessionContextValue {
|
||||
const context = useContext(SessionContext);
|
||||
if (!context) {
|
||||
throw new Error('useSession 必须在 SessionProvider 内使用');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,40 +1,14 @@
|
||||
/**
|
||||
* 认证相关类型,形状对齐 docs/api/auth.md。
|
||||
* 后续 @atlas/contracts 补充认证 schema 后可迁移为 zod 推导类型。
|
||||
* 临时类型:@atlas/contracts 目前只提供认证请求与会话 schema,
|
||||
* refresh / me 的响应 schema 尚未收录(契约缺口已按协作约定第 3 节提报后端 AI)。
|
||||
* 形状对齐 docs/api/auth.md §4/§5;contracts 补齐后删除本文件并改用共享类型。
|
||||
*/
|
||||
import type { AuthUser, Workspace } from '@atlas/contracts';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
email_verified_at: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface AuthWorkspace {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/** 注册 / 登录成功返回的会话(access token 短期有效,refresh token 在 HttpOnly cookie 中) */
|
||||
export interface AuthSession {
|
||||
/** POST /api/auth/refresh 成功响应(信封 { data } 已在 api 层解包) */
|
||||
export interface RefreshResult {
|
||||
access_token: string;
|
||||
access_token_expires_in: number;
|
||||
user: AuthUser;
|
||||
workspace: AuthWorkspace;
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PlatformSummary {
|
||||
@@ -43,10 +17,10 @@ export interface PlatformSummary {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** GET /api/auth/me 的返回;default_store 摘要字段以后续门店接口文档为准 */
|
||||
/** GET /api/auth/me 成功响应;default_store 摘要字段以后续门店接口文档为准 */
|
||||
export interface MeResult {
|
||||
user: AuthUser;
|
||||
workspace: AuthWorkspace;
|
||||
workspace: Workspace;
|
||||
platforms: PlatformSummary[];
|
||||
suggested_context: {
|
||||
platform_id: string;
|
||||
|
||||
Reference in New Issue
Block a user