feat: tighten workspace auth and onboarding flow

This commit is contained in:
2026-03-27 02:02:12 +08:00
parent 927fdfa97d
commit 2bcf32d678
7 changed files with 1094 additions and 28 deletions

View File

@@ -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 3851) with:
```tsx
{/* Left: Home link */}
<div className="flex items-center gap-2">
<Link
to="/"
className="flex items-center gap-1.5 px-2 py-1 text-ink-muted hover:text-ink-secondary text-xs font-medium transition-colors rounded-md hover:bg-cream-200/60"
>
<Home size={13} />
<span className="hidden sm:inline">{t.marketing.nav.home}</span>
</Link>
</div>
```
- [ ] **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 && <AuthModal onClose={() => 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
<div className="flex items-center justify-between text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">
<div className="flex items-center gap-2">
<Clock size={14} />
<span>{t.sidebar.historyHeader}</span>
</div>
<button
onClick={onToggleHistory}
className={`relative w-8 h-4 rounded-full transition-colors duration-200 focus:outline-none ${
historyEnabled ? 'bg-blue-500' : 'bg-gray-300'
}`}
title={t.sidebar.historyToggle}
aria-pressed={historyEnabled}
>
<span
className={`absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full shadow transition-transform duration-200 ${
historyEnabled ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
</div>
```
- [ ] **Step 6: Gate the history list behind `historyEnabled`**
Replace the entire `<div ref={listRef} ...>` scrollable list with:
```tsx
<div
ref={listRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto space-y-1 pr-2 -mr-2 custom-scrollbar"
>
{!historyEnabled ? (
<div className="text-center py-12 text-gray-400 text-sm">
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
{t.sidebar.historyToggle}
</div>
) : !user ? (
<div className="text-center py-12 text-gray-400 text-sm">
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
{t.sidebar.historyLoginRequired}
</div>
) : files.length === 0 ? (
<div className="text-center py-12 text-gray-400 text-sm">
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
{t.sidebar.noHistory}
</div>
) : (
<>
{files.map((file) => (
<button
key={file.id}
onClick={() => onFileSelect(file.id)}
className={`w-full p-3 rounded-lg text-left transition-all border group relative ${selectedFileId === file.id
? 'bg-blue-50 border-blue-200 shadow-sm'
: 'bg-white border-transparent hover:bg-gray-50 hover:border-gray-100'
}`}
>
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${selectedFileId === file.id ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
<FileText size={18} />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium truncate ${selectedFileId === file.id ? 'text-blue-900' : 'text-gray-700'}`}>
{file.filename}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-400">
{new Date(file.created_at).toLocaleDateString()}
</span>
<span className={`w-1.5 h-1.5 rounded-full ${file.status === 'completed' ? 'bg-green-500' :
file.status === 'processing' ? 'bg-yellow-500' : 'bg-red-500'
}`} />
</div>
</div>
</div>
</button>
))}
{loadingMore && (
<div className="flex items-center justify-center py-3 text-gray-400">
<Loader2 size={18} className="animate-spin" />
<span className="ml-2 text-xs">{t.common.loading}</span>
</div>
)}
{!hasMore && files.length > 0 && (
<div className="text-center py-3 text-xs text-gray-400">
{t.sidebar.noMore}
</div>
)}
</>
)}
</div>
```
- [ ] **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 && (
<div className="p-4 border-t border-gray-100 bg-gray-50/30">
<div className="flex items-center gap-3 p-2 rounded-lg bg-white border border-gray-100 shadow-sm">
<div className="w-8 h-8 bg-gray-900 rounded-full flex items-center justify-center flex-shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 12C14.21 12 16 10.21 16 8C16 5.79 14.21 4 12 4C9.79 4 8 5.79 8 8C8 10.21 9.79 12 12 12ZM12 14C9.33 14 4 15.34 4 18V20H20V18C20 15.34 14.67 14 12 14Z" fill="white" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{displayName}</p>
</div>
<button
onClick={() => signOut()}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
title={t.common.logout}
>
<LogOut size={16} />
</button>
</div>
</div>
)}
```
- [ ] **Step 8: Fix collapsed view — remove LogIn icon button**
In the `if (isCollapsed)` return block, remove the bottom `<button>` that shows the `LogIn` icon:
```tsx
<button
onClick={() => !user && setShowAuthModal(true)}
className="p-3 rounded-lg text-gray-600 hover:bg-gray-200 transition-colors mt-auto"
title={user ? 'Signed In' : t.common.login}
>
<LogIn size={20} />
</button>
```
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 && (<AuthModal onClose={() => setShowAuthModal(false)} />)}` and replace with:
```tsx
{showAuthModal && (
<AuthModal
onClose={() => { setShowAuthModal(false); setAuthModalMandatory(false); }}
mandatory={authModalMandatory}
/>
)}
```
- [ ] **Step 7: Pass `historyEnabled` and `onToggleHistory` to LeftSidebar**
Find the `<LeftSidebar` JSX and add the two new props:
```tsx
historyEnabled={historyEnabled}
onToggleHistory={handleToggleHistory}
```
- [ ] **Step 8: After login via mandatory modal, auto-enable history**
In the `useEffect` that watches `user`, add history auto-enable when user logs in while `historyEnabled` is still false — actually simpler to auto-enable history when user is set and they just came from a mandatory flow. Instead, just handle this in the `useEffect` that watches user change after modal closes:
Replace the logout-only effect from Step 3 with:
```ts
useEffect(() => {
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<void> {
await http.post<null>('/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<AuthData>('/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<NodeJS.Timeout | null>(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 `<div style={s.fieldGroup}>` block. Replace it with a version that adds the send-code button when in signup mode:
```tsx
<div style={s.fieldGroup}>
<label htmlFor="auth-email" style={s.label}>{t.auth.email}</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<input
id="auth-email"
type="email"
value={email}
onChange={(e) => {
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 && <p style={s.fieldError}>{fieldErrors.email}</p>}
{mode === 'signup' && <p style={s.fieldHint}>{t.auth.emailHint}</p>}
</div>
{mode === 'signup' && (
<button
type="button"
onClick={handleSendCode}
disabled={isBusy || sendingCode || codeCountdown > 0}
style={{
flexShrink: 0,
height: '42px',
padding: '0 12px',
background: codeCountdown > 0 ? '#F5DDD0' : '#C8622A',
color: codeCountdown > 0 ? '#AA9685' : 'white',
border: 'none',
borderRadius: '12px',
fontSize: '13px',
fontWeight: 600,
fontFamily: "'DM Sans', sans-serif",
cursor: codeCountdown > 0 || sendingCode ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap',
transition: 'all 0.2s',
}}
>
{codeCountdown > 0
? `${codeCountdown}s`
: codeSent
? t.auth.resendCode
: t.auth.sendCode}
</button>
)}
</div>
</div>
```
- [ ] **Step 5: Add the verification code input field (signup mode, after confirm password)**
After the confirm-password `{mode === 'signup' && (...)}` block, add:
```tsx
{mode === 'signup' && (
<div style={s.fieldGroup}>
<label htmlFor="auth-code" style={s.label}>{t.auth.verificationCode}</label>
<input
id="auth-code"
type="text"
inputMode="numeric"
maxLength={6}
value={verificationCode}
onChange={(e) => {
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
? <p style={s.fieldError}>{fieldErrors.verificationCode}</p>
: <p style={s.fieldHint}>{t.auth.verificationCodeHint}</p>
}
</div>
)}
```
- [ ] **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

View File

@@ -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
<div style={s.tabs}>
<button
type="button"
onClick={() => { setMode('signin'); setFieldErrors({}); setLocalError(''); }}
onClick={() => {
setMode('signin');
setFieldErrors({});
setLocalError('');
resetSignupState();
}}
aria-pressed={mode === 'signin'}
disabled={isBusy}
style={mode === 'signin' ? s.tabActive : s.tabInactive}
@@ -279,7 +359,11 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps
</button>
<button
type="button"
onClick={() => { setMode('signup'); setFieldErrors({}); setLocalError(''); }}
onClick={() => {
setMode('signup');
setFieldErrors({});
setLocalError('');
}}
aria-pressed={mode === 'signup'}
disabled={isBusy}
style={mode === 'signup' ? s.tabActive : s.tabInactive}
@@ -298,6 +382,7 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps
onChange={(e) => {
setEmail(e.target.value);
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }));
if (localError) setLocalError('');
}}
style={fieldErrors.email ? { ...s.input, ...s.inputError } : s.input}
placeholder="your@email.com"
@@ -317,6 +402,7 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps
onChange={(e) => {
setPassword(e.target.value);
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }));
if (localError) setLocalError('');
}}
style={fieldErrors.password ? { ...s.input, ...s.inputError } : s.input}
placeholder="••••••••"
@@ -328,6 +414,54 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps
{mode === 'signup' && <p style={s.fieldHint}>{t.auth.passwordHint}</p>}
</div>
{mode === 'signup' && (
<div style={s.fieldGroup}>
<label htmlFor="auth-code" style={s.label}>{t.auth.verificationCode}</label>
<div style={s.codeRow}>
<input
id="auth-code"
type="text"
value={verificationCode}
onChange={(e) => {
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}
/>
<button
type="button"
onClick={handleSendCode}
disabled={isBusy || sendingCode || countdown > 0}
style={{
...s.codeButton,
opacity: isBusy || sendingCode || countdown > 0 ? 0.65 : 1,
cursor: isBusy || sendingCode || countdown > 0 ? 'not-allowed' : 'pointer',
}}
>
{sendingCode
? t.common.loading
: countdown > 0
? `${t.auth.resendCode} (${countdown}s)`
: codeSent
? t.auth.resendCode
: t.auth.sendCode}
</button>
</div>
{fieldErrors.verificationCode && <p style={s.fieldError}>{fieldErrors.verificationCode}</p>}
<p style={s.fieldHint}>
{codeSent ? `${t.auth.codeSent}. ${t.auth.verificationCodeHint}` : t.auth.verificationCodeHint}
</p>
</div>
)}
{mode === 'signup' && (
<div style={s.fieldGroup}>
<label htmlFor="auth-password-confirm" style={s.label}>{t.auth.confirmPassword}</label>
@@ -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="••••••••"

View File

@@ -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<string, unknown>) => ({
});
describe('AuthModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('requires verification code before signup submit', async () => {
useAuthMock.mockReturnValue(createAuthState());
render(<AuthModal onClose={vi.fn()} />);
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(<AuthModal onClose={vi.fn()} />);
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(<AuthModal onClose={vi.fn()} />);

View File

@@ -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<void>;
@@ -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) {

View File

@@ -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<void> {
await http.post<null>('/user/email/code', { email } satisfies SendEmailCodeRequest, { skipAuth: true });
},
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });

View File

@@ -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 && (
<AuthModal
onClose={() => setShowAuthModal(false)}
onClose={() => {
setShowAuthModal(false);
setAuthModalMandatory(false);
}}
mandatory={authModalMandatory}
/>
)}

View File

@@ -50,6 +50,11 @@ export interface LoginRequest {
export interface RegisterRequest {
email: string;
password: string;
code: string;
}
export interface SendEmailCodeRequest {
email: string;
}
// OSS 签名响应数据