Files
OpenFlare/frontend/components/auth/cap-widget.tsx
T
ryan b7e5e811d1 fix(auth): register CAP scope, 400 on captcha, 403 for permission
Navigating from login reused a send_email_code token on register.
Captcha failure used 401 so the client stored /register as the
post-login target and never left the page. Permission denials now
return 403, and the API client no longer wipes the session on 401.
2026-09-02 18:26:22 +08:00

132 lines
3.8 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import {
CheckCircle2,
Loader2,
ShieldAlert,
ShieldCheck,
ShieldQuestion,
} from 'lucide-react';
import { getCapToken } from '@/lib/cap-solver';
import { useTranslations } from 'next-intl';
type CapStatus = 'idle' | 'solving' | 'solved' | 'error';
interface CapWidgetProps {
/** Called with the one-time token once the challenge is solved */
onToken: (token: string) => void;
/** Called when the challenge fails or encounters an error */
onError?: (err: Error) => void;
/** Whether to auto-start solving when the component mounts. Defaults to true. */
autoStart?: boolean;
/** PoW scope sent to the backend */
scope?: string;
}
/**
* Cap 人机验证小部件
*
* autoStart=true(默认):挂载后立即在后台运行 PoW 求解,完成后回调 onToken。
* autoStart=false:显示「点击开始验证」按钮,用户手动触发后才开始求解。
*/
export function CapWidget({
onToken,
onError,
autoStart = true,
scope = 'login',
}: CapWidgetProps) {
const [status, setStatus] = useState<CapStatus>('idle');
const [errorMsg, setErrorMsg] = useState('');
const solving = useRef(false);
const t = useTranslations('auth.capWidget');
const solve = async () => {
if (solving.current) return;
solving.current = true;
setStatus('solving');
setErrorMsg('');
try {
const token = await getCapToken(scope);
setStatus('solved');
onToken(token);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
setStatus('error');
setErrorMsg(error.message);
onError?.(error);
} finally {
solving.current = false;
}
};
useEffect(() => {
solving.current = false;
if (autoStart) {
void solve();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoStart, scope]);
return (
<div className='flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm'>
{/* idle + autoStart=false: 手动触发按钮 */}
{status === 'idle' && !autoStart && (
<button
type='button'
className='flex w-full items-center gap-2 text-left'
onClick={() => void solve()}
>
<ShieldQuestion className='size-4 shrink-0 text-muted-foreground' />
<span className='flex-1 text-muted-foreground'>
{t('clickToStart')}
</span>
</button>
)}
{/* idle + autoStart=true: 等待自动开始(极短暂状态) */}
{status === 'idle' && autoStart && (
<>
<ShieldAlert className='size-4 shrink-0 text-muted-foreground' />
<span className='text-muted-foreground'>
{t('waitingVerification')}
</span>
</>
)}
{status === 'solving' && (
<>
<Loader2 className='size-4 shrink-0 animate-spin text-primary' />
<span className='text-muted-foreground'>{t('verifying')}</span>
</>
)}
{status === 'solved' && (
<>
<CheckCircle2 className='size-4 shrink-0 text-green-500' />
<span className='text-green-600 dark:text-green-400'>
{t('verificationPassed')}
</span>
<ShieldCheck className='ml-auto size-4 shrink-0 text-green-500' />
</>
)}
{status === 'error' && (
<button
type='button'
className='flex w-full items-center gap-2 text-left'
onClick={() => void solve()}
>
<ShieldAlert className='size-4 shrink-0 text-destructive' />
<span className='flex-1 text-destructive'>
{errorMsg || t('verificationFailed')}
</span>
<span className='text-xs text-muted-foreground underline'>
{t('retry')}
</span>
</button>
)}
</div>
);
}