mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-12 02:50:03 +08:00
feat: add password reset functionality with email templates
This commit is contained in:
@@ -32,6 +32,15 @@ export interface UpdateMePayload {
|
||||
new_password?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestPayload {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmPayload {
|
||||
token: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
register: async (username: string, password: string, email?: string, full_name?: string): Promise<any> => {
|
||||
return request('/auth/register', {
|
||||
@@ -68,4 +77,19 @@ export const authApi = {
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
requestPasswordReset: async (payload: PasswordResetRequestPayload) => {
|
||||
return await request('/auth/password-reset/request', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
verifyPasswordResetToken: async (token: string) => {
|
||||
return await request<{ username: string; email: string }>('/auth/password-reset/verify?token=' + encodeURIComponent(token));
|
||||
},
|
||||
confirmPasswordReset: async (payload: PasswordResetConfirmPayload) => {
|
||||
return await request('/auth/password-reset/confirm', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
41
web/src/api/email.ts
Normal file
41
web/src/api/email.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import request from './client';
|
||||
|
||||
export interface EmailTestPayload {
|
||||
to: string;
|
||||
subject: string;
|
||||
template?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function sendTestEmail(payload: EmailTestPayload) {
|
||||
return request<{ task_id: string }>('/email/test', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
template: 'test',
|
||||
context: {},
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listEmailTemplates() {
|
||||
return request<{ templates: string[] }>('/email/templates');
|
||||
}
|
||||
|
||||
export async function getEmailTemplate(name: string) {
|
||||
return request<{ name: string; content: string }>(`/email/templates/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export async function updateEmailTemplate(name: string, content: string) {
|
||||
return request(`/email/templates/${encodeURIComponent(name)}`, {
|
||||
method: 'POST',
|
||||
json: { content },
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewEmailTemplate(name: string, context: Record<string, unknown>) {
|
||||
return request<{ html: string }>(`/email/templates/${encodeURIComponent(name)}/preview`, {
|
||||
method: 'POST',
|
||||
json: { context },
|
||||
});
|
||||
}
|
||||
@@ -284,6 +284,7 @@ export const en = {
|
||||
'Custom CSS': 'Custom CSS',
|
||||
'Save': 'Save',
|
||||
'App Settings': 'App Settings',
|
||||
'Email Settings': 'Email Settings',
|
||||
'AI Settings': 'AI Settings',
|
||||
'Vision Model': 'Vision Model',
|
||||
'Embedding Model': 'Embedding Model',
|
||||
@@ -331,6 +332,62 @@ export const en = {
|
||||
'Favicon URL': 'Favicon URL',
|
||||
'App Domain': 'App Domain',
|
||||
'File Domain': 'File Domain',
|
||||
'SMTP Settings': 'SMTP Settings',
|
||||
'SMTP Host': 'SMTP Host',
|
||||
'Please input SMTP host': 'Please input SMTP host',
|
||||
'SMTP Port': 'SMTP Port',
|
||||
'Please input SMTP port': 'Please input SMTP port',
|
||||
'Security': 'Security',
|
||||
'None': 'None',
|
||||
'SSL': 'SSL',
|
||||
'STARTTLS': 'STARTTLS',
|
||||
'Timeout (seconds)': 'Timeout (seconds)',
|
||||
'Sender': 'Sender',
|
||||
'Sender Name': 'Sender Name',
|
||||
'Sender Email': 'Sender Email',
|
||||
'Please input sender email': 'Please input sender email',
|
||||
'Authentication': 'Authentication',
|
||||
'SMTP Username': 'SMTP Username',
|
||||
'SMTP Password': 'SMTP Password',
|
||||
'Test Email': 'Test Email',
|
||||
'Current Configuration': 'Current Configuration',
|
||||
'Available variables': 'Available variables',
|
||||
'Not set': 'Not set',
|
||||
'Password Reset Template': 'Password Reset Template',
|
||||
'Live Preview': 'Live Preview',
|
||||
'Foxel Mail Test': 'Foxel Mail Test',
|
||||
'Recipient Address': 'Recipient Address',
|
||||
'Please input recipient email': 'Please input recipient email',
|
||||
'Test Subject': 'Test Subject',
|
||||
'Test User Name': 'Test User Name',
|
||||
'Optional': 'Optional',
|
||||
'Send Test Email': 'Send Test Email',
|
||||
'Please complete all required fields': 'Please complete all required fields',
|
||||
'SMTP port must be a positive number': 'SMTP port must be a positive number',
|
||||
'Test email queued (task {{taskId}})': 'Test email queued (task {{taskId}})',
|
||||
'Test email failed': 'Test email failed',
|
||||
|
||||
// Auth reset
|
||||
'Forgot Password?': 'Forgot password?',
|
||||
'Reset Your Password': 'Reset Your Password',
|
||||
'Enter the email linked to your account and we will send a reset link.': 'Enter the email linked to your account and we will send a reset link.',
|
||||
'If the email exists, a reset link has been sent.': 'If the email exists, a reset link has been sent.',
|
||||
'Send Reset Link': 'Send Reset Link',
|
||||
'Resend Link': 'Resend Link',
|
||||
'Back to login': 'Back to login',
|
||||
'Request failed': 'Request failed',
|
||||
'Reset link is invalid': 'Reset link is invalid',
|
||||
'Reset link is invalid or expired': 'Reset link is invalid or expired',
|
||||
'Reset failed': 'Reset failed',
|
||||
'Try again': 'Try again',
|
||||
'Set a new password': 'Set a new password',
|
||||
'Please enter new password': 'Please enter new password',
|
||||
'Confirm Password': 'Confirm Password',
|
||||
'Please confirm new password': 'Please confirm new password',
|
||||
'Update Password': 'Update Password',
|
||||
'Passwords do not match': 'Passwords do not match',
|
||||
'Password updated, please login again.': 'Password updated, please login again.',
|
||||
'Failed to reset password': 'Failed to reset password',
|
||||
'Vision API URL': 'Vision API URL',
|
||||
'Vision API Key': 'Vision API Key',
|
||||
'Embedding API URL': 'Embedding API URL',
|
||||
@@ -599,7 +656,6 @@ export const en = {
|
||||
'This is the first account with full permissions': 'This is the first account with full permissions',
|
||||
'Username': 'Username',
|
||||
'Please input a valid email!': 'Please input a valid email!',
|
||||
'Confirm Password': 'Confirm Password',
|
||||
'Please confirm your password!': 'Please confirm your password!',
|
||||
'Passwords do not match!': 'Passwords do not match!',
|
||||
'System Initialization': 'System Initialization',
|
||||
|
||||
@@ -63,12 +63,32 @@ export const zh = {
|
||||
'Sign In': '登录',
|
||||
'Please enter username and password': '请输入用户名与密码',
|
||||
'Login failed': '登录失败',
|
||||
'Forgot Password?': '忘记密码?',
|
||||
'Your next-generation file manager': '您的下一代文件管理系统',
|
||||
'Cross-platform sync, access anywhere': '跨平台同步,随时随地访问',
|
||||
'AI-powered search for quick find': 'AI 驱动的智能搜索,快速定位文件',
|
||||
'Flexible sharing and collaboration': '灵活的分享与协作,提升团队效率',
|
||||
'Powerful automation to simplify tasks': '强大的自动化工作流,简化繁琐任务',
|
||||
'Join our community:': '加入我们的社区:',
|
||||
'Reset Your Password': '重置你的密码',
|
||||
'Enter the email linked to your account and we will send a reset link.': '请输入你账户绑定的邮箱,我们会发送重置链接。',
|
||||
'If the email exists, a reset link has been sent.': '如果邮箱存在,我们已发送重置链接。',
|
||||
'Send Reset Link': '发送重置链接',
|
||||
'Resend Link': '重新发送链接',
|
||||
'Back to login': '返回登录',
|
||||
'Request failed': '请求失败',
|
||||
'Reset link is invalid': '重置链接无效',
|
||||
'Reset link is invalid or expired': '重置链接无效或已过期',
|
||||
'Reset failed': '重置失败',
|
||||
'Try again': '重试',
|
||||
'Set a new password': '设置新密码',
|
||||
'Please enter new password': '请输入新密码',
|
||||
'Confirm Password': '确认新密码',
|
||||
'Please confirm new password': '请确认新密码',
|
||||
'Update Password': '更新密码',
|
||||
'Passwords do not match': '两次输入的密码不一致',
|
||||
'Password updated, please login again.': '密码已更新,请重新登录。',
|
||||
'Failed to reset password': '密码重置失败',
|
||||
|
||||
// Share page
|
||||
'Refresh': '刷新',
|
||||
@@ -285,6 +305,7 @@ export const zh = {
|
||||
'Custom CSS': '自定义 CSS',
|
||||
'Save': '保存',
|
||||
'App Settings': '应用设置',
|
||||
'Email Settings': '邮箱设置',
|
||||
'AI Settings': 'AI设置',
|
||||
'Choose Template': '选择模板',
|
||||
'Configure Provider': '配置提供商',
|
||||
@@ -336,6 +357,44 @@ export const zh = {
|
||||
'Favicon URL': 'Favicon 地址',
|
||||
'App Domain': '应用域名',
|
||||
'File Domain': '文件域名',
|
||||
'SMTP Settings': 'SMTP 配置',
|
||||
'SMTP Host': 'SMTP 服务器',
|
||||
'Please input SMTP host': '请输入 SMTP 服务器',
|
||||
'SMTP Port': 'SMTP 端口',
|
||||
'Please input SMTP port': '请输入 SMTP 端口',
|
||||
'Security': '安全协议',
|
||||
'None': '无',
|
||||
'SSL': 'SSL',
|
||||
'STARTTLS': 'STARTTLS',
|
||||
'Timeout (seconds)': '超时时间(秒)',
|
||||
'Sender': '发件人',
|
||||
'Sender Name': '发件人名称',
|
||||
'Sender Email': '发件人邮箱',
|
||||
'Please input sender email': '请输入发件人邮箱',
|
||||
'Authentication': '身份认证',
|
||||
'SMTP Username': 'SMTP 用户名',
|
||||
'SMTP Password': 'SMTP 密码',
|
||||
'Test Email': '测试发信',
|
||||
'Current Configuration': '当前配置摘要',
|
||||
'Available variables': '可用变量',
|
||||
'Not set': '未设置',
|
||||
'Password Reset Template': '密码重置模板',
|
||||
'Live Preview': '实时预览',
|
||||
'Template saved': '模板已保存',
|
||||
'Failed to save template': '模板保存失败',
|
||||
'Failed to load template': '模板加载失败',
|
||||
'Preview failed': '预览失败',
|
||||
'Foxel Mail Test': 'Foxel 邮件测试',
|
||||
'Recipient Address': '收件人地址',
|
||||
'Please input recipient email': '请输入收件人邮箱',
|
||||
'Test Subject': '测试邮件标题',
|
||||
'Test User Name': '测试用户名',
|
||||
'Optional': '可选',
|
||||
'Send Test Email': '发送测试邮件',
|
||||
'Please complete all required fields': '请填写所有必填项',
|
||||
'SMTP port must be a positive number': 'SMTP 端口必须为正数',
|
||||
'Test email queued (task {{taskId}})': '测试邮件已入队(任务 {{taskId}})',
|
||||
'Test email failed': '测试邮件发送失败',
|
||||
'Vision API URL': '视觉模型 API 地址',
|
||||
'Vision API Key': '视觉模型 API Key',
|
||||
'Embedding API URL': '嵌入模型 API 地址',
|
||||
@@ -612,7 +671,6 @@ export const zh = {
|
||||
'This is the first account with full permissions': '这是系统的第一个账户,将拥有最高权限。',
|
||||
'Username': '用户名',
|
||||
'Please input a valid email!': '请输入有效的邮箱地址!',
|
||||
'Confirm Password': '确认密码',
|
||||
'Please confirm your password!': '请确认您的密码!',
|
||||
'Passwords do not match!': '两次输入的密码不一致!',
|
||||
'System Initialization': '系统初始化',
|
||||
|
||||
104
web/src/pages/ForgotPasswordPage.tsx
Normal file
104
web/src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Form, Input, Button, Typography, message } from 'antd';
|
||||
import { MailOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleSubmit = async (values: { email: string }) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await authApi.requestPasswordReset({ email: values.email });
|
||||
message.success(t('If the email exists, a reset link has been sent.'));
|
||||
setSent(true);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Request failed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 16px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: 16, right: 16 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 460,
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 24px 60px rgba(15,23,42,0.12)',
|
||||
border: '1px solid rgba(99,102,241,0.12)',
|
||||
}}
|
||||
styles={{ body: { padding: '40px 36px' } }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
margin: '0 auto 16px',
|
||||
background: 'linear-gradient(135deg,#6366f1,#8b5cf6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: 28,
|
||||
}}>
|
||||
<MailOutlined />
|
||||
</div>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>{t('Reset Your Password')}</Title>
|
||||
<Text type="secondary">
|
||||
{t('Enter the email linked to your account and we will send a reset link.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" size="large" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label={t('Email')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input recipient email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="me@example.com" autoComplete="email" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginTop: 32 }}>
|
||||
<Button type="primary" htmlType="submit" loading={submitting} block>
|
||||
{sent ? t('Resend Link') : t('Send Reset Link')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/login')}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
{t('Back to login')}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,6 +107,12 @@ export default function LoginPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 8, textAlign: 'right' }}>
|
||||
<Button type="link" onClick={() => navigate('/forgot-password')} style={{ padding: 0 }}>
|
||||
{t('Forgot Password?')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
146
web/src/pages/ResetPasswordPage.tsx
Normal file
146
web/src/pages/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Card, Form, Input, Button, Typography, message, Result } from 'antd';
|
||||
import { LockOutlined, CheckCircleTwoTone } from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const token = useMemo(() => new URLSearchParams(location.search).get('token') || '', [location.search]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userInfo, setUserInfo] = useState<{ username: string; email: string } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError(t('Reset link is invalid'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
authApi.verifyPasswordResetToken(token)
|
||||
.then(setUserInfo)
|
||||
.catch((err) => {
|
||||
setError(err?.message || t('Reset link is invalid or expired'));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [token, t]);
|
||||
|
||||
const handleSubmit = async (values: { password: string; confirm: string }) => {
|
||||
if (values.password !== values.confirm) {
|
||||
message.error(t('Passwords do not match'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await authApi.confirmPasswordReset({ token, password: values.password });
|
||||
setSuccess(true);
|
||||
message.success(t('Password updated, please login again.'));
|
||||
setTimeout(() => navigate('/login'), 1500);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to reset password'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Result
|
||||
status="error"
|
||||
title={t('Reset failed')}
|
||||
subTitle={error}
|
||||
extra={[
|
||||
<Button type="primary" key="back" onClick={() => navigate('/forgot-password')}>
|
||||
{t('Try again')}
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 16px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: 16, right: 16 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(99,102,241,0.14)',
|
||||
boxShadow: '0 24px 60px rgba(79,70,229,0.18)',
|
||||
}}
|
||||
bodyStyle={{ padding: '40px 36px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
margin: '0 auto 16px',
|
||||
background: success ? '#ecfdf5' : 'linear-gradient(135deg,#6366f1,#8b5cf6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: success ? '#047857' : '#fff',
|
||||
fontSize: success ? 32 : 28,
|
||||
}}>
|
||||
{success ? <CheckCircleTwoTone twoToneColor="#22c55e" /> : <LockOutlined />}
|
||||
</div>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>{t('Set a new password')}</Title>
|
||||
{userInfo && <Text type="secondary">{userInfo.email}</Text>}
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" size="large" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={t('New Password')}
|
||||
rules={[{ required: true, message: t('Please enter new password') }]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
label={t('Confirm Password')}
|
||||
rules={[{ required: true, message: t('Please confirm new password') }]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={submitting}
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
{t('Update Password')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { message, Tabs, Space } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import PageCard from '../../components/PageCard';
|
||||
import { getAllConfig, setConfig } from '../../api/config';
|
||||
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined } from '@ant-design/icons';
|
||||
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined, MailOutlined } from '@ant-design/icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import '../../styles/settings-tabs.css';
|
||||
import { useI18n } from '../../i18n';
|
||||
@@ -10,10 +10,11 @@ import AppearanceSettingsTab from './components/AppearanceSettingsTab';
|
||||
import AppSettingsTab from './components/AppSettingsTab';
|
||||
import AiSettingsTab from './components/AiSettingsTab';
|
||||
import VectorDbSettingsTab from './components/VectorDbSettingsTab';
|
||||
import EmailSettingsTab from './components/EmailSettingsTab';
|
||||
|
||||
type TabKey = 'appearance' | 'app' | 'ai' | 'vector-db';
|
||||
type TabKey = 'appearance' | 'app' | 'email' | 'ai' | 'vector-db';
|
||||
|
||||
const TAB_KEYS: TabKey[] = ['appearance', 'app', 'ai', 'vector-db'];
|
||||
const TAB_KEYS: TabKey[] = ['appearance', 'app', 'email', 'ai', 'vector-db'];
|
||||
const DEFAULT_TAB: TabKey = 'appearance';
|
||||
|
||||
const isValidTab = (key?: string): key is TabKey => !!key && (TAB_KEYS as string[]).includes(key);
|
||||
@@ -150,6 +151,22 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: (
|
||||
<span>
|
||||
<MailOutlined style={{ marginRight: 8 }} />
|
||||
{t('Email Settings')}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<EmailSettingsTab
|
||||
config={config}
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
label: (
|
||||
|
||||
440
web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx
Normal file
440
web/src/pages/SystemSettingsPage/components/EmailSettingsTab.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
Tag,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import { HighlightOutlined, EyeOutlined, SaveOutlined, SendOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import {
|
||||
sendTestEmail,
|
||||
getEmailTemplate,
|
||||
updateEmailTemplate,
|
||||
previewEmailTemplate,
|
||||
} from '../../../api/email';
|
||||
|
||||
interface EmailSettingsTabProps {
|
||||
config: Record<string, string>;
|
||||
loading: boolean;
|
||||
onSave: (values: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
interface EmailFormValues {
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
sender_name?: string;
|
||||
sender_email: string;
|
||||
security: 'none' | 'ssl' | 'starttls';
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface TestFormValues {
|
||||
to: string;
|
||||
subject: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface PreviewContext extends Record<string, unknown> {
|
||||
username: string;
|
||||
reset_link: string;
|
||||
expire_minutes: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: EmailFormValues = {
|
||||
host: '',
|
||||
port: 465,
|
||||
username: '',
|
||||
password: '',
|
||||
sender_name: '',
|
||||
sender_email: '',
|
||||
security: 'ssl',
|
||||
timeout: 30,
|
||||
};
|
||||
|
||||
const TEMPLATE_NAME = 'password_reset';
|
||||
|
||||
function parseEmailConfig(raw?: string | null): EmailFormValues {
|
||||
if (!raw) return { ...DEFAULT_FORM };
|
||||
try {
|
||||
const data = JSON.parse(raw) as Partial<EmailFormValues>;
|
||||
return {
|
||||
...DEFAULT_FORM,
|
||||
...data,
|
||||
port: Number(data?.port ?? DEFAULT_FORM.port),
|
||||
timeout: data?.timeout !== undefined ? Number(data.timeout) : DEFAULT_FORM.timeout,
|
||||
security: (data?.security ?? DEFAULT_FORM.security) as EmailFormValues['security'],
|
||||
};
|
||||
} catch (_err) {
|
||||
return { ...DEFAULT_FORM };
|
||||
}
|
||||
}
|
||||
|
||||
export default function EmailSettingsTab({ config, loading, onSave }: EmailSettingsTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [testForm] = Form.useForm<TestFormValues>();
|
||||
const [previewForm] = Form.useForm<PreviewContext>();
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [template, setTemplate] = useState<string>('');
|
||||
const [templateLoading, setTemplateLoading] = useState(true);
|
||||
const [templateSaving, setTemplateSaving] = useState(false);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
|
||||
const initialValues = useMemo(() => parseEmailConfig(config?.EMAIL_CONFIG), [config]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const parsed = parseEmailConfig(config?.EMAIL_CONFIG);
|
||||
return [
|
||||
{ label: t('SMTP Host'), value: parsed.host || '-' },
|
||||
{ label: t('SMTP Port'), value: parsed.port || '-' },
|
||||
{ label: t('Security'), value: parsed.security.toUpperCase() },
|
||||
{ label: t('Sender Email'), value: parsed.sender_email || '-' },
|
||||
{ label: t('Sender Name'), value: parsed.sender_name || t('Not set') },
|
||||
{ label: t('Timeout (seconds)'), value: parsed.timeout || '-' },
|
||||
];
|
||||
}, [config, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setTemplateLoading(true);
|
||||
getEmailTemplate(TEMPLATE_NAME)
|
||||
.then((res) => setTemplate(res.content))
|
||||
.catch((err) => {
|
||||
message.error(err?.message || t('Failed to load template'));
|
||||
})
|
||||
.finally(() => setTemplateLoading(false));
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
previewForm.setFieldsValue({
|
||||
username: 'Foxel 用户',
|
||||
reset_link: 'https://foxel.cc/reset-password?token=demo',
|
||||
expire_minutes: 10,
|
||||
});
|
||||
}, [previewForm]);
|
||||
|
||||
const handleSaveConfig = async (values: EmailFormValues) => {
|
||||
if (!values.host || !values.port || !values.sender_email) {
|
||||
message.error(t('Please complete all required fields'));
|
||||
return;
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
host: values.host.trim(),
|
||||
port: Number(values.port),
|
||||
sender_email: values.sender_email.trim(),
|
||||
security: values.security,
|
||||
};
|
||||
if (!Number.isFinite(payload.port as number) || (payload.port as number) <= 0) {
|
||||
message.error(t('SMTP port must be a positive number'));
|
||||
return;
|
||||
}
|
||||
if (values.username?.trim()) {
|
||||
payload.username = values.username.trim();
|
||||
}
|
||||
if (values.password?.length) {
|
||||
payload.password = values.password;
|
||||
}
|
||||
if (values.sender_name?.trim()) {
|
||||
payload.sender_name = values.sender_name.trim();
|
||||
}
|
||||
if (values.timeout !== undefined && values.timeout !== null) {
|
||||
const timeoutNumber = Number(values.timeout);
|
||||
if (Number.isFinite(timeoutNumber) && timeoutNumber > 0) {
|
||||
payload.timeout = timeoutNumber;
|
||||
}
|
||||
}
|
||||
await onSave({ EMAIL_CONFIG: JSON.stringify(payload) });
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
try {
|
||||
const values = await testForm.validateFields();
|
||||
setTesting(true);
|
||||
const response = await sendTestEmail({
|
||||
to: values.to,
|
||||
subject: values.subject,
|
||||
template: 'test',
|
||||
context: { username: values.username || values.to },
|
||||
});
|
||||
message.success(t('Test email queued (task {{taskId}})', { taskId: response.task_id }));
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) {
|
||||
return;
|
||||
}
|
||||
message.error(err?.message || t('Test email failed'));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewTemplate = async () => {
|
||||
try {
|
||||
const values = await previewForm.validateFields();
|
||||
setPreviewing(true);
|
||||
const res = await previewEmailTemplate(TEMPLATE_NAME, values);
|
||||
setPreviewHtml(res.html);
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) return;
|
||||
message.error(err?.message || t('Preview failed'));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async () => {
|
||||
setTemplateSaving(true);
|
||||
try {
|
||||
await updateEmailTemplate(TEMPLATE_NAME, template);
|
||||
message.success(t('Template saved'));
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Failed to save template'));
|
||||
} finally {
|
||||
setTemplateSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={32} style={{ width: '100%', marginTop: 24 }}>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={15}>
|
||||
<Card
|
||||
title={t('SMTP Settings')}
|
||||
extra={<InfoCircleOutlined style={{ color: 'var(--ant-color-primary)' }} />}
|
||||
bodyStyle={{ paddingBottom: 12 }}
|
||||
>
|
||||
<Form<EmailFormValues>
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
onFinish={handleSaveConfig}
|
||||
key={'email-settings-' + (config?.EMAIL_CONFIG ?? '')}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<Form.Item
|
||||
name="host"
|
||||
label={t('SMTP Host')}
|
||||
rules={[{ required: true, message: t('Please input SMTP host') }]}
|
||||
>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Form.Item
|
||||
name="port"
|
||||
label={t('SMTP Port')}
|
||||
rules={[{ required: true, message: t('Please input SMTP port') }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="security" label={t('Security')}>
|
||||
<Select
|
||||
size="large"
|
||||
options={[
|
||||
{ value: 'none', label: t('None') },
|
||||
{ value: 'ssl', label: 'SSL' },
|
||||
{ value: 'starttls', label: 'STARTTLS' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="timeout" label={t('Timeout (seconds)')}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider />
|
||||
<Form.Item name="sender_name" label={t('Sender Name')}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="sender_email"
|
||||
label={t('Sender Email')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input sender email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="username" label={t('SMTP Username')}>
|
||||
<Input size="large" autoComplete="username" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="password" label={t('SMTP Password')}>
|
||||
<Input.Password size="large" autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item style={{ marginTop: 24 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} icon={<SaveOutlined />} block>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={9}>
|
||||
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
||||
<Card title={t('Current Configuration')} bodyStyle={{ paddingBottom: 12 }}>
|
||||
<Descriptions column={1} size="small" colon={false}>
|
||||
{summary.map(item => (
|
||||
<Descriptions.Item key={item.label} label={<TextLabel text={item.label} />}>
|
||||
<Typography.Text strong>{item.value}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Card title={t('Test Email')} extra={<SendOutlined style={{ color: 'var(--ant-color-primary)' }} />}>
|
||||
<Form<TestFormValues>
|
||||
form={testForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
subject: t('Foxel Mail Test'),
|
||||
username: '',
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="to"
|
||||
label={t('Recipient Address')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input recipient email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="subject" label={t('Test Subject')}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="username" label={t('Test User Name')}>
|
||||
<Input size="large" placeholder={t('Optional')} />
|
||||
</Form.Item>
|
||||
<Button type="primary" onClick={handleTest} loading={testing} block icon={<SendOutlined />}>
|
||||
{t('Send Test Email')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HighlightOutlined />
|
||||
{t('Password Reset Template')}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<EyeOutlined />} onClick={handlePreviewTemplate} loading={previewing}>
|
||||
{t('Preview')}
|
||||
</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSaveTemplate} loading={templateSaving}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={14}>
|
||||
{templateLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
) : (
|
||||
<Input.TextArea
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
autoSize={{ minRows: 20 }}
|
||||
style={{ fontFamily: 'monospace', background: '#0f172a', color: '#e2e8f0' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Text type="secondary">{t('Available variables')}:</Typography.Text>
|
||||
<Space wrap style={{ marginTop: 8 }}>
|
||||
<Tag color="blue">${'{username}'}</Tag>
|
||||
<Tag color="blue">${'{reset_link}'}</Tag>
|
||||
<Tag color="blue">${'{expire_minutes}'}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card title={t('Preview Context')} size="small" style={{ marginBottom: 16 }}>
|
||||
<Form<PreviewContext> layout="vertical" form={previewForm}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="username"
|
||||
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="reset_link"
|
||||
label="reset_link"
|
||||
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="expire_minutes"
|
||||
label="expire_minutes"
|
||||
rules={[{ required: true, message: t('Please complete all required fields') }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card title={t('Live Preview')} size="small" className="email-template-preview">
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid rgba(148,163,184,0.2)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
height: 360,
|
||||
background: '#f8fafc',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
title="email-preview"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
srcDoc={previewHtml || template}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function TextLabel({ text }: { text: string }) {
|
||||
return <Typography.Text type="secondary">{text}</Typography.Text>;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import LayoutShell from './LayoutShell.tsx';
|
||||
import LoginPage from '../pages/LoginPage.tsx';
|
||||
import SetupPage from '../pages/SetupPage.tsx';
|
||||
import PublicSharePage from '../pages/PublicSharePage';
|
||||
import ForgotPasswordPage from '../pages/ForgotPasswordPage';
|
||||
import ResetPasswordPage from '../pages/ResetPasswordPage';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
@@ -13,12 +15,16 @@ export const routes: RouteObject[] = [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/share/:token', element: <PublicSharePage /> },
|
||||
{ path: '/setup', element: <SetupPage /> },
|
||||
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
||||
{ path: '/reset-password', element: <ResetPasswordPage /> },
|
||||
];
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const location = useLocation();
|
||||
if (!isAuthenticated && !location.pathname.startsWith('/share/') && location.pathname !== '/login' && location.pathname !== '/register') {
|
||||
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password'];
|
||||
const isPublic = publicPaths.some((p) => location.pathname.startsWith(p));
|
||||
if (!isAuthenticated && !location.pathname.startsWith('/share/') && !isPublic) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return children;
|
||||
|
||||
Reference in New Issue
Block a user