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

@@ -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();
});
});