Files
edgeguard-native/management-ui/src/pages/Settings/index.tsx
Debian 57b9cd89b2 feat(settings): Copy + Download buttons for config preview
v1.1.100

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 10:48:45 +02:00

843 lines
31 KiB
TypeScript

import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Select, Space, Spin, Switch, Tooltip, Typography, message } from 'antd'
import { CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
import { useAuthStore } from '../../stores/auth'
interface SetupStatus {
completed: boolean
admin_email: string
acme_email: string
fqdn: string
}
interface ContactEmailValues {
admin_email: string
acme_email: string
}
interface SystemHealth {
status: string
version: string
hostname?: string
kernel?: string
os?: string
}
interface ChangePasswordValues {
current_password: string
new_password: string
confirm_password: string
}
export default function SettingsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [pwForm] = Form.useForm<ChangePasswordValues>()
const { data: setupStatus, isLoading: loadingSetup } = useQuery({
queryKey: ['setup', 'status'],
queryFn: async () => {
const r = await apiClient.get('/setup/status')
if (isEnvelope(r.data)) return r.data.data as SetupStatus
return null
},
})
const { data: health, isLoading: loadingHealth } = useQuery({
queryKey: ['system', 'health'],
queryFn: async () => {
const r = await apiClient.get('/system/health')
if (isEnvelope(r.data)) return r.data.data as SystemHealth
return null
},
})
const [emailForm] = Form.useForm<ContactEmailValues>()
const updateEmails = useMutation({
mutationFn: async (v: ContactEmailValues) => {
const r = await apiClient.post('/setup/contact-emails', v)
return r.data
},
onSuccess: () => {
msg.success(t('settings.emailsSaved'))
void qc.invalidateQueries({ queryKey: ['setup', 'status'] })
},
onError: (e: Error) => {
msg.error(t('settings.emailsFailed') + ': ' + e.message)
},
})
const { data: maintenance, refetch: refetchMaintenance } = useQuery({
queryKey: ['system', 'maintenance'],
queryFn: async () => {
const r = await apiClient.get('/system/maintenance')
return isEnvelope(r.data)
? (r.data.data as { enabled: boolean; message: string })
: { enabled: false, message: '' }
},
})
const [maintMessage, setMaintMessage] = useState('')
// Bei Daten-Aktualisierung: lokales Textarea mit DB-Wert syncen,
// wenn der Operator gerade nicht tippt. Trigger via key-Prop unten.
const toggleMaintenance = useMutation({
mutationFn: async (vals: { enabled: boolean; message: string }) => {
const r = await apiClient.post('/system/maintenance', vals)
return r.data
},
onSuccess: () => {
msg.success(t('settings.maintenanceSaved'))
void refetchMaintenance()
},
onError: (e: Error) => {
msg.error(t('settings.maintenanceFailed') + ': ' + e.message)
},
})
const { data: backupRetention } = useQuery({
queryKey: ['system', 'backup-retention'],
queryFn: async () => {
const r = await apiClient.get('/system/backup-retention')
return isEnvelope(r.data)
? (r.data.data as { keep: number; default: number })
: { keep: 0, default: 14 }
},
})
const setBackupRetention = useMutation({
mutationFn: async (keep: number) => {
const r = await apiClient.post('/system/backup-retention', { keep })
return r.data
},
onSuccess: () => {
msg.success(t('settings.backupRetentionSaved'))
void qc.invalidateQueries({ queryKey: ['system', 'backup-retention'] })
},
onError: (e: Error) => msg.error(t('settings.backupRetentionFailed') + ': ' + e.message),
})
const haproxyReload = useMutation({
mutationFn: async () => apiClient.post('/system/haproxy-reload'),
onSuccess: () => msg.success(t('settings.haproxyReloadOk')),
onError: (e: Error) => msg.error(t('settings.haproxyReloadFailed') + ': ' + e.message),
})
const renderConfigs = useMutation({
mutationFn: async () => apiClient.post('/system/render-configs'),
onSuccess: (r) => {
const d = r.data?.data as { ok: boolean; rendered: string[]; errors: Record<string, string> } | undefined
if (d && !d.ok && d.errors && Object.keys(d.errors).length > 0) {
const failed = Object.entries(d.errors).map(([k, v]) => `${k}: ${v}`).join('; ')
msg.warning(t('settings.renderConfigsPartial', { failed }))
} else {
msg.success(t('settings.renderConfigsOk'))
}
},
onError: (e: Error) => msg.error(t('settings.renderConfigsFailed') + ': ' + e.message),
})
const triggerBackup = useMutation({
mutationFn: async () => apiClient.post('/backups'),
onSuccess: () => msg.success(t('settings.backupNowOk')),
onError: (e: Error) => msg.error(t('settings.backupNowFailed') + ': ' + e.message),
})
const [restartingService, setRestartingService] = useState<string | null>(null)
const serviceRestart = useMutation({
mutationFn: async (service: string) => {
setRestartingService(service)
await apiClient.post('/system/service-restart', { service })
},
onSuccess: (_, service) => {
msg.success(t('settings.serviceRestartOk', { service }))
setRestartingService(null)
void qc.invalidateQueries({ queryKey: ['system', 'services'] })
},
onError: (e: Error, service) => {
msg.error(t('settings.serviceRestartFailed', { service }) + ': ' + e.message)
setRestartingService(null)
},
})
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
const { data: services, refetch: refetchServices } = useQuery({
queryKey: ['system', 'services'],
queryFn: async () => {
const r = await apiClient.get('/system/services')
return isEnvelope(r.data) ? (r.data.data as { services: ServiceStatus[] }).services : []
},
refetchInterval: 15_000,
})
// Restartable services — subset der Allowlist; API lehnt andere ab.
const RESTARTABLE = ['haproxy', 'squid', 'unbound', 'chrony', 'edgeguard-scheduler']
const { data: upgradeStatus, refetch: refetchUpgrade } = useQuery({
queryKey: ['system', 'upgrade-status'],
queryFn: async () => {
const r = await apiClient.get('/system/upgrade-status')
return isEnvelope(r.data)
? (r.data.data as {
state: string
result: string
exec_main_pid: number
exit_code: number
started_at: string
finished_at: string
log: string[]
})
: null
},
refetchInterval: 30_000,
})
const { data: dbSize } = useQuery({
queryKey: ['system', 'db-size'],
queryFn: async () => {
const r = await apiClient.get('/system/db-size')
return isEnvelope(r.data)
? (r.data.data as {
total_bytes: number
human_total: string
top_tables: { name: string; bytes: number; human_size: string }[]
})
: null
},
// DB-Größe ändert sich langsam → 5 min Refresh, eher konservativ.
refetchInterval: 5 * 60_000,
})
const { data: auditRetention } = useQuery({
queryKey: ['system', 'audit-retention'],
queryFn: async () => {
const r = await apiClient.get('/system/audit-retention')
return isEnvelope(r.data)
? (r.data.data as { days: number; default: number })
: { days: 0, default: 90 }
},
})
const setAuditRetention = useMutation({
mutationFn: async (days: number) => {
const r = await apiClient.post('/system/audit-retention', { days })
return r.data
},
onSuccess: () => {
msg.success(t('settings.auditRetentionSaved'))
void qc.invalidateQueries({ queryKey: ['system', 'audit-retention'] })
},
onError: (e: Error) => msg.error(t('settings.auditRetentionFailed') + ': ' + e.message),
})
const { data: autoUpdate } = useQuery({
queryKey: ['system', 'auto-update'],
queryFn: async () => {
const r = await apiClient.get('/system/auto-update')
return isEnvelope(r.data) ? (r.data.data as { enabled: boolean }) : { enabled: false }
},
})
const toggleAutoUpdate = useMutation({
mutationFn: async (enabled: boolean) => {
const r = await apiClient.post('/system/auto-update', { enabled })
return r.data
},
onSuccess: () => {
msg.success(t('settings.autoUpdateToggled'))
void qc.invalidateQueries({ queryKey: ['system', 'auto-update'] })
},
onError: (e: Error) => {
msg.error(t('settings.autoUpdateFailed') + ': ' + e.message)
},
})
const { data: ipv6 } = useQuery({
queryKey: ['system', 'ipv6'],
queryFn: async () => {
const r = await apiClient.get('/system/ipv6')
return isEnvelope(r.data) ? (r.data.data as { enabled: boolean }) : { enabled: false }
},
})
const toggleIPv6 = useMutation({
mutationFn: async (enabled: boolean) => {
const r = await apiClient.post('/system/ipv6', { enabled })
return r.data
},
onSuccess: () => {
msg.success(t('settings.ipv6Toggled'))
void qc.invalidateQueries({ queryKey: ['system', 'ipv6'] })
},
onError: (e: Error) => {
msg.error(t('settings.ipv6Failed') + ': ' + e.message)
},
})
const [previewGen, setPreviewGen] = useState('haproxy')
const {
data: previewData,
isFetching: previewLoading,
refetch: loadPreview,
} = useQuery({
queryKey: ['system', 'config-preview', previewGen],
queryFn: async () => {
const r = await apiClient.get('/system/config-preview', { params: { generator: previewGen } })
return isEnvelope(r.data) ? (r.data.data as { generator: string; content: string }) : null
},
enabled: false,
})
const changePassword = useMutation({
mutationFn: async (v: { current_password: string; new_password: string }) => {
const r = await apiClient.post('/auth/change-password', v)
return r.data
},
onSuccess: () => {
msg.success(t('settings.passwordChanged'))
pwForm.resetFields()
},
onError: (e: Error) => {
// API liefert 401 mit error="invalid_current_password" oder
// 400 mit error-Message; wir zeigen beides als Toast.
msg.error(t('settings.passwordChangeFailed') + ': ' + e.message)
},
})
// Form-Pre-Fill nach Status-Reload: setup-status liefert die zwei
// Email-Felder; wir resetten das Form drauf damit nach Save der frische
// Wert sichtbar wird.
useEffect(() => {
if (setupStatus) {
emailForm.setFieldsValue({
admin_email: setupStatus.admin_email,
acme_email: setupStatus.acme_email,
})
}
}, [setupStatus, emailForm])
if (loadingSetup || loadingHealth) {
return <Spin />
}
return (
<div>
{msgCtx}
<PageHeader
icon={<SettingOutlined />}
title={t('settings.title')}
subtitle={t('settings.intro')}
/>
<Card title={t('settings.systemInfo')} className="mb-12" size="small">
<Descriptions column={1}>
<Descriptions.Item label={t('settings.version')}>{health?.version ?? '—'}</Descriptions.Item>
<Descriptions.Item label={t('settings.status')}>{health?.status ?? '—'}</Descriptions.Item>
{health?.hostname && <Descriptions.Item label={t('settings.hostname')}><code>{health.hostname}</code></Descriptions.Item>}
{health?.os && <Descriptions.Item label={t('settings.os')}>{health.os}</Descriptions.Item>}
{health?.kernel && <Descriptions.Item label={t('settings.kernel')}><code style={{ fontSize: 11 }}>{health.kernel}</code></Descriptions.Item>}
{dbSize && (
<Descriptions.Item label={t('settings.dbSize')}>
<Space direction="vertical" size={2}>
<Typography.Text>{dbSize.human_total}</Typography.Text>
{dbSize.top_tables.length > 0 && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{t('settings.dbSizeTop')}:{' '}
{dbSize.top_tables.slice(0, 3).map(tbl =>
`${tbl.name} (${tbl.human_size})`
).join(', ')}
</Typography.Text>
)}
</Space>
</Descriptions.Item>
)}
</Descriptions>
</Card>
<Card
title={<><ToolOutlined /> {t('settings.actionsCardTitle')}</>}
className="mb-12"
size="small"
>
<Space wrap>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
icon={<ReloadOutlined />}
loading={haproxyReload.isPending}
disabled={isViewer}
onClick={() => haproxyReload.mutate()}
>
{t('settings.haproxyReloadBtn')}
</Button>
</Tooltip>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
icon={<ReloadOutlined />}
loading={renderConfigs.isPending}
disabled={isViewer}
onClick={() => renderConfigs.mutate()}
>
{t('settings.renderConfigsBtn')}
</Button>
</Tooltip>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
icon={<DatabaseOutlined />}
loading={triggerBackup.isPending}
disabled={isViewer}
onClick={() => triggerBackup.mutate()}
>
{t('settings.backupNowBtn')}
</Button>
</Tooltip>
</Space>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8, marginBottom: 0 }}>
{t('settings.actionsHint')}
</Typography.Paragraph>
</Card>
<Card
title={<><ReloadOutlined /> {t('settings.serviceRestartCardTitle')}</>}
className="mb-12"
size="small"
extra={<Button size="small" icon={<ReloadOutlined />} onClick={() => refetchServices()}>{t('common.refresh')}</Button>}
>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
{RESTARTABLE.map((svc) => {
const status = services?.find(s => s.unit === svc + '.service' || s.unit === svc)
return (
<Space key={svc} style={{ width: '100%', justifyContent: 'space-between' }}>
<Space size={6}>
<span
style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: status?.active ? '#22c55e' : '#ef4444',
flexShrink: 0,
}}
/>
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }}>{svc}</Typography.Text>
{status && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{status.state}
</Typography.Text>
)}
</Space>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
size="small"
icon={<ReloadOutlined />}
loading={restartingService === svc}
disabled={isViewer}
onClick={() => serviceRestart.mutate(svc)}
>
{t('settings.serviceRestartBtn')}
</Button>
</Tooltip>
</Space>
)
})}
</Space>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8, marginBottom: 0 }}>
{t('settings.serviceRestartHint')}
</Typography.Paragraph>
</Card>
{upgradeStatus && upgradeStatus.started_at && (
<Card
title={<><CloudDownloadOutlined /> {t('settings.upgradeStatusCardTitle')}</>}
className="mb-12"
size="small"
extra={
<Button size="small" icon={<ReloadOutlined />} onClick={() => refetchUpgrade()}>
{t('common.refresh')}
</Button>
}
>
<Descriptions size="small" column={2} bordered>
<Descriptions.Item label={t('settings.upgradeStatusStarted')}>
{upgradeStatus.started_at
? new Date(upgradeStatus.started_at).toLocaleString()
: '—'}
</Descriptions.Item>
<Descriptions.Item label={t('settings.upgradeStatusFinished')}>
{upgradeStatus.finished_at
? new Date(upgradeStatus.finished_at).toLocaleString()
: '—'}
</Descriptions.Item>
<Descriptions.Item label={t('settings.upgradeStatusResult')}>
{upgradeStatus.result === 'success' ? (
<Typography.Text type="success">{t('settings.upgradeStatusOk')}</Typography.Text>
) : (
<Typography.Text type="danger">
{upgradeStatus.result || upgradeStatus.state}
{upgradeStatus.exit_code !== 0 && ` (exit ${upgradeStatus.exit_code})`}
</Typography.Text>
)}
</Descriptions.Item>
<Descriptions.Item label={t('settings.upgradeStatusState')}>
{upgradeStatus.state}
</Descriptions.Item>
</Descriptions>
{upgradeStatus.log.length > 0 && (
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: 'pointer', fontSize: 12, color: '#475569' }}>
{t('settings.upgradeStatusShowLog', { n: upgradeStatus.log.length })}
</summary>
<pre style={{
marginTop: 8, padding: 8, background: '#f8fafc',
fontSize: 11, lineHeight: 1.4, overflow: 'auto', maxHeight: 320,
border: '1px solid #e2e8f0', borderRadius: 4,
}}>{upgradeStatus.log.join('\n')}</pre>
</details>
)}
</Card>
)}
<Card title={t('settings.setupInfo')} className="mb-12" size="small">
<Descriptions column={1}>
<Descriptions.Item label={t('settings.fqdn')}>{setupStatus?.fqdn ?? '—'}</Descriptions.Item>
<Descriptions.Item label={t('settings.setupCompleted')}>
{setupStatus?.completed ? t('common.yes') : t('common.no')}
</Descriptions.Item>
</Descriptions>
</Card>
<Card
title={<><MailOutlined /> {t('settings.emailsCardTitle')}</>}
className="mb-12"
size="small"
>
<Form<ContactEmailValues>
form={emailForm}
layout="vertical"
onFinish={(v) => updateEmails.mutate(v)}
>
<Form.Item
label={t('settings.adminEmail')}
name="admin_email"
extra={t('settings.adminEmailHint')}
rules={[{ required: true, type: 'email' }]}
>
<Input type="email" />
</Form.Item>
<Form.Item
label={t('settings.acmeEmail')}
name="acme_email"
extra={t('settings.acmeEmailHint')}
rules={[{ required: true, type: 'email' }]}
>
<Input type="email" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Space>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" htmlType="submit" loading={updateEmails.isPending} disabled={isViewer}>
{t('common.save')}
</Button>
</Tooltip>
<Button onClick={() => emailForm.resetFields()}>
{t('common.cancel')}
</Button>
</Space>
</Form.Item>
</Form>
</Card>
<Card
title={<><StopOutlined style={{ color: maintenance?.enabled ? '#cf1322' : undefined }} /> {t('settings.maintenanceCardTitle')}</>}
className="mb-12"
size="small"
>
{maintenance?.enabled && (
<Alert
type="error"
showIcon
icon={<ExclamationCircleOutlined />}
message={t('settings.maintenanceActiveTitle')}
description={t('settings.maintenanceActiveDesc')}
className="mb-12"
/>
)}
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space>
<Switch
checked={maintenance?.enabled ?? false}
loading={toggleMaintenance.isPending}
disabled={isViewer}
onChange={(checked) => toggleMaintenance.mutate({
enabled: checked,
message: maintMessage || maintenance?.message || '',
})}
/>
<Typography.Text>
{maintenance?.enabled ? t('settings.maintenanceOn') : t('settings.maintenanceOff')}
</Typography.Text>
</Space>
<Form.Item
label={t('settings.maintenanceMessage')}
extra={t('settings.maintenanceMessageHint')}
style={{ marginBottom: 0 }}
>
<Input.TextArea
key={maintenance?.message ?? ''}
defaultValue={maintenance?.message ?? ''}
onChange={(e) => setMaintMessage(e.target.value)}
placeholder={t('settings.maintenanceMessagePlaceholder')}
rows={2}
maxLength={500}
/>
</Form.Item>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.maintenanceHint')}
</Typography.Text>
</Space>
</Card>
<Card
title={<><DatabaseOutlined /> {t('settings.backupRetentionCardTitle')}</>}
className="mb-12"
size="small"
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space>
<InputNumber
min={0}
max={365}
step={1}
value={backupRetention?.keep ?? 0}
onChange={(v) => setBackupRetention.mutate((v as number) ?? 0)}
disabled={setBackupRetention.isPending || isViewer}
addonAfter={t('settings.backupRetentionUnit')}
style={{ width: 200 }}
/>
<Typography.Text type="secondary">
{(backupRetention?.keep ?? 0) === 0
? t('settings.backupRetentionDefault', { n: backupRetention?.default ?? 14 })
: t('settings.backupRetentionCustom', { n: backupRetention?.keep })}
</Typography.Text>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.backupRetentionHint')}
</Typography.Text>
</Space>
</Card>
<Card
title={<><FileSearchOutlined /> {t('settings.auditRetentionCardTitle')}</>}
className="mb-12"
size="small"
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space>
<InputNumber
min={0}
max={3650}
step={30}
value={auditRetention?.days ?? 0}
onChange={(v) => setAuditRetention.mutate((v as number) ?? 0)}
disabled={setAuditRetention.isPending || isViewer}
addonAfter={t('settings.auditRetentionUnit')}
style={{ width: 200 }}
/>
<Typography.Text type="secondary">
{(auditRetention?.days ?? 0) === 0
? t('settings.auditRetentionDefault', { n: auditRetention?.default ?? 90 })
: t('settings.auditRetentionCustom', { n: auditRetention?.days })}
</Typography.Text>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.auditRetentionHint')}
</Typography.Text>
</Space>
</Card>
<Card
title={<><CloudSyncOutlined /> {t('settings.autoUpdateCardTitle')}</>}
className="mb-12"
size="small"
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space>
<Switch
checked={autoUpdate?.enabled ?? false}
loading={toggleAutoUpdate.isPending}
disabled={isViewer}
onChange={(checked) => toggleAutoUpdate.mutate(checked)}
/>
<Typography.Text>
{autoUpdate?.enabled ? t('settings.autoUpdateOn') : t('settings.autoUpdateOff')}
</Typography.Text>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.autoUpdateHint')}
</Typography.Text>
</Space>
</Card>
<Card
title={<><GlobalOutlined /> {t('settings.ipv6CardTitle')}</>}
className="mb-12"
size="small"
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space>
<Switch
checked={ipv6?.enabled ?? false}
loading={toggleIPv6.isPending}
disabled={isViewer}
onChange={(checked) => toggleIPv6.mutate(checked)}
/>
<Typography.Text>
{ipv6?.enabled ? t('settings.ipv6On') : t('settings.ipv6Off')}
</Typography.Text>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.ipv6Hint')}
</Typography.Text>
</Space>
</Card>
<Card
title={<><CodeOutlined /> {t('settings.configPreviewCardTitle')}</>}
className="mb-12"
size="small"
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space wrap>
<Select
value={previewGen}
onChange={(v) => setPreviewGen(v)}
style={{ width: 140 }}
options={[
{ value: 'haproxy', label: 'haproxy' },
{ value: 'nftables', label: 'nftables' },
{ value: 'squid', label: 'squid' },
{ value: 'unbound', label: 'unbound' },
{ value: 'chrony', label: 'chrony' },
{ value: 'wireguard', label: 'wireguard' },
]}
/>
<Button
icon={<ReloadOutlined />}
loading={previewLoading}
onClick={() => { void loadPreview() }}
>
{t('settings.configPreviewBtn')}
</Button>
{previewData?.content && (
<>
<Button
icon={<CopyOutlined />}
onClick={() => {
void navigator.clipboard.writeText(previewData.content)
void msg.success(t('settings.configCopied'))
}}
>
{t('common.copy')}
</Button>
<Button
icon={<DownloadOutlined />}
onClick={() => {
const blob = new Blob([previewData.content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${previewData.generator}.conf`
a.click()
URL.revokeObjectURL(url)
}}
>
{t('common.download')}
</Button>
</>
)}
</Space>
{previewData?.content && (
<pre style={{
marginTop: 4, padding: 10, background: '#f8fafc',
fontSize: 11, lineHeight: 1.5, overflow: 'auto', maxHeight: 400,
border: '1px solid #e2e8f0', borderRadius: 4, whiteSpace: 'pre',
}}>
{previewData.content}
</pre>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.configPreviewHint')}
</Typography.Text>
</Space>
</Card>
<Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
<Form<ChangePasswordValues>
form={pwForm}
layout="vertical"
onFinish={(v) => {
if (v.new_password !== v.confirm_password) {
msg.error(t('settings.passwordMismatch'))
return
}
changePassword.mutate({
current_password: v.current_password,
new_password: v.new_password,
})
}}
// Wir lassen den Submit-Button explizit click-bar — autoComplete
// off damit der Browser nicht "Current password" mit dem im
// Manager gespeicherten autofill'd.
autoComplete="off"
>
<Form.Item
label={t('settings.currentPassword')}
name="current_password"
rules={[{ required: true }]}
>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item
label={t('settings.newPassword')}
name="new_password"
extra={t('settings.newPasswordHint')}
rules={[
{ required: true },
{ min: 12, message: t('settings.passwordMinLen') },
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
label={t('settings.confirmPassword')}
name="confirm_password"
dependencies={['new_password']}
rules={[
{ required: true },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error(t('settings.passwordMismatch')))
},
}),
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={changePassword.isPending}
>
{t('settings.changePasswordBtn')}
</Button>
<Button onClick={() => pwForm.resetFields()}>
{t('common.cancel')}
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}