Merge branch 'main' into feat_subscribe_sp1

This commit is contained in:
Little Write
2025-10-20 22:36:33 +08:00
396 changed files with 28798 additions and 5948 deletions

View File

@@ -35,6 +35,7 @@ const OperationSetting = () => {
PreConsumedQuota: 0,
QuotaForInviter: 0,
QuotaForInvitee: 0,
'quota_setting.enable_free_model_pre_consume': true,
/* 通用设置 */
TopUpLink: '',
@@ -42,7 +43,7 @@ const OperationSetting = () => {
QuotaPerUnit: 0,
USDExchangeRate: 0,
RetryTimes: 0,
DisplayInCurrencyEnabled: false,
'general_setting.quota_display_type': 'USD',
DisplayTokenStatEnabled: false,
DefaultCollapseSidebar: false,
DemoSiteEnabled: false,

View File

@@ -34,10 +34,15 @@ import { useTranslation } from 'react-i18next';
import { StatusContext } from '../../context/Status';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
const LEGAL_USER_AGREEMENT_KEY = 'legal.user_agreement';
const LEGAL_PRIVACY_POLICY_KEY = 'legal.privacy_policy';
const OtherSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
Notice: '',
[LEGAL_USER_AGREEMENT_KEY]: '',
[LEGAL_PRIVACY_POLICY_KEY]: '',
SystemName: '',
Logo: '',
Footer: '',
@@ -69,6 +74,8 @@ const OtherSetting = () => {
const [loadingInput, setLoadingInput] = useState({
Notice: false,
[LEGAL_USER_AGREEMENT_KEY]: false,
[LEGAL_PRIVACY_POLICY_KEY]: false,
SystemName: false,
Logo: false,
HomePageContent: false,
@@ -96,6 +103,50 @@ const OtherSetting = () => {
setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: false }));
}
};
// 通用设置 - UserAgreement
const submitUserAgreement = async () => {
try {
setLoadingInput((loadingInput) => ({
...loadingInput,
[LEGAL_USER_AGREEMENT_KEY]: true,
}));
await updateOption(
LEGAL_USER_AGREEMENT_KEY,
inputs[LEGAL_USER_AGREEMENT_KEY],
);
showSuccess(t('用户协议已更新'));
} catch (error) {
console.error(t('用户协议更新失败'), error);
showError(t('用户协议更新失败'));
} finally {
setLoadingInput((loadingInput) => ({
...loadingInput,
[LEGAL_USER_AGREEMENT_KEY]: false,
}));
}
};
// 通用设置 - PrivacyPolicy
const submitPrivacyPolicy = async () => {
try {
setLoadingInput((loadingInput) => ({
...loadingInput,
[LEGAL_PRIVACY_POLICY_KEY]: true,
}));
await updateOption(
LEGAL_PRIVACY_POLICY_KEY,
inputs[LEGAL_PRIVACY_POLICY_KEY],
);
showSuccess(t('隐私政策已更新'));
} catch (error) {
console.error(t('隐私政策更新失败'), error);
showError(t('隐私政策更新失败'));
} finally {
setLoadingInput((loadingInput) => ({
...loadingInput,
[LEGAL_PRIVACY_POLICY_KEY]: false,
}));
}
};
// 个性化设置
const formAPIPersonalization = useRef();
// 个性化设置 - SystemName
@@ -324,6 +375,40 @@ const OtherSetting = () => {
<Button onClick={submitNotice} loading={loadingInput['Notice']}>
{t('设置公告')}
</Button>
<Form.TextArea
label={t('用户协议')}
placeholder={t(
'在此输入用户协议内容,支持 Markdown & HTML 代码',
)}
field={LEGAL_USER_AGREEMENT_KEY}
onChange={handleInputChange}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 6, maxRows: 12 }}
helpText={t('填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议')}
/>
<Button
onClick={submitUserAgreement}
loading={loadingInput[LEGAL_USER_AGREEMENT_KEY]}
>
{t('设置用户协议')}
</Button>
<Form.TextArea
label={t('隐私政策')}
placeholder={t(
'在此输入隐私政策内容,支持 Markdown & HTML 代码',
)}
field={LEGAL_PRIVACY_POLICY_KEY}
onChange={handleInputChange}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 6, maxRows: 12 }}
helpText={t('填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策')}
/>
<Button
onClick={submitPrivacyPolicy}
loading={loadingInput[LEGAL_PRIVACY_POLICY_KEY]}
>
{t('设置隐私政策')}
</Button>
</Form.Section>
</Card>
</Form>

View File

@@ -46,6 +46,7 @@ const PaymentSetting = () => {
StripePriceId: '',
StripeUnitPrice: 8.0,
StripeMinTopUp: 1,
StripePromotionCodesEnabled: false,
});
let [loading, setLoading] = useState(false);

View File

@@ -19,7 +19,18 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
import {
API,
copy,
showError,
showInfo,
showSuccess,
setStatusData,
prepareCredentialCreationOptions,
buildRegistrationResult,
isPasskeySupported,
setUserData,
} from '../../helpers';
import { UserContext } from '../../context/User';
import { Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
@@ -59,6 +70,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,
@@ -66,23 +81,52 @@ const PersonalSetting = () => {
webhookSecret: '',
notificationEmail: '',
barkUrl: '',
gotifyUrl: '',
gotifyToken: '',
gotifyPriority: 5,
acceptUnsetModelRatioModel: false,
recordIpLog: false,
});
useEffect(() => {
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
if (status.turnstile_check) {
let saved = localStorage.getItem('status');
if (saved) {
const parsed = JSON.parse(saved);
setStatus(parsed);
if (parsed.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
setTurnstileSiteKey(parsed.turnstile_site_key);
} else {
setTurnstileEnabled(false);
setTurnstileSiteKey('');
}
}
getUserData().then((res) => {
console.log(userState);
});
// Always refresh status from server to avoid stale flags (e.g., admin just enabled OAuth)
(async () => {
try {
const res = await API.get('/api/status');
const { success, data } = res.data;
if (success && data) {
setStatus(data);
setStatusData(data);
if (data.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(data.turnstile_site_key);
} else {
setTurnstileEnabled(false);
setTurnstileSiteKey('');
}
}
} catch (e) {
// ignore and keep local status
}
})();
getUserData();
isPasskeySupported()
.then(setPasskeySupported)
.catch(() => setPasskeySupported(false));
}, []);
useEffect(() => {
@@ -108,6 +152,10 @@ const PersonalSetting = () => {
webhookSecret: settings.webhook_secret || '',
notificationEmail: settings.notification_email || '',
barkUrl: settings.bark_url || '',
gotifyUrl: settings.gotify_url || '',
gotifyToken: settings.gotify_token || '',
gotifyPriority:
settings.gotify_priority !== undefined ? settings.gotify_priority : 5,
acceptUnsetModelRatioModel:
settings.accept_unset_model_ratio_model || false,
recordIpLog: settings.record_ip_log || false,
@@ -131,11 +179,95 @@ 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 });
setUserData(data);
await loadPasskeyStatus();
} else {
showError(message);
}
@@ -286,6 +418,12 @@ const PersonalSetting = () => {
webhook_secret: notificationSettings.webhookSecret,
notification_email: notificationSettings.notificationEmail,
bark_url: notificationSettings.barkUrl,
gotify_url: notificationSettings.gotifyUrl,
gotify_token: notificationSettings.gotifyToken,
gotify_priority: (() => {
const parsed = parseInt(notificationSettings.gotifyPriority);
return isNaN(parsed) ? 5 : parsed;
})(),
accept_unset_model_ratio_model:
notificationSettings.acceptUnsetModelRatioModel,
record_ip_log: notificationSettings.recordIpLog,
@@ -323,6 +461,12 @@ const PersonalSetting = () => {
handleSystemTokenClick={handleSystemTokenClick}
setShowChangePasswordModal={setShowChangePasswordModal}
setShowAccountDeleteModal={setShowAccountDeleteModal}
passkeyStatus={passkeyStatus}
passkeySupported={passkeySupported}
passkeyRegisterLoading={passkeyRegisterLoading}
passkeyDeleteLoading={passkeyDeleteLoading}
onPasskeyRegister={handleRegisterPasskey}
onPasskeyDelete={handleRemovePasskey}
/>
{/* 右侧:其他设置 */}

View File

@@ -30,6 +30,7 @@ import {
Spin,
Card,
Radio,
Select,
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import {
@@ -45,7 +46,6 @@ import { useTranslation } from 'react-i18next';
const SystemSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
PasswordLoginEnabled: '',
PasswordRegisterEnabled: '',
EmailVerificationEnabled: '',
@@ -77,6 +77,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: '',
@@ -173,9 +180,25 @@ 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':
// origins是逗号分隔的字符串直接使用
item.value = item.value || '';
break;
case 'passkey.rp_display_name':
case 'passkey.rp_id':
case 'passkey.attachment_preference':
// 确保字符串字段不为null/undefined
item.value = item.value || '';
break;
case 'passkey.user_verification':
// 确保有默认值
item.value = item.value || 'preferred';
break;
case 'Price':
case 'MinTopUp':
item.value = parseFloat(item.value);
@@ -188,7 +211,9 @@ const SystemSetting = () => {
setInputs(newInputs);
setOriginInputs(newInputs);
// 同步模式布尔到本地状态
if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') {
if (
typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined'
) {
setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']);
}
if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') {
@@ -582,6 +607,45 @@ const SystemSetting = () => {
}
};
const submitPasskeySettings = async () => {
// 使用formApi直接获取当前表单值
const formValues = formApiRef.current?.getValues() || {};
const options = [];
options.push({
key: 'passkey.rp_display_name',
value:
formValues['passkey.rp_display_name'] ||
inputs['passkey.rp_display_name'] ||
'',
});
options.push({
key: 'passkey.rp_id',
value: formValues['passkey.rp_id'] || inputs['passkey.rp_id'] || '',
});
options.push({
key: 'passkey.user_verification',
value:
formValues['passkey.user_verification'] ||
inputs['passkey.user_verification'] ||
'preferred',
});
options.push({
key: 'passkey.attachment_preference',
value:
formValues['passkey.attachment_preference'] ||
inputs['passkey.attachment_preference'] ||
'',
});
options.push({
key: 'passkey.origins',
value: formValues['passkey.origins'] || inputs['passkey.origins'] || '',
});
await updateOptions(options);
};
const handleCheckboxChange = async (optionKey, event) => {
const value = event.target.checked;
@@ -641,8 +705,15 @@ const SystemSetting = () => {
<Card>
<Form.Section text={t('代理设置')}>
<Banner
type='info'
description={t(
'此代理仅用于图片请求转发Webhook通知发送等AI API请求仍然由服务器直接发出可在渠道设置中单独配置代理',
)}
style={{ marginBottom: 20, marginTop: 16 }}
/>
<Text>
支持{' '}
{t('仅支持')}{' '}
<a
href='https://github.com/Calcium-Ion/new-api-worker'
target='_blank'
@@ -650,7 +721,7 @@ const SystemSetting = () => {
>
new-api-worker
</a>
{' '}{t('或其兼容new-api-worker格式的其他版本')}
</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
@@ -695,14 +766,17 @@ const SystemSetting = () => {
noLabel
extraText={t('SSRF防护开关详细说明')}
onChange={(e) =>
handleCheckboxChange('fetch_setting.enable_ssrf_protection', e)
handleCheckboxChange(
'fetch_setting.enable_ssrf_protection',
e,
)
}
>
{t('启用SSRF防护推荐开启以保护服务器安全')}
</Form.Checkbox>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
@@ -713,14 +787,19 @@ const SystemSetting = () => {
noLabel
extraText={t('私有IP访问详细说明')}
onChange={(e) =>
handleCheckboxChange('fetch_setting.allow_private_ip', e)
handleCheckboxChange(
'fetch_setting.allow_private_ip',
e,
)
}
>
{t('允许访问私有IP地址127.0.0.1、192.168.x.x等内网地址')}
{t(
'允许访问私有IP地址127.0.0.1、192.168.x.x等内网地址',
)}
</Form.Checkbox>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
@@ -731,7 +810,10 @@ const SystemSetting = () => {
noLabel
extraText={t('域名IP过滤详细说明')}
onChange={(e) =>
handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e)
handleCheckboxChange(
'fetch_setting.apply_ip_filter_for_domain',
e,
)
}
style={{ marginBottom: 8 }}
>
@@ -740,17 +822,23 @@ const SystemSetting = () => {
<Text strong>
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{t('支持通配符格式example.com, *.api.example.com')}
<Text
type='secondary'
style={{ display: 'block', marginBottom: 8 }}
>
{t(
'支持通配符格式example.com, *.api.example.com',
)}
</Text>
<Radio.Group
type='button'
value={domainFilterMode ? 'whitelist' : 'blacklist'}
onChange={(val) => {
const selected = val && val.target ? val.target.value : val;
const selected =
val && val.target ? val.target.value : val;
const isWhitelist = selected === 'whitelist';
setDomainFilterMode(isWhitelist);
setInputs(prev => ({
setInputs((prev) => ({
...prev,
'fetch_setting.domain_filter_mode': isWhitelist,
}));
@@ -765,9 +853,9 @@ const SystemSetting = () => {
onChange={(value) => {
setDomainList(value);
// 触发Form的onChange事件
setInputs(prev => ({
setInputs((prev) => ({
...prev,
'fetch_setting.domain_list': value
'fetch_setting.domain_list': value,
}));
}}
placeholder={t('输入域名后回车example.com')}
@@ -784,17 +872,21 @@ const SystemSetting = () => {
<Text strong>
{t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
<Text
type='secondary'
style={{ display: 'block', marginBottom: 8 }}
>
{t('支持CIDR格式8.8.8.8, 192.168.1.0/24')}
</Text>
<Radio.Group
type='button'
value={ipFilterMode ? 'whitelist' : 'blacklist'}
onChange={(val) => {
const selected = val && val.target ? val.target.value : val;
const selected =
val && val.target ? val.target.value : val;
const isWhitelist = selected === 'whitelist';
setIpFilterMode(isWhitelist);
setInputs(prev => ({
setInputs((prev) => ({
...prev,
'fetch_setting.ip_filter_mode': isWhitelist,
}));
@@ -809,9 +901,9 @@ const SystemSetting = () => {
onChange={(value) => {
setIpList(value);
// 触发Form的onChange事件
setInputs(prev => ({
setInputs((prev) => ({
...prev,
'fetch_setting.ip_list': value
'fetch_setting.ip_list': value,
}));
}}
placeholder={t('输入IP地址后回车8.8.8.8')}
@@ -826,7 +918,10 @@ const SystemSetting = () => {
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Text strong>{t('允许的端口')}</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
<Text
type='secondary'
style={{ display: 'block', marginBottom: 8 }}
>
{t('支持单个端口和端口范围80, 443, 8000-8999')}
</Text>
<TagInput
@@ -834,15 +929,18 @@ const SystemSetting = () => {
onChange={(value) => {
setAllowedPorts(value);
// 触发Form的onChange事件
setInputs(prev => ({
setInputs((prev) => ({
...prev,
'fetch_setting.allowed_ports': value
'fetch_setting.allowed_ports': value,
}));
}}
placeholder={t('输入端口后回车80 或 8000-8999')}
style={{ width: '100%' }}
/>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
<Text
type='secondary'
style={{ display: 'block', marginBottom: 8 }}
>
{t('端口配置详细说明')}
</Text>
</Col>
@@ -957,6 +1055,135 @@ 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(
'留空则默认使用服务器地址注意不能携带http://或者https://',
)}
/>
</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}>
<Form.Input
field="['passkey.origins']"
label={t('允许的 Origins')}
placeholder={t('填写带https的域名逗号分隔')}
extraText={t(
'为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[]需使用https',
)}
/>
</Col>
</Row>
<Button
onClick={submitPasskeySettings}
style={{ marginTop: 16 }}
>
{t('保存 Passkey 设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text={t('配置邮箱域名白名单')}>
<Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>

View File

@@ -28,6 +28,7 @@ import {
Tabs,
TabPane,
Popover,
Modal,
} from '@douyinfe/semi-ui';
import {
IconMail,
@@ -58,6 +59,12 @@ const AccountManagement = ({
handleSystemTokenClick,
setShowChangePasswordModal,
setShowAccountDeleteModal,
passkeyStatus,
passkeySupported,
passkeyRegisterLoading,
passkeyDeleteLoading,
onPasskeyRegister,
onPasskeyDelete,
}) => {
const renderAccountInfo = (accountId, label) => {
if (!accountId || accountId === '') {
@@ -83,6 +90,14 @@ const AccountManagement = ({
</Popover>
);
};
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'>
{/* 卡片头部 */}
@@ -142,7 +157,7 @@ const AccountManagement = ({
size='small'
onClick={() => setShowEmailBindModal(true)}
>
{userState.user && userState.user.email !== ''
{isBound(userState.user?.email)
? t('修改绑定')
: t('绑定')}
</Button>
@@ -165,9 +180,11 @@ const AccountManagement = ({
{t('微信')}
</div>
<div className='text-sm text-gray-500 truncate'>
{userState.user && userState.user.wechat_id !== ''
? t('已绑定')
: t('未绑定')}
{!status.wechat_login
? t('未启用')
: isBound(userState.user?.wechat_id)
? t('已绑定')
: t('未绑定')}
</div>
</div>
</div>
@@ -179,7 +196,7 @@ const AccountManagement = ({
disabled={!status.wechat_login}
onClick={() => setShowWeChatBindModal(true)}
>
{userState.user && userState.user.wechat_id !== ''
{isBound(userState.user?.wechat_id)
? t('修改绑定')
: status.wechat_login
? t('绑定')
@@ -220,7 +237,7 @@ const AccountManagement = ({
onGitHubOAuthClicked(status.github_client_id)
}
disabled={
(userState.user && userState.user.github_id !== '') ||
isBound(userState.user?.github_id) ||
!status.github_oauth
}
>
@@ -264,8 +281,7 @@ const AccountManagement = ({
)
}
disabled={
(userState.user && userState.user.oidc_id !== '') ||
!status.oidc_enabled
isBound(userState.user?.oidc_id) || !status.oidc_enabled
}
>
{status.oidc_enabled ? t('绑定') : t('未启用')}
@@ -298,26 +314,56 @@ const AccountManagement = ({
</div>
<div className='flex-shrink-0'>
{status.telegram_oauth ? (
userState.user.telegram_id !== '' ? (
<Button disabled={true} size='small'>
isBound(userState.user?.telegram_id) ? (
<Button
disabled
size='small'
type='primary'
theme='outline'
>
{t('已绑定')}
</Button>
) : (
<div className='scale-75'>
<TelegramLoginButton
dataAuthUrl='/api/oauth/telegram/bind'
botName={status.telegram_bot_name}
/>
</div>
<Button
type='primary'
theme='outline'
size='small'
onClick={() => setShowTelegramBindModal(true)}
>
{t('绑定')}
</Button>
)
) : (
<Button disabled={true} size='small'>
<Button
disabled
size='small'
type='primary'
theme='outline'
>
{t('未启用')}
</Button>
)}
</div>
</div>
</Card>
<Modal
title={t('绑定 Telegram')}
visible={showTelegramBindModal}
onCancel={() => setShowTelegramBindModal(false)}
footer={null}
>
<div className='my-3 text-sm text-gray-600'>
{t('点击下方按钮通过 Telegram 完成绑定')}
</div>
<div className='flex justify-center'>
<div className='scale-90'>
<TelegramLoginButton
dataAuthUrl='/api/oauth/telegram/bind'
botName={status.telegram_bot_name}
/>
</div>
</div>
</Modal>
{/* LinuxDO绑定 */}
<Card className='!rounded-xl'>
@@ -350,7 +396,7 @@ const AccountManagement = ({
onLinuxDOOAuthClicked(status.linuxdo_client_id)
}
disabled={
(userState.user && userState.user.linux_do_id !== '') ||
isBound(userState.user?.linux_do_id) ||
!status.linuxdo_oauth
}
>
@@ -443,6 +489,77 @@ 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={passkeyEnabled ? 'danger' : 'primary'}
theme={passkeyEnabled ? 'solid' : 'solid'}
onClick={
passkeyEnabled
? () => {
Modal.confirm({
title: t('确认解绑 Passkey'),
content: t(
'解绑后将无法使用 Passkey 登录,确定要继续吗?',
),
okText: t('确认解绑'),
cancelText: t('取消'),
okType: 'danger',
onOk: onPasskeyDelete,
});
}
: onPasskeyRegister
}
className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
icon={<IconKey />}
disabled={!passkeySupported && !passkeyEnabled}
loading={
passkeyEnabled
? passkeyDeleteLoading
: passkeyRegisterLoading
}
>
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
</Button>
</div>
</Card>
{/* 两步验证设置 */}
<TwoFASetting t={t} />

View File

@@ -400,6 +400,7 @@ const NotificationSettings = ({
<Radio value='email'>{t('邮件通知')}</Radio>
<Radio value='webhook'>{t('Webhook通知')}</Radio>
<Radio value='bark'>{t('Bark通知')}</Radio>
<Radio value='gotify'>{t('Gotify通知')}</Radio>
</Form.RadioGroup>
<Form.AutoComplete
@@ -589,7 +590,108 @@ const NotificationSettings = ({
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 font-medium'
>
Bark 官方文档
Bark {t('官方文档')}
</a>
</div>
</div>
</div>
</>
)}
{/* Gotify推送设置 */}
{notificationSettings.warningType === 'gotify' && (
<>
<Form.Input
field='gotifyUrl'
label={t('Gotify服务器地址')}
placeholder={t(
'请输入Gotify服务器地址例如: https://gotify.example.com',
)}
onChange={(val) => handleFormChange('gotifyUrl', val)}
prefix={<IconLink />}
extraText={t(
'支持HTTP和HTTPS填写Gotify服务器的完整URL地址',
)}
showClear
rules={[
{
required:
notificationSettings.warningType === 'gotify',
message: t('请输入Gotify服务器地址'),
},
{
pattern: /^https?:\/\/.+/,
message: t(
'Gotify服务器地址必须以http://或https://开头',
),
},
]}
/>
<Form.Input
field='gotifyToken'
label={t('Gotify应用令牌')}
placeholder={t('请输入Gotify应用令牌')}
onChange={(val) => handleFormChange('gotifyToken', val)}
prefix={<IconKey />}
extraText={t(
'在Gotify服务器创建应用后获得的令牌用于发送通知',
)}
showClear
rules={[
{
required:
notificationSettings.warningType === 'gotify',
message: t('请输入Gotify应用令牌'),
},
]}
/>
<Form.AutoComplete
field='gotifyPriority'
label={t('消息优先级')}
placeholder={t('请选择消息优先级')}
data={[
{ value: 0, label: t('0 - 最低') },
{ value: 2, label: t('2 - 低') },
{ value: 5, label: t('5 - 正常(默认)') },
{ value: 8, label: t('8 - 高') },
{ value: 10, label: t('10 - 最高') },
]}
onChange={(val) =>
handleFormChange('gotifyPriority', val)
}
prefix={<IconBell />}
extraText={t('消息优先级范围0-10默认为5')}
style={{ width: '100%', maxWidth: '300px' }}
/>
<div className='mt-3 p-4 bg-gray-50/50 rounded-xl'>
<div className='text-sm text-gray-700 mb-3'>
<strong>{t('配置说明')}</strong>
</div>
<div className='text-xs text-gray-500 space-y-2'>
<div>
1. {t('在Gotify服务器的应用管理中创建新应用')}
</div>
<div>
2.{' '}
{t(
'复制应用的令牌Token并填写到上方的应用令牌字段',
)}
</div>
<div>3. {t('填写Gotify服务器的完整URL地址')}</div>
<div className='mt-3 pt-3 border-t border-gray-200'>
<span className='text-gray-400'>
{t('更多信息请参考')}
</span>{' '}
<a
href='https://gotify.net/'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 font-medium'
>
Gotify {t('官方文档')}
</a>
</div>
</div>