feat: add google oauth
This commit is contained in:
@@ -73,6 +73,7 @@ export class ApiError extends Error {
|
||||
*/
|
||||
interface RequestConfig extends RequestInit {
|
||||
skipAuth?: boolean;
|
||||
successCodes?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +83,7 @@ async function request<T>(
|
||||
endpoint: string,
|
||||
config: RequestConfig = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const { skipAuth = false, headers: customHeaders, ...restConfig } = config;
|
||||
const { skipAuth = false, successCodes = [200], headers: customHeaders, ...restConfig } = config;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -108,7 +109,7 @@ async function request<T>(
|
||||
const data: ApiResponse<T> = await response.json();
|
||||
|
||||
// 统一处理业务错误
|
||||
if (data.code !== 200) {
|
||||
if (!successCodes.includes(data.code)) {
|
||||
throw new ApiError(data.code, data.message, data.request_id);
|
||||
}
|
||||
|
||||
@@ -153,4 +154,3 @@ export const http = {
|
||||
};
|
||||
|
||||
export default http;
|
||||
|
||||
|
||||
@@ -1,36 +1,61 @@
|
||||
/**
|
||||
* 认证服务
|
||||
* 处理用户登录、注册、登出等认证相关操作
|
||||
* 处理用户登录、注册、OAuth、登出等认证相关操作
|
||||
*/
|
||||
|
||||
import { http, tokenManager, ApiError } from './api';
|
||||
import type { AuthData, LoginRequest, RegisterRequest, UserInfo } from '../types/api';
|
||||
import type {
|
||||
AuthData,
|
||||
GoogleAuthUrlData,
|
||||
GoogleOAuthCallbackRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
UserInfoData,
|
||||
UserInfo,
|
||||
} from '../types/api';
|
||||
|
||||
// 重新导出 ApiErrorMessages 以便使用
|
||||
export { ApiErrorMessages } from '../types/api';
|
||||
|
||||
/**
|
||||
* 从 JWT Token 解析用户信息
|
||||
*/
|
||||
function parseJwtPayload(token: string): UserInfo | null {
|
||||
function decodeJwtPayload(token: string): UserInfo | null {
|
||||
try {
|
||||
// 移除 Bearer 前缀
|
||||
const actualToken = token.replace('Bearer ', '');
|
||||
const base64Payload = actualToken.split('.')[1];
|
||||
const payload = JSON.parse(atob(base64Payload));
|
||||
const normalized = base64Payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||||
const payload = JSON.parse(atob(padded));
|
||||
return payload as UserInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
*/
|
||||
function normalizeUser(payload: UserInfo, emailHint?: string): UserInfo {
|
||||
return {
|
||||
...payload,
|
||||
email: payload.email || emailHint || '',
|
||||
id: String(payload.user_id),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(authData: AuthData, emailHint?: string): { user: UserInfo; token: string; expiresAt: number } {
|
||||
const { token, expires_at } = authData;
|
||||
const parsedUser = decodeJwtPayload(token);
|
||||
|
||||
if (!parsedUser) {
|
||||
throw new ApiError(-1, 'Token 解析失败');
|
||||
}
|
||||
|
||||
const user = normalizeUser(parsedUser, emailHint);
|
||||
tokenManager.setToken(token, expires_at, user.email || undefined);
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
expiresAt: expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
async login(credentials: LoginRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||
const response = await http.post<AuthData>('/user/login', credentials, { skipAuth: true });
|
||||
|
||||
@@ -38,35 +63,9 @@ export const authService = {
|
||||
throw new ApiError(-1, '登录失败,请重试');
|
||||
}
|
||||
|
||||
const { token, expires_at } = response.data;
|
||||
|
||||
// 存储 Token 和 email
|
||||
tokenManager.setToken(token, expires_at, credentials.email);
|
||||
|
||||
// 解析用户信息
|
||||
const user = parseJwtPayload(token);
|
||||
if (!user) {
|
||||
throw new ApiError(-1, 'Token 解析失败');
|
||||
}
|
||||
|
||||
// 补充 email 和 id 兼容字段
|
||||
const userWithEmail: UserInfo = {
|
||||
...user,
|
||||
email: credentials.email,
|
||||
id: String(user.user_id),
|
||||
};
|
||||
|
||||
return {
|
||||
user: userWithEmail,
|
||||
token,
|
||||
expiresAt: expires_at,
|
||||
};
|
||||
return buildSession(response.data, credentials.email);
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* 注意:业务错误码 (code !== 200) 已在 api.ts 中统一处理,会抛出 ApiError
|
||||
*/
|
||||
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });
|
||||
|
||||
@@ -74,55 +73,58 @@ export const authService = {
|
||||
throw new ApiError(-1, '注册失败,请重试');
|
||||
}
|
||||
|
||||
const { token, expires_at } = response.data;
|
||||
|
||||
// 存储 Token 和 email
|
||||
tokenManager.setToken(token, expires_at, credentials.email);
|
||||
|
||||
// 解析用户信息
|
||||
const user = parseJwtPayload(token);
|
||||
if (!user) {
|
||||
throw new ApiError(-1, 'Token 解析失败');
|
||||
}
|
||||
|
||||
// 补充 email 和 id 兼容字段
|
||||
const userWithEmail: UserInfo = {
|
||||
...user,
|
||||
email: credentials.email,
|
||||
id: String(user.user_id),
|
||||
};
|
||||
|
||||
return {
|
||||
user: userWithEmail,
|
||||
token,
|
||||
expiresAt: expires_at,
|
||||
};
|
||||
return buildSession(response.data, credentials.email);
|
||||
},
|
||||
|
||||
async getGoogleOAuthUrl(redirectUri: string, state: string): Promise<{ authUrl: string }> {
|
||||
const query = new URLSearchParams({ redirect_uri: redirectUri, state });
|
||||
const response = await http.get<GoogleAuthUrlData>(`/user/oauth/google/url?${query.toString()}`, {
|
||||
skipAuth: true,
|
||||
});
|
||||
|
||||
if (!response.data?.auth_url) {
|
||||
throw new ApiError(-1, '获取 Google 授权地址失败');
|
||||
}
|
||||
|
||||
return { authUrl: response.data.auth_url };
|
||||
},
|
||||
|
||||
async exchangeGoogleCode(
|
||||
payload: GoogleOAuthCallbackRequest
|
||||
): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||
const response = await http.post<AuthData>('/user/oauth/google/callback', payload, { skipAuth: true });
|
||||
|
||||
if (!response.data) {
|
||||
throw new ApiError(-1, 'Google 登录失败,请重试');
|
||||
}
|
||||
|
||||
return buildSession(response.data);
|
||||
},
|
||||
|
||||
async getUserInfo(): Promise<UserInfoData> {
|
||||
const response = await http.get<UserInfoData>('/user/info', {
|
||||
successCodes: [0, 200],
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
throw new ApiError(-1, '获取用户信息失败');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
logout(): void {
|
||||
tokenManager.removeToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return tokenManager.isTokenValid();
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前存储的 Token
|
||||
*/
|
||||
getToken(): string | null {
|
||||
return tokenManager.getToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* 从存储的 Token 恢复用户会话
|
||||
*/
|
||||
restoreSession(): { user: UserInfo; token: string; expiresAt: number } | null {
|
||||
const token = tokenManager.getToken();
|
||||
const expiresAt = tokenManager.getExpiresAt();
|
||||
@@ -133,21 +135,14 @@ export const authService = {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUser = parseJwtPayload(token);
|
||||
const parsedUser = decodeJwtPayload(token);
|
||||
if (!parsedUser) {
|
||||
tokenManager.removeToken();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 补充 email 和 id 兼容字段
|
||||
const user: UserInfo = {
|
||||
...parsedUser,
|
||||
email: email || '',
|
||||
id: String(parsedUser.user_id),
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
user: normalizeUser(parsedUser, email || ''),
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
@@ -156,4 +151,3 @@ export const authService = {
|
||||
|
||||
export { ApiError };
|
||||
export default authService;
|
||||
|
||||
|
||||
@@ -60,6 +60,15 @@ export const translations = {
|
||||
genericError: 'An error occurred, please try again',
|
||||
hasAccount: 'Already have an account? Login',
|
||||
noAccount: 'No account? Register',
|
||||
continueWithGoogle: 'Google',
|
||||
emailHint: 'Used only for sign-in and history sync.',
|
||||
passwordHint: 'Use at least 6 characters. Letters and numbers are recommended.',
|
||||
confirmPassword: 'Confirm Password',
|
||||
passwordMismatch: 'The two passwords do not match.',
|
||||
oauthRedirecting: 'Redirecting to Google...',
|
||||
oauthExchanging: 'Completing Google sign-in...',
|
||||
invalidOAuthState: 'Invalid OAuth state, please retry.',
|
||||
oauthFailed: 'Google sign-in failed, please retry.',
|
||||
},
|
||||
export: {
|
||||
title: 'Export',
|
||||
@@ -156,6 +165,15 @@ export const translations = {
|
||||
genericError: '发生错误,请重试',
|
||||
hasAccount: '已有账号?去登录',
|
||||
noAccount: '没有账号?去注册',
|
||||
continueWithGoogle: 'Google',
|
||||
emailHint: '仅用于登录和同步记录。',
|
||||
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
|
||||
confirmPassword: '确认密码',
|
||||
passwordMismatch: '两次输入的密码不一致。',
|
||||
oauthRedirecting: '正在跳转 Google...',
|
||||
oauthExchanging: '正在完成 Google 登录...',
|
||||
invalidOAuthState: 'OAuth 状态校验失败,请重试',
|
||||
oauthFailed: 'Google 登录失败,请重试',
|
||||
},
|
||||
export: {
|
||||
title: '导出',
|
||||
|
||||
Reference in New Issue
Block a user