feat: umfangreiches UI+API-Polish (v1.1.36–1.1.42)
Backend: - Audit-Log: Search-Endpoint mit ILIKE-Filter (actor/action/subject/date) - NTP: /ntp/status via chronyc tracking (Stratum, Offset, Quelle) - System: /service-restart mit Allowlist (haproxy/squid/unbound/chrony/scheduler) - Domain-Response-Headers + Rate-Limit (Migration 0024) - Join-Tokens (Migration 0025), Cluster-mTLS, Aggregator-Fan-Out - apt-Service für Update-Banner (apt-get update + Versionsprüfung) - Backup-Retry mit exponential backoff (retry_apt 3×) - publish.sh fail-fast + cleanup-old.sh (max 10 Versionen) Frontend: - Audit-Log-Page (/audit) mit Filter + Pagination - ErrorBoundary an React-Root + Vite build-target festgenagelt (iOS 15+) - Storage-Schema-Stamp: auto-wipe bei Versions-Mismatch (blank-page-Fix) - EmptyState-Komponente überall ausgerollt - SSL: Aggregate-Karte (total/expiring/expired/errors) - Backups: Aggregate-Karte (letzter Backup/Größe/Fehlschläge 24h) + Backup-Now - NTP: Sync-Status-Karte (chronyc tracking live) - Domains: Backend-UP/DOWN-Chip aus HAProxy-Stats - Backends: HAProxy-Status-Spalte (UP/DEGRADED/DOWN) - Settings: Service-Neustart-Karte (haproxy/squid/unbound/chrony/scheduler) - Settings: Upgrade-Status-Card, Wartungsmodus, Auto-Update, Retention - Dashboard: Recent-Alerts, Cluster-Health, License-Chip, Onboarding-Hint - System-Regeln im Firewall als eigener Tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Card, Descriptions, Spin } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Space, Spin, Switch, Typography, message } from 'antd'
|
||||
import { CloudDownloadOutlined, CloudSyncOutlined, DatabaseOutlined, ExclamationCircleOutlined, FileSearchOutlined, 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'
|
||||
@@ -9,16 +10,31 @@ 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'],
|
||||
@@ -38,12 +54,226 @@ export default function SettingsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
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 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')}
|
||||
@@ -54,18 +284,399 @@ export default function SettingsPage() {
|
||||
<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={t('settings.setupInfo')} size="small">
|
||||
<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.adminEmail')}>{setupStatus?.admin_email ?? '—'}</Descriptions.Item>
|
||||
<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={<><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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user