码桶
发现社区成员的开源项目
PasskeyModal.jsx6.9 KB
import React, { useState, useEffect } from 'react';
import { Fingerprint, Plus, Trash2, RefreshCw, X } from 'lucide-react';
import { startRegistration } from '@simplewebauthn/browser';
const PasskeyModal = ({ show, onClose, auth, t, showToast }) => {
const [passkeys, setPasskeys] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (show && auth?.token) fetchPasskeys();
}, [show]);
useEffect(() => {
if (!show) return;
const handleKeyDown = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [show, onClose]);
const fetchPasskeys = async () => {
try {
const res = await fetch('/api/passkey/credentials', {
headers: { 'Authorization': `Bearer ${auth.token}` }
});
const data = await res.json();
if (res.ok) setPasskeys(data.credentials || []);
} catch (err) { console.error('Failed to fetch passkeys:', err); }
};
const handleRegister = async () => {
if (!window.PublicKeyCredential) {
setError(t('passkeyNoSupport'));
return;
}
setLoading(true);
setError('');
try {
const optRes = await fetch('/api/passkey/register-options', {
headers: { 'Authorization': `Bearer ${auth.token}` }
});
const optData = await optRes.json();
if (!optRes.ok) {
setError(optData.error || t('passkeyError'));
setLoading(false);
return;
}
const regResp = await startRegistration({ optionsJSON: optData });
regResp.credentialName = `Passkey ${passkeys.length + 1}`;
const verifyRes = await fetch('/api/passkey/register-verify', {
method: 'POST',
headers: { 'Authorization': `Bearer ${auth.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(regResp)
});
const verifyData = await verifyRes.json();
if (verifyRes.ok && verifyData.success) {
showToast(t('passkeyRegistered'), 'success');
fetchPasskeys();
} else {
setError(verifyData.error || t('passkeyError'));
}
} catch (err) {
if (err.name !== 'NotAllowedError') {
setError(t('passkeyError'));
}
}
setLoading(false);
};
const handleDelete = async (credId) => {
try {
const res = await fetch(`/api/passkey/credentials?id=${encodeURIComponent(credId)}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${auth.token}`, 'Content-Type': 'application/json' }
});
if (res.ok) {
showToast(t('passkeyDeleted'), 'success');
fetchPasskeys();
}
} catch (err) { console.error('Failed to delete passkey:', err); showToast(t('errorOccurred'), 'error'); }
};
if (!show) return null;
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('passkeyManage')} 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' }}>
<Fingerprint size={18} /> {t('passkeyManage')}
</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>
{passkeys.length === 0 ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', textAlign: 'center', padding: '1rem 0' }}>
{t('passkeyNone')}
</p>
) : (
<div style={{ marginBottom: '1rem', maxHeight: '200px', overflowY: 'auto' }}>
{passkeys.map((pk) => (
<div key={pk.id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0.6rem 0.75rem', borderRadius: '8px', marginBottom: '0.4rem',
background: 'var(--hover-bg)', border: '1px solid var(--border)'
}}>
<div>
<div style={{ fontSize: '0.85rem', fontWeight: 500, display: 'flex', alignItems: 'center', gap: '6px' }}>
<Fingerprint size={14} style={{ color: '#9333ea' }} />
{pk.name}
</div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '2px' }}>
{new Date(pk.createdAt).toLocaleDateString()}
</div>
</div>
<button onClick={() => handleDelete(pk.id)}
style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: '4px', display: 'flex', color: 'var(--error)' }}
title="Delete"
aria-label={`Delete passkey ${pk.name}`}>
<Trash2 size={14} />
</button>
</div>
))}
</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={onClose}>{t('cancel')}</button>
<button className="btn btn-primary" onClick={handleRegister} disabled={loading}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{loading ? <RefreshCw className="spin" size={14} /> : <Plus size={14} />}
{t('passkeyRegister')}
</button>
</div>
</div>
</div>
);
};
export default PasskeyModal;