Skip to content

Commit 213969c

Browse files
committed
fix(auth): 修复邮箱验证跳转死页面、重置密码链接失效与登录循环
内测用户点击 QQ 邮箱验证链接后落到 localhost:3000 死页面,误以为 验证失败(实际已验证成功),进而反馈'不用验证也能登录'。 - signUp 补 emailRedirectTo 指向官网新增 /verified 落地页,该页兼顾 成功与链接过期(hash 含 error)两种结果,用 useSyncExternalStore 读取 hash 以规避静态导出的 hydration 不匹配 - ResetPassword 改为应用内验证码(OTP)流程:桌面端 file:// 协议无法 作为 redirectTo,链接回跳结构性不可用;顺带移除原 token_hash 分支 从未调用 verifyOtp 的死代码 - 修复登录循环:AuthGuard 与'跳过登录'的模式降级缺口 + session-expired 事件风暴 需配合 Supabase Dashboard:Site URL、Redirect URLs 白名单、Recovery 邮件模板加 Token 占位符(均已配置)
1 parent 0e27603 commit 213969c

8 files changed

Lines changed: 349 additions & 65 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @ai-context: useSessionExpiry 回归测试——并发 401 与 SIGNED_OUT 会在短时间
3+
* 内派发多个 kb:session-expired 事件,冷却窗口内必须只弹一次 Toast。
4+
* @ai-context: Regression test — bursts of kb:session-expired events must be
5+
* deduplicated within the cooldown window (single toast, single redirect).
6+
*/
7+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8+
import { renderHook, act } from '@testing-library/react';
9+
import React from 'react';
10+
import { MemoryRouter } from 'react-router-dom';
11+
import { useSessionExpiry } from './useSessionExpiry';
12+
13+
const toastSpy = vi.fn();
14+
15+
vi.mock('@/components/ui', () => ({
16+
useToast: () => ({ toast: toastSpy }),
17+
}));
18+
19+
function wrapper({ children }: { children: React.ReactNode }) {
20+
return React.createElement(MemoryRouter, null, children);
21+
}
22+
23+
describe('useSessionExpiry - 事件风暴去重', () => {
24+
beforeEach(() => {
25+
vi.useFakeTimers();
26+
toastSpy.mockClear();
27+
});
28+
29+
afterEach(() => {
30+
vi.useRealTimers();
31+
});
32+
33+
it('冷却窗口内的多个 session-expired 事件只弹一次 Toast', () => {
34+
renderHook(() => useSessionExpiry(), { wrapper });
35+
36+
act(() => {
37+
// 模拟多个并发 401 请求 + SIGNED_OUT 各自派发事件
38+
window.dispatchEvent(new CustomEvent('kb:session-expired'));
39+
window.dispatchEvent(new CustomEvent('kb:session-expired'));
40+
window.dispatchEvent(new CustomEvent('kb:session-expired'));
41+
});
42+
43+
expect(toastSpy).toHaveBeenCalledTimes(1);
44+
});
45+
46+
it('冷却窗口过后允许再次提示', () => {
47+
renderHook(() => useSessionExpiry(), { wrapper });
48+
49+
act(() => {
50+
window.dispatchEvent(new CustomEvent('kb:session-expired'));
51+
});
52+
act(() => {
53+
vi.advanceTimersByTime(10000); // 越过冷却窗口
54+
window.dispatchEvent(new CustomEvent('kb:session-expired'));
55+
});
56+
57+
expect(toastSpy).toHaveBeenCalledTimes(2);
58+
});
59+
});

‎client/src/hooks/useSessionExpiry.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import { useToast } from '@/components/ui';
77

88
const SESSION_EXPIRED_EVENT = 'kb:session-expired';
99

10+
/**
11+
* 事件去重冷却窗口:并发 401 请求与 Supabase SIGNED_OUT 会在短时间内
12+
* 各自派发 session-expired,窗口内只处理第一个,避免重复弹 Toast
13+
* 让用户感知为"持续要求登录"(内测反馈 bug)
14+
*/
15+
const SESSION_EXPIRED_COOLDOWN_MS = 5000;
16+
1017
/**
1118
* 监听 session 过期事件,弹出 Toast 提示并提供重新登录入口
1219
* 需在 AppLayout 或其他全局组件中调用
@@ -23,9 +30,16 @@ export function useSessionExpiry() {
2330

2431
// Bug #15: 防止重复设置 setTimeout
2532
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
33+
// 冷却窗口去重:记录上次处理事件的时间戳
34+
const lastHandledAtRef = useRef(0);
2635

2736
useEffect(() => {
2837
function handleSessionExpired() {
38+
// 冷却窗口内的重复事件(并发 401 / SIGNED_OUT 风暴)直接忽略
39+
const now = Date.now();
40+
if (now - lastHandledAtRef.current < SESSION_EXPIRED_COOLDOWN_MS) return;
41+
lastHandledAtRef.current = now;
42+
2943
toastRef.current({
3044
type: 'warning',
3145
message: '登录已过期,请重新登录',

‎client/src/lib/auth/AuthContext.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
100100
if (isPlaceholder) {
101101
return { error: { message: '云服务尚未配置,请先在 .env 中设置 VITE_SUPABASE_URL 和 VITE_SUPABASE_ANON_KEY' } as AuthError };
102102
}
103-
const { error } = await supabase.auth.signUp({ email, password });
103+
// emailRedirectTo:验证邮件链接在系统浏览器打开,桌面端无法回到应用,
104+
// 故落地到官网验证成功页(需同步加入 Supabase Redirect URLs 白名单,
105+
// 否则回退到 Site URL)
106+
const { error } = await supabase.auth.signUp({
107+
email,
108+
password,
109+
options: { emailRedirectTo: 'https://entropydecrease.com/verified' },
110+
});
104111
return { error };
105112
}, []);
106113

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @ai-context: LoginPage 回归测试——"跳过登录"必须降级到 local 模式,
3+
* 否则 AuthGuard 会在 hybrid/full 模式下把未登录用户弹回登录页(死循环)。
4+
* @ai-context: Regression test — "skip login" must downgrade to local mode,
5+
* otherwise AuthGuard keeps redirecting unauthenticated users back to /login.
6+
*/
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
8+
import { render, screen, fireEvent } from '@testing-library/react';
9+
import { MemoryRouter } from 'react-router-dom';
10+
import LoginPage from './LoginPage';
11+
import { modeManager } from '@/lib/mode/ModeManager';
12+
13+
vi.mock('@/lib/auth/AuthContext', () => ({
14+
useAuth: () => ({
15+
signIn: vi.fn().mockResolvedValue({ error: null }),
16+
}),
17+
}));
18+
19+
describe('LoginPage - 跳过登录', () => {
20+
beforeEach(() => {
21+
localStorage.clear();
22+
});
23+
24+
it('点击"跳过登录"应将模式降级为 local,避免 AuthGuard 循环重定向', () => {
25+
// 模拟内测用户此前开启过云同步(hybrid 模式已持久化)
26+
modeManager.setMode('hybrid');
27+
28+
render(
29+
<MemoryRouter initialEntries={['/login']}>
30+
<LoginPage />
31+
</MemoryRouter>,
32+
);
33+
34+
fireEvent.click(screen.getByText(/跳过登录/));
35+
36+
// 核心断言:模式必须降级,否则回到 / 会被 AuthGuard 再次踢回 /login
37+
expect(modeManager.getMode()).toBe('local');
38+
});
39+
});

‎client/src/pages/LoginPage.tsx‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Mail, Lock, AlertCircle } from 'lucide-react';
77
import { Button, Input, Card } from '@/components/ui';
88
import { cn } from '@/lib/utils';
99
import { useAuth } from '@/lib/auth/AuthContext';
10+
import { modeManager } from '@/lib/mode/ModeManager';
1011

1112
export default function LoginPage() {
1213
const navigate = useNavigate();
@@ -17,6 +18,16 @@ export default function LoginPage() {
1718
const [error, setError] = useState<string | null>(null);
1819
const [loading, setLoading] = useState(false);
1920

21+
/**
22+
* 跳过登录:必须先把模式降级为 local,再回首页。
23+
* 否则 hybrid/full 模式下 AuthGuard 会立即把未登录用户踢回登录页,
24+
* 形成"持续要求登录"的死循环(内测反馈 bug)。
25+
*/
26+
const handleSkipLogin = () => {
27+
modeManager.setMode('local');
28+
navigate('/', { replace: true });
29+
};
30+
2031
const handleSubmit = async (e: FormEvent) => {
2132
e.preventDefault();
2233
setError(null);
@@ -136,11 +147,15 @@ export default function LoginPage() {
136147
</p>
137148
</Card>
138149

139-
{/* Skip link */}
150+
{/* Skip link:降级到本地模式后再离开,避免 AuthGuard 循环重定向 */}
140151
<p className="text-c1 text-text-tertiary text-center mt-5">
141-
<Link to="/" className="hover:text-text-secondary transition-colors">
152+
<button
153+
type="button"
154+
onClick={handleSkipLogin}
155+
className="hover:text-text-secondary transition-colors"
156+
>
142157
跳过登录,继续使用本地功能
143-
</Link>
158+
</button>
144159
</p>
145160
</div>
146161
</div>

‎client/src/pages/ResetPassword.tsx‎

Lines changed: 59 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,32 @@
11
/**
2-
* @ai-context: 页面组件:ResetPassword。
2+
* @ai-context: 页面组件:ResetPassword。重置密码采用邮箱验证码(OTP)流程:
3+
* 请求重置 → 邮件收 6 位验证码 → 应用内输入验证码+新密码。
4+
* Why: 桌面端邮件链接在系统浏览器打开、恢复会话无法回到应用(file:// 协议
5+
* 也无法作为 redirectTo),链接回跳模式结构性不可用,故全程留在应用内。
6+
* 依赖 Supabase Recovery 邮件模板包含 {{ .Token }} 验证码。
37
*/
48
import { useState, useEffect, useRef, type FormEvent } from 'react';
5-
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
6-
import { Mail, Lock, AlertCircle, CheckCircle2, ArrowLeft } from 'lucide-react';
9+
import { Link, useNavigate } from 'react-router-dom';
10+
import { Mail, Lock, KeyRound, AlertCircle, CheckCircle2, ArrowLeft } from 'lucide-react';
711
import { Button, Input, Card } from '@/components/ui';
812
import { cn } from '@/lib/utils';
913
import { supabase } from '@/lib/auth/supabaseClient';
1014

1115
type ViewMode = 'request' | 'reset';
1216

1317
export default function ResetPassword() {
14-
const [searchParams] = useSearchParams();
1518
const navigate = useNavigate();
1619

17-
const tokenHash = searchParams.get('token_hash');
18-
const type = searchParams.get('type');
19-
const isResetMode = tokenHash && type === 'recovery';
20-
21-
const [viewMode, setViewMode] = useState<ViewMode>(isResetMode ? 'reset' : 'request');
20+
const [viewMode, setViewMode] = useState<ViewMode>('request');
2221
const [email, setEmail] = useState('');
22+
const [otpCode, setOtpCode] = useState('');
2323
const [password, setPassword] = useState('');
2424
const [confirmPassword, setConfirmPassword] = useState('');
2525
const [error, setError] = useState<string | null>(null);
2626
const [success, setSuccess] = useState(false);
2727
const [loading, setLoading] = useState(false);
2828
const navigateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2929

30-
useEffect(() => {
31-
if (isResetMode) setViewMode('reset');
32-
}, [isResetMode]);
33-
3430
useEffect(() => {
3531
return () => {
3632
if (navigateTimerRef.current) clearTimeout(navigateTimerRef.current);
@@ -48,12 +44,10 @@ export default function ResetPassword() {
4844

4945
setLoading(true);
5046
try {
51-
const redirectTo = `${window.location.origin}${window.location.pathname}#/reset-password`;
52-
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email.trim(), {
53-
redirectTo,
54-
});
47+
// 不传 redirectTo:验证走应用内 OTP 验证码,不依赖邮件链接跳转
48+
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email.trim());
5549
if (resetError) throw resetError;
56-
setSuccess(true);
50+
setViewMode('reset');
5751
} catch (err: unknown) {
5852
const msg = err instanceof Error ? err.message : '发送失败,请稍后重试';
5953
setError(msg);
@@ -66,6 +60,10 @@ export default function ResetPassword() {
6660
e.preventDefault();
6761
setError(null);
6862

63+
if (!otpCode.trim() || otpCode.trim().length < 6) {
64+
setError('请输入邮件中的 6 位验证码');
65+
return;
66+
}
6967
if (!password || password.length < 8) {
7068
setError('密码长度至少 8 位');
7169
return;
@@ -77,6 +75,13 @@ export default function ResetPassword() {
7775

7876
setLoading(true);
7977
try {
78+
// 先用验证码换取恢复会话,再更新密码
79+
const { error: otpError } = await supabase.auth.verifyOtp({
80+
email: email.trim(),
81+
token: otpCode.trim(),
82+
type: 'recovery',
83+
});
84+
if (otpError) throw otpError;
8085
const { error: updateError } = await supabase.auth.updateUser({ password });
8186
if (updateError) throw updateError;
8287
setSuccess(true);
@@ -107,7 +112,9 @@ export default function ResetPassword() {
107112
{viewMode === 'request' ? '重置密码' : '设置新密码'}
108113
</h1>
109114
<p className="text-b2 text-text-tertiary mt-1">
110-
{viewMode === 'request' ? '输入邮箱以接收重置链接' : '输入你的新密码'}
115+
{viewMode === 'request'
116+
? '输入邮箱以接收验证码'
117+
: `验证码已发送至 ${email}`}
111118
</p>
112119
</div>
113120
</div>
@@ -117,41 +124,25 @@ export default function ResetPassword() {
117124
{success ? (
118125
<div className="flex flex-col items-center gap-4 py-2">
119126
<CheckCircle2 className="w-12 h-12 text-emerald-500" strokeWidth={1.5} />
120-
<p className="text-b1 font-medium text-text-primary text-center">
121-
{viewMode === 'request'
122-
? '重置邮件已发送'
123-
: '密码重置成功'}
124-
</p>
125-
<p className="text-b2 text-text-tertiary text-center">
126-
{viewMode === 'request'
127-
? `如果 ${email} 已注册,你将收到一封重置密码的邮件`
128-
: '即将跳转到登录页...'}
129-
</p>
130-
{viewMode === 'request' && (
131-
<Link
132-
to="/login"
133-
className="text-b2 text-brand-600 font-medium hover:text-brand-700 transition-colors mt-2"
134-
>
135-
返回登录
136-
</Link>
137-
)}
127+
<p className="text-b1 font-medium text-text-primary text-center">密码重置成功</p>
128+
<p className="text-b2 text-text-tertiary text-center">即将跳转到登录页...</p>
138129
</div>
139130
) : (
140131
<>
141-
{viewMode === 'request' ? (
142-
<form onSubmit={handleRequestReset} className="flex flex-col gap-kb-md">
143-
{error && (
144-
<div
145-
className={cn(
146-
'flex items-start gap-2 px-3 py-2.5 rounded-kb-md',
147-
'bg-[#F43F5E]/10 border border-[#F43F5E]/30',
148-
)}
149-
>
150-
<AlertCircle className="w-icon-sm h-icon-sm text-[#F43F5E] flex-shrink-0 mt-0.5" strokeWidth={1.5} />
151-
<p className="text-b3 text-[#F43F5E]">{error}</p>
152-
</div>
132+
{error && (
133+
<div
134+
className={cn(
135+
'flex items-start gap-2 px-3 py-2.5 rounded-kb-md mb-4',
136+
'bg-[#F43F5E]/10 border border-[#F43F5E]/30',
153137
)}
138+
>
139+
<AlertCircle className="w-icon-sm h-icon-sm text-[#F43F5E] flex-shrink-0 mt-0.5" strokeWidth={1.5} />
140+
<p className="text-b3 text-[#F43F5E]">{error}</p>
141+
</div>
142+
)}
154143

144+
{viewMode === 'request' ? (
145+
<form onSubmit={handleRequestReset} className="flex flex-col gap-kb-md">
155146
<Input
156147
label="注册邮箱"
157148
type="email"
@@ -163,22 +154,21 @@ export default function ResetPassword() {
163154
/>
164155

165156
<Button type="submit" variant="primary" size="lg" loading={loading} className="w-full mt-1">
166-
发送重置链接
157+
发送验证码
167158
</Button>
168159
</form>
169160
) : (
170161
<form onSubmit={handleResetPassword} className="flex flex-col gap-kb-md">
171-
{error && (
172-
<div
173-
className={cn(
174-
'flex items-start gap-2 px-3 py-2.5 rounded-kb-md',
175-
'bg-[#F43F5E]/10 border border-[#F43F5E]/30',
176-
)}
177-
>
178-
<AlertCircle className="w-icon-sm h-icon-sm text-[#F43F5E] flex-shrink-0 mt-0.5" strokeWidth={1.5} />
179-
<p className="text-b3 text-[#F43F5E]">{error}</p>
180-
</div>
181-
)}
162+
<Input
163+
label="邮箱验证码"
164+
type="text"
165+
inputMode="numeric"
166+
placeholder="邮件中的 6 位验证码"
167+
autoComplete="one-time-code"
168+
prefix={<KeyRound className="w-icon-sm h-icon-sm" strokeWidth={1.5} />}
169+
value={otpCode}
170+
onChange={(e) => setOtpCode(e.target.value)}
171+
/>
182172

183173
<Input
184174
label="新密码"
@@ -203,6 +193,14 @@ export default function ResetPassword() {
203193
<Button type="submit" variant="primary" size="lg" loading={loading} className="w-full mt-1">
204194
重置密码
205195
</Button>
196+
197+
<button
198+
type="button"
199+
onClick={() => { setViewMode('request'); setError(null); }}
200+
className="text-b3 text-text-tertiary hover:text-text-secondary transition-colors text-center"
201+
>
202+
没收到验证码?返回重新发送
203+
</button>
206204
</form>
207205
)}
208206
</>

0 commit comments

Comments
 (0)