| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284 |
- import { ReloadOutlined, SaveOutlined, WarningOutlined } from '@ant-design/icons';
- import {
- Alert,
- App as AntApp,
- Button,
- Form,
- Input,
- Select,
- Skeleton,
- Space,
- Switch,
- Table,
- Typography
- } from 'antd';
- import type { ColumnsType } from 'antd/es/table';
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
- import { type BlockerFunction, useBeforeUnload, useBlocker } from 'react-router-dom';
- import { PageHeader } from '../components/common/PageHeader';
- import { SectionCard } from '../components/common/SectionCard';
- import { StatusTag } from '../components/common/StatusTag';
- import { useAppContext } from '../frontend/app-context';
- import { getDnsCurrentValues } from '../frontend/domain-model.js';
- import { useI18n } from '../frontend/i18n/react';
- import { api } from '../frontend/services/api';
- import type { DnsRecord, RuntimeConfig } from '../frontend/types';
- export default function Settings() {
- const { message, modal } = AntApp.useApp();
- const { locale, t } = useI18n();
- const { user: me } = useAppContext();
- const [form] = Form.useForm<Partial<RuntimeConfig>>();
- const appBaseUrl = Form.useWatch('appBaseUrl', form);
- const [settings, setSettings] = useState<RuntimeConfig | null>(null);
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [loadError, setLoadError] = useState('');
- const [dirty, setDirty] = useState(false);
- const leaveConfirmation = useRef<{ destroy: () => void } | null>(null);
- const leaveCopy = useMemo(() => locale.startsWith('en')
- ? {
- title: 'Discard unsaved changes?',
- content: 'You have unsaved settings. Leaving this page will discard them.',
- confirm: 'Leave page',
- cancel: 'Keep editing'
- }
- : {
- title: '放弃未保存的更改?',
- content: '当前设置尚未保存,离开此页面将丢失这些更改。',
- confirm: '离开页面',
- cancel: '继续编辑'
- }, [locale]);
- const copy = locale.startsWith('en') ? settingsCopyEn : settingsCopyZh;
- const appUrlError = publicUrlSecurityError(appBaseUrl, locale);
- const shouldBlock = useCallback<BlockerFunction>(({ currentLocation, nextLocation }) => (
- dirty && locationIdentity(currentLocation) !== locationIdentity(nextLocation)
- ), [dirty]);
- const blocker = useBlocker(shouldBlock);
- useBeforeUnload(useCallback((event) => {
- if (!dirty) return;
- event.preventDefault();
- event.returnValue = '';
- }, [dirty]), { capture: true });
- const loadSettings = useCallback(async () => {
- setLoading(true);
- setLoadError('');
- try {
- if (me?.role !== 'admin') return;
- const result = await api.adminSettings();
- setSettings(result.settings);
- form.setFieldsValue(result.settings);
- setDirty(false);
- } catch (error) {
- setLoadError(error instanceof Error ? error.message : t('common.error'));
- } finally {
- setLoading(false);
- }
- }, [form, me?.role, t]);
- useEffect(() => {
- void loadSettings();
- }, [loadSettings]);
- useEffect(() => {
- if (blocker.state !== 'blocked' || leaveConfirmation.current) return;
- leaveConfirmation.current = modal.confirm({
- title: leaveCopy.title,
- content: leaveCopy.content,
- okText: leaveCopy.confirm,
- cancelText: leaveCopy.cancel,
- okButtonProps: { danger: true },
- onOk: () => {
- leaveConfirmation.current = null;
- setDirty(false);
- blocker.proceed();
- },
- onCancel: () => {
- leaveConfirmation.current = null;
- blocker.reset();
- },
- afterClose: () => {
- leaveConfirmation.current = null;
- }
- });
- }, [blocker, leaveCopy, modal]);
- useEffect(() => () => {
- leaveConfirmation.current?.destroy();
- leaveConfirmation.current = null;
- }, []);
- async function save(values: Partial<RuntimeConfig>) {
- setSaving(true);
- try {
- const result = await api.saveAdminSettings(values);
- setSettings(result.settings);
- form.setFieldsValue(result.settings);
- setDirty(false);
- message.success(t('actions.settingsSaved'));
- } catch (error) {
- message.error(error instanceof Error ? error.message : t('common.error'));
- } finally {
- setSaving(false);
- }
- }
- if (!loading && me?.role !== 'admin') {
- return (
- <Space direction="vertical" size={20} className="full-width">
- <PageHeader title={t('nav.settings')} />
- <Alert type="warning" showIcon message={t('settings.noPermission')} />
- </Space>
- );
- }
- const checkColumns: ColumnsType<DnsRecord> = [
- { title: t('settings.checkItem'), dataIndex: 'label', width: 160 },
- { title: t('dnsRecord.hostname'), dataIndex: 'host', width: 180, render: (value: string) => <Typography.Text code>{value || '—'}</Typography.Text> },
- { title: t('dnsRecord.targetValue'), dataIndex: 'value', width: 220, render: (value: string) => <Typography.Text code>{value || '—'}</Typography.Text> },
- {
- title: t('dnsRecord.currentValue'),
- width: 260,
- render: (_, record) => {
- const values: string[] = getDnsCurrentValues(record);
- 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>;
- }
- },
- { title: t('common.status'), width: 120, render: (_, record) => <StatusTag record={record} /> }
- ];
- const checkData = settings?.systemChecks?.ptr ? [settings.systemChecks.ptr] : [];
- return (
- <Space direction="vertical" size={20} className="full-width">
- <PageHeader
- title={t('nav.settings')}
- subtitle={locale.startsWith('en') ? 'Runtime and delivery defaults for this MailHub instance.' : '配置当前 MailHub 实例的运行参数和投递默认行为。'}
- extra={
- <Button type="primary" icon={<SaveOutlined />} loading={saving} disabled={!dirty || loading} onClick={() => form.submit()} style={{ minHeight: 44 }}>
- {t('settings.save')}
- </Button>
- }
- />
- {loadError ? <Alert type="error" showIcon message={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadSettings()}>{t('common.refresh')}</Button>} /> : null}
- {loading ? <SectionCard><Skeleton active paragraph={{ rows: 10 }} /></SectionCard> : (
- <Form form={form} layout="vertical" onFinish={save} onValuesChange={() => setDirty(true)}>
- <Space direction="vertical" size={20} className="full-width">
- <SectionCard title={locale.startsWith('en') ? 'Instance' : '实例配置'}>
- {appUrlError ? <Alert type="error" showIcon message={copy.publicUrlInvalid} description={appUrlError} style={{ marginBottom: 20 }} /> : null}
- <div className="form-grid two">
- <Form.Item
- name="appBaseUrl"
- label={copy.publicUrl}
- extra={envExtra('APP_BASE_URL')}
- rules={[{ required: true, message: copy.publicUrlRequired }, { validator: (_, value) => publicUrlSecurityError(value, locale) ? Promise.reject(new Error(publicUrlSecurityError(value, locale))) : Promise.resolve() }]}
- >
- <Input aria-label="APP_BASE_URL" inputMode="url" placeholder="https://mail.example.com" />
- </Form.Item>
- <Form.Item name="mailHostname" label={copy.mailHostname} extra={envExtra('MAIL_HOSTNAME')}><Input aria-label="MAIL_HOSTNAME" /></Form.Item>
- <Form.Item name="sendingIp" label={copy.sendingIp} extra={envExtra('SENDING_IP')}><Input aria-label="SENDING_IP" inputMode="decimal" /></Form.Item>
- <Form.Item name="defaultSpfMechanisms" label={copy.spfMechanisms} extra={envExtra('DEFAULT_SPF_MECHANISMS')}><Input aria-label="DEFAULT_SPF_MECHANISMS" /></Form.Item>
- <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>
- <Form.Item name="dmarcRua" label={copy.dmarcReports} extra={envExtra('DMARC_RUA')}><Input aria-label="DMARC_RUA" inputMode="email" /></Form.Item>
- </div>
- </SectionCard>
- <SectionCard title={t('settings.registrationPolicy')}>
- <Form.Item
- name="registrationRequiresApproval"
- label={t('settings.registrationRequiresApproval')}
- extra={t('settings.registrationRequiresApprovalHint')}
- valuePropName="checked"
- style={{ marginBottom: 0 }}
- >
- <Switch
- aria-label={t('settings.registrationRequiresApproval')}
- checkedChildren={t('settings.approvalRequired')}
- unCheckedChildren={t('settings.approvalNotRequired')}
- />
- </Form.Item>
- </SectionCard>
- <SectionCard title={locale.startsWith('en') ? 'Message and tracking defaults' : '邮件与跟踪默认值'}>
- <div className="form-grid two">
- <Form.Item name="listUnsubscribeMailto" label={copy.unsubscribeEmail} extra={envExtra('LIST_UNSUBSCRIBE_MAILTO')}><Input aria-label="LIST_UNSUBSCRIBE_MAILTO" placeholder="unsubscribe@example.com" /></Form.Item>
- <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>
- <Form.Item name="reportAbuseTo" label={copy.abuseMailbox} extra={envExtra('REPORT_ABUSE_TO')}><Input aria-label="REPORT_ABUSE_TO" placeholder="abuse@example.com" /></Form.Item>
- <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>
- <Form.Item name="bounceAddress" label={copy.bounceAddress} extra={envExtra('BOUNCE_ADDRESS')}><Input aria-label="BOUNCE_ADDRESS" placeholder="bounce@example.com" /></Form.Item>
- </div>
- <Space direction="vertical" size={16}>
- <Form.Item name="engagementTrackingEnabled" label={t('settings.engagementTracking')} valuePropName="checked" style={{ marginBottom: 0 }}><Switch /></Form.Item>
- <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>
- <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>
- </Space>
- </SectionCard>
- <SectionCard
- title={t('settings.deliveryChecks')}
- extra={settings?.systemChecks?.checkedAt ? <Typography.Text type="secondary">{new Date(settings.systemChecks.checkedAt).toLocaleString()}</Typography.Text> : null}
- >
- <Table rowKey="key" columns={checkColumns} dataSource={checkData} pagination={false} scroll={{ x: 940 }} />
- </SectionCard>
- <SectionCard title={<Space><WarningOutlined style={{ color: '#D92D20' }} />{locale.startsWith('en') ? 'Danger zone' : '高风险设置'}</Space>}>
- <Alert
- type="warning"
- showIcon
- message={locale.startsWith('en') ? 'Changes here alter the sending security boundary.' : '以下设置会改变发信安全边界,请在保存前确认影响。'}
- description={settings?.usingDefaultAdminPassword ? (locale.startsWith('en') ? 'The default administrator password is still in use. Change it before exposing this instance.' : '当前仍在使用默认管理员密码,请在对外开放前完成修改。') : undefined}
- style={{ marginBottom: 20 }}
- />
- <Space direction="vertical" size={16}>
- <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>
- <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>
- </Space>
- </SectionCard>
- </Space>
- </Form>
- )}
- </Space>
- );
- }
- function locationIdentity(location: { pathname: string; search: string; hash: string }) {
- return `${location.pathname}${location.search}${location.hash}`;
- }
- function envExtra(name: string) {
- return <Typography.Text type="secondary" code>{name}</Typography.Text>;
- }
- function publicUrlSecurityError(value: unknown, locale: string) {
- if (!value) return '';
- let url: URL;
- try {
- url = new URL(String(value));
- } catch {
- return locale.startsWith('en') ? 'Enter a valid absolute URL.' : '请输入有效的完整访问地址。';
- }
- const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
- if (url.protocol !== 'https:' && !local) {
- return locale.startsWith('en')
- ? 'Public instances must use HTTPS so API credentials are never sent over plaintext HTTP.'
- : '公网实例必须使用 HTTPS,避免 API 密钥通过明文 HTTP 传输。';
- }
- return '';
- }
- const settingsCopyZh = {
- publicUrl: '公开访问地址', publicUrlInvalid: '公开访问地址不安全', publicUrlRequired: '请输入公开访问地址',
- mailHostname: '邮件服务器主机名', sendingIp: '发信公网 IP', spfMechanisms: '默认 SPF 扩展', dmarcPolicy: '默认 DMARC 策略', dmarcReports: 'DMARC 报告邮箱',
- unsubscribeEmail: '退订邮箱', unsubscribeUrl: '退订链接', abuseMailbox: '滥用举报邮箱', csaMailbox: 'CSA 投诉邮箱', bounceAddress: '退信地址',
- oneClickUnsubscribe: '启用一键退订', feedbackId: '启用 Feedback-ID', requireVerifiedDomain: '仅允许已验证域名发信', bounceEnvelope: '启用退信信封地址'
- };
- const settingsCopyEn: typeof settingsCopyZh = {
- publicUrl: 'Public URL', publicUrlInvalid: 'Public URL is insecure', publicUrlRequired: 'Enter the public URL',
- mailHostname: 'Mail server hostname', sendingIp: 'Public sending IP', spfMechanisms: 'Default SPF mechanisms', dmarcPolicy: 'Default DMARC policy', dmarcReports: 'DMARC report mailbox',
- unsubscribeEmail: 'Unsubscribe mailbox', unsubscribeUrl: 'Unsubscribe URL', abuseMailbox: 'Abuse mailbox', csaMailbox: 'CSA complaints mailbox', bounceAddress: 'Bounce address',
- oneClickUnsubscribe: 'Enable one-click unsubscribe', feedbackId: 'Enable Feedback-ID', requireVerifiedDomain: 'Require a verified sending domain', bounceEnvelope: 'Enable bounce envelope address'
- };
|