Settings.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import { ReloadOutlined, SaveOutlined, WarningOutlined } from '@ant-design/icons';
  2. import {
  3. Alert,
  4. App as AntApp,
  5. Button,
  6. Form,
  7. Input,
  8. Select,
  9. Skeleton,
  10. Space,
  11. Switch,
  12. Table,
  13. Typography
  14. } from 'antd';
  15. import type { ColumnsType } from 'antd/es/table';
  16. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  17. import { type BlockerFunction, useBeforeUnload, useBlocker } from 'react-router-dom';
  18. import { PageHeader } from '../components/common/PageHeader';
  19. import { SectionCard } from '../components/common/SectionCard';
  20. import { StatusTag } from '../components/common/StatusTag';
  21. import { useAppContext } from '../frontend/app-context';
  22. import { getDnsCurrentValues } from '../frontend/domain-model.js';
  23. import { useI18n } from '../frontend/i18n/react';
  24. import { api } from '../frontend/services/api';
  25. import type { DnsRecord, RuntimeConfig } from '../frontend/types';
  26. export default function Settings() {
  27. const { message, modal } = AntApp.useApp();
  28. const { locale, t } = useI18n();
  29. const { user: me } = useAppContext();
  30. const [form] = Form.useForm<Partial<RuntimeConfig>>();
  31. const appBaseUrl = Form.useWatch('appBaseUrl', form);
  32. const [settings, setSettings] = useState<RuntimeConfig | null>(null);
  33. const [loading, setLoading] = useState(true);
  34. const [saving, setSaving] = useState(false);
  35. const [loadError, setLoadError] = useState('');
  36. const [dirty, setDirty] = useState(false);
  37. const leaveConfirmation = useRef<{ destroy: () => void } | null>(null);
  38. const leaveCopy = useMemo(() => locale.startsWith('en')
  39. ? {
  40. title: 'Discard unsaved changes?',
  41. content: 'You have unsaved settings. Leaving this page will discard them.',
  42. confirm: 'Leave page',
  43. cancel: 'Keep editing'
  44. }
  45. : {
  46. title: '放弃未保存的更改?',
  47. content: '当前设置尚未保存,离开此页面将丢失这些更改。',
  48. confirm: '离开页面',
  49. cancel: '继续编辑'
  50. }, [locale]);
  51. const copy = locale.startsWith('en') ? settingsCopyEn : settingsCopyZh;
  52. const appUrlError = publicUrlSecurityError(appBaseUrl, locale);
  53. const shouldBlock = useCallback<BlockerFunction>(({ currentLocation, nextLocation }) => (
  54. dirty && locationIdentity(currentLocation) !== locationIdentity(nextLocation)
  55. ), [dirty]);
  56. const blocker = useBlocker(shouldBlock);
  57. useBeforeUnload(useCallback((event) => {
  58. if (!dirty) return;
  59. event.preventDefault();
  60. event.returnValue = '';
  61. }, [dirty]), { capture: true });
  62. const loadSettings = useCallback(async () => {
  63. setLoading(true);
  64. setLoadError('');
  65. try {
  66. if (me?.role !== 'admin') return;
  67. const result = await api.adminSettings();
  68. setSettings(result.settings);
  69. form.setFieldsValue(result.settings);
  70. setDirty(false);
  71. } catch (error) {
  72. setLoadError(error instanceof Error ? error.message : t('common.error'));
  73. } finally {
  74. setLoading(false);
  75. }
  76. }, [form, me?.role, t]);
  77. useEffect(() => {
  78. void loadSettings();
  79. }, [loadSettings]);
  80. useEffect(() => {
  81. if (blocker.state !== 'blocked' || leaveConfirmation.current) return;
  82. leaveConfirmation.current = modal.confirm({
  83. title: leaveCopy.title,
  84. content: leaveCopy.content,
  85. okText: leaveCopy.confirm,
  86. cancelText: leaveCopy.cancel,
  87. okButtonProps: { danger: true },
  88. onOk: () => {
  89. leaveConfirmation.current = null;
  90. setDirty(false);
  91. blocker.proceed();
  92. },
  93. onCancel: () => {
  94. leaveConfirmation.current = null;
  95. blocker.reset();
  96. },
  97. afterClose: () => {
  98. leaveConfirmation.current = null;
  99. }
  100. });
  101. }, [blocker, leaveCopy, modal]);
  102. useEffect(() => () => {
  103. leaveConfirmation.current?.destroy();
  104. leaveConfirmation.current = null;
  105. }, []);
  106. async function save(values: Partial<RuntimeConfig>) {
  107. setSaving(true);
  108. try {
  109. const result = await api.saveAdminSettings(values);
  110. setSettings(result.settings);
  111. form.setFieldsValue(result.settings);
  112. setDirty(false);
  113. message.success(t('actions.settingsSaved'));
  114. } catch (error) {
  115. message.error(error instanceof Error ? error.message : t('common.error'));
  116. } finally {
  117. setSaving(false);
  118. }
  119. }
  120. if (!loading && me?.role !== 'admin') {
  121. return (
  122. <Space direction="vertical" size={20} className="full-width">
  123. <PageHeader title={t('nav.settings')} />
  124. <Alert type="warning" showIcon message={t('settings.noPermission')} />
  125. </Space>
  126. );
  127. }
  128. const checkColumns: ColumnsType<DnsRecord> = [
  129. { title: t('settings.checkItem'), dataIndex: 'label', width: 160 },
  130. { title: t('dnsRecord.hostname'), dataIndex: 'host', width: 180, render: (value: string) => <Typography.Text code>{value || '—'}</Typography.Text> },
  131. { title: t('dnsRecord.targetValue'), dataIndex: 'value', width: 220, render: (value: string) => <Typography.Text code>{value || '—'}</Typography.Text> },
  132. {
  133. title: t('dnsRecord.currentValue'),
  134. width: 260,
  135. render: (_, record) => {
  136. const values: string[] = getDnsCurrentValues(record);
  137. return values.length ? <Space direction="vertical" size={4}>{values.map((value) => <Typography.Text key={value} code>{value}</Typography.Text>)}</Space> : <Typography.Text type="secondary">{t('dnsRecord.emptyCurrent')}</Typography.Text>;
  138. }
  139. },
  140. { title: t('common.status'), width: 120, render: (_, record) => <StatusTag record={record} /> }
  141. ];
  142. const checkData = settings?.systemChecks?.ptr ? [settings.systemChecks.ptr] : [];
  143. return (
  144. <Space direction="vertical" size={20} className="full-width">
  145. <PageHeader
  146. title={t('nav.settings')}
  147. subtitle={locale.startsWith('en') ? 'Runtime and delivery defaults for this MailHub instance.' : '配置当前 MailHub 实例的运行参数和投递默认行为。'}
  148. extra={
  149. <Button type="primary" icon={<SaveOutlined />} loading={saving} disabled={!dirty || loading} onClick={() => form.submit()} style={{ minHeight: 44 }}>
  150. {t('settings.save')}
  151. </Button>
  152. }
  153. />
  154. {loadError ? <Alert type="error" showIcon message={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadSettings()}>{t('common.refresh')}</Button>} /> : null}
  155. {loading ? <SectionCard><Skeleton active paragraph={{ rows: 10 }} /></SectionCard> : (
  156. <Form form={form} layout="vertical" onFinish={save} onValuesChange={() => setDirty(true)}>
  157. <Space direction="vertical" size={20} className="full-width">
  158. <SectionCard title={locale.startsWith('en') ? 'Instance' : '实例配置'}>
  159. {appUrlError ? <Alert type="error" showIcon message={copy.publicUrlInvalid} description={appUrlError} style={{ marginBottom: 20 }} /> : null}
  160. <div className="form-grid two">
  161. <Form.Item
  162. name="appBaseUrl"
  163. label={copy.publicUrl}
  164. extra={envExtra('APP_BASE_URL')}
  165. rules={[{ required: true, message: copy.publicUrlRequired }, { validator: (_, value) => publicUrlSecurityError(value, locale) ? Promise.reject(new Error(publicUrlSecurityError(value, locale))) : Promise.resolve() }]}
  166. >
  167. <Input aria-label="APP_BASE_URL" inputMode="url" placeholder="https://mail.example.com" />
  168. </Form.Item>
  169. <Form.Item name="mailHostname" label={copy.mailHostname} extra={envExtra('MAIL_HOSTNAME')}><Input aria-label="MAIL_HOSTNAME" /></Form.Item>
  170. <Form.Item name="sendingIp" label={copy.sendingIp} extra={envExtra('SENDING_IP')}><Input aria-label="SENDING_IP" inputMode="decimal" /></Form.Item>
  171. <Form.Item name="defaultSpfMechanisms" label={copy.spfMechanisms} extra={envExtra('DEFAULT_SPF_MECHANISMS')}><Input aria-label="DEFAULT_SPF_MECHANISMS" /></Form.Item>
  172. <Form.Item name="dmarcPolicy" label={copy.dmarcPolicy} extra={envExtra('DMARC_POLICY')}><Select aria-label="DMARC_POLICY" options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} /></Form.Item>
  173. <Form.Item name="dmarcRua" label={copy.dmarcReports} extra={envExtra('DMARC_RUA')}><Input aria-label="DMARC_RUA" inputMode="email" /></Form.Item>
  174. </div>
  175. </SectionCard>
  176. <SectionCard title={t('settings.registrationPolicy')}>
  177. <Form.Item
  178. name="registrationRequiresApproval"
  179. label={t('settings.registrationRequiresApproval')}
  180. extra={t('settings.registrationRequiresApprovalHint')}
  181. valuePropName="checked"
  182. style={{ marginBottom: 0 }}
  183. >
  184. <Switch
  185. aria-label={t('settings.registrationRequiresApproval')}
  186. checkedChildren={t('settings.approvalRequired')}
  187. unCheckedChildren={t('settings.approvalNotRequired')}
  188. />
  189. </Form.Item>
  190. </SectionCard>
  191. <SectionCard title={locale.startsWith('en') ? 'Message and tracking defaults' : '邮件与跟踪默认值'}>
  192. <div className="form-grid two">
  193. <Form.Item name="listUnsubscribeMailto" label={copy.unsubscribeEmail} extra={envExtra('LIST_UNSUBSCRIBE_MAILTO')}><Input aria-label="LIST_UNSUBSCRIBE_MAILTO" placeholder="unsubscribe@example.com" /></Form.Item>
  194. <Form.Item name="listUnsubscribeUrl" label={copy.unsubscribeUrl} extra={envExtra('LIST_UNSUBSCRIBE_URL')}><Input aria-label="LIST_UNSUBSCRIBE_URL" placeholder="https://example.com/unsubscribe/{eventId}" /></Form.Item>
  195. <Form.Item name="reportAbuseTo" label={copy.abuseMailbox} extra={envExtra('REPORT_ABUSE_TO')}><Input aria-label="REPORT_ABUSE_TO" placeholder="abuse@example.com" /></Form.Item>
  196. <Form.Item name="csaComplaintsTo" label={copy.csaMailbox} extra={envExtra('CSA_COMPLAINTS_TO')}><Input aria-label="CSA_COMPLAINTS_TO" placeholder="csa-complaints@example.com" /></Form.Item>
  197. <Form.Item name="bounceAddress" label={copy.bounceAddress} extra={envExtra('BOUNCE_ADDRESS')}><Input aria-label="BOUNCE_ADDRESS" placeholder="bounce@example.com" /></Form.Item>
  198. </div>
  199. <Space direction="vertical" size={16}>
  200. <Form.Item name="engagementTrackingEnabled" label={t('settings.engagementTracking')} valuePropName="checked" style={{ marginBottom: 0 }}><Switch /></Form.Item>
  201. <Form.Item name="listUnsubscribePostEnabled" label={copy.oneClickUnsubscribe} extra={envExtra('LIST_UNSUBSCRIBE_POST_ENABLED')} valuePropName="checked" style={{ marginBottom: 0 }}><Switch aria-label="LIST_UNSUBSCRIBE_POST_ENABLED" /></Form.Item>
  202. <Form.Item name="feedbackIdEnabled" label={copy.feedbackId} extra={envExtra('FEEDBACK_ID_ENABLED')} valuePropName="checked" style={{ marginBottom: 0 }}><Switch aria-label="FEEDBACK_ID_ENABLED" /></Form.Item>
  203. </Space>
  204. </SectionCard>
  205. <SectionCard
  206. title={t('settings.deliveryChecks')}
  207. extra={settings?.systemChecks?.checkedAt ? <Typography.Text type="secondary">{new Date(settings.systemChecks.checkedAt).toLocaleString()}</Typography.Text> : null}
  208. >
  209. <Table rowKey="key" columns={checkColumns} dataSource={checkData} pagination={false} scroll={{ x: 940 }} />
  210. </SectionCard>
  211. <SectionCard title={<Space><WarningOutlined style={{ color: '#D92D20' }} />{locale.startsWith('en') ? 'Danger zone' : '高风险设置'}</Space>}>
  212. <Alert
  213. type="warning"
  214. showIcon
  215. message={locale.startsWith('en') ? 'Changes here alter the sending security boundary.' : '以下设置会改变发信安全边界,请在保存前确认影响。'}
  216. description={settings?.usingDefaultAdminPassword ? (locale.startsWith('en') ? 'The default administrator password is still in use. Change it before exposing this instance.' : '当前仍在使用默认管理员密码,请在对外开放前完成修改。') : undefined}
  217. style={{ marginBottom: 20 }}
  218. />
  219. <Space direction="vertical" size={16}>
  220. <Form.Item name="sendRequiresVerified" label={copy.requireVerifiedDomain} extra={envExtra('SEND_REQUIRES_VERIFIED')} valuePropName="checked" style={{ marginBottom: 0 }}><Switch aria-label="SEND_REQUIRES_VERIFIED" /></Form.Item>
  221. <Form.Item name="bounceEnvelopeEnabled" label={copy.bounceEnvelope} extra={envExtra('BOUNCE_ENVELOPE_ENABLED')} valuePropName="checked" style={{ marginBottom: 0 }}><Switch aria-label="BOUNCE_ENVELOPE_ENABLED" /></Form.Item>
  222. </Space>
  223. </SectionCard>
  224. </Space>
  225. </Form>
  226. )}
  227. </Space>
  228. );
  229. }
  230. function locationIdentity(location: { pathname: string; search: string; hash: string }) {
  231. return `${location.pathname}${location.search}${location.hash}`;
  232. }
  233. function envExtra(name: string) {
  234. return <Typography.Text type="secondary" code>{name}</Typography.Text>;
  235. }
  236. function publicUrlSecurityError(value: unknown, locale: string) {
  237. if (!value) return '';
  238. let url: URL;
  239. try {
  240. url = new URL(String(value));
  241. } catch {
  242. return locale.startsWith('en') ? 'Enter a valid absolute URL.' : '请输入有效的完整访问地址。';
  243. }
  244. const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
  245. if (url.protocol !== 'https:' && !local) {
  246. return locale.startsWith('en')
  247. ? 'Public instances must use HTTPS so API credentials are never sent over plaintext HTTP.'
  248. : '公网实例必须使用 HTTPS,避免 API 密钥通过明文 HTTP 传输。';
  249. }
  250. return '';
  251. }
  252. const settingsCopyZh = {
  253. publicUrl: '公开访问地址', publicUrlInvalid: '公开访问地址不安全', publicUrlRequired: '请输入公开访问地址',
  254. mailHostname: '邮件服务器主机名', sendingIp: '发信公网 IP', spfMechanisms: '默认 SPF 扩展', dmarcPolicy: '默认 DMARC 策略', dmarcReports: 'DMARC 报告邮箱',
  255. unsubscribeEmail: '退订邮箱', unsubscribeUrl: '退订链接', abuseMailbox: '滥用举报邮箱', csaMailbox: 'CSA 投诉邮箱', bounceAddress: '退信地址',
  256. oneClickUnsubscribe: '启用一键退订', feedbackId: '启用 Feedback-ID', requireVerifiedDomain: '仅允许已验证域名发信', bounceEnvelope: '启用退信信封地址'
  257. };
  258. const settingsCopyEn: typeof settingsCopyZh = {
  259. publicUrl: 'Public URL', publicUrlInvalid: 'Public URL is insecure', publicUrlRequired: 'Enter the public URL',
  260. mailHostname: 'Mail server hostname', sendingIp: 'Public sending IP', spfMechanisms: 'Default SPF mechanisms', dmarcPolicy: 'Default DMARC policy', dmarcReports: 'DMARC report mailbox',
  261. unsubscribeEmail: 'Unsubscribe mailbox', unsubscribeUrl: 'Unsubscribe URL', abuseMailbox: 'Abuse mailbox', csaMailbox: 'CSA complaints mailbox', bounceAddress: 'Bounce address',
  262. oneClickUnsubscribe: 'Enable one-click unsubscribe', feedbackId: 'Enable Feedback-ID', requireVerifiedDomain: 'Require a verified sending domain', bounceEnvelope: 'Enable bounce envelope address'
  263. };