- setup.State.IPv6Enabled + Store.SetIPv6Enabled() - GET/POST /system/ipv6 im SystemHandler; HAProxy-Reload on save - HAProxy-Template: bind [::]:80, [::]:443, quic6@:443, [::]:3443 werden nur emittiert wenn IPv6Enabled=true - haproxy.View.IPv6Enabled aus SetupStore befüllt - Settings-UI: neues IPv6-Card (zwischen Auto-Update und Passwort) - i18n de+en ergänzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
726 lines
26 KiB
TypeScript
726 lines
26 KiB
TypeScript
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Space, Spin, Switch, Typography, message } from 'antd'
|
|
import { CloudDownloadOutlined, CloudSyncOutlined, DatabaseOutlined, 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'
|
|
|
|
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
|
|
}
|
|
|
|
interface ChangePasswordValues {
|
|
current_password: string
|
|
new_password: string
|
|
confirm_password: string
|
|
}
|
|
|
|
export default function SettingsPage() {
|
|
const { t } = useTranslation()
|
|
const qc = useQueryClient()
|
|
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: () => 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 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>
|
|
{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(t =>
|
|
`${t.name} (${t.human_size})`
|
|
).join(', ')}
|
|
</Typography.Text>
|
|
)}
|
|
</Space>
|
|
</Descriptions.Item>
|
|
)}
|
|
</Descriptions>
|
|
</Card>
|
|
|
|
<Card
|
|
title={<><ToolOutlined /> {t('settings.actionsCardTitle')}</>}
|
|
className="mb-12"
|
|
size="small"
|
|
>
|
|
<Space wrap>
|
|
<Button
|
|
icon={<ReloadOutlined />}
|
|
loading={haproxyReload.isPending}
|
|
onClick={() => haproxyReload.mutate()}
|
|
>
|
|
{t('settings.haproxyReloadBtn')}
|
|
</Button>
|
|
<Button
|
|
icon={<ReloadOutlined />}
|
|
loading={renderConfigs.isPending}
|
|
onClick={() => renderConfigs.mutate()}
|
|
>
|
|
{t('settings.renderConfigsBtn')}
|
|
</Button>
|
|
<Button
|
|
icon={<DatabaseOutlined />}
|
|
loading={triggerBackup.isPending}
|
|
onClick={() => triggerBackup.mutate()}
|
|
>
|
|
{t('settings.backupNowBtn')}
|
|
</Button>
|
|
</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>
|
|
<Button
|
|
size="small"
|
|
icon={<ReloadOutlined />}
|
|
loading={restartingService === svc}
|
|
onClick={() => serviceRestart.mutate(svc)}
|
|
>
|
|
{t('settings.serviceRestartBtn')}
|
|
</Button>
|
|
</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>
|
|
<Button type="primary" htmlType="submit" loading={updateEmails.isPending}>
|
|
{t('common.save')}
|
|
</Button>
|
|
<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}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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={<><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>
|
|
)
|
|
}
|