From 2bcf32d678207fb75d0bdd9d415f5aa59dd45c8f Mon Sep 17 00:00:00 2001 From: yoge Date: Fri, 27 Mar 2026 02:02:12 +0800 Subject: [PATCH] feat: tighten workspace auth and onboarding flow --- .../2026-03-27-workspace-improvements.md | 856 ++++++++++++++++++ src/components/AuthModal.tsx | 147 ++- src/components/__tests__/AuthModal.test.tsx | 50 +- src/contexts/AuthContext.tsx | 6 +- src/lib/authService.ts | 5 + src/pages/WorkspacePage.tsx | 53 +- src/types/api.ts | 5 + 7 files changed, 1094 insertions(+), 28 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-27-workspace-improvements.md diff --git a/docs/superpowers/plans/2026-03-27-workspace-improvements.md b/docs/superpowers/plans/2026-03-27-workspace-improvements.md new file mode 100644 index 0000000..7d0e07a --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-workspace-improvements.md @@ -0,0 +1,856 @@ +# Workspace Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Polish the workspace by removing logo/login button clutter, adding a history toggle, enforcing mandatory login after 3 guest uploads, and adding email verification code to the registration flow. + +**Architecture:** Five independent changes across the workspace layer. History toggle state lives in `WorkspacePage` and is passed into `LeftSidebar`. Email verification adds a two-step flow inside `AuthModal` only — no new files needed. All i18n strings go through `translations.ts`. + +**Tech Stack:** React 18, TypeScript, Tailwind CSS (utility classes), Lucide React icons, existing `http` client in `lib/api.ts` + +--- + +## File Map + +| File | Change | +|------|--------| +| `src/components/layout/AppNavbar.tsx` | Remove logo icon + text + divider | +| `src/components/LeftSidebar.tsx` | Remove login button; add history toggle prop | +| `src/pages/WorkspacePage.tsx` | Add `historyEnabled` state; pass to sidebar; force mandatory modal after 3rd upload | +| `src/components/AuthModal.tsx` | Add send-code button, countdown, verification code field to signup tab | +| `src/lib/authService.ts` | Add `sendEmailCode()` method; update `register()` to send `code` | +| `src/types/api.ts` | Add `SendEmailCodeRequest`; add `code` to `RegisterRequest` | +| `src/lib/translations.ts` | Add new i18n keys for history toggle, verification code flow | + +--- + +## Task 1: Add i18n strings for new features + +**Files:** +- Modify: `src/lib/translations.ts` + +- [ ] **Step 1: Add new keys to both `en` and `zh` auth sections and sidebar section** + +In `src/lib/translations.ts`, find the `en.auth` block and add after `oauthFailed`: +```ts + sendCode: 'Send Code', + resendCode: 'Resend', + codeSent: 'Code sent', + verificationCode: 'Verification Code', + verificationCodePlaceholder: 'Enter 6-digit code', + verificationCodeRequired: 'Please enter the verification code.', + verificationCodeHint: 'Check your inbox for the 6-digit code.', + sendCodeFailed: 'Failed to send verification code, please retry.', +``` + +In `en.sidebar`, add after `historyHeader`: +```ts + historyToggle: 'Show History', + historyLoginRequired: 'Login to enable history', +``` + +In `zh.auth`, add after `oauthFailed`: +```ts + sendCode: '发送验证码', + resendCode: '重新发送', + codeSent: '验证码已发送', + verificationCode: '验证码', + verificationCodePlaceholder: '请输入 6 位验证码', + verificationCodeRequired: '请输入验证码。', + verificationCodeHint: '请查收邮箱中的 6 位验证码。', + sendCodeFailed: '发送验证码失败,请重试。', +``` + +In `zh.sidebar`, add after `historyHeader`: +```ts + historyToggle: '显示历史', + historyLoginRequired: '登录后开启历史记录', +``` + +- [ ] **Step 2: Commit** +```bash +git add src/lib/translations.ts +git commit -m "feat: add i18n keys for history toggle and email verification" +``` + +--- + +## Task 2: Remove logo from AppNavbar + +**Files:** +- Modify: `src/components/layout/AppNavbar.tsx:38-50` + +- [ ] **Step 1: Remove logo image, text, and divider — keep only the Home icon link** + +Replace the entire `{/* Left: Logo + Home link */}` div (lines 38–51) with: +```tsx + {/* Left: Home link */} +
+ + + {t.marketing.nav.home} + +
+``` + +- [ ] **Step 2: Verify dev server renders correctly** + +```bash +npm run dev +``` +Navigate to `/app`. Confirm the navbar no longer shows the TexPixel icon or text, only the Home link on the left. + +- [ ] **Step 3: Commit** +```bash +git add src/components/layout/AppNavbar.tsx +git commit -m "feat: remove logo from workspace navbar" +``` + +--- + +## Task 3: Remove login button from LeftSidebar and add history toggle prop + +**Files:** +- Modify: `src/components/LeftSidebar.tsx` + +- [ ] **Step 1: Add `historyEnabled` and `onToggleHistory` to the props interface** + +Replace the existing `LeftSidebarProps` interface with: +```ts +interface LeftSidebarProps { + files: FileRecord[]; + selectedFileId: string | null; + onFileSelect: (fileId: string) => void; + onUploadClick: () => void; + canUploadAnonymously: boolean; + onRequireAuth: () => void; + isCollapsed: boolean; + onToggleCollapse: () => void; + onUploadFiles: (files: File[]) => void; + hasMore: boolean; + loadingMore: boolean; + onLoadMore: () => void; + historyEnabled: boolean; + onToggleHistory: () => void; +} +``` + +- [ ] **Step 2: Destructure the two new props in the function signature** + +Replace the destructuring block (the function params) to add `historyEnabled` and `onToggleHistory`: +```ts +export default function LeftSidebar({ + files, + selectedFileId, + onFileSelect, + onUploadClick, + canUploadAnonymously, + onRequireAuth, + isCollapsed, + onToggleCollapse, + onUploadFiles, + hasMore, + loadingMore, + onLoadMore, + historyEnabled, + onToggleHistory, +}: LeftSidebarProps) { +``` + +- [ ] **Step 3: Add `Toggle` icon to lucide imports** + +Change the import line at the top from: +```ts +import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react'; +``` +to: +```ts +import { Upload, LogOut, FileText, Clock, ChevronLeft, ChevronRight, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react'; +``` +(Remove `LogIn`, `History` — no longer used in expanded view) + +- [ ] **Step 4: Remove the `showAuthModal` state and the `AuthModal` import/usage from LeftSidebar** + +Remove these lines near the top of the function body: +```ts + const [showAuthModal, setShowAuthModal] = useState(false); +``` +and the `useEffect` that sets `showAuthModal` to false on auth: +```ts + useEffect(() => { + if (isAuthenticated) { + setShowAuthModal(false); + } + }, [isAuthenticated]); +``` +Also remove `import AuthModal from './AuthModal';` at the top of the file, and at the bottom remove the `{showAuthModal && setShowAuthModal(false)} />}` JSX. + +- [ ] **Step 5: Replace the history header section with a toggle switch** + +Find the `{/* Middle Area: History */}` block. Replace the header div (the one with `Clock` icon and `historyHeader` text) with: +```tsx +
+
+ + {t.sidebar.historyHeader} +
+ +
+``` + +- [ ] **Step 6: Gate the history list behind `historyEnabled`** + +Replace the entire `
` scrollable list with: +```tsx +
+ {!historyEnabled ? ( +
+
+ {t.sidebar.historyToggle} +
+ ) : !user ? ( +
+
+ {t.sidebar.historyLoginRequired} +
+ ) : files.length === 0 ? ( +
+
+ {t.sidebar.noHistory} +
+ ) : ( + <> + {files.map((file) => ( + + ))} + {loadingMore && ( +
+ + {t.common.loading} +
+ )} + {!hasMore && files.length > 0 && ( +
+ {t.sidebar.noMore} +
+ )} + + )} +
+``` + +- [ ] **Step 7: Replace the bottom user/login area — remove login button, keep only logged-in user view** + +Replace the entire `{/* Bottom Area: User/Login */}` div with: +```tsx + {/* Bottom Area: User info (only shown when logged in) */} + {user && ( +
+
+
+ + + +
+
+

{displayName}

+
+ +
+
+ )} +``` + +- [ ] **Step 8: Fix collapsed view — remove LogIn icon button** + +In the `if (isCollapsed)` return block, remove the bottom ` +``` +Replace with nothing (delete that button entirely). The `isAuthenticated` import can stay for now. + +- [ ] **Step 9: Commit** +```bash +git add src/components/LeftSidebar.tsx +git commit -m "feat: remove login button from sidebar, add history toggle" +``` + +--- + +## Task 4: Wire history toggle state and mandatory auth in WorkspacePage + +**Files:** +- Modify: `src/pages/WorkspacePage.tsx` + +- [ ] **Step 1: Add `historyEnabled` state** + +After the existing `const [loadingMore, setLoadingMore] = useState(false);` line, add: +```ts + const [historyEnabled, setHistoryEnabled] = useState(false); +``` + +- [ ] **Step 2: Add `handleToggleHistory` callback** + +After the `openAuthModal` callback, add: +```ts + const handleToggleHistory = useCallback(() => { + if (!historyEnabled) { + // Turning on + if (!user) { + openAuthModal(); + return; + } + setHistoryEnabled(true); + if (!hasLoadedFiles.current) { + hasLoadedFiles.current = true; + loadFiles(); + } + } else { + setHistoryEnabled(false); + } + }, [historyEnabled, user, openAuthModal]); +``` + +- [ ] **Step 3: Remove auto-load on auth — history is now opt-in** + +Find the `useEffect` that auto-calls `loadFiles()` when `user` becomes available: +```ts + useEffect(() => { + if (!initializing && user && !hasLoadedFiles.current) { + hasLoadedFiles.current = true; + loadFiles(); + } + if (!user) { + hasLoadedFiles.current = false; + setFiles([]); + setSelectedFileId(null); + setCurrentPage(1); + setHasMore(false); + } + }, [initializing, user]); +``` + +Replace with (keep the reset on logout, remove the auto-load): +```ts + useEffect(() => { + if (!user) { + hasLoadedFiles.current = false; + setHistoryEnabled(false); + setFiles([]); + setSelectedFileId(null); + setCurrentPage(1); + setHasMore(false); + } + }, [user]); +``` + +- [ ] **Step 4: Make the auth modal mandatory after 3rd upload** + +Add a new state after `showAuthModal`: +```ts + const [authModalMandatory, setAuthModalMandatory] = useState(false); +``` + +Change `openAuthModal` to accept an optional `mandatory` parameter: +```ts + const openAuthModal = useCallback((mandatory = false) => { + setAuthModalMandatory(mandatory); + setShowAuthModal(true); + }, []); +``` + +In `handleUpload`, after `incrementGuestUsage()`, add a mandatory modal trigger when the new count hits the limit: +```ts + if (!user && successfulUploads > 0) { + incrementGuestUsage(); + // Force login after hitting the limit + setGuestUsageCount(prev => { + const next = prev + 1; + if (next >= GUEST_USAGE_LIMIT) { + openAuthModal(true); + } + return next; + }); + } +``` + +Wait — `incrementGuestUsage` already increments. We need to check the new count after increment. Replace the `if (!user && successfulUploads > 0)` block at the end of `handleUpload` with: +```ts + if (!user && successfulUploads > 0) { + const nextCount = guestUsageCount + successfulUploads; + const newCount = Math.min(nextCount, GUEST_USAGE_LIMIT + 10); + localStorage.setItem(GUEST_USAGE_COUNT_KEY, String(newCount)); + setGuestUsageCount(newCount); + if (newCount >= GUEST_USAGE_LIMIT) { + openAuthModal(true); + } + } +``` +And remove the separate `incrementGuestUsage` call entirely (delete the `incrementGuestUsage` callback too). + +- [ ] **Step 5: Update the two places that check upload limit to pass `mandatory=false` (keep non-mandatory for pre-upload checks)** + +The `onUploadClick` handler and paste handler already call `openAuthModal()` with no arg (defaults to `false`) — that stays as-is. + +- [ ] **Step 6: Update AuthModal JSX to pass `mandatory` prop and update close handler** + +Find `{showAuthModal && ( setShowAuthModal(false)} />)}` and replace with: +```tsx + {showAuthModal && ( + { setShowAuthModal(false); setAuthModalMandatory(false); }} + mandatory={authModalMandatory} + /> + )} +``` + +- [ ] **Step 7: Pass `historyEnabled` and `onToggleHistory` to LeftSidebar** + +Find the ` { + if (user && !hasLoadedFiles.current && historyEnabled) { + // user logged in while history was already toggled on + hasLoadedFiles.current = true; + loadFiles(); + } + if (!user) { + hasLoadedFiles.current = false; + setHistoryEnabled(false); + setFiles([]); + setSelectedFileId(null); + setCurrentPage(1); + setHasMore(false); + } + }, [user]); +``` + +- [ ] **Step 9: Commit** +```bash +git add src/pages/WorkspacePage.tsx +git commit -m "feat: history toggle state, mandatory auth after 3 guest uploads" +``` + +--- + +## Task 5: Add email verification to API types and authService + +**Files:** +- Modify: `src/types/api.ts` +- Modify: `src/lib/authService.ts` + +- [ ] **Step 1: Update `RegisterRequest` and add `SendEmailCodeRequest` in `src/types/api.ts`** + +Find the existing `RegisterRequest` interface: +```ts +export interface RegisterRequest { + email: string; + password: string; +} +``` +Replace with: +```ts +export interface RegisterRequest { + email: string; + password: string; + code: string; +} + +export interface SendEmailCodeRequest { + email: string; +} +``` + +- [ ] **Step 2: Add `sendEmailCode()` to `authService` in `src/lib/authService.ts`** + +Add the import of `SendEmailCodeRequest` — it will be used below. Then add a new method inside `export const authService = { ... }` after the `login` method: +```ts + async sendEmailCode(email: string): Promise { + await http.post('/user/email/code', { email } satisfies SendEmailCodeRequest, { skipAuth: true }); + }, +``` + +Also update the `register` method signature to accept `code`: +```ts + async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> { + const response = await http.post('/user/register', credentials, { skipAuth: true }); + + if (!response.data) { + throw new ApiError(-1, '注册失败,请重试'); + } + + return buildSession(response.data, credentials.email); + }, +``` +(Signature stays the same — just ensuring `credentials` now includes `code` via the updated type.) + +- [ ] **Step 3: Update the import in `authService.ts` to include `SendEmailCodeRequest`** + +Find: +```ts +import type { + AuthData, + GoogleAuthUrlData, + GoogleOAuthCallbackRequest, + LoginRequest, + RegisterRequest, + UserInfoData, + UserInfo, +} from '../types/api'; +``` +Replace with: +```ts +import type { + AuthData, + GoogleAuthUrlData, + GoogleOAuthCallbackRequest, + LoginRequest, + RegisterRequest, + SendEmailCodeRequest, + UserInfoData, + UserInfo, +} from '../types/api'; +``` + +- [ ] **Step 4: Commit** +```bash +git add src/types/api.ts src/lib/authService.ts +git commit -m "feat: add sendEmailCode API and code field to RegisterRequest" +``` + +--- + +## Task 6: Update AuthContext to pass `code` through signUp + +**Files:** +- Modify: `src/contexts/AuthContext.tsx` + +- [ ] **Step 1: Update `signUp` to accept and forward `code`** + +Find the `signUp` function/action in `AuthContext.tsx`. It currently calls `authService.register({ email, password })`. Update the `signUp` signature to accept a third `code` parameter and pass it: +```ts +signUp: async (email: string, password: string, code: string) => { ... } +``` +Inside, change: +```ts +await authService.register({ email, password, code }); +``` + +Also update the `AuthContextValue` interface (or wherever `signUp` type is declared) to: +```ts +signUp: (email: string, password: string, code: string) => Promise<{ error: string | null }>; +``` + +- [ ] **Step 2: Commit** +```bash +git add src/contexts/AuthContext.tsx +git commit -m "feat: thread verification code through AuthContext signUp" +``` + +--- + +## Task 7: Add email verification UI to AuthModal + +**Files:** +- Modify: `src/components/AuthModal.tsx` + +- [ ] **Step 1: Add verification code state variables** + +After the existing `useState` declarations in `AuthModal`, add: +```ts + const [verificationCode, setVerificationCode] = useState(''); + const [codeSent, setCodeSent] = useState(false); + const [codeCountdown, setCodeCountdown] = useState(0); + const [sendingCode, setSendingCode] = useState(false); + const countdownRef = useRef(null); +``` + +Add `useRef` to the React import if not already there. + +Also import `authService` at the top: +```ts +import { authService } from '../lib/authService'; +``` + +- [ ] **Step 2: Add `sendCode` handler** + +After the `handleGoogleOAuth` function, add: +```ts + const handleSendCode = async () => { + const normalizedEmail = email.trim(); + if (!normalizedEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) { + setFieldErrors(prev => ({ ...prev, email: t.auth.emailInvalid })); + return; + } + setSendingCode(true); + try { + await authService.sendEmailCode(normalizedEmail); + setCodeSent(true); + setCodeCountdown(60); + countdownRef.current = setInterval(() => { + setCodeCountdown(prev => { + if (prev <= 1) { + clearInterval(countdownRef.current!); + return 0; + } + return prev - 1; + }); + }, 1000); + } catch { + setLocalError(t.auth.sendCodeFailed); + } finally { + setSendingCode(false); + } + }; +``` + +Add a cleanup `useEffect` for the countdown interval: +```ts + useEffect(() => { + return () => { + if (countdownRef.current) clearInterval(countdownRef.current); + }; + }, []); +``` + +- [ ] **Step 3: Update `handleSubmit` to validate code and pass it to `signUp`** + +In the `handleSubmit` function, inside the `if (mode === 'signup')` validation block, add a check for the verification code: +```ts + if (!verificationCode.trim()) { + nextFieldErrors.verificationCode = t.auth.verificationCodeRequired; + } +``` + +Update `fieldErrors` type to include `verificationCode`: +```ts + const [fieldErrors, setFieldErrors] = useState<{ + email?: string; + password?: string; + confirmPassword?: string; + verificationCode?: string; + }>({}); +``` + +Update the `signUp` call to pass the code: +```ts + const result = mode === 'signup' + ? await signUp(normalizedEmail, password, verificationCode.trim()) + : await signIn(normalizedEmail, password); +``` + +- [ ] **Step 4: Add Send Code button inline with the email field (signup mode only)** + +In the JSX, find the email `
` block. Replace it with a version that adds the send-code button when in signup mode: +```tsx +
+ +
+
+ { + setEmail(e.target.value); + if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined })); + }} + style={fieldErrors.email ? { ...s.input, ...s.inputError } : s.input} + placeholder="your@email.com" + required + disabled={isBusy} + /> + {fieldErrors.email &&

{fieldErrors.email}

} + {mode === 'signup' &&

{t.auth.emailHint}

} +
+ {mode === 'signup' && ( + + )} +
+
+``` + +- [ ] **Step 5: Add the verification code input field (signup mode, after confirm password)** + +After the confirm-password `{mode === 'signup' && (...)}` block, add: +```tsx + {mode === 'signup' && ( +
+ + { + setVerificationCode(e.target.value.replace(/\D/g, '')); + if (fieldErrors.verificationCode) setFieldErrors(prev => ({ ...prev, verificationCode: undefined })); + }} + style={fieldErrors.verificationCode ? { ...s.input, ...s.inputError } : s.input} + placeholder={t.auth.verificationCodePlaceholder} + disabled={isBusy} + /> + {fieldErrors.verificationCode + ?

{fieldErrors.verificationCode}

+ :

{t.auth.verificationCodeHint}

+ } +
+ )} +``` + +- [ ] **Step 6: Reset code state when switching to signin tab** + +In the `setMode('signin')` onClick handler, add resets: +```ts +onClick={() => { + setMode('signin'); + setFieldErrors({}); + setLocalError(''); + setVerificationCode(''); + setCodeSent(false); + setCodeCountdown(0); + if (countdownRef.current) clearInterval(countdownRef.current); +}} +``` + +- [ ] **Step 7: Verify in browser — register flow should now require sending code first** + +```bash +npm run dev +``` +Open `/app`, click register. Confirm: +1. Email field has "Send Code" button +2. Clicking it (with valid email) triggers send and starts countdown +3. Verification code field appears below confirm-password +4. Submitting without code shows validation error +5. Login tab does not show the code field + +- [ ] **Step 8: Commit** +```bash +git add src/components/AuthModal.tsx +git commit -m "feat: add email verification code step to registration flow" +``` + +--- + +## Task 8: Final smoke test + +- [ ] **Step 1: Run type check** +```bash +npm run typecheck +``` +Expected: no errors + +- [ ] **Step 2: Run unit tests** +```bash +npm run test +``` +Expected: all pass (existing tests should still pass) + +- [ ] **Step 3: Manual end-to-end check** +```bash +npm run dev +``` +Verify: +1. Workspace navbar has no logo — only Home link on left +2. Sidebar bottom shows no login button when logged out +3. History toggle is off by default, shows placeholder text +4. Toggling history on while logged out opens auth modal +5. After 3 guest uploads, mandatory auth modal appears (no close button) +6. Register tab: Send Code button appears, countdown works, code field required +7. Sign-in tab: no code field visible +8. Logged-in user: history toggle on loads history; logout resets toggle to off diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index 0154f09..fb44cf2 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -1,7 +1,8 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { X } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; +import { authService } from '../lib/authService'; interface AuthModalProps { onClose: () => void; @@ -16,8 +17,12 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); + const [verificationCode, setVerificationCode] = useState(''); const [localError, setLocalError] = useState(''); - const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string }>({}); + const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string; verificationCode?: string }>({}); + const [sendingCode, setSendingCode] = useState(false); + const [countdown, setCountdown] = useState(0); + const [codeSent, setCodeSent] = useState(false); const isBusy = useMemo( () => ['email_signing_in', 'email_signing_up', 'oauth_redirecting', 'oauth_exchanging'].includes(authPhase), @@ -26,10 +31,55 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps const submitText = mode === 'signup' ? t.auth.signUp : t.auth.signIn; + useEffect(() => { + if (countdown <= 0) { + return; + } + + const timer = window.setTimeout(() => { + setCountdown((prev) => Math.max(prev - 1, 0)); + }, 1000); + + return () => window.clearTimeout(timer); + }, [countdown]); + + const resetSignupState = () => { + setVerificationCode(''); + setCodeSent(false); + setCountdown(0); + }; + + const handleSendCode = async () => { + setLocalError(''); + const normalizedEmail = email.trim(); + + if (!normalizedEmail) { + setFieldErrors((prev) => ({ ...prev, email: t.auth.emailRequired })); + return; + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) { + setFieldErrors((prev) => ({ ...prev, email: t.auth.emailInvalid })); + return; + } + + setSendingCode(true); + try { + await authService.sendEmailCode(normalizedEmail); + setCodeSent(true); + setCountdown(60); + setFieldErrors((prev) => ({ ...prev, email: undefined, verificationCode: undefined })); + } catch { + setLocalError(t.auth.sendCodeFailed); + } finally { + setSendingCode(false); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLocalError(''); - const nextFieldErrors: { email?: string; password?: string; confirmPassword?: string } = {}; + const nextFieldErrors: { email?: string; password?: string; confirmPassword?: string; verificationCode?: string } = {}; const normalizedEmail = email.trim(); if (!normalizedEmail) { @@ -47,6 +97,10 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps nextFieldErrors.password = t.auth.passwordHint; } + if (!verificationCode.trim()) { + nextFieldErrors.verificationCode = t.auth.verificationCodeRequired; + } + if (!confirmPassword) { nextFieldErrors.confirmPassword = t.auth.passwordRequired; } else if (password !== confirmPassword) { @@ -59,7 +113,9 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps return; } - const result = mode === 'signup' ? await signUp(normalizedEmail, password) : await signIn(normalizedEmail, password); + const result = mode === 'signup' + ? await signUp(normalizedEmail, password, verificationCode.trim()) + : await signIn(normalizedEmail, password); if (!result.error) { onClose(); @@ -187,6 +243,25 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps fontSize: '12px', color: '#AA9685', }, + codeRow: { + display: 'flex', + gap: '10px', + alignItems: 'stretch', + }, + codeButton: { + minWidth: '118px', + padding: '0 14px', + border: '1.5px solid #F1E6D8', + borderRadius: '12px', + background: '#FFFFFF', + color: '#C8622A', + fontSize: '13px', + fontWeight: 600, + fontFamily: "'DM Sans', sans-serif", + cursor: 'pointer', + transition: 'all 0.15s', + whiteSpace: 'nowrap' as const, + }, errorBox: { padding: '10px 14px', background: '#fff0ed', @@ -270,7 +345,12 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps
+ {mode === 'signup' && ( +
+ +
+ { + const nextValue = e.target.value.replace(/\D/g, '').slice(0, 6); + setVerificationCode(nextValue); + if (fieldErrors.verificationCode) { + setFieldErrors((prev) => ({ ...prev, verificationCode: undefined })); + } + if (localError) setLocalError(''); + }} + style={fieldErrors.verificationCode ? { ...s.input, ...s.inputError } : s.input} + placeholder={t.auth.verificationCodePlaceholder} + inputMode="numeric" + autoComplete="one-time-code" + disabled={isBusy} + /> + +
+ {fieldErrors.verificationCode &&

{fieldErrors.verificationCode}

} +

+ {codeSent ? `${t.auth.codeSent}. ${t.auth.verificationCodeHint}` : t.auth.verificationCodeHint} +

+
+ )} + {mode === 'signup' && (
@@ -338,6 +472,7 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps onChange={(e) => { setConfirmPassword(e.target.value); if (fieldErrors.confirmPassword) setFieldErrors((prev) => ({ ...prev, confirmPassword: undefined })); + if (localError) setLocalError(''); }} style={fieldErrors.confirmPassword ? { ...s.input, ...s.inputError } : s.input} placeholder="••••••••" diff --git a/src/components/__tests__/AuthModal.test.tsx b/src/components/__tests__/AuthModal.test.tsx index 944db69..34204af 100644 --- a/src/components/__tests__/AuthModal.test.tsx +++ b/src/components/__tests__/AuthModal.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import AuthModal from '../AuthModal'; @@ -7,14 +7,24 @@ const useAuthMock = vi.fn(); const signInMock = vi.fn().mockResolvedValue({ error: null }); const signUpMock = vi.fn().mockResolvedValue({ error: null }); const beginGoogleOAuthMock = vi.fn().mockResolvedValue({ error: null }); +const sendEmailCodeMock = vi.fn().mockResolvedValue(undefined); vi.mock('../../contexts/AuthContext', () => ({ useAuth: () => useAuthMock(), })); +vi.mock('../../lib/authService', () => ({ + authService: { + sendEmailCode: (...args: unknown[]) => sendEmailCodeMock(...args), + }, +})); + vi.mock('../../contexts/LanguageContext', () => ({ useLanguage: () => ({ t: { + common: { + loading: '加载中...', + }, auth: { signIn: '登录', signUp: '注册', @@ -34,6 +44,14 @@ vi.mock('../../contexts/LanguageContext', () => ({ emailRequired: '请输入邮箱地址。', emailInvalid: '请输入有效的邮箱地址。', passwordRequired: '请输入密码。', + sendCode: '发送验证码', + resendCode: '重新发送', + codeSent: '验证码已发送', + verificationCode: '验证码', + verificationCodePlaceholder: '请输入 6 位验证码', + verificationCodeRequired: '请输入验证码。', + verificationCodeHint: '请查收邮箱中的 6 位验证码。', + sendCodeFailed: '发送验证码失败,请重试。', oauthRedirecting: '正在跳转 Google...', }, }, @@ -50,6 +68,36 @@ const createAuthState = (overrides?: Record) => ({ }); describe('AuthModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('requires verification code before signup submit', async () => { + useAuthMock.mockReturnValue(createAuthState()); + render(); + + fireEvent.click(screen.getByRole('button', { name: '注册', pressed: false })); + fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'test@example.com' } }); + fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'password123' } }); + fireEvent.change(screen.getByLabelText('确认密码'), { target: { value: 'password123' } }); + fireEvent.click(document.querySelector('button[type="submit"]') as HTMLButtonElement); + + expect(await screen.findByText('请输入验证码。')).toBeInTheDocument(); + expect(signUpMock).not.toHaveBeenCalled(); + }); + + it('sends verification code in signup mode', async () => { + useAuthMock.mockReturnValue(createAuthState()); + render(); + + fireEvent.click(screen.getByRole('button', { name: '注册', pressed: false })); + fireEvent.change(screen.getByLabelText('邮箱'), { target: { value: 'test@example.com' } }); + fireEvent.click(screen.getByRole('button', { name: '发送验证码' })); + + expect(sendEmailCodeMock).toHaveBeenCalledWith('test@example.com'); + expect(await screen.findByText(/验证码已发送/)).toBeInTheDocument(); + }); + it('shows email required message for empty signin submit', async () => { useAuthMock.mockReturnValue(createAuthState()); render(); diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 491efff..8460293 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -15,7 +15,7 @@ interface AuthContextType { authPhase: AuthPhase; authError: string | null; signIn: (email: string, password: string) => Promise<{ error: Error | null }>; - signUp: (email: string, password: string) => Promise<{ error: Error | null }>; + signUp: (email: string, password: string, code: string) => Promise<{ error: Error | null }>; beginGoogleOAuth: () => Promise<{ error: Error | null }>; completeGoogleOAuth: (params: GoogleOAuthCallbackRequest) => Promise<{ error: Error | null }>; signOut: () => Promise; @@ -74,11 +74,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []); - const signUp = useCallback(async (email: string, password: string) => { + const signUp = useCallback(async (email: string, password: string, code: string) => { dispatch({ type: 'EMAIL_SIGNUP_START' }); try { - const result = await authService.register({ email, password }); + const result = await authService.register({ email, password, code }); dispatch({ type: 'EMAIL_SIGNUP_SUCCESS', payload: { user: result.user, token: result.token } }); return { error: null }; } catch (error) { diff --git a/src/lib/authService.ts b/src/lib/authService.ts index b312bd8..f0d948d 100644 --- a/src/lib/authService.ts +++ b/src/lib/authService.ts @@ -10,6 +10,7 @@ import type { GoogleOAuthCallbackRequest, LoginRequest, RegisterRequest, + SendEmailCodeRequest, UserInfoData, UserInfo, } from '../types/api'; @@ -66,6 +67,10 @@ export const authService = { return buildSession(response.data, credentials.email); }, + async sendEmailCode(email: string): Promise { + await http.post('/user/email/code', { email } satisfies SendEmailCodeRequest, { skipAuth: true }); + }, + async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> { const response = await http.post('/user/register', credentials, { skipAuth: true }); diff --git a/src/pages/WorkspacePage.tsx b/src/pages/WorkspacePage.tsx index 641932e..ddb2132 100644 --- a/src/pages/WorkspacePage.tsx +++ b/src/pages/WorkspacePage.tsx @@ -35,6 +35,8 @@ export default function WorkspacePage() { const [currentPage, setCurrentPage] = useState(1); const [hasMore, setHasMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false); + const [historyEnabled, setHistoryEnabled] = useState(false); + const [authModalMandatory, setAuthModalMandatory] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(320); const [isResizing, setIsResizing] = useState(false); @@ -50,28 +52,31 @@ export default function WorkspacePage() { const selectedFile = files.find((f) => f.id === selectedFileId) || null; const canUploadAnonymously = !user && guestUsageCount < GUEST_USAGE_LIMIT; - const openAuthModal = useCallback(() => { + const openAuthModal = useCallback((mandatory = false) => { + setAuthModalMandatory(mandatory); setShowAuthModal(true); }, []); - const incrementGuestUsage = useCallback(() => { - setGuestUsageCount((prev) => { - const nextCount = prev + 1; - localStorage.setItem(GUEST_USAGE_COUNT_KEY, String(nextCount)); - return nextCount; - }); - }, []); + const handleToggleHistory = useCallback(() => { + if (!historyEnabled) { + if (!user) { + openAuthModal(); + return; + } + setHistoryEnabled(true); + if (!hasLoadedFiles.current) { + hasLoadedFiles.current = true; + loadFiles(); + } + } else { + setHistoryEnabled(false); + } + }, [historyEnabled, user, openAuthModal]); useEffect(() => { const handleStartGuide = () => setShowUserGuide(true); window.addEventListener('start-user-guide', handleStartGuide); - const hasSeenGuide = localStorage.getItem('hasSeenGuide'); - if (!hasSeenGuide) { - setTimeout(() => setShowUserGuide(true), 1500); - localStorage.setItem('hasSeenGuide', 'true'); - } - return () => window.removeEventListener('start-user-guide', handleStartGuide); }, []); @@ -96,18 +101,19 @@ export default function WorkspacePage() { }, [initializing]); useEffect(() => { - if (!initializing && user && !hasLoadedFiles.current) { + if (user && !hasLoadedFiles.current && historyEnabled) { hasLoadedFiles.current = true; loadFiles(); } if (!user) { hasLoadedFiles.current = false; + setHistoryEnabled(false); setFiles([]); setSelectedFileId(null); setCurrentPage(1); setHasMore(false); } - }, [initializing, user]); + }, [user]); useEffect(() => { selectedFileIdRef.current = selectedFileId; @@ -438,7 +444,12 @@ export default function WorkspacePage() { } if (!user && successfulUploads > 0) { - incrementGuestUsage(); + const nextCount = guestUsageCount + successfulUploads; + localStorage.setItem(GUEST_USAGE_COUNT_KEY, String(nextCount)); + setGuestUsageCount(nextCount); + if (nextCount >= GUEST_USAGE_LIMIT) { + openAuthModal(true); + } } } catch (error) { console.error('Error uploading files:', error); @@ -493,6 +504,8 @@ export default function WorkspacePage() { hasMore={hasMore} loadingMore={loadingMore} onLoadMore={loadMoreFiles} + historyEnabled={historyEnabled} + onToggleHistory={handleToggleHistory} /> {!sidebarCollapsed && ( @@ -525,7 +538,11 @@ export default function WorkspacePage() { {showAuthModal && ( setShowAuthModal(false)} + onClose={() => { + setShowAuthModal(false); + setAuthModalMandatory(false); + }} + mandatory={authModalMandatory} /> )} diff --git a/src/types/api.ts b/src/types/api.ts index 5700326..16e6528 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -50,6 +50,11 @@ export interface LoginRequest { export interface RegisterRequest { email: string; password: string; + code: string; +} + +export interface SendEmailCodeRequest { + email: string; } // OSS 签名响应数据