feat: passkey

This commit is contained in:
Seefs
2025-09-29 17:45:09 +08:00
parent 78b9b21a05
commit 1599a8403f
30 changed files with 2924 additions and 110 deletions

View File

@@ -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('允许不安全的 OriginHTTP')}
</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>