feat: add password reset functionality with email templates

This commit is contained in:
shiyu
2025-11-06 15:31:13 +08:00
parent ba62bd0d4a
commit 4e724b9c4a
21 changed files with 1643 additions and 7 deletions

View File

@@ -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
View 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 },
});
}

View File

@@ -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',

View File

@@ -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': '系统初始化',

View 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>
);
}

View File

@@ -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"

View 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>
);
}

View File

@@ -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: (

View 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>;
}

View File

@@ -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;