feat: add google oauth

This commit is contained in:
liuyuanchuang
2026-03-06 14:30:30 +08:00
parent bc4b547e03
commit f70a9a85c8
24 changed files with 3593 additions and 334 deletions

View File

@@ -66,57 +66,57 @@ deploy_to_server() {
print_info "${server} 上执行部署操作..."
print_info "部署路径: ${DEPLOY_PATH}/${DEPLOY_NAME}"
# 注意:密码通过环境变量传递,避免在命令行中暴露
ssh_output=$(SSH_SUDO_PASSWORD="${SUDO_PASSWORD}" ssh ${server} bash << SSH_EOF
ssh_output=$(ssh "${server}" "SSH_SUDO_PASSWORD='${SUDO_PASSWORD}' SSH_DEPLOY_PATH='${DEPLOY_PATH}' SSH_DEPLOY_NAME='${DEPLOY_NAME}' bash -s" << 'SSH_EOF'
set -e
DEPLOY_PATH="${DEPLOY_PATH}"
DEPLOY_NAME="${DEPLOY_NAME}"
SUDO_PASSWORD="\${SSH_SUDO_PASSWORD}"
DEPLOY_PATH="${SSH_DEPLOY_PATH}"
DEPLOY_NAME="${SSH_DEPLOY_NAME}"
SUDO_PASSWORD="${SSH_SUDO_PASSWORD}"
# 检查部署目录是否存在
if [ ! -d "\${DEPLOY_PATH}" ]; then
echo "错误:部署目录 \${DEPLOY_PATH} 不存在,请检查路径是否正确"
if [ ! -d "${DEPLOY_PATH}" ]; then
echo "错误:部署目录 ${DEPLOY_PATH} 不存在,请检查路径是否正确"
exit 1
fi
# 检查是否有权限写入(尝试创建测试文件)
if ! touch "\${DEPLOY_PATH}/.deploy_test" 2>/dev/null; then
if ! touch "${DEPLOY_PATH}/.deploy_test" 2>/dev/null; then
echo "提示:没有直接写入权限,将使用 sudo 执行操作"
USE_SUDO=1
else
rm -f "\${DEPLOY_PATH}/.deploy_test"
rm -f "${DEPLOY_PATH}/.deploy_test"
USE_SUDO=0
fi
# 备份旧版本(如果存在)
if [ -d "\${DEPLOY_PATH}/\${DEPLOY_NAME}" ]; then
if [ -d "${DEPLOY_PATH}/${DEPLOY_NAME}" ]; then
echo "备份旧版本..."
if [ "\$USE_SUDO" = "1" ]; then
echo "\${SUDO_PASSWORD}" | sudo -S rm -rf "\${DEPLOY_PATH}/\${DEPLOY_NAME}_bak" 2>/dev/null || true
echo "\${SUDO_PASSWORD}" | sudo -S mv "\${DEPLOY_PATH}/\${DEPLOY_NAME}" "\${DEPLOY_PATH}/\${DEPLOY_NAME}_bak" || { echo "错误:备份失败,权限不足"; exit 1; }
if [ "$USE_SUDO" = "1" ]; then
echo "${SUDO_PASSWORD}" | sudo -S rm -rf "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" 2>/dev/null || true
echo "${SUDO_PASSWORD}" | sudo -S mv "${DEPLOY_PATH}/${DEPLOY_NAME}" "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" || { echo "错误:备份失败,权限不足"; exit 1; }
else
rm -rf "\${DEPLOY_PATH}/\${DEPLOY_NAME}_bak" 2>/dev/null || true
mv "\${DEPLOY_PATH}/\${DEPLOY_NAME}" "\${DEPLOY_PATH}/\${DEPLOY_NAME}_bak" || { echo "错误:备份失败"; exit 1; }
rm -rf "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" 2>/dev/null || true
mv "${DEPLOY_PATH}/${DEPLOY_NAME}" "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" || { echo "错误:备份失败"; exit 1; }
fi
fi
# 移动新版本到部署目录(覆盖现有目录)
if [ -d ~/\${DEPLOY_NAME} ]; then
if [ -d ~/${DEPLOY_NAME} ]; then
echo "移动新版本到部署目录..."
if [ "\$USE_SUDO" = "1" ]; then
echo "\${SUDO_PASSWORD}" | sudo -S mv ~/\${DEPLOY_NAME} "\${DEPLOY_PATH}/" || { echo "错误:移动文件失败,权限不足"; exit 1; }
if [ "$USE_SUDO" = "1" ]; then
echo "${SUDO_PASSWORD}" | sudo -S mv ~/${DEPLOY_NAME} "${DEPLOY_PATH}/" || { echo "错误:移动文件失败,权限不足"; exit 1; }
else
mv ~/\${DEPLOY_NAME} "\${DEPLOY_PATH}/" || { echo "错误:移动文件失败"; exit 1; }
mv ~/${DEPLOY_NAME} "${DEPLOY_PATH}/" || { echo "错误:移动文件失败"; exit 1; }
fi
echo "部署完成!"
else
echo "错误:找不到 ~/\${DEPLOY_NAME} 目录"
echo "错误:找不到 ~/${DEPLOY_NAME} 目录"
exit 1
fi
# 重新加载 nginx如果配置了
if command -v nginx &> /dev/null; then
echo "重新加载 nginx..."
echo "\${SUDO_PASSWORD}" | sudo -S nginx -t && echo "\${SUDO_PASSWORD}" | sudo -S nginx -s reload || echo "警告nginx 重新加载失败,请手动检查"
echo "${SUDO_PASSWORD}" | sudo -S nginx -t && echo "${SUDO_PASSWORD}" | sudo -S nginx -s reload || echo "警告nginx 重新加载失败,请手动检查"
fi
SSH_EOF
)

57
e2e/auth-email.spec.ts Normal file
View File

@@ -0,0 +1,57 @@
import { expect, test } from '@playwright/test';
const jwtPayload = Buffer.from(
JSON.stringify({ user_id: 7, email: 'user@example.com', exp: 1999999999, iat: 1111111 })
)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
const token = `header.${jwtPayload}.sig`;
test('email login should authenticate and display user email', async ({ page }) => {
await page.route('**/user/login', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
request_id: 'req_login',
code: 200,
message: 'ok',
data: {
token,
expires_at: 1999999999,
},
}),
});
});
await page.route('**/task/list**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
request_id: 'req_tasks',
code: 200,
message: 'ok',
data: {
task_list: [],
total: 0,
},
}),
});
});
await page.goto('/');
const loginButton = page.getByRole('button', { name: /Login|登录/ }).first();
await loginButton.click();
await page.fill('#auth-email', 'user@example.com');
await page.fill('#auth-password', '123456');
await page.locator('button[type="submit"]').click();
await expect(page.getByText('user@example.com')).toBeVisible();
});

80
e2e/auth-oauth.spec.ts Normal file
View File

@@ -0,0 +1,80 @@
import { expect, test } from '@playwright/test';
const jwtPayload = Buffer.from(
JSON.stringify({ user_id: 9, email: 'oauth@example.com', exp: 1999999999, iat: 1111111 })
)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
const token = `header.${jwtPayload}.sig`;
test('google oauth callback with valid state should complete login', async ({ page }) => {
await page.route('**/user/oauth/google/url**', async (route, request) => {
const url = new URL(request.url());
const state = url.searchParams.get('state') ?? '';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
request_id: 'req_oauth_url',
code: 200,
message: 'ok',
data: {
auth_url: `http://127.0.0.1:4173/auth/google/callback?code=oauth_code&state=${state}`,
},
}),
});
});
await page.route('**/user/oauth/google/callback', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
request_id: 'req_oauth_callback',
code: 200,
message: 'ok',
data: {
token,
expires_at: 1999999999,
},
}),
});
});
await page.route('**/task/list**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
request_id: 'req_tasks',
code: 200,
message: 'ok',
data: {
task_list: [],
total: 0,
},
}),
});
});
await page.goto('/');
await page.getByRole('button', { name: /Login|登录/ }).first().click();
await page.getByRole('button', { name: /Google/ }).click();
await expect(page.getByText('oauth@example.com')).toBeVisible();
});
test('google oauth callback with invalid state should show error', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => {
sessionStorage.setItem('texpixel_oauth_state', 'expected_state');
});
await page.goto('/auth/google/callback?code=fake_code&state=wrong_state');
await expect(page.getByText('OAuth state 校验失败')).toBeVisible();
});

2294
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,9 @@
"build:dev": "VITE_ENV=development vite build",
"build:prod": "VITE_ENV=production vite build",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.app.json"
},
@@ -25,6 +28,7 @@
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-math": "^6.0.0",
@@ -33,7 +37,11 @@
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@playwright/test": "^1.58.2",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
@@ -42,10 +50,12 @@
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"jsdom": "^28.1.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
"vite": "^5.4.2",
"vitest": "^4.0.18"
}
}

24
playwright.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: 0,
reporter: 'list',
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: true,
timeout: 120000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@@ -10,8 +10,11 @@ import FilePreview from './components/FilePreview';
import ResultPanel from './components/ResultPanel';
import UploadModal from './components/UploadModal';
import UserGuide from './components/UserGuide';
import AuthModal from './components/AuthModal';
const PAGE_SIZE = 6;
const GUEST_USAGE_LIMIT = 3;
const GUEST_USAGE_COUNT_KEY = 'texpixel_guest_usage_count';
function App() {
const { user, initializing } = useAuth();
@@ -21,7 +24,13 @@ function App() {
const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showUserGuide, setShowUserGuide] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const [loading, setLoading] = useState(false);
const [guestUsageCount, setGuestUsageCount] = useState<number>(() => {
const storedCount = localStorage.getItem(GUEST_USAGE_COUNT_KEY);
const parsedCount = storedCount ? Number.parseInt(storedCount, 10) : 0;
return Number.isFinite(parsedCount) ? parsedCount : 0;
});
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
@@ -44,6 +53,19 @@ function App() {
const hasLoadedFiles = useRef(false);
const selectedFile = files.find((f) => f.id === selectedFileId) || null;
const canUploadAnonymously = !user && guestUsageCount < GUEST_USAGE_LIMIT;
const openAuthModal = useCallback(() => {
setShowAuthModal(true);
}, []);
const incrementGuestUsage = useCallback(() => {
setGuestUsageCount((prev) => {
const nextCount = prev + 1;
localStorage.setItem(GUEST_USAGE_COUNT_KEY, String(nextCount));
return nextCount;
});
}, []);
useEffect(() => {
const handleStartGuide = () => setShowUserGuide(true);
@@ -97,6 +119,10 @@ function App() {
const handlePaste = (e: ClipboardEvent) => {
// If modal is open, let the modal handle paste events to avoid double upload
if (showUploadModal) return;
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
openAuthModal();
return;
}
const items = e.clipboardData?.items;
if (!items) return;
@@ -116,7 +142,7 @@ function App() {
document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [user, showUploadModal]);
}, [guestUsageCount, openAuthModal, showUploadModal, user]);
const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => {
mouseDownEvent.preventDefault();
@@ -391,8 +417,15 @@ function App() {
};
const handleUpload = async (uploadFiles: File[]) => {
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
openAuthModal();
return;
}
setLoading(true);
try {
let successfulUploads = 0;
for (const file of uploadFiles) {
// 1. Upload file to OSS (or check duplicate)
const fileHash = await uploadService.calculateMD5(file);
@@ -430,6 +463,12 @@ function App() {
if (taskData.task_no) {
startPolling(taskData.task_no, fileId);
}
successfulUploads += 1;
}
if (!user && successfulUploads > 0) {
incrementGuestUsage();
}
} catch (error) {
console.error('Error uploading files:', error);
@@ -464,7 +503,15 @@ function App() {
files={files}
selectedFileId={selectedFileId}
onFileSelect={setSelectedFileId}
onUploadClick={() => setShowUploadModal(true)}
onUploadClick={() => {
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
openAuthModal();
return;
}
setShowUploadModal(true);
}}
canUploadAnonymously={canUploadAnonymously}
onRequireAuth={openAuthModal}
isCollapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
onUploadFiles={handleUpload}
@@ -502,6 +549,12 @@ function App() {
/>
)}
{showAuthModal && (
<AuthModal
onClose={() => setShowAuthModal(false)}
/>
)}
<UserGuide
isOpen={showUserGuide}
onClose={() => setShowUserGuide(false)}

View File

@@ -1,41 +1,55 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { X } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
interface AuthModalProps {
onClose: () => void;
mandatory?: boolean;
}
export default function AuthModal({ onClose }: AuthModalProps) {
const { signIn, signUp } = useAuth();
export default function AuthModal({ onClose, mandatory = false }: AuthModalProps) {
const { signIn, signUp, beginGoogleOAuth, authPhase, authError } = useAuth();
const { t } = useLanguage();
const [isSignUp, setIsSignUp] = useState(false);
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [localError, setLocalError] = useState('');
const isBusy = useMemo(
() => ['email_signing_in', 'email_signing_up', 'oauth_redirecting', 'oauth_exchanging'].includes(authPhase),
[authPhase]
);
const submitText = mode === 'signup' ? t.auth.signUp : t.auth.signIn;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
setLocalError('');
try {
const { error } = isSignUp
? await signUp(email, password)
: await signIn(email, password);
if (error) {
setError(error.message);
} else {
onClose();
if (mode === 'signup') {
if (password.length < 6) {
setLocalError(t.auth.passwordHint);
return;
}
if (password !== confirmPassword) {
setLocalError(t.auth.passwordMismatch);
return;
}
} catch (err) {
setError('发生错误,请重试');
} finally {
setLoading(false);
}
const result = mode === 'signup' ? await signUp(email, password) : await signIn(email, password);
if (!result.error) {
onClose();
}
};
const handleGoogleOAuth = async () => {
await beginGoogleOAuth();
};
return (
@@ -43,36 +57,72 @@ export default function AuthModal({ onClose }: AuthModalProps) {
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">
{isSignUp ? t.auth.signUpTitle : t.auth.signInTitle}
{mode === 'signup' ? t.auth.signUpTitle : t.auth.signInTitle}
</h2>
{!mandatory && (
<button
type="button"
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
aria-label="close"
disabled={isBusy}
>
<X size={20} />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 mb-4">
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
type="button"
onClick={() => setMode('signin')}
aria-pressed={mode === 'signin'}
disabled={isBusy}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
mode === 'signin' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
>
<X size={20} />
{t.auth.signIn}
</button>
<button
type="button"
onClick={() => setMode('signup')}
aria-pressed={mode === 'signup'}
disabled={isBusy}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
mode === 'signup' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
>
{t.auth.signUp}
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="auth-email" className="block text-sm font-medium text-gray-700 mb-1">
{t.auth.email}
</label>
<input
id="auth-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="your@email.com"
required
disabled={isBusy}
/>
{mode === 'signup' && (
<p className="mt-1 text-xs text-gray-500">{t.auth.emailHint}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="auth-password" className="block text-sm font-medium text-gray-700 mb-1">
{t.auth.password}
</label>
<input
id="auth-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -80,32 +130,72 @@ export default function AuthModal({ onClose }: AuthModalProps) {
placeholder="••••••••"
required
minLength={6}
disabled={isBusy}
/>
{mode === 'signup' && (
<p className="mt-1 text-xs text-gray-500">{t.auth.passwordHint}</p>
)}
</div>
{error && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg text-sm font-medium animate-pulse">
{t.auth.error}: {error}
{mode === 'signup' && (
<div>
<label htmlFor="auth-password-confirm" className="block text-sm font-medium text-gray-700 mb-1">
{t.auth.confirmPassword}
</label>
<input
id="auth-password-confirm"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="••••••••"
required
minLength={6}
disabled={isBusy}
/>
</div>
)}
{(localError || authError) && (
<div className="p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm font-medium">
{t.auth.error}: {localError || authError}
</div>
)}
<button
type="submit"
disabled={loading}
disabled={isBusy}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-80 disabled:cursor-wait"
>
{isSignUp ? t.auth.signUp : t.auth.signIn}
{submitText}
</button>
<div className="relative py-1">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-white px-2 text-gray-400">OR</span>
</div>
</div>
<button
type="button"
onClick={handleGoogleOAuth}
disabled={isBusy}
className="w-full py-3 px-4 border border-gray-300 text-gray-800 rounded-lg hover:bg-gray-50 transition-colors font-medium disabled:opacity-80 disabled:cursor-wait inline-flex items-center justify-center gap-2"
>
<img
src="https://upload.wikimedia.org/wikipedia/commons/7/7e/Gmail_icon_%282020%29.svg"
alt=""
aria-hidden="true"
className="w-[18px] h-[18px]"
loading="lazy"
decoding="async"
/>
{authPhase === 'oauth_redirecting' ? t.auth.oauthRedirecting : t.auth.continueWithGoogle}
</button>
</form>
<div className="mt-4 text-center">
<button
onClick={() => setIsSignUp(!isSignUp)}
className="text-sm text-blue-600 hover:text-blue-700"
>
{isSignUp ? t.auth.hasAccount : t.auth.noAccount}
</button>
</div>
</div>
</div>
);

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, Settings, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { FileRecord } from '../types';
@@ -10,6 +10,8 @@ interface LeftSidebarProps {
selectedFileId: string | null;
onFileSelect: (fileId: string) => void;
onUploadClick: () => void;
canUploadAnonymously: boolean;
onRequireAuth: () => void;
isCollapsed: boolean;
onToggleCollapse: () => void;
onUploadFiles: (files: File[]) => void;
@@ -23,6 +25,8 @@ export default function LeftSidebar({
selectedFileId,
onFileSelect,
onUploadClick,
canUploadAnonymously,
onRequireAuth,
isCollapsed,
onToggleCollapse,
onUploadFiles,
@@ -30,12 +34,19 @@ export default function LeftSidebar({
loadingMore,
onLoadMore,
}: LeftSidebarProps) {
const { user, signOut } = useAuth();
const { user, signOut, isAuthenticated } = useAuth();
const { t } = useLanguage();
const [showAuthModal, setShowAuthModal] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const displayName = user?.username?.trim() || user?.email || '';
useEffect(() => {
if (isAuthenticated) {
setShowAuthModal(false);
}
}, [isAuthenticated]);
// ... (rest of the logic remains the same)
// Handle scroll to load more
@@ -84,6 +95,10 @@ export default function LeftSidebar({
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
if (!user && !canUploadAnonymously) {
onRequireAuth();
return;
}
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
@@ -97,6 +112,10 @@ export default function LeftSidebar({
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!user && !canUploadAnonymously) {
onRequireAuth();
return;
}
if (e.target.files && e.target.files.length > 0) {
onUploadFiles(Array.from(e.target.files));
}
@@ -117,7 +136,13 @@ export default function LeftSidebar({
</button>
<button
onClick={onUploadClick}
onClick={() => {
if (!user && !canUploadAnonymously) {
onRequireAuth();
return;
}
onUploadClick();
}}
className="p-3 rounded-xl bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20 transition-all mb-6"
title={t.common.upload}
>
@@ -162,9 +187,15 @@ export default function LeftSidebar({
<div className="mb-2" id="sidebar-upload-area">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => {
if (!user && !canUploadAnonymously) {
onRequireAuth();
return;
}
fileInputRef.current?.click();
}}
className={`
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200 group
${isDragging
@@ -284,7 +315,7 @@ export default function LeftSidebar({
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{user.email}</p>
<p className="text-sm font-medium text-gray-900 truncate">{displayName}</p>
</div>
<button
onClick={() => signOut()}

View File

@@ -91,11 +91,9 @@ export default function UserGuide({ isOpen, onClose }: { isOpen: boolean; onClos
}
};
return (
<div className="fixed inset-0 z-[100] pointer-events-none">
{/* Backdrop with hole */}
<div className="absolute inset-0 bg-black/60 pointer-events-auto" onClick={onClose} style={{
clipPath: highlightStyle.top !== undefined ? `polygon(
const backdropClipPath =
highlightStyle.top !== undefined
? `polygon(
0% 0%, 0% 100%,
${highlightStyle.left}px 100%,
${highlightStyle.left}px ${highlightStyle.top}px,
@@ -104,8 +102,17 @@ export default function UserGuide({ isOpen, onClose }: { isOpen: boolean; onClos
${highlightStyle.left}px ${(highlightStyle.top as number) + (highlightStyle.height as number)}px,
${highlightStyle.left}px 100%,
100% 100%, 100% 0%
)` : 'none'
}} />
)`
: 'none';
return (
<div className="fixed inset-0 z-[100] pointer-events-none">
{/* Backdrop with hole */}
<div
className="absolute inset-0 bg-black/60 pointer-events-auto"
onClick={onClose}
style={{ clipPath: backdropClipPath }}
/>
{/* Highlight border */}
<div

View File

@@ -0,0 +1,112 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import App from '../../App';
const { useAuthMock } = vi.hoisted(() => ({
useAuthMock: vi.fn(),
}));
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => useAuthMock(),
}));
vi.mock('../../contexts/LanguageContext', () => ({
useLanguage: () => ({
t: {
common: { loading: '加载中', processing: '处理中' },
alerts: {
taskTimeout: '超时',
networkError: '网络错误',
uploadFailed: '上传失败',
},
},
}),
}));
vi.mock('../../lib/uploadService', () => ({
uploadService: {
getTaskList: vi.fn().mockResolvedValue({ task_list: [], total: 0 }),
getTaskResult: vi.fn(),
calculateMD5: vi.fn(),
uploadFile: vi.fn(),
createRecognitionTask: vi.fn(),
},
}));
vi.mock('../../components/Navbar', () => ({
default: () => <div>navbar</div>,
}));
vi.mock('../../components/LeftSidebar', () => ({
default: ({
onUploadClick,
onRequireAuth,
canUploadAnonymously,
}: {
onUploadClick: () => void;
onRequireAuth: () => void;
canUploadAnonymously: boolean;
}) => (
<div>
<button onClick={onUploadClick}>open-upload</button>
<button onClick={onRequireAuth}>open-auth</button>
<span>{canUploadAnonymously ? 'guest-allowed' : 'guest-blocked'}</span>
</div>
),
}));
vi.mock('../../components/FilePreview', () => ({
default: () => <div>preview</div>,
}));
vi.mock('../../components/ResultPanel', () => ({
default: () => <div>result</div>,
}));
vi.mock('../../components/UploadModal', () => ({
default: () => <div>upload-modal</div>,
}));
vi.mock('../../components/UserGuide', () => ({
default: () => null,
}));
vi.mock('../../components/AuthModal', () => ({
default: () => <div>auth-modal</div>,
}));
describe('App anonymous usage limit', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
localStorage.setItem('hasSeenGuide', 'true');
useAuthMock.mockReturnValue({
user: null,
initializing: false,
});
});
it('allows anonymous upload before the limit', () => {
localStorage.setItem('texpixel_guest_usage_count', '2');
render(<App />);
expect(screen.getByText('guest-allowed')).toBeInTheDocument();
fireEvent.click(screen.getByText('open-upload'));
expect(screen.getByText('upload-modal')).toBeInTheDocument();
});
it('forces login after three anonymous uses', () => {
localStorage.setItem('texpixel_guest_usage_count', '3');
render(<App />);
expect(screen.getByText('guest-blocked')).toBeInTheDocument();
fireEvent.click(screen.getByText('open-upload'));
expect(screen.getByText('auth-modal')).toBeInTheDocument();
expect(screen.queryByText('upload-modal')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,103 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import AuthModal from '../AuthModal';
const useAuthMock = vi.fn();
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => useAuthMock(),
}));
vi.mock('../../contexts/LanguageContext', () => ({
useLanguage: () => ({
t: {
auth: {
signIn: '登录',
signUp: '注册',
signInTitle: '登录账号',
signUpTitle: '注册账号',
email: '邮箱',
password: '密码',
error: '错误',
genericError: '发生错误,请重试',
hasAccount: '已有账号?去登录',
noAccount: '没有账号?去注册',
continueWithGoogle: 'Google',
emailHint: '仅用于登录和同步记录。',
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
confirmPassword: '确认密码',
passwordMismatch: '两次输入的密码不一致。',
oauthRedirecting: '正在跳转 Google...',
},
},
}),
}));
describe('AuthModal', () => {
it('renders google oauth button', () => {
useAuthMock.mockReturnValue({
signIn: vi.fn().mockResolvedValue({ error: null }),
signUp: vi.fn().mockResolvedValue({ error: null }),
beginGoogleOAuth: vi.fn().mockResolvedValue({ error: null }),
authPhase: 'idle',
authError: null,
});
render(<AuthModal onClose={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Google' })).toBeInTheDocument();
});
it('disables inputs and submit while oauth redirecting', () => {
useAuthMock.mockReturnValue({
signIn: vi.fn().mockResolvedValue({ error: null }),
signUp: vi.fn().mockResolvedValue({ error: null }),
beginGoogleOAuth: vi.fn().mockResolvedValue({ error: null }),
authPhase: 'oauth_redirecting',
authError: null,
});
render(<AuthModal onClose={vi.fn()} />);
const emailInput = screen.getByLabelText('邮箱');
const submitButton = document.querySelector('button[type="submit"]') as HTMLButtonElement;
expect(emailInput).toBeDisabled();
expect(submitButton).toBeDisabled();
});
it('switches between signin and signup with segmented tabs', () => {
useAuthMock.mockReturnValue({
signIn: vi.fn().mockResolvedValue({ error: null }),
signUp: vi.fn().mockResolvedValue({ error: null }),
beginGoogleOAuth: vi.fn().mockResolvedValue({ error: null }),
authPhase: 'idle',
authError: null,
});
render(<AuthModal onClose={vi.fn()} />);
const signupTab = screen.getByRole('button', { name: '注册', pressed: false });
fireEvent.click(signupTab);
expect(screen.getByRole('button', { name: '注册', pressed: true })).toBeInTheDocument();
});
it('shows friendlier signup guidance', () => {
useAuthMock.mockReturnValue({
signIn: vi.fn().mockResolvedValue({ error: null }),
signUp: vi.fn().mockResolvedValue({ error: null }),
beginGoogleOAuth: vi.fn().mockResolvedValue({ error: null }),
authPhase: 'idle',
authError: null,
});
render(<AuthModal onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: '注册', pressed: false }));
expect(screen.getByText(/密码至少 6 位/i)).toBeInTheDocument();
expect(screen.getByText(/仅用于登录和同步记录/i)).toBeInTheDocument();
});
});

View File

@@ -1,122 +1,207 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import { createContext, useContext, useMemo, ReactNode, useCallback, useEffect, useReducer } from 'react';
import { authService } from '../lib/authService';
import { ApiErrorMessages } from '../types/api';
import type { UserInfo } from '../types/api';
import type { GoogleOAuthCallbackRequest, UserInfo } from '../types/api';
import { authReducer, createInitialAuthState, type AuthPhase } from './authMachine';
export const OAUTH_STATE_KEY = 'texpixel_oauth_state';
export const OAUTH_POST_LOGIN_REDIRECT_KEY = 'texpixel_post_login_redirect';
interface AuthContextType {
user: UserInfo | null;
token: string | null;
loading: boolean;
initializing: boolean; // 新增初始化状态
initializing: boolean;
authPhase: AuthPhase;
authError: string | null;
signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
signUp: (email: string, password: string) => Promise<{ error: Error | null }>;
beginGoogleOAuth: () => Promise<{ error: Error | null }>;
completeGoogleOAuth: (params: GoogleOAuthCallbackRequest) => Promise<{ error: Error | null }>;
signOut: () => Promise<void>;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const oauthExchangeInFlight = new Map<string, Promise<{ error: Error | null }>>();
function getErrorMessage(error: unknown, fallback: string): string {
if (error && typeof error === 'object' && 'code' in error) {
const apiError = error as { code: number; message?: string };
return ApiErrorMessages[apiError.code] || apiError.message || fallback;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
function createOAuthState(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
function mergeUserProfile(user: UserInfo, profile: { username: string; email: string }): UserInfo {
return {
...user,
username: profile.username || user.username || '',
email: profile.email || user.email,
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
// 直接在 useState 初始化函数中同步恢复会话
const [user, setUser] = useState<UserInfo | null>(() => {
try {
const session = authService.restoreSession();
return session ? session.user : null;
} catch {
return null;
}
});
const restoredSession = authService.restoreSession();
const [token, setToken] = useState<string | null>(() => {
try {
const session = authService.restoreSession();
return session ? session.token : null;
} catch {
return null;
}
});
const [state, dispatch] = useReducer(
authReducer,
createInitialAuthState(restoredSession ? { user: restoredSession.user, token: restoredSession.token } : null)
);
const [loading, setLoading] = useState(false);
const [initializing, setInitializing] = useState(false); // 不需要初始化过程了,因为是同步的
// 不再需要 useEffect 里的 restoreSession
/**
* 从错误对象中提取用户友好的错误消息
*/
const getErrorMessage = (error: unknown, fallback: string): string => {
// 检查是否是 ApiError通过 code 属性判断,避免 instanceof 在热更新时失效)
if (error && typeof error === 'object' && 'code' in error) {
const apiError = error as { code: number; message: string };
return ApiErrorMessages[apiError.code] || apiError.message || fallback;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
};
/**
* 登录
*/
const signIn = useCallback(async (email: string, password: string) => {
setLoading(true);
dispatch({ type: 'EMAIL_SIGNIN_START' });
try {
const result = await authService.login({ email, password });
setUser(result.user);
setToken(result.token);
dispatch({ type: 'EMAIL_SIGNIN_SUCCESS', payload: { user: result.user, token: result.token } });
return { error: null };
} catch (error) {
const message = getErrorMessage(error, '登录失败');
dispatch({ type: 'EMAIL_SIGNIN_FAIL', payload: { error: message } });
return { error: new Error(message) };
} finally {
setLoading(false);
}
}, []);
/**
* 注册
*/
const signUp = useCallback(async (email: string, password: string) => {
setLoading(true);
dispatch({ type: 'EMAIL_SIGNUP_START' });
try {
const result = await authService.register({ email, password });
setUser(result.user);
setToken(result.token);
dispatch({ type: 'EMAIL_SIGNUP_SUCCESS', payload: { user: result.user, token: result.token } });
return { error: null };
} catch (error) {
const message = getErrorMessage(error, '注册失败');
dispatch({ type: 'EMAIL_SIGNUP_FAIL', payload: { error: message } });
return { error: new Error(message) };
} finally {
setLoading(false);
}
}, []);
/**
* 登出
*/
const signOut = useCallback(async () => {
setLoading(true);
const beginGoogleOAuth = useCallback(async () => {
dispatch({ type: 'OAUTH_REDIRECT_START' });
try {
authService.logout();
setUser(null);
setToken(null);
} finally {
setLoading(false);
const stateToken = createOAuthState();
const redirectUri = `${window.location.origin}/auth/google/callback`;
sessionStorage.setItem(OAUTH_STATE_KEY, stateToken);
sessionStorage.setItem(OAUTH_POST_LOGIN_REDIRECT_KEY, window.location.href);
const { authUrl } = await authService.getGoogleOAuthUrl(redirectUri, stateToken);
window.location.assign(authUrl);
return { error: null };
} catch (error) {
const message = getErrorMessage(error, 'Google 登录失败');
dispatch({ type: 'OAUTH_EXCHANGE_FAIL', payload: { error: message } });
return { error: new Error(message) };
}
}, []);
const value: AuthContextType = {
user,
token,
loading,
initializing,
signIn,
signUp,
signOut,
isAuthenticated: !!user && !!token,
};
const completeGoogleOAuth = useCallback(async (params: GoogleOAuthCallbackRequest) => {
const requestKey = params.code;
const existing = oauthExchangeInFlight.get(requestKey);
if (existing) {
return existing;
}
const promise = (async () => {
dispatch({ type: 'OAUTH_EXCHANGE_START' });
try {
const expectedState = sessionStorage.getItem(OAUTH_STATE_KEY);
if (!expectedState || expectedState !== params.state) {
const invalidStateMessage = 'OAuth state 校验失败';
dispatch({ type: 'OAUTH_EXCHANGE_FAIL', payload: { error: invalidStateMessage } });
return { error: new Error(invalidStateMessage) };
}
const result = await authService.exchangeGoogleCode(params);
sessionStorage.removeItem(OAUTH_STATE_KEY);
dispatch({ type: 'OAUTH_EXCHANGE_SUCCESS', payload: { user: result.user, token: result.token } });
return { error: null };
} catch (error) {
const message = getErrorMessage(error, 'Google 登录失败');
dispatch({ type: 'OAUTH_EXCHANGE_FAIL', payload: { error: message } });
return { error: new Error(message) };
} finally {
oauthExchangeInFlight.delete(requestKey);
}
})();
oauthExchangeInFlight.set(requestKey, promise);
return promise;
}, []);
const signOut = useCallback(async () => {
authService.logout();
dispatch({ type: 'SIGN_OUT' });
}, []);
useEffect(() => {
let cancelled = false;
const syncUserProfile = async () => {
if (!state.user || !state.token) {
return;
}
try {
const profile = await authService.getUserInfo();
if (cancelled) {
return;
}
dispatch({
type: 'UPDATE_USER',
payload: {
user: mergeUserProfile(state.user, profile),
},
});
} catch {
// Keep token-derived identity if profile sync fails.
}
};
void syncUserProfile();
return () => {
cancelled = true;
};
}, [state.token, state.user]);
const value = useMemo<AuthContextType>(() => {
const loadingPhases: AuthPhase[] = [
'email_signing_in',
'email_signing_up',
'oauth_redirecting',
'oauth_exchanging',
];
return {
user: state.user,
token: state.token,
loading: loadingPhases.includes(state.authPhase),
initializing: state.initializing,
authPhase: state.authPhase,
authError: state.authError,
signIn,
signUp,
beginGoogleOAuth,
completeGoogleOAuth,
signOut,
isAuthenticated: !!state.user && !!state.token,
};
}, [beginGoogleOAuth, completeGoogleOAuth, signIn, signOut, signUp, state]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

View File

@@ -0,0 +1,207 @@
import { useEffect } from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { AuthProvider, useAuth } from '../AuthContext';
const {
loginMock,
registerMock,
logoutMock,
restoreSessionMock,
getGoogleOAuthUrlMock,
exchangeGoogleCodeMock,
} = vi.hoisted(() => ({
loginMock: vi.fn(),
registerMock: vi.fn(),
logoutMock: vi.fn(),
restoreSessionMock: vi.fn(() => null),
getGoogleOAuthUrlMock: vi.fn(),
exchangeGoogleCodeMock: vi.fn(),
}));
vi.mock('../../lib/authService', () => ({
authService: {
login: loginMock,
register: registerMock,
logout: logoutMock,
restoreSession: restoreSessionMock,
getGoogleOAuthUrl: getGoogleOAuthUrlMock,
exchangeGoogleCode: exchangeGoogleCodeMock,
},
}));
function Harness({ onReady }: { onReady: (ctx: ReturnType<typeof useAuth>) => void }) {
const auth = useAuth();
useEffect(() => {
onReady(auth);
}, [auth, onReady]);
return null;
}
function renderWithProvider(onReady: (ctx: ReturnType<typeof useAuth>) => void) {
return render(
<AuthProvider>
<Harness onReady={onReady} />
</AuthProvider>
);
}
describe('AuthContext OAuth flow', () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
localStorage.clear();
restoreSessionMock.mockReturnValue(null);
});
it('beginGoogleOAuth writes state and redirect then redirects browser', async () => {
getGoogleOAuthUrlMock.mockResolvedValue({ authUrl: 'https://accounts.google.com/o/oauth2/v2/auth' });
let ctxRef: ReturnType<typeof useAuth> | null = null;
renderWithProvider((ctx) => {
ctxRef = ctx;
});
await waitFor(() => {
expect(ctxRef).toBeTruthy();
});
await act(async () => {
await (ctxRef as ReturnType<typeof useAuth>).beginGoogleOAuth();
});
expect(sessionStorage.getItem('texpixel_oauth_state')).toBeTruthy();
expect(sessionStorage.getItem('texpixel_post_login_redirect')).toBe(window.location.href);
expect(getGoogleOAuthUrlMock).toHaveBeenCalledTimes(1);
});
it('completeGoogleOAuth rejects when state mismatches', async () => {
sessionStorage.setItem('texpixel_oauth_state', 'expected_state');
let ctxRef: ReturnType<typeof useAuth> | null = null;
renderWithProvider((ctx) => {
ctxRef = ctx;
});
await waitFor(() => {
expect(ctxRef).toBeTruthy();
});
let result: { error: Error | null } = { error: null };
await act(async () => {
result = await (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
code: 'abc',
state: 'wrong_state',
redirect_uri: 'http://localhost:5173/auth/google/callback',
});
});
expect(result.error).toBeTruthy();
expect(exchangeGoogleCodeMock).not.toHaveBeenCalled();
expect(localStorage.getItem('texpixel_token')).toBeNull();
});
it('completeGoogleOAuth stores session on success', async () => {
sessionStorage.setItem('texpixel_oauth_state', 'state_ok');
exchangeGoogleCodeMock.mockImplementation(async () => {
localStorage.setItem('texpixel_token', 'Bearer header.payload.sig');
return {
token: 'Bearer header.payload.sig',
expiresAt: 1999999999,
user: {
user_id: 7,
id: '7',
email: 'oauth@example.com',
exp: 1999999999,
iat: 1111111,
},
};
});
let ctxRef: ReturnType<typeof useAuth> | null = null;
renderWithProvider((ctx) => {
ctxRef = ctx;
});
await waitFor(() => {
expect(ctxRef).toBeTruthy();
});
let result: { error: Error | null } = { error: null };
await act(async () => {
result = await (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
code: 'code_ok',
state: 'state_ok',
redirect_uri: 'http://localhost:5173/auth/google/callback',
});
});
expect(result.error).toBeNull();
await waitFor(() => {
expect((ctxRef as ReturnType<typeof useAuth>).isAuthenticated).toBe(true);
});
expect(localStorage.getItem('texpixel_token')).toBe('Bearer header.payload.sig');
});
it('completeGoogleOAuth deduplicates same code requests', async () => {
sessionStorage.setItem('texpixel_oauth_state', 'state_ok');
exchangeGoogleCodeMock.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => {
localStorage.setItem('texpixel_token', 'Bearer header.payload.sig');
resolve({
token: 'Bearer header.payload.sig',
expiresAt: 1999999999,
user: {
user_id: 7,
id: '7',
email: 'oauth@example.com',
exp: 1999999999,
iat: 1111111,
},
});
}, 20);
})
);
let ctxRef: ReturnType<typeof useAuth> | null = null;
renderWithProvider((ctx) => {
ctxRef = ctx;
});
await waitFor(() => {
expect(ctxRef).toBeTruthy();
});
let result1: { error: Error | null } = { error: null };
let result2: { error: Error | null } = { error: null };
await act(async () => {
const p1 = (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
code: 'same_code',
state: 'state_ok',
redirect_uri: 'http://localhost:5173/auth/google/callback',
});
const p2 = (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
code: 'same_code',
state: 'state_ok',
redirect_uri: 'http://localhost:5173/auth/google/callback',
});
[result1, result2] = await Promise.all([p1, p2]);
});
expect(result1.error).toBeNull();
expect(result2.error).toBeNull();
expect(exchangeGoogleCodeMock).toHaveBeenCalledTimes(1);
});
});

135
src/contexts/authMachine.ts Normal file
View File

@@ -0,0 +1,135 @@
import type { UserInfo } from '../types/api';
export type AuthPhase =
| 'idle'
| 'email_signing_in'
| 'email_signing_up'
| 'oauth_redirecting'
| 'oauth_exchanging'
| 'authenticated'
| 'error';
export interface AuthState {
user: UserInfo | null;
token: string | null;
authPhase: AuthPhase;
authError: string | null;
initializing: boolean;
}
export type AuthAction =
| { type: 'RESTORE_SESSION'; payload: { user: UserInfo | null; token: string | null } }
| { type: 'EMAIL_SIGNIN_START' }
| { type: 'EMAIL_SIGNIN_SUCCESS'; payload: { user: UserInfo; token: string } }
| { type: 'EMAIL_SIGNIN_FAIL'; payload: { error: string } }
| { type: 'EMAIL_SIGNUP_START' }
| { type: 'EMAIL_SIGNUP_SUCCESS'; payload: { user: UserInfo; token: string } }
| { type: 'EMAIL_SIGNUP_FAIL'; payload: { error: string } }
| { type: 'OAUTH_REDIRECT_START' }
| { type: 'OAUTH_EXCHANGE_START' }
| { type: 'OAUTH_EXCHANGE_SUCCESS'; payload: { user: UserInfo; token: string } }
| { type: 'OAUTH_EXCHANGE_FAIL'; payload: { error: string } }
| { type: 'UPDATE_USER'; payload: { user: UserInfo } }
| { type: 'SIGN_OUT' };
export function createInitialAuthState(session: { user: UserInfo; token: string } | null): AuthState {
if (session) {
return {
user: session.user,
token: session.token,
authPhase: 'authenticated',
authError: null,
initializing: false,
};
}
return {
user: null,
token: null,
authPhase: 'idle',
authError: null,
initializing: false,
};
}
export function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'RESTORE_SESSION': {
if (action.payload.user && action.payload.token) {
return {
...state,
user: action.payload.user,
token: action.payload.token,
authPhase: 'authenticated',
authError: null,
initializing: false,
};
}
return {
...state,
user: null,
token: null,
authPhase: 'idle',
authError: null,
initializing: false,
};
}
case 'EMAIL_SIGNIN_START':
return { ...state, authPhase: 'email_signing_in', authError: null };
case 'EMAIL_SIGNIN_SUCCESS':
return {
...state,
user: action.payload.user,
token: action.payload.token,
authPhase: 'authenticated',
authError: null,
};
case 'EMAIL_SIGNIN_FAIL':
return { ...state, authPhase: 'error', authError: action.payload.error };
case 'EMAIL_SIGNUP_START':
return { ...state, authPhase: 'email_signing_up', authError: null };
case 'EMAIL_SIGNUP_SUCCESS':
return {
...state,
user: action.payload.user,
token: action.payload.token,
authPhase: 'authenticated',
authError: null,
};
case 'EMAIL_SIGNUP_FAIL':
return { ...state, authPhase: 'error', authError: action.payload.error };
case 'OAUTH_REDIRECT_START':
return { ...state, authPhase: 'oauth_redirecting', authError: null };
case 'OAUTH_EXCHANGE_START':
return { ...state, authPhase: 'oauth_exchanging', authError: null };
case 'OAUTH_EXCHANGE_SUCCESS':
return {
...state,
user: action.payload.user,
token: action.payload.token,
authPhase: 'authenticated',
authError: null,
};
case 'OAUTH_EXCHANGE_FAIL':
return { ...state, authPhase: 'error', authError: action.payload.error };
case 'UPDATE_USER':
return { ...state, user: action.payload.user };
case 'SIGN_OUT':
return {
...state,
user: null,
token: null,
authPhase: 'idle',
authError: null,
};
default:
return state;
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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: '导出',

View File

@@ -1,9 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { AuthProvider } from './contexts/AuthContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { BrowserRouter } from 'react-router-dom';
import AppRouter from './routes/AppRouter';
// 错误处理:捕获未处理的错误
window.addEventListener('error', (event) => {
@@ -22,11 +23,13 @@ if (!rootElement) {
try {
createRoot(rootElement).render(
<StrictMode>
<AuthProvider>
<LanguageProvider>
<App />
</LanguageProvider>
</AuthProvider>
<BrowserRouter>
<AuthProvider>
<LanguageProvider>
<AppRouter />
</LanguageProvider>
</AuthProvider>
</BrowserRouter>
</StrictMode>
);
} catch (error) {

View File

@@ -0,0 +1,81 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth, OAUTH_POST_LOGIN_REDIRECT_KEY } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
function toInternalPath(urlOrPath: string): string {
try {
const parsed = new URL(urlOrPath, window.location.origin);
if (parsed.origin !== window.location.origin) {
return '/';
}
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch {
return '/';
}
}
export default function AuthCallbackPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { completeGoogleOAuth } = useAuth();
const { t } = useLanguage();
const [error, setError] = useState<string | null>(null);
const code = useMemo(() => searchParams.get('code') ?? '', [searchParams]);
const state = useMemo(() => searchParams.get('state') ?? '', [searchParams]);
useEffect(() => {
let mounted = true;
const run = async () => {
if (!code || !state) {
if (mounted) {
setError(t.auth.oauthFailed);
}
return;
}
const redirectUri = `${window.location.origin}/auth/google/callback`;
const result = await completeGoogleOAuth({ code, state, redirect_uri: redirectUri });
if (result.error) {
if (mounted) {
setError(result.error.message || t.auth.oauthFailed);
}
return;
}
const redirectTarget = sessionStorage.getItem(OAUTH_POST_LOGIN_REDIRECT_KEY) || '/';
navigate(toInternalPath(redirectTarget), { replace: true });
};
run();
return () => {
mounted = false;
};
}, [code, completeGoogleOAuth, navigate, state, t.auth.oauthFailed]);
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6 text-center">
<h1 className="text-xl font-bold text-gray-900 mb-3">Google OAuth</h1>
{!error && <p className="text-gray-600">{t.auth.oauthExchanging}</p>}
{error && (
<>
<p className="text-red-600 text-sm mb-4">{error}</p>
<button
type="button"
onClick={() => navigate('/', { replace: true })}
className="px-4 py-2 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
>
Back Home
</button>
</>
)}
</div>
</div>
);
}

12
src/routes/AppRouter.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { Routes, Route } from 'react-router-dom';
import App from '../App';
import AuthCallbackPage from '../pages/AuthCallbackPage';
export default function AppRouter() {
return (
<Routes>
<Route path="/" element={<App />} />
<Route path="/auth/google/callback" element={<AuthCallbackPage />} />
</Routes>
);
}

1
src/test/setup.ts Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

View File

@@ -14,10 +14,26 @@ export interface AuthData {
expires_at: number;
}
export interface UserInfoData {
username: string;
email: string;
}
export interface GoogleAuthUrlData {
auth_url: string;
}
export interface GoogleOAuthCallbackRequest {
code: string;
state: string;
redirect_uri: string;
}
// 用户信息(从 token 解析或 API 返回)
export interface UserInfo {
user_id: number;
email: string;
username?: string;
exp: number;
iat: number;
// 兼容字段,方便代码使用

12
vitest.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
exclude: ['e2e/**', 'node_modules/**', 'dist/**'],
},
});