feat: add SMS verification registration with UniSMS provider
- Add phone field to user model with index and helper methods - Implement SMS provider interface with UniSMS (合一短信) implementation - Add SMS verification code sending endpoint with rate limiting (1/60s) - Support SMS registration in Register() (mutually exclusive with email) - Add SMS configuration to admin settings (provider, keys, signature, template) - Display phone number in admin user list contact column - Add i18n translations for all SMS-related messages (zh-CN, en, zh-TW) - Add Claude Code skills: sync-upstream, migrate-server - Update CLAUDE.md with git conventions and deployment guide Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,7 @@ import {
|
||||
IconUser,
|
||||
IconLock,
|
||||
IconKey,
|
||||
IconPhone,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
onGitHubOAuthClicked,
|
||||
@@ -79,6 +80,8 @@ const RegisterForm = () => {
|
||||
password2: '',
|
||||
email: '',
|
||||
verification_code: '',
|
||||
phone: '',
|
||||
sms_verification_code: '',
|
||||
wechat_verification_code: '',
|
||||
});
|
||||
const { username, password, password2 } = inputs;
|
||||
@@ -142,9 +145,14 @@ const RegisterForm = () => {
|
||||
);
|
||||
|
||||
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
||||
const [showSmsVerification, setShowSmsVerification] = useState(false);
|
||||
const [smsCodeLoading, setSmsCodeLoading] = useState(false);
|
||||
const [smsDisableButton, setSmsDisableButton] = useState(false);
|
||||
const [smsCountdown, setSmsCountdown] = useState(60);
|
||||
|
||||
useEffect(() => {
|
||||
setShowEmailVerification(!!status?.email_verification);
|
||||
setShowSmsVerification(!!status?.sms_verification);
|
||||
if (status?.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
@@ -168,6 +176,19 @@ const RegisterForm = () => {
|
||||
return () => clearInterval(countdownInterval); // Clean up on unmount
|
||||
}, [disableButton, countdown]);
|
||||
|
||||
useEffect(() => {
|
||||
let smsInterval = null;
|
||||
if (smsDisableButton && smsCountdown > 0) {
|
||||
smsInterval = setInterval(() => {
|
||||
setSmsCountdown(smsCountdown - 1);
|
||||
}, 1000);
|
||||
} else if (smsCountdown === 0) {
|
||||
setSmsDisableButton(false);
|
||||
setSmsCountdown(60);
|
||||
}
|
||||
return () => clearInterval(smsInterval);
|
||||
}, [smsDisableButton, smsCountdown]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (githubTimeoutRef.current) {
|
||||
@@ -279,6 +300,31 @@ const RegisterForm = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const sendSmsVerificationCode = async () => {
|
||||
if (inputs.phone === '') return;
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setSmsCodeLoading(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/sms_verification?phone=${encodeURIComponent(inputs.phone)}&turnstile=${turnstileToken}`,
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('验证码发送成功,请查看手机短信!'));
|
||||
setSmsDisableButton(true);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('发送验证码失败,请重试'));
|
||||
} finally {
|
||||
setSmsCodeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubClick = () => {
|
||||
if (githubButtonDisabled) {
|
||||
return;
|
||||
@@ -637,6 +683,40 @@ const RegisterForm = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSmsVerification && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='phone'
|
||||
label={t('手机号')}
|
||||
placeholder={t('输入手机号')}
|
||||
name='phone'
|
||||
onChange={(value) => handleChange('phone', value)}
|
||||
prefix={<IconPhone />}
|
||||
suffix={
|
||||
<Button
|
||||
onClick={sendSmsVerificationCode}
|
||||
loading={smsCodeLoading}
|
||||
disabled={smsDisableButton || smsCodeLoading}
|
||||
>
|
||||
{smsDisableButton
|
||||
? `${t('重新发送')} (${smsCountdown})`
|
||||
: t('获取验证码')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field='sms_verification_code'
|
||||
label={t('短信验证码')}
|
||||
placeholder={t('输入短信验证码')}
|
||||
name='sms_verification_code'
|
||||
onChange={(value) =>
|
||||
handleChange('sms_verification_code', value)
|
||||
}
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='pt-4'>
|
||||
<Checkbox
|
||||
|
||||
@@ -50,6 +50,12 @@ const SystemSetting = () => {
|
||||
PasswordLoginEnabled: '',
|
||||
PasswordRegisterEnabled: '',
|
||||
EmailVerificationEnabled: '',
|
||||
SMSVerificationEnabled: '',
|
||||
SMSProvider: '',
|
||||
SMSAccessKeyId: '',
|
||||
SMSAccessKeySecret: '',
|
||||
SMSSignName: '',
|
||||
SMSTemplateCode: '',
|
||||
GitHubOAuthEnabled: '',
|
||||
GitHubClientId: '',
|
||||
GitHubClientSecret: '',
|
||||
@@ -174,6 +180,7 @@ const SystemSetting = () => {
|
||||
case 'PasswordLoginEnabled':
|
||||
case 'PasswordRegisterEnabled':
|
||||
case 'EmailVerificationEnabled':
|
||||
case 'SMSVerificationEnabled':
|
||||
case 'GitHubOAuthEnabled':
|
||||
case 'WeChatAuthEnabled':
|
||||
case 'TelegramOAuthEnabled':
|
||||
@@ -347,6 +354,34 @@ const SystemSetting = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const submitSMS = async () => {
|
||||
const options = [];
|
||||
if (originInputs['SMSProvider'] !== inputs.SMSProvider) {
|
||||
options.push({ key: 'SMSProvider', value: inputs.SMSProvider });
|
||||
}
|
||||
if (originInputs['SMSAccessKeyId'] !== inputs.SMSAccessKeyId) {
|
||||
options.push({ key: 'SMSAccessKeyId', value: inputs.SMSAccessKeyId });
|
||||
}
|
||||
if (
|
||||
originInputs['SMSAccessKeySecret'] !== inputs.SMSAccessKeySecret &&
|
||||
inputs.SMSAccessKeySecret !== ''
|
||||
) {
|
||||
options.push({
|
||||
key: 'SMSAccessKeySecret',
|
||||
value: inputs.SMSAccessKeySecret,
|
||||
});
|
||||
}
|
||||
if (originInputs['SMSSignName'] !== inputs.SMSSignName) {
|
||||
options.push({ key: 'SMSSignName', value: inputs.SMSSignName });
|
||||
}
|
||||
if (originInputs['SMSTemplateCode'] !== inputs.SMSTemplateCode) {
|
||||
options.push({ key: 'SMSTemplateCode', value: inputs.SMSTemplateCode });
|
||||
}
|
||||
if (options.length > 0) {
|
||||
await updateOptions(options);
|
||||
}
|
||||
};
|
||||
|
||||
const submitEmailDomainWhitelist = async () => {
|
||||
if (Array.isArray(emailDomainWhitelist)) {
|
||||
await updateOptions([
|
||||
@@ -681,6 +716,24 @@ const SystemSetting = () => {
|
||||
|
||||
if (optionKey === 'PasswordLoginEnabled' && !value) {
|
||||
setShowPasswordLoginConfirmModal(true);
|
||||
} else if (optionKey === 'SMSVerificationEnabled' && value) {
|
||||
// When enabling SMS verification, disable email verification (mutual exclusion)
|
||||
await updateOptions([
|
||||
{ key: 'SMSVerificationEnabled', value: true },
|
||||
{ key: 'EmailVerificationEnabled', value: false },
|
||||
]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('EmailVerificationEnabled', false);
|
||||
}
|
||||
} else if (optionKey === 'EmailVerificationEnabled' && value) {
|
||||
// When enabling email verification, disable SMS verification (mutual exclusion)
|
||||
await updateOptions([
|
||||
{ key: 'EmailVerificationEnabled', value: true },
|
||||
{ key: 'SMSVerificationEnabled', value: false },
|
||||
]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('SMSVerificationEnabled', false);
|
||||
}
|
||||
} else {
|
||||
await updateOptions([{ key: optionKey, value }]);
|
||||
}
|
||||
@@ -1015,6 +1068,15 @@ const SystemSetting = () => {
|
||||
>
|
||||
{t('通过密码注册时需要进行邮箱验证')}
|
||||
</Form.Checkbox>
|
||||
<Form.Checkbox
|
||||
field='SMSVerificationEnabled'
|
||||
noLabel
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('SMSVerificationEnabled', e)
|
||||
}
|
||||
>
|
||||
{t('通过密码注册时需要进行短信验证(与邮箱验证互斥)')}
|
||||
</Form.Checkbox>
|
||||
<Form.Checkbox
|
||||
field='RegisterEnabled'
|
||||
noLabel
|
||||
@@ -1340,6 +1402,57 @@ const SystemSetting = () => {
|
||||
<Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
<Card>
|
||||
<Form.Section text={t('配置短信服务')}>
|
||||
<Text>{t('用以支持短信验证码注册(与邮箱验证互斥)')}</Text>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Select
|
||||
field='SMSProvider'
|
||||
label={t('短信服务商')}
|
||||
placeholder={t('选择短信服务商')}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Select.Option value='unisms'>UniSMS (合一短信)</Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='SMSAccessKeyId'
|
||||
label={t('AccessKey ID')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='SMSAccessKeySecret'
|
||||
label={t('AccessKey Secret')}
|
||||
type='password'
|
||||
placeholder={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={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='SMSSignName'
|
||||
label={t('短信签名')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='SMSTemplateCode'
|
||||
label={t('短信模板 ID')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitSMS} style={{ marginTop: 10 }}>{t('保存短信设置')}</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
<Card>
|
||||
<Form.Section text={t('配置 OIDC')}>
|
||||
<Text>
|
||||
|
||||
@@ -331,6 +331,26 @@ export const getUsersColumns = ({
|
||||
key: 'quota_usage',
|
||||
render: (text, record) => renderQuotaUsage(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('联系方式'),
|
||||
key: 'contact',
|
||||
render: (text, record) => {
|
||||
const parts = [];
|
||||
if (record.email) parts.push(record.email);
|
||||
if (record.phone) parts.push(record.phone);
|
||||
return parts.length > 0 ? (
|
||||
<Space spacing={2} vertical align='start'>
|
||||
{parts.map((p, i) => (
|
||||
<Tag key={i} color='white' shape='circle' className='!text-xs'>
|
||||
{p}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
'-'
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('分组'),
|
||||
dataIndex: 'group',
|
||||
|
||||
@@ -2603,6 +2603,21 @@
|
||||
"验证数据库连接状态": "Verify database connection status",
|
||||
"验证码": "Verification Code",
|
||||
"验证码发送成功,请检查邮箱!": "The verification code was sent successfully, please check your email!",
|
||||
"验证码发送成功,请查看手机短信!": "Verification code sent successfully, please check your SMS!",
|
||||
"发送验证码失败,请重试": "Failed to send verification code, please try again",
|
||||
"手机号": "Phone Number",
|
||||
"输入手机号": "Enter phone number",
|
||||
"短信验证码": "SMS Verification Code",
|
||||
"输入短信验证码": "Enter SMS verification code",
|
||||
"通过密码注册时需要进行短信验证(与邮箱验证互斥)": "SMS verification required for password registration (mutually exclusive with email verification)",
|
||||
"配置短信服务": "Configure SMS Service",
|
||||
"用以支持短信验证码注册(与邮箱验证互斥)": "To support SMS verification code registration (mutually exclusive with email verification)",
|
||||
"短信服务商": "SMS Provider",
|
||||
"选择短信服务商": "Select SMS provider",
|
||||
"短信签名": "SMS Signature",
|
||||
"短信模板 ID": "SMS Template ID",
|
||||
"保存短信设置": "Save SMS Settings",
|
||||
"联系方式": "Contact",
|
||||
"验证设置": "Verify setup",
|
||||
"验证身份": "Verify identity",
|
||||
"验证配置错误": "Verification configuration error",
|
||||
|
||||
@@ -2622,6 +2622,21 @@
|
||||
"验证数据库连接状态": "验证数据库连接状态",
|
||||
"验证码": "验证码",
|
||||
"验证码发送成功,请检查邮箱!": "验证码发送成功,请检查邮箱!",
|
||||
"验证码发送成功,请查看手机短信!": "验证码发送成功,请查看手机短信!",
|
||||
"发送验证码失败,请重试": "发送验证码失败,请重试",
|
||||
"手机号": "手机号",
|
||||
"输入手机号": "输入手机号",
|
||||
"短信验证码": "短信验证码",
|
||||
"输入短信验证码": "输入短信验证码",
|
||||
"通过密码注册时需要进行短信验证(与邮箱验证互斥)": "通过密码注册时需要进行短信验证(与邮箱验证互斥)",
|
||||
"配置短信服务": "配置短信服务",
|
||||
"用以支持短信验证码注册(与邮箱验证互斥)": "用以支持短信验证码注册(与邮箱验证互斥)",
|
||||
"短信服务商": "短信服务商",
|
||||
"选择短信服务商": "选择短信服务商",
|
||||
"短信签名": "短信签名",
|
||||
"短信模板 ID": "短信模板 ID",
|
||||
"保存短信设置": "保存短信设置",
|
||||
"联系方式": "联系方式",
|
||||
"验证设置": "验证设置",
|
||||
"验证身份": "验证身份",
|
||||
"验证配置错误": "验证配置错误",
|
||||
|
||||
Reference in New Issue
Block a user