码桶
发现社区成员的开源项目
TotpModal.jsx11.7 KB
import React, { useState, useEffect } from 'react';
import { Shield, CheckCircle, RefreshCw, Copy, X } from 'lucide-react';
const TotpModal = ({ show, onClose, auth, t, showToast }) => {
const [setupStep, setSetupStep] = useState(null);
const [secret, setSecret] = useState('');
const [uri, setUri] = useState('');
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [enabled, setEnabled] = useState(false);
useEffect(() => {
if (!show) return;
const handleKeyDown = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [show, onClose]);
if (!show) return null;
const handleSetup = async () => {
setSetupStep('loading');
setError('');
setCode('');
try {
const res = await fetch('/api/account/totp-setup', {
headers: { 'Authorization': `Bearer ${auth.token}` }
});
const data = await res.json();
if (res.ok && data.secret) {
setSecret(data.secret);
setUri(data.uri);
setSetupStep('qr');
} else {
setError(data.error || 'Failed to generate TOTP secret');
setSetupStep(null);
}
} catch (e) {
console.error('Failed to start TOTP setup:', e);
setError('Failed to start TOTP setup');
setSetupStep(null);
}
};
const handleVerify = async () => {
if (!code || code.length !== 6) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/account/totp-setup', {
method: 'POST',
headers: { 'Authorization': `Bearer ${auth.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
const data = await res.json();
if (res.ok && data.success) {
setEnabled(true);
setSetupStep(null);
setCode('');
showToast(t('totpVerified'), 'success');
} else {
setError(data.error || t('totpInvalid'));
}
} catch (e) {
console.error('Failed to verify TOTP:', e);
setError(t('errorOccurred'));
}
setLoading(false);
};
const handleDisable = async () => {
if (!code || code.length !== 6) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/account/totp-setup', {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${auth.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
const data = await res.json();
if (res.ok && data.success) {
setEnabled(false);
setSetupStep(null);
setCode('');
showToast(t('totpDisabled'), 'success');
} else {
setError(data.error || t('totpInvalid'));
}
} catch (e) {
console.error('Failed to disable TOTP:', e);
setError(t('errorOccurred'));
}
setLoading(false);
};
return (
<div className="modal-overlay" style={{ zIndex: 200 }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="glass-card fade-in modal-content" role="dialog" aria-label={t('totpManage')} style={{ width: '100%', maxWidth: '420px', padding: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h3 style={{ fontSize: '1rem', margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={18} color="#9333ea" /> {t('totpManage')}
</h3>
<button onClick={onClose} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: '4px', display: 'flex' }} aria-label="Close">
<X size={18} color="var(--text-muted)" />
</button>
</div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{t('totpSetupDesc')}
</p>
{setupStep === 'qr' ? (
<div className="fade-in">
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('totpScanQR')}
</p>
<div style={{ padding: '0.75rem', background: 'var(--hover-bg)', borderRadius: '8px', marginBottom: '0.75rem', textAlign: 'center' }}>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '0.25rem' }}>{t('totpSecretKey')}</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem' }}>
<code style={{ fontSize: '0.8rem', fontWeight: 600, wordBreak: 'break-all', color: 'var(--text)' }}>{secret}</code>
<button onClick={() => { navigator.clipboard.writeText(secret); showToast(t('copied'), 'success'); }}
style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: '2px', display: 'flex', color: 'var(--text-muted)', flexShrink: 0 }}
aria-label="Copy secret key">
<Copy size={14} />
</button>
</div>
</div>
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('totpEnterCode')}</p>
<div style={{ position: 'relative', marginBottom: '0.75rem' }}>
<input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6}
placeholder={t('totpCodePlaceholder')} value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
onKeyDown={(e) => { if (e.key === 'Enter' && code.length === 6) handleVerify(); }}
style={{ width: '100%', textAlign: 'center', fontSize: '1.2rem', letterSpacing: '0.3em' }}
autoFocus />
</div>
{error && <p style={{ color: 'var(--error)', fontSize: '0.75rem', marginBottom: '0.75rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button className="btn btn-outline" onClick={() => { setSetupStep(null); setCode(''); setError(''); }}>{t('cancel')}</button>
<button className="btn btn-primary" onClick={handleVerify} disabled={loading || code.length !== 6}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{loading ? <RefreshCw className="spin" size={14} /> : <CheckCircle size={14} />}
{t('totpVerify')}
</button>
</div>
</div>
) : setupStep === 'disabling' ? (
<div className="fade-in">
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('totpDisableDesc')}
</p>
<div style={{ position: 'relative', marginBottom: '0.75rem' }}>
<input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6}
placeholder={t('totpCodePlaceholder')} value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
onKeyDown={(e) => { if (e.key === 'Enter' && code.length === 6) handleDisable(); }}
style={{ width: '100%', textAlign: 'center', fontSize: '1.2rem', letterSpacing: '0.3em' }}
autoFocus />
</div>
{error && <p style={{ color: 'var(--error)', fontSize: '0.75rem', marginBottom: '0.75rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button className="btn btn-outline" onClick={() => { setSetupStep(null); setCode(''); setError(''); }}>{t('cancel')}</button>
<button className="btn btn-primary" onClick={handleDisable} disabled={loading || code.length !== 6}
style={{ display: 'flex', alignItems: 'center', gap: '6px', background: 'var(--error)', borderColor: 'var(--error)' }}>
{loading ? <RefreshCw className="spin" size={14} /> : <X size={14} />}
{t('totpDisable')}
</button>
</div>
</div>
) : setupStep === 'loading' ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<RefreshCw className="spin" size={20} color="var(--primary)" />
</div>
) : (
<div>
<div style={{
padding: '0.75rem 1rem', background: enabled ? 'var(--success-bg)' : 'var(--hover-bg)',
borderRadius: '8px', marginBottom: '1rem',
border: enabled ? '1px solid var(--success-border)' : '1px solid var(--border)',
display: 'flex', alignItems: 'center', gap: '0.75rem'
}}>
<Shield size={20} color={enabled ? 'var(--success)' : 'var(--text-muted)'} />
<div>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: enabled ? 'var(--success)' : 'var(--text)' }}>
{enabled ? t('totpEnabled') : t('totpNotEnabled')}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button className="btn btn-outline" onClick={onClose}>{t('cancel')}</button>
{enabled ? (
<button className="btn btn-outline" onClick={() => { setSetupStep('disabling'); setCode(''); setError(''); }}
style={{ color: 'var(--error)', borderColor: 'var(--error)', display: 'flex', alignItems: 'center', gap: '6px' }}>
<X size={14} />
{t('totpDisable')}
</button>
) : (
<button className="btn btn-primary" onClick={handleSetup}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Shield size={14} />
{t('totpEnable')}
</button>
)}
</div>
</div>
)}
</div>
</div>
);
};
export default TotpModal;