Installer (EDGEGUARD_CHANNEL), Kanal-Lesen/-Schreiben ohne DB-State (sources.list ist Quelle der Wahrheit), Cluster-Endpoints für Kanalwechsel mit mTLS-Peer-Propagation + Drift-Erkennung, --allow-downgrades für testing→stable-Downgrades über den bestehenden sicheren Rolling-Update- Flow, Settings-UI mit Bestätigung, neues scripts/release.sh (Testing-Push datumsbasiert YYYY.MM.DD.NN, Stable-Promotion mit Verify-Gate + Git-Tag), publish.sh/cleanup-old.sh kanalfähig mit Stable-Tag-Schutz. Migriert Bestandsnodes automatisch von der alten "main"-Komponente auf "stable" (postinst, idempotent) — ohne das würden vor diesem Release installierte Nodes stillschweigend keine Updates mehr sehen, sobald main nicht mehr bespielt wird. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1128 lines
42 KiB
TypeScript
1128 lines
42 KiB
TypeScript
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Popconfirm, Select, Space, Spin, Switch, Tooltip, Typography, message } from 'antd'
|
|
import { ApartmentOutlined, BranchesOutlined, 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
|
|
}
|
|
|
|
interface VIPSettingsValues {
|
|
vip_address?: string
|
|
vip_interface?: string
|
|
vip_auth_pass?: string
|
|
vrrp_router_id?: number
|
|
hb_interface?: string
|
|
hb_src_ip?: string
|
|
hb_peer_ip?: string
|
|
hb_router_id?: number
|
|
gw_check_ip?: string
|
|
}
|
|
|
|
interface OIDCSettingsView {
|
|
enabled: boolean
|
|
issuer_url: string
|
|
client_id: string
|
|
scopes: string
|
|
email_claim: string
|
|
button_label: string
|
|
secret_configured: boolean
|
|
redirect_uri: string
|
|
}
|
|
|
|
interface OIDCFormValues {
|
|
enabled: boolean
|
|
issuer_url: string
|
|
client_id: string
|
|
client_secret?: string
|
|
scopes: string
|
|
email_claim: string
|
|
button_label: 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 [vipForm] = Form.useForm<VIPSettingsValues>()
|
|
|
|
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 { data: vipSettings } = useQuery({
|
|
queryKey: ['cluster', 'vip-settings'],
|
|
queryFn: async () => {
|
|
const r = await apiClient.get('/cluster/vip-settings')
|
|
return isEnvelope(r.data) ? r.data.data as VIPSettingsValues : null
|
|
},
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (vipSettings) vipForm.setFieldsValue(vipSettings)
|
|
}, [vipSettings, vipForm])
|
|
|
|
const updateVIP = useMutation({
|
|
mutationFn: async (v: VIPSettingsValues) => apiClient.put('/cluster/vip-settings', v),
|
|
onSuccess: () => {
|
|
msg.success(t('cluster.vipCard.saved'))
|
|
void qc.invalidateQueries({ queryKey: ['cluster', 'vip-settings'] })
|
|
},
|
|
onError: (e: Error) => msg.error(t('cluster.vipCard.saveFailed') + ': ' + e.message),
|
|
})
|
|
|
|
const [oidcForm] = Form.useForm<OIDCFormValues>()
|
|
const { data: oidc } = useQuery({
|
|
queryKey: ['oidc', 'settings'],
|
|
queryFn: async () => {
|
|
const r = await apiClient.get('/oidc/settings')
|
|
return isEnvelope(r.data) ? r.data.data as OIDCSettingsView : null
|
|
},
|
|
})
|
|
useEffect(() => {
|
|
if (oidc) {
|
|
oidcForm.setFieldsValue({
|
|
enabled: oidc.enabled,
|
|
issuer_url: oidc.issuer_url,
|
|
client_id: oidc.client_id,
|
|
scopes: oidc.scopes,
|
|
email_claim: oidc.email_claim,
|
|
button_label: oidc.button_label,
|
|
client_secret: '',
|
|
})
|
|
}
|
|
}, [oidc, oidcForm])
|
|
const updateOIDC = useMutation({
|
|
mutationFn: async (v: OIDCFormValues) => {
|
|
const body: Record<string, unknown> = { ...v }
|
|
// leeres Secret = unverändert → Feld weglassen (Backend: nil)
|
|
if (!v.client_secret) delete body.client_secret
|
|
return apiClient.put('/oidc/settings', body)
|
|
},
|
|
onSuccess: () => {
|
|
msg.success(t('settings.oidc.saved'))
|
|
void qc.invalidateQueries({ queryKey: ['oidc', 'settings'] })
|
|
},
|
|
onError: (e: Error) => msg.error(t('settings.oidc.saveFailed') + ': ' + e.message),
|
|
})
|
|
|
|
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 [channelDraft, setChannelDraft] = useState<string | null>(null)
|
|
const { data: updateChannel } = useQuery({
|
|
queryKey: ['cluster', 'update-channel'],
|
|
queryFn: async () => {
|
|
const r = await apiClient.get('/cluster/update-channel')
|
|
return isEnvelope(r.data)
|
|
? (r.data.data as { channel: string; peer_channel?: string; peer_reached: boolean; peer_drifted: boolean })
|
|
: { channel: 'stable', peer_reached: false, peer_drifted: false }
|
|
},
|
|
})
|
|
const setUpdateChannel = useMutation({
|
|
mutationFn: async (channel: string) => {
|
|
const r = await apiClient.post('/cluster/update-channel', { channel })
|
|
return r.data
|
|
},
|
|
onSuccess: () => {
|
|
msg.success(t('settings.updateChannelSaved'))
|
|
void qc.invalidateQueries({ queryKey: ['cluster', 'update-channel'] })
|
|
void qc.invalidateQueries({ queryKey: ['system', 'package-versions'] })
|
|
},
|
|
onError: (e: Error) => msg.error(t('settings.updateChannelFailed') + ': ' + e.message),
|
|
onSettled: () => setChannelDraft(null),
|
|
})
|
|
|
|
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 className={`status-dot ${status?.active ? 'online' : 'offline'}`} />
|
|
<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}>
|
|
<Popconfirm
|
|
title={t('settings.serviceRestartConfirmTitle', { service: svc })}
|
|
description={t('settings.serviceRestartConfirmDesc')}
|
|
okText={t('settings.serviceRestartConfirmOk')}
|
|
cancelText={t('common.cancel')}
|
|
disabled={isViewer}
|
|
onConfirm={() => serviceRestart.mutate(svc)}
|
|
>
|
|
<Button
|
|
size="small"
|
|
icon={<ReloadOutlined />}
|
|
loading={restartingService === svc}
|
|
disabled={isViewer}
|
|
>
|
|
{t('settings.serviceRestartBtn')}
|
|
</Button>
|
|
</Popconfirm>
|
|
</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={<><BranchesOutlined /> {t('settings.updateChannelCardTitle')}</>}
|
|
className="mb-12"
|
|
size="small"
|
|
>
|
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
|
<Space>
|
|
<Select
|
|
value={channelDraft ?? updateChannel?.channel ?? 'stable'}
|
|
style={{ width: 160 }}
|
|
disabled={isViewer}
|
|
options={[
|
|
{ value: 'stable', label: t('settings.updateChannelStable') },
|
|
{ value: 'testing', label: t('settings.updateChannelTesting') },
|
|
]}
|
|
onChange={setChannelDraft}
|
|
/>
|
|
{channelDraft && channelDraft !== (updateChannel?.channel ?? 'stable') && (
|
|
<Popconfirm
|
|
title={t('settings.updateChannelConfirmTitle')}
|
|
description={
|
|
channelDraft === 'testing'
|
|
? t('settings.updateChannelSwitchTestingWarn')
|
|
: t('settings.updateChannelSwitchStableWarn')
|
|
}
|
|
okText={t('settings.updateChannelApply')}
|
|
cancelText={t('common.cancel')}
|
|
onConfirm={() => setUpdateChannel.mutate(channelDraft)}
|
|
onCancel={() => setChannelDraft(null)}
|
|
>
|
|
<Button size="small" type="primary" loading={setUpdateChannel.isPending}>
|
|
{t('settings.updateChannelApply')}
|
|
</Button>
|
|
</Popconfirm>
|
|
)}
|
|
</Space>
|
|
{updateChannel?.peer_drifted && (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message={t('settings.updateChannelDrift', { peer: updateChannel?.peer_channel ?? '?' })}
|
|
/>
|
|
)}
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
{t('settings.updateChannelHint')}
|
|
</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={<><ApartmentOutlined /> {t('cluster.vipCard.title')}</>}
|
|
size="small"
|
|
className="mb-12"
|
|
>
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
className="mb-12"
|
|
message={t('cluster.vipCard.hintTitle')}
|
|
description={
|
|
<ul style={{ margin: '4px 0', paddingLeft: 18 }}>
|
|
<li><Typography.Text code>{t('cluster.vipCard.hintPrimary')}</Typography.Text></li>
|
|
<li><Typography.Text code>{t('cluster.vipCard.hintStandby')}</Typography.Text></li>
|
|
<li><Typography.Text code>{t('cluster.vipCard.hintKeepalived')}</Typography.Text></li>
|
|
<li><Typography.Text code>{t('cluster.vipCard.hintFailover')}</Typography.Text></li>
|
|
</ul>
|
|
}
|
|
/>
|
|
<Form<VIPSettingsValues>
|
|
form={vipForm}
|
|
layout="vertical"
|
|
onFinish={(v) => updateVIP.mutate(v)}
|
|
initialValues={{ vrrp_router_id: 51 }}
|
|
>
|
|
<Form.Item label={t('cluster.vipCard.vipAddress')} name="vip_address"
|
|
extra={t('cluster.vipCard.vipAddressHelp')}>
|
|
<Input placeholder="89.163.205.10" disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('cluster.vipCard.vipInterface')} name="vip_interface"
|
|
extra={t('cluster.vipCard.vipInterfaceHelp')}>
|
|
<Input placeholder="eth0" disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('cluster.vipCard.vipAuthPass')} name="vip_auth_pass"
|
|
extra={t('cluster.vipCard.vipAuthPassHelp')}
|
|
rules={[{ max: 8, message: 'Max. 8 Zeichen (Keepalived-Limit)' }]}>
|
|
<Input.Password placeholder="max 8 chars" disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('cluster.vipCard.vrrpRouterId')} name="vrrp_router_id"
|
|
extra={t('cluster.vipCard.vrrpRouterIdHelp')}>
|
|
<InputNumber min={1} max={255} disabled={isViewer} />
|
|
</Form.Item>
|
|
|
|
<Typography.Text strong style={{ display: 'block', marginBottom: 12, marginTop: 8 }}>
|
|
{t('cluster.vipCard.splitBrainSection')}
|
|
</Typography.Text>
|
|
<Form.Item label={t('cluster.vipCard.hbInterface')} name="hb_interface"
|
|
extra={t('cluster.vipCard.hbInterfaceHelp')}>
|
|
<Input placeholder="eth1" disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('cluster.vipCard.hbSrcIp')} name="hb_src_ip"
|
|
extra={t('cluster.vipCard.hbSrcIpHelp')}>
|
|
<Input placeholder="192.168.1.1" disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('cluster.vipCard.hbPeerIp')} name="hb_peer_ip"
|
|
extra={t('cluster.vipCard.hbPeerIpHelp')}>
|
|
<Input placeholder="192.168.1.2" disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('cluster.vipCard.hbRouterId')} name="hb_router_id"
|
|
extra={t('cluster.vipCard.hbRouterIdHelp')}>
|
|
<InputNumber min={1} max={255} disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('cluster.vipCard.gwCheckIp')} name="gw_check_ip"
|
|
extra={t('cluster.vipCard.gwCheckIpHelp')}>
|
|
<Input placeholder="89.163.205.1" disabled={isViewer} />
|
|
</Form.Item>
|
|
|
|
{!isViewer && (
|
|
<Form.Item>
|
|
<Button type="primary" htmlType="submit" loading={updateVIP.isPending}>
|
|
{t('cluster.vipCard.saveBtn')}
|
|
</Button>
|
|
</Form.Item>
|
|
)}
|
|
</Form>
|
|
</Card>
|
|
|
|
<Card title={<><GlobalOutlined /> {t('settings.oidc.title')}</>} className="mb-12" size="small">
|
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
|
{t('settings.oidc.intro')}
|
|
</Typography.Paragraph>
|
|
<Form<OIDCFormValues> form={oidcForm} layout="vertical" onFinish={(v) => updateOIDC.mutate(v)}>
|
|
<Form.Item label={t('settings.oidc.enabled')} name="enabled" valuePropName="checked">
|
|
<Switch disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('settings.oidc.issuerUrl')} name="issuer_url" extra={t('settings.oidc.issuerHint')}>
|
|
<Input placeholder="https://keycloak.example.com/realms/edgeguard" disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('settings.oidc.clientId')} name="client_id">
|
|
<Input disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
label={t('settings.oidc.clientSecret')}
|
|
name="client_secret"
|
|
extra={oidc?.secret_configured ? t('settings.oidc.secretSet') : t('settings.oidc.secretUnset')}
|
|
>
|
|
<Input.Password placeholder={oidc?.secret_configured ? '••••••••' : ''} autoComplete="new-password" disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('settings.oidc.scopes')} name="scopes">
|
|
<Input placeholder="openid email profile" disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('settings.oidc.emailClaim')} name="email_claim">
|
|
<Input placeholder="email" disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('settings.oidc.buttonLabel')} name="button_label">
|
|
<Input disabled={isViewer} />
|
|
</Form.Item>
|
|
<Form.Item label={t('settings.oidc.redirectUri')} extra={t('settings.oidc.redirectHint')}>
|
|
<Typography.Text copyable code style={{ fontSize: 12 }}>{oidc?.redirect_uri || ''}</Typography.Text>
|
|
</Form.Item>
|
|
<Form.Item style={{ marginBottom: 0 }}>
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" htmlType="submit" loading={updateOIDC.isPending} disabled={isViewer}>
|
|
{t('common.save')}
|
|
</Button>
|
|
</Tooltip>
|
|
</Form.Item>
|
|
</Form>
|
|
</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>
|
|
)
|
|
}
|