mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
b7e5e811d1
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.
132 lines
3.8 KiB
TypeScript
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>
|
|
);
|
|
}
|