feat: tighten workspace auth and onboarding flow
This commit is contained in:
856
docs/superpowers/plans/2026-03-27-workspace-improvements.md
Normal file
856
docs/superpowers/plans/2026-03-27-workspace-improvements.md
Normal 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 38–51) 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
|
||||
@@ -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="••••••••"
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ export interface LoginRequest {
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface SendEmailCodeRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
// OSS 签名响应数据
|
||||
|
||||
Reference in New Issue
Block a user