feat: passkey
This commit is contained in:
@@ -26,6 +26,9 @@ import {
|
||||
showInfo,
|
||||
showSuccess,
|
||||
setStatusData,
|
||||
prepareCredentialCreationOptions,
|
||||
buildRegistrationResult,
|
||||
isPasskeySupported,
|
||||
} from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
@@ -66,6 +69,10 @@ const PersonalSetting = () => {
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [systemToken, setSystemToken] = useState('');
|
||||
const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false });
|
||||
const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
|
||||
const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
|
||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||
const [notificationSettings, setNotificationSettings] = useState({
|
||||
warningType: 'email',
|
||||
warningThreshold: 100000,
|
||||
@@ -112,6 +119,10 @@ const PersonalSetting = () => {
|
||||
})();
|
||||
|
||||
getUserData();
|
||||
|
||||
isPasskeySupported()
|
||||
.then(setPasskeySupported)
|
||||
.catch(() => setPasskeySupported(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -160,11 +171,89 @@ const PersonalSetting = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadPasskeyStatus = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/user/passkey');
|
||||
const { success, data, message } = res.data;
|
||||
if (success) {
|
||||
setPasskeyStatus({
|
||||
enabled: data?.enabled || false,
|
||||
last_used_at: data?.last_used_at || null,
|
||||
backup_eligible: data?.backup_eligible || false,
|
||||
backup_state: data?.backup_state || false,
|
||||
});
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略错误,保留默认状态
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterPasskey = async () => {
|
||||
if (!passkeySupported || !window.PublicKeyCredential) {
|
||||
showInfo(t('当前设备不支持 Passkey'));
|
||||
return;
|
||||
}
|
||||
setPasskeyRegisterLoading(true);
|
||||
try {
|
||||
const beginRes = await API.post('/api/user/passkey/register/begin');
|
||||
const { success, message, data } = beginRes.data;
|
||||
if (!success) {
|
||||
showError(message || t('无法发起 Passkey 注册'));
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data);
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
const payload = buildRegistrationResult(credential);
|
||||
if (!payload) {
|
||||
showError(t('Passkey 注册失败,请重试'));
|
||||
return;
|
||||
}
|
||||
|
||||
const finishRes = await API.post('/api/user/passkey/register/finish', payload);
|
||||
if (finishRes.data.success) {
|
||||
showSuccess(t('Passkey 注册成功'));
|
||||
await loadPasskeyStatus();
|
||||
} else {
|
||||
showError(finishRes.data.message || t('Passkey 注册失败,请重试'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
showInfo(t('已取消 Passkey 注册'));
|
||||
} else {
|
||||
showError(t('Passkey 注册失败,请重试'));
|
||||
}
|
||||
} finally {
|
||||
setPasskeyRegisterLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePasskey = async () => {
|
||||
setPasskeyDeleteLoading(true);
|
||||
try {
|
||||
const res = await API.delete('/api/user/passkey');
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('Passkey 已解绑'));
|
||||
await loadPasskeyStatus();
|
||||
} else {
|
||||
showError(message || t('操作失败,请重试'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败,请重试'));
|
||||
} finally {
|
||||
setPasskeyDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserData = async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
await loadPasskeyStatus();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -352,6 +441,12 @@ const PersonalSetting = () => {
|
||||
handleSystemTokenClick={handleSystemTokenClick}
|
||||
setShowChangePasswordModal={setShowChangePasswordModal}
|
||||
setShowAccountDeleteModal={setShowAccountDeleteModal}
|
||||
passkeyStatus={passkeyStatus}
|
||||
passkeySupported={passkeySupported}
|
||||
passkeyRegisterLoading={passkeyRegisterLoading}
|
||||
passkeyDeleteLoading={passkeyDeleteLoading}
|
||||
onPasskeyRegister={handleRegisterPasskey}
|
||||
onPasskeyDelete={handleRemovePasskey}
|
||||
/>
|
||||
|
||||
{/* 右侧:其他设置 */}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Spin,
|
||||
Card,
|
||||
Radio,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
const { Text } = Typography;
|
||||
import {
|
||||
@@ -77,6 +78,13 @@ const SystemSetting = () => {
|
||||
TurnstileSiteKey: '',
|
||||
TurnstileSecretKey: '',
|
||||
RegisterEnabled: '',
|
||||
'passkey.enabled': '',
|
||||
'passkey.rp_display_name': '',
|
||||
'passkey.rp_id': '',
|
||||
'passkey.origins': [],
|
||||
'passkey.allow_insecure_origin': '',
|
||||
'passkey.user_verification': 'preferred',
|
||||
'passkey.attachment_preference': '',
|
||||
EmailDomainRestrictionEnabled: '',
|
||||
EmailAliasRestrictionEnabled: '',
|
||||
SMTPSSLEnabled: '',
|
||||
@@ -114,6 +122,7 @@ const SystemSetting = () => {
|
||||
const [domainList, setDomainList] = useState([]);
|
||||
const [ipList, setIpList] = useState([]);
|
||||
const [allowedPorts, setAllowedPorts] = useState([]);
|
||||
const [passkeyOrigins, setPasskeyOrigins] = useState([]);
|
||||
|
||||
const getOptions = async () => {
|
||||
setLoading(true);
|
||||
@@ -173,9 +182,28 @@ const SystemSetting = () => {
|
||||
case 'SMTPSSLEnabled':
|
||||
case 'LinuxDOOAuthEnabled':
|
||||
case 'oidc.enabled':
|
||||
case 'passkey.enabled':
|
||||
case 'passkey.allow_insecure_origin':
|
||||
case 'WorkerAllowHttpImageRequestEnabled':
|
||||
item.value = toBoolean(item.value);
|
||||
break;
|
||||
case 'passkey.origins':
|
||||
try {
|
||||
const origins = item.value ? JSON.parse(item.value) : [];
|
||||
setPasskeyOrigins(Array.isArray(origins) ? origins : []);
|
||||
item.value = Array.isArray(origins) ? origins : [];
|
||||
} catch (e) {
|
||||
setPasskeyOrigins([]);
|
||||
item.value = [];
|
||||
}
|
||||
break;
|
||||
case 'passkey.rp_display_name':
|
||||
case 'passkey.rp_id':
|
||||
case 'passkey.user_verification':
|
||||
case 'passkey.attachment_preference':
|
||||
// 确保字符串字段不为null/undefined
|
||||
item.value = item.value || '';
|
||||
break;
|
||||
case 'Price':
|
||||
case 'MinTopUp':
|
||||
item.value = parseFloat(item.value);
|
||||
@@ -582,6 +610,45 @@ const SystemSetting = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const submitPasskeySettings = async () => {
|
||||
const options = [];
|
||||
|
||||
// 只在值有变化时才提交,并确保空值转换为空字符串
|
||||
if (originInputs['passkey.rp_display_name'] !== inputs['passkey.rp_display_name']) {
|
||||
options.push({
|
||||
key: 'passkey.rp_display_name',
|
||||
value: inputs['passkey.rp_display_name'] || '',
|
||||
});
|
||||
}
|
||||
if (originInputs['passkey.rp_id'] !== inputs['passkey.rp_id']) {
|
||||
options.push({
|
||||
key: 'passkey.rp_id',
|
||||
value: inputs['passkey.rp_id'] || '',
|
||||
});
|
||||
}
|
||||
if (originInputs['passkey.user_verification'] !== inputs['passkey.user_verification']) {
|
||||
options.push({
|
||||
key: 'passkey.user_verification',
|
||||
value: inputs['passkey.user_verification'] || 'preferred',
|
||||
});
|
||||
}
|
||||
if (originInputs['passkey.attachment_preference'] !== inputs['passkey.attachment_preference']) {
|
||||
options.push({
|
||||
key: 'passkey.attachment_preference',
|
||||
value: inputs['passkey.attachment_preference'] || '',
|
||||
});
|
||||
}
|
||||
// Origins总是提交,因为它们可能会被用户清空
|
||||
options.push({
|
||||
key: 'passkey.origins',
|
||||
value: JSON.stringify(Array.isArray(passkeyOrigins) ? passkeyOrigins : []),
|
||||
});
|
||||
|
||||
if (options.length > 0) {
|
||||
await updateOptions(options);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckboxChange = async (optionKey, event) => {
|
||||
const value = event.target.checked;
|
||||
|
||||
@@ -957,6 +1024,126 @@ const SystemSetting = () => {
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form.Section text={t('配置 Passkey')}>
|
||||
<Text>{t('用以支持基于 WebAuthn 的无密码登录注册')}</Text>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t('Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式')}
|
||||
style={{ marginBottom: 20, marginTop: 16 }}
|
||||
/>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Form.Checkbox
|
||||
field='passkey.enabled'
|
||||
noLabel
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('passkey.enabled', e)
|
||||
}
|
||||
>
|
||||
{t('允许通过 Passkey 登录 & 注册')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field='passkey.rp_display_name'
|
||||
label={t('服务显示名称')}
|
||||
placeholder={t('默认使用系统名称')}
|
||||
extraText={t('用户注册时看到的网站名称,比如\'我的网站\'')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field='passkey.rp_id'
|
||||
label={t('网站域名标识')}
|
||||
placeholder={t('例如:example.com')}
|
||||
extraText={t('留空自动使用当前域名')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Select
|
||||
field='passkey.user_verification'
|
||||
label={t('安全验证级别')}
|
||||
placeholder={t('是否要求指纹/面容等生物识别')}
|
||||
optionList={[
|
||||
{ label: t('推荐使用(用户可选)'), value: 'preferred' },
|
||||
{ label: t('强制要求'), value: 'required' },
|
||||
{ label: t('不建议使用'), value: 'discouraged' },
|
||||
]}
|
||||
extraText={t('推荐:用户可以选择是否使用指纹等验证')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Select
|
||||
field='passkey.attachment_preference'
|
||||
label={t('设备类型偏好')}
|
||||
placeholder={t('选择支持的认证设备类型')}
|
||||
optionList={[
|
||||
{ label: t('不限制'), value: '' },
|
||||
{ label: t('本设备内置'), value: 'platform' },
|
||||
{ label: t('外接设备'), value: 'cross-platform' },
|
||||
]}
|
||||
extraText={t('本设备:手机指纹/面容,外接:USB安全密钥')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Form.Checkbox
|
||||
field='passkey.allow_insecure_origin'
|
||||
noLabel
|
||||
extraText={t('仅用于开发环境,生产环境应使用 HTTPS')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('passkey.allow_insecure_origin', e)
|
||||
}
|
||||
>
|
||||
{t('允许不安全的 Origin(HTTP)')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Text strong>{t('允许的 Origins')}</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('留空将自动使用服务器地址,多个 Origin 用于支持多域名部署')}
|
||||
</Text>
|
||||
<TagInput
|
||||
value={passkeyOrigins}
|
||||
onChange={(value) => {
|
||||
setPasskeyOrigins(value);
|
||||
setInputs(prev => ({
|
||||
...prev,
|
||||
'passkey.origins': value
|
||||
}));
|
||||
}}
|
||||
placeholder={t('输入 Origin 后回车,如:https://example.com')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitPasskeySettings} style={{ marginTop: 16 }}>
|
||||
{t('保存 Passkey 设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form.Section text={t('配置邮箱域名白名单')}>
|
||||
<Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>
|
||||
|
||||
@@ -59,6 +59,12 @@ const AccountManagement = ({
|
||||
handleSystemTokenClick,
|
||||
setShowChangePasswordModal,
|
||||
setShowAccountDeleteModal,
|
||||
passkeyStatus,
|
||||
passkeySupported,
|
||||
passkeyRegisterLoading,
|
||||
passkeyDeleteLoading,
|
||||
onPasskeyRegister,
|
||||
onPasskeyDelete,
|
||||
}) => {
|
||||
const renderAccountInfo = (accountId, label) => {
|
||||
if (!accountId || accountId === '') {
|
||||
@@ -86,6 +92,10 @@ const AccountManagement = ({
|
||||
};
|
||||
const isBound = (accountId) => Boolean(accountId);
|
||||
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
|
||||
const passkeyEnabled = passkeyStatus?.enabled;
|
||||
const lastUsedLabel = passkeyStatus?.last_used_at
|
||||
? new Date(passkeyStatus.last_used_at).toLocaleString()
|
||||
: t('尚未使用');
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl'>
|
||||
@@ -476,6 +486,58 @@ const AccountManagement = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Passkey 设置 */}
|
||||
<Card className='!rounded-xl w-full'>
|
||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
||||
<div className='flex items-start w-full sm:w-auto'>
|
||||
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
||||
<IconKey size='large' className='text-slate-600' />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title heading={6} className='mb-1'>
|
||||
{t('Passkey 登录')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{passkeyEnabled
|
||||
? t('已启用 Passkey,无需密码即可登录')
|
||||
: t('使用 Passkey 实现免密且更安全的登录体验')}
|
||||
</Typography.Text>
|
||||
<div className='mt-2 text-xs text-gray-500 space-y-1'>
|
||||
<div>
|
||||
{t('最后使用时间')}:{lastUsedLabel}
|
||||
</div>
|
||||
{/*{passkeyEnabled && (*/}
|
||||
{/* <div>*/}
|
||||
{/* {t('备份支持')}:*/}
|
||||
{/* {passkeyStatus?.backup_eligible*/}
|
||||
{/* ? t('支持备份')*/}
|
||||
{/* : t('不支持')}*/}
|
||||
{/* ,{t('备份状态')}:*/}
|
||||
{/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
{!passkeySupported && (
|
||||
<div className='text-amber-600'>
|
||||
{t('当前设备不支持 Passkey')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='primary'
|
||||
theme={passkeyEnabled ? 'outline' : 'solid'}
|
||||
onClick={passkeyEnabled ? onPasskeyDelete : onPasskeyRegister}
|
||||
className='w-full sm:w-auto'
|
||||
icon={<IconKey />}
|
||||
disabled={!passkeySupported && !passkeyEnabled}
|
||||
loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
|
||||
>
|
||||
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 两步验证设置 */}
|
||||
<TwoFASetting t={t} />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user