feat: HA-Cluster v1.2.x — Split-Brain, TOTP, Enterprise-FW, Drift-Fix, VIP-Recovery
- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung - keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload) - confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix) - TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login - Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator - fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen - VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034) - Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032) - unbound-control: edgeguard in unbound-Gruppe via postinst Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Alert, Button, Card, Descriptions, Input, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import { Alert, Button, Card, Descriptions, Input, List, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined, SwapOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -87,6 +87,28 @@ interface CertStatus {
|
||||
peer?: CertInfo
|
||||
}
|
||||
|
||||
interface VIPInfo {
|
||||
id: number
|
||||
address: string
|
||||
prefix: number
|
||||
device: string
|
||||
}
|
||||
|
||||
interface VIPStatusEntry {
|
||||
vip: VIPInfo
|
||||
active_on: string[]
|
||||
}
|
||||
|
||||
interface VIPTestStep {
|
||||
step: string
|
||||
ok: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface VIPTestResult {
|
||||
steps: VIPTestStep[]
|
||||
}
|
||||
|
||||
function statusTag(s: HANode['status'], t: (k: string) => string) {
|
||||
switch (s) {
|
||||
case 'online': return <Tag color="green">{t('cluster.status.online')}</Tag>
|
||||
@@ -272,6 +294,42 @@ export default function ClusterPage() {
|
||||
onError: () => message.error(t('cluster.joinTokenFailed')),
|
||||
})
|
||||
|
||||
const isClusterMode = data?.mode === 'cluster'
|
||||
|
||||
const vipStatusQuery = useQuery({
|
||||
queryKey: ['cluster', 'vip-status'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/cluster/vip-status')
|
||||
const payload = isEnvelope(r.data) ? (r.data.data as { vips?: VIPStatusEntry[] }) : null
|
||||
return payload?.vips ?? []
|
||||
},
|
||||
enabled: isClusterMode,
|
||||
refetchInterval: 30_000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
const [vipTestResult, setVipTestResult] = useState<{ id: number; steps: VIPTestStep[] } | null>(null)
|
||||
|
||||
const vipSwing = useMutation({
|
||||
mutationFn: async ({ id, action }: { id: number; action: 'to_secondary' | 'restore' }) => {
|
||||
const r = await apiClient.post('/cluster/vip-test', { ip_address_id: id, action })
|
||||
return isEnvelope(r.data) ? (r.data.data as VIPTestResult) : null
|
||||
},
|
||||
onSuccess: (result, { id, action }) => {
|
||||
if (result) setVipTestResult({ id, steps: result.steps })
|
||||
const allOk = result?.steps.every(s => s.ok) ?? false
|
||||
if (allOk) {
|
||||
const key = action === 'to_secondary' ? 'cluster.vipTest.swingOk' : 'cluster.vipTest.restoreOk'
|
||||
void message.success(t(key))
|
||||
} else {
|
||||
const key = action === 'to_secondary' ? 'cluster.vipTest.swingFailed' : 'cluster.vipTest.restoreFailed'
|
||||
void message.error(t(key))
|
||||
}
|
||||
void vipStatusQuery.refetch()
|
||||
},
|
||||
onError: (e: Error) => void message.error(e.message),
|
||||
})
|
||||
|
||||
const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
|
||||
|
||||
const peerColumns: ColumnsType<HANode> = [
|
||||
@@ -641,6 +699,134 @@ export default function ClusterPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── VIP-Schwenk Test ─────────────────────────────────── */}
|
||||
{isClusterMode && (
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><SwapOutlined />{t('cluster.vipTest.cardTitle')}</Space>}
|
||||
className="mb-16"
|
||||
extra={
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => void vipStatusQuery.refetch()}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('cluster.vipTest.cardDesc')}
|
||||
className="mb-12"
|
||||
/>
|
||||
{vipStatusQuery.isLoading ? (
|
||||
<Spin />
|
||||
) : (vipStatusQuery.data?.length ?? 0) === 0 ? (
|
||||
<Text type="secondary">{t('cluster.vipTest.noVips')}</Text>
|
||||
) : (
|
||||
<Table<VIPStatusEntry>
|
||||
size="small"
|
||||
rowKey={r => String(r.vip.id)}
|
||||
dataSource={vipStatusQuery.data ?? []}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
expandedRowRender: r => {
|
||||
const res = vipTestResult?.id === r.vip.id ? vipTestResult : null
|
||||
if (!res) return null
|
||||
return (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={res.steps}
|
||||
renderItem={s => (
|
||||
<List.Item>
|
||||
<Space>
|
||||
<Tag color={s.ok ? 'green' : 'red'}>{s.ok ? t('cluster.vipTest.stepOk') : t('cluster.vipTest.stepFail')}</Tag>
|
||||
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.step}</Text>
|
||||
{s.message && <Text type="danger" style={{ fontSize: 12 }}>{s.message}</Text>}
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
rowExpandable: r => vipTestResult?.id === r.vip.id,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: t('cluster.vipTest.colAddress'),
|
||||
key: 'address',
|
||||
render: (_, r) => (
|
||||
<Text style={{ fontFamily: 'monospace' }}>{r.vip.address}/{r.vip.prefix}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('cluster.vipTest.colInterface'),
|
||||
key: 'device',
|
||||
width: 120,
|
||||
render: (_, r) => <Tag>{r.vip.device}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('cluster.vipTest.colActiveOn'),
|
||||
key: 'activeOn',
|
||||
render: (_, r) => {
|
||||
if (!r.active_on || r.active_on.length === 0) {
|
||||
return <Tag color="red">{t('cluster.vipTest.unknown')}</Tag>
|
||||
}
|
||||
return (
|
||||
<Space size={4}>
|
||||
{r.active_on.map(fqdn => <Tag key={fqdn} color="green">{fqdn}</Tag>)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.actions'),
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_, r) => {
|
||||
const localFqdn = data?.local_node?.fqdn
|
||||
const onLocal = r.active_on?.includes(localFqdn ?? '') ?? false
|
||||
const onPeer = r.active_on?.some(f => f !== localFqdn) ?? false
|
||||
const loading = vipSwing.isPending && (vipSwing.variables as { id: number })?.id === r.vip.id
|
||||
return (
|
||||
<Space size={4}>
|
||||
{!isViewer && !onPeer && (
|
||||
<Popconfirm
|
||||
title={t('cluster.vipTest.confirmSwing', { addr: r.vip.address })}
|
||||
okText={t('common.yes')}
|
||||
cancelText={t('common.no')}
|
||||
onConfirm={() => vipSwing.mutate({ id: r.vip.id, action: 'to_secondary' })}
|
||||
>
|
||||
<Button size="small" loading={loading && onLocal}>
|
||||
{t('cluster.vipTest.swingBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{!isViewer && onPeer && (
|
||||
<Popconfirm
|
||||
title={t('cluster.vipTest.confirmRestore', { addr: r.vip.address })}
|
||||
okText={t('common.yes')}
|
||||
cancelText={t('common.no')}
|
||||
onConfirm={() => vipSwing.mutate({ id: r.vip.id, action: 'restore' })}
|
||||
>
|
||||
<Button size="small" type="primary" loading={loading}>
|
||||
{t('cluster.vipTest.restoreBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{isViewer && (
|
||||
<Tooltip title={t('auth.viewerBadge')}>
|
||||
<Button size="small" disabled>{t('cluster.vipTest.swingBtn')}</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Per-Node Resources ────────────────────────────────── */}
|
||||
<Card
|
||||
size="small"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert, Button, Card, Col, Drawer, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import { Alert, Button, Card, Col, Divider, Drawer, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { BarChartOutlined, CheckCircleOutlined, ClearOutlined, CloseCircleOutlined, GlobalOutlined, NodeIndexOutlined, PlusOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -43,6 +43,10 @@ interface Settings {
|
||||
qname_minimisation: boolean
|
||||
cache_min_ttl: number
|
||||
cache_max_ttl: number
|
||||
prefetch: boolean
|
||||
serve_expired: boolean
|
||||
msg_cache_size_mb: number
|
||||
rrset_cache_size_mb: number
|
||||
}
|
||||
|
||||
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'NS', 'PTR', 'CAA']
|
||||
@@ -464,6 +468,7 @@ function SettingsTab() {
|
||||
for (const i of sys ?? []) {
|
||||
if (i.ifname === 'lo') continue
|
||||
for (const a of i.addr_info ?? []) {
|
||||
if (a.local.startsWith('fe80:')) continue
|
||||
ipOptions.push({
|
||||
value: a.local,
|
||||
label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
|
||||
@@ -477,7 +482,20 @@ function SettingsTab() {
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
} : undefined
|
||||
} : {
|
||||
listen_addresses: [],
|
||||
listen_port: 53,
|
||||
upstream_forwards: '1.1.1.1, 9.9.9.9',
|
||||
access_acl: '10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16',
|
||||
dnssec: false,
|
||||
qname_minimisation: true,
|
||||
cache_min_ttl: 60,
|
||||
cache_max_ttl: 86400,
|
||||
prefetch: false,
|
||||
serve_expired: false,
|
||||
msg_cache_size_mb: 64,
|
||||
rrset_cache_size_mb: 128,
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: SettingsForm) => {
|
||||
@@ -566,9 +584,19 @@ function SettingsTab() {
|
||||
<Form.Item label={t('dns.settings.qnameMin')} name="qname_minimisation" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Form.Item label={t('dns.settings.prefetch')} name="prefetch" valuePropName="checked"
|
||||
extra={t('dns.settings.prefetchExtra')}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dns.settings.serveExpired')} name="serve_expired" valuePropName="checked"
|
||||
extra={t('dns.settings.serveExpiredExtra')}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Divider plain>{t('dns.settings.cacheSection')}</Divider>
|
||||
<Space wrap>
|
||||
<Form.Item label={t('dns.settings.cacheMin')} name="cache_min_ttl" dependencies={['cache_max_ttl']}>
|
||||
<InputNumber min={0} style={{ width: 120 }} />
|
||||
<InputNumber min={0} style={{ width: 130 }} addonAfter="s" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('dns.settings.cacheMax')}
|
||||
@@ -586,7 +614,15 @@ function SettingsTab() {
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<InputNumber min={60} style={{ width: 120 }} />
|
||||
<InputNumber min={60} style={{ width: 130 }} addonAfter="s" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dns.settings.msgCacheSizeMB')} name="msg_cache_size_mb"
|
||||
extra={t('dns.settings.msgCacheSizeMBExtra')}>
|
||||
<InputNumber min={8} max={4096} style={{ width: 130 }} addonAfter="MB" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dns.settings.rrsetCacheSizeMB')} name="rrset_cache_size_mb"
|
||||
extra={t('dns.settings.rrsetCacheSizeMBExtra')}>
|
||||
<InputNumber min={16} max={8192} style={{ width: 130 }} addonAfter="MB" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -116,7 +116,17 @@ export default function NATRulesTab() {
|
||||
|
||||
const columns: ColumnsType<NATRule> = [
|
||||
{ title: '#', dataIndex: 'priority', key: 'priority', width: 70 },
|
||||
{ title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag> },
|
||||
{ title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', width: 100, render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag> },
|
||||
{
|
||||
title: t('fw.nat.name'), key: 'name', width: 200,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
{r.name && <div style={{ fontWeight: 500 }}>{r.name}</div>}
|
||||
{r.comment && <div style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)' }}>{r.comment}</div>}
|
||||
{!r.name && !r.comment && <span style={{ color: 'var(--ant-color-text-quaternary)' }}>—</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('fw.nat.match'), key: 'match',
|
||||
render: (_, r) => (
|
||||
@@ -126,11 +136,11 @@ export default function NATRulesTab() {
|
||||
{r.proto && <Tag>{r.proto}</Tag>}
|
||||
{r.match_src_cidr && <code>src={r.match_src_cidr}</code>}
|
||||
{r.match_dst_cidr && <code>dst={r.match_dst_cidr}</code>}
|
||||
{r.match_dport_start && <code>dport={r.match_dport_start}{r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</code>}
|
||||
{r.match_dport_start && <code>dport={r.match_dport_start}{r.match_dport_end && r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</code>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: t('fw.nat.target'), key: 'target', render: (_, r) => renderTarget(r) },
|
||||
{ title: t('fw.nat.target'), key: 'target', width: 200, render: (_, r) => renderTarget(r) },
|
||||
{
|
||||
title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80,
|
||||
render: (v: boolean, row: NATRule) => (
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, EyeOutlined, FireOutlined, PlusOutlined,
|
||||
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
|
||||
EyeOutlined, FireOutlined, PlusOutlined, WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
const { Text } = Typography
|
||||
@@ -159,10 +160,27 @@ export default function RulesTab() {
|
||||
if (cidr) return cidr
|
||||
return 'any'
|
||||
}
|
||||
|
||||
// Auto-generates a human-readable one-liner like "LAN/any → WAN/10.0.0.0/24 · HTTPS"
|
||||
const autoDescription = (r: FwRule): string => {
|
||||
const src = renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)
|
||||
const dst = renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)
|
||||
const svc = r.service_object_id ? svLabel(r.service_object_id)
|
||||
: r.service_group_id ? sgLabel(r.service_group_id)
|
||||
: 'any'
|
||||
return `${r.src_zone}/${src} → ${r.dst_zone}/${dst} · ${svc}`
|
||||
}
|
||||
|
||||
const renderService = (objID?: number | null, grpID?: number | null) => {
|
||||
if (objID) return <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>{svLabel(objID)}</Tag>
|
||||
if (grpID) return <Tag color="purple" style={{ fontFamily: 'monospace', fontSize: 11 }}>⊂ {sgLabel(grpID)}</Tag>
|
||||
return <Tag style={{ fontSize: 11, color: '#94A3B8' }}>any</Tag>
|
||||
return <span className="fw-addr-any">any</span>
|
||||
}
|
||||
|
||||
const renderAddr = (objID?: number | null, grpID?: number | null, cidr?: string | null) => {
|
||||
const label = renderAddrCompact(objID, grpID, cidr)
|
||||
if (label === 'any') return <span className="fw-addr-any">any</span>
|
||||
return <Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>{label}</Text>
|
||||
}
|
||||
|
||||
// ── Filter state ─────────────────────────────────────────────
|
||||
@@ -294,11 +312,7 @@ export default function RulesTab() {
|
||||
render: (_, r) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<ZoneBadge zone={r.src_zone} />
|
||||
{(r.src_address_object_id || r.src_address_group_id || r.src_cidr) && (
|
||||
<Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>
|
||||
{renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
|
||||
</Text>
|
||||
)}
|
||||
{renderAddr(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -311,11 +325,7 @@ export default function RulesTab() {
|
||||
render: (_, r) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<ZoneBadge zone={r.dst_zone} />
|
||||
{(r.dst_address_object_id || r.dst_address_group_id || r.dst_cidr) && (
|
||||
<Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>
|
||||
{renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
|
||||
</Text>
|
||||
)}
|
||||
{renderAddr(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -327,11 +337,16 @@ export default function RulesTab() {
|
||||
title: t('fw.rule.name'), key: 'name', ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
{r.name && <div style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>}
|
||||
{r.comment && (
|
||||
<div style={{ fontSize: 11, color: '#94A3B8', marginTop: 1 }}>{r.comment}</div>
|
||||
)}
|
||||
{!r.name && !r.comment && <Text type="secondary" style={{ fontSize: 11 }}>—</Text>}
|
||||
{r.name
|
||||
? <div className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>
|
||||
: <div className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>
|
||||
{t('fw.rule.unnamed')}
|
||||
</div>
|
||||
}
|
||||
{r.comment
|
||||
? <div style={{ fontSize: 11, color: '#64748B', marginTop: 1 }}>{r.comment}</div>
|
||||
: <div className="fw-rule-desc">{autoDescription(r)}</div>
|
||||
}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -339,7 +354,13 @@ export default function RulesTab() {
|
||||
title: t('fw.rule.hits'), key: 'hits', width: 80, align: 'right' as const,
|
||||
render: (_, r) => {
|
||||
const c = counterByID.get(r.id)
|
||||
if (!c || c.packets === 0) return <Text type="secondary" style={{ fontSize: 11 }}>—</Text>
|
||||
if (!c || c.packets === 0) return (
|
||||
<Tooltip title={r.enabled ? t('fw.rule.zeroHitHint') : undefined}>
|
||||
<span style={{ fontSize: 11, color: r.enabled ? '#FAAD14' : '#CBD5E1' }}>
|
||||
{r.enabled ? <><WarningOutlined style={{ fontSize: 10, marginRight: 2 }} />0</> : '—'}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
return (
|
||||
<Tooltip title={`${c.packets.toLocaleString()} pkts · ${fmtBytes(c.bytes)}`}>
|
||||
<Text style={{ fontSize: 11, fontVariantNumeric: 'tabular-nums', color: '#0EA5E9' }}>
|
||||
@@ -372,19 +393,19 @@ export default function RulesTab() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '', key: 'move', width: 60,
|
||||
title: '', key: 'move', width: 52,
|
||||
render: (_, row) => {
|
||||
const idx = sortedRules.findIndex(r => r.id === row.id)
|
||||
const swapping = swap.isPending
|
||||
return (
|
||||
<Space size={2}>
|
||||
<Space size={1} className="fw-row-actions">
|
||||
<Tooltip title={t('fw.rule.moveUp')}>
|
||||
<Button size="small" icon={<ArrowUpOutlined />}
|
||||
<Button type="text" size="small" icon={<ArrowUpOutlined />}
|
||||
disabled={isViewer || idx <= 0 || swapping}
|
||||
onClick={() => swap.mutate({ a: row, b: sortedRules[idx - 1] })} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('fw.rule.moveDown')}>
|
||||
<Button size="small" icon={<ArrowDownOutlined />}
|
||||
<Button type="text" size="small" icon={<ArrowDownOutlined />}
|
||||
disabled={isViewer || idx >= sortedRules.length - 1 || swapping}
|
||||
onClick={() => swap.mutate({ a: row, b: sortedRules[idx + 1] })} />
|
||||
</Tooltip>
|
||||
@@ -393,30 +414,27 @@ export default function RulesTab() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '', key: 'actions', width: 120,
|
||||
title: '', key: 'actions', width: 88,
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Space size={0} className="fw-row-actions">
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
|
||||
<Button size="small" disabled={isViewer} onClick={() => editFromRow(row)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button type="text" size="small" icon={<EditOutlined />}
|
||||
disabled={isViewer}
|
||||
onClick={() => editFromRow(row)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('fw.rule.duplicate')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
<Button type="text" size="small" icon={<CopyOutlined />}
|
||||
disabled={isViewer}
|
||||
loading={duplicate.isPending && duplicate.variables?.id === row.id}
|
||||
onClick={() => duplicate.mutate(row)}
|
||||
/>
|
||||
onClick={() => duplicate.mutate(row)} />
|
||||
</Tooltip>
|
||||
{isViewer ? (
|
||||
<Tooltip title={t('auth.viewerBadge')}>
|
||||
<Button size="small" danger disabled>{t('common.delete')}</Button>
|
||||
<Button type="text" size="small" icon={<DeleteOutlined />} danger disabled />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Popconfirm title={t('fw.rule.deleteConfirm')} onConfirm={() => del.mutate(row.id)}>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
<Button type="text" size="small" icon={<DeleteOutlined />} danger />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
@@ -486,7 +504,14 @@ export default function RulesTab() {
|
||||
loading={isLoading}
|
||||
dataSource={filteredRules}
|
||||
columns={columns}
|
||||
rowClassName={(row: FwRule) => row.enabled ? '' : 'fw-rule-row--disabled'}
|
||||
rowClassName={(row: FwRule) => {
|
||||
const c = counterByID.get(row.id)
|
||||
const zeroHit = row.enabled && (!c || c.packets === 0)
|
||||
return [
|
||||
!row.enabled ? 'fw-rule-row--disabled' : '',
|
||||
zeroHit ? 'fw-rule-row--zero-hit' : '',
|
||||
].filter(Boolean).join(' ')
|
||||
}}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<FireOutlined />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message,
|
||||
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message, Divider,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, CloudServerOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
@@ -31,6 +31,18 @@ interface ACL {
|
||||
|
||||
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
|
||||
|
||||
interface ProxySettings {
|
||||
id: number
|
||||
listen_addresses: string
|
||||
listen_port: number
|
||||
cache_mem_mb: number
|
||||
cache_dir_mb: number
|
||||
max_obj_size_mb: number
|
||||
connect_timeout: number
|
||||
read_timeout: number
|
||||
request_timeout: number
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string
|
||||
acl_type: string
|
||||
@@ -98,6 +110,27 @@ export default function ForwardProxyPage() {
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ['fwd-proxy', 'settings'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/forward-proxy/settings')
|
||||
if (!isEnvelope(r.data)) return null
|
||||
return r.data.data as ProxySettings
|
||||
},
|
||||
})
|
||||
|
||||
const [settingsForm] = Form.useForm<ProxySettings>()
|
||||
const saveSettings = useMutation({
|
||||
mutationFn: async (v: ProxySettings) => {
|
||||
await apiClient.put('/forward-proxy/settings', v)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('common.save'))
|
||||
void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'settings'] })
|
||||
},
|
||||
onError: () => message.error(t('fwd.settings.saveFailed')),
|
||||
})
|
||||
|
||||
const [editing, setEditing] = useState<ACL | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [form] = Form.useForm<FormValues>()
|
||||
@@ -284,6 +317,97 @@ export default function ForwardProxyPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
className="mb-12"
|
||||
title={t('fwd.settings.title')}
|
||||
loading={settingsLoading}
|
||||
>
|
||||
<Form
|
||||
form={settingsForm}
|
||||
layout="vertical"
|
||||
initialValues={settings ?? {
|
||||
listen_addresses: '', listen_port: 3128,
|
||||
cache_mem_mb: 64, cache_dir_mb: 100, max_obj_size_mb: 4,
|
||||
connect_timeout: 60, read_timeout: 300, request_timeout: 300,
|
||||
}}
|
||||
key={settings?.id ?? 'loading'}
|
||||
onFinish={(v) => saveSettings.mutate(v)}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={16}>
|
||||
<Form.Item
|
||||
label={t('fwd.settings.listenAddresses')}
|
||||
name="listen_addresses"
|
||||
extra={t('fwd.settings.listenAddressesExtra')}
|
||||
>
|
||||
<Input placeholder="10.0.5.1, 10.0.20.1" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item
|
||||
label={t('fwd.settings.listenPort')}
|
||||
name="listen_port"
|
||||
extra={t('fwd.settings.listenPortExtra')}
|
||||
>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider plain>{t('fwd.settings.cacheSection')}</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label={t('fwd.settings.cacheMemMB')} name="cache_mem_mb" extra={t('fwd.settings.cacheMemMBExtra')}>
|
||||
<InputNumber min={16} max={8192} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label={t('fwd.settings.cacheDirMB')} name="cache_dir_mb" extra={t('fwd.settings.cacheDirMBExtra')}>
|
||||
<InputNumber min={100} max={102400} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label={t('fwd.settings.maxObjSizeMB')} name="max_obj_size_mb" extra={t('fwd.settings.maxObjSizeMBExtra')}>
|
||||
<InputNumber min={1} max={1024} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider plain>{t('fwd.settings.timeoutSection')}</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label={t('fwd.settings.connectTimeout')} name="connect_timeout" extra={t('fwd.settings.connectTimeoutExtra')}>
|
||||
<InputNumber min={5} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label={t('fwd.settings.readTimeout')} name="read_timeout" extra={t('fwd.settings.readTimeoutExtra')}>
|
||||
<InputNumber min={30} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label={t('fwd.settings.requestTimeout')} name="request_timeout" extra={t('fwd.settings.requestTimeoutExtra')}>
|
||||
<InputNumber min={30} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={saveSettings.isPending}
|
||||
disabled={isViewer}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
|
||||
@@ -193,26 +193,6 @@ export default function IPAddressesPage() {
|
||||
subtitle={t('ips.intro')}
|
||||
/>
|
||||
|
||||
<Card title={t('ips.systemDiscovered')} size="small" className="mb-12">
|
||||
{(sysAddrs ?? []).length === 0
|
||||
? <Typography.Text type="secondary">—</Typography.Text>
|
||||
: (
|
||||
<DataTable
|
||||
size="small"
|
||||
rowKey={(r) => `${r.ifname}-${r.address}`}
|
||||
dataSource={sysAddrs ?? []}
|
||||
|
||||
columns={[
|
||||
{ title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => <code>{s}</code> },
|
||||
{ title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => <code>{row.address}/{row.prefix}</code> },
|
||||
{ title: t('ips.family'), dataIndex: 'family', key: 'family', render: (f: string) => <Tag>{f === 'inet' ? 'IPv4' : 'IPv6'}</Tag> },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Card>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 8 }}>{t('ips.managedTitle')}</Typography.Title>
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
@@ -240,6 +220,24 @@ export default function IPAddressesPage() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title={t('ips.systemDiscovered')} size="small" className="mt-12">
|
||||
{(sysAddrs ?? []).length === 0
|
||||
? <Typography.Text type="secondary">—</Typography.Text>
|
||||
: (
|
||||
<DataTable
|
||||
size="small"
|
||||
rowKey={(r) => `${r.ifname}-${r.address}`}
|
||||
dataSource={sysAddrs ?? []}
|
||||
columns={[
|
||||
{ title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => <code>{s}</code> },
|
||||
{ title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => <code>{row.address}/{row.prefix}</code> },
|
||||
{ title: t('ips.family'), dataIndex: 'family', key: 'family', render: (f: string) => <Tag>{f === 'inet' ? 'IPv4' : 'IPv6'}</Tag> },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Card>
|
||||
<Modal
|
||||
title={editing ? t('ips.editAddress') : t('ips.addAddress')}
|
||||
open={editing !== null || creating}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Form, Input, message, Typography } from 'antd'
|
||||
import { KeyOutlined } from '@ant-design/icons'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -17,20 +19,24 @@ interface LoginValues {
|
||||
export default function LoginPage({ onLogin }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [totpRequired, setTotpRequired] = useState(false)
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
|
||||
const onFinish = async (vals: LoginValues) => {
|
||||
try {
|
||||
const r = await apiClient.post('/auth/login', vals)
|
||||
if (isEnvelope(r.data)) {
|
||||
const u = r.data.data as SessionUser
|
||||
onLogin(u)
|
||||
navigate('/dashboard', { replace: true })
|
||||
if (!isEnvelope(r.data)) return
|
||||
const d = r.data.data as { totp_required?: boolean } & SessionUser
|
||||
if (d.totp_required) {
|
||||
setTotpRequired(true)
|
||||
return
|
||||
}
|
||||
onLogin(d)
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; status?: number }
|
||||
if (err.status === 503) {
|
||||
// setup-mode → drop to wizard
|
||||
navigate('/setup', { replace: true })
|
||||
return
|
||||
}
|
||||
@@ -38,36 +44,73 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const onTOTPVerify = async () => {
|
||||
if (!totpCode || totpCode.length < 6) return
|
||||
setVerifying(true)
|
||||
try {
|
||||
const r = await apiClient.post('/auth/totp-verify', { code: totpCode })
|
||||
if (isEnvelope(r.data)) {
|
||||
onLogin(r.data.data as SessionUser)
|
||||
navigate('/dashboard', { replace: true })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string }
|
||||
message.error(err.message ?? t('auth.totp.invalidCode'))
|
||||
setTotpCode('')
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
|
||||
<Card style={{ width: 400 }}>
|
||||
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
{t('app.title')}
|
||||
</Typography.Title>
|
||||
<Form layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
label={t('auth.email')}
|
||||
name="email"
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input autoComplete="email" autoFocus />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('auth.password')}
|
||||
name="password"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
{t('auth.login')}
|
||||
|
||||
{!totpRequired ? (
|
||||
<Form layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item label={t('auth.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
||||
<Input autoComplete="email" autoFocus />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('auth.password')} name="password" rules={[{ required: true }]}>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>{t('auth.login')}</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : (
|
||||
<div>
|
||||
<Typography.Paragraph style={{ textAlign: 'center' }}>
|
||||
<KeyOutlined style={{ fontSize: 32, color: '#1677ff', marginBottom: 8 }} /><br />
|
||||
{t('auth.totp.prompt')}
|
||||
</Typography.Paragraph>
|
||||
<Input
|
||||
size="large"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
onPressEnter={onTOTPVerify}
|
||||
style={{ textAlign: 'center', letterSpacing: 8, fontSize: 20, marginBottom: 16 }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="primary" block loading={verifying} onClick={onTOTPVerify}>
|
||||
{t('auth.totp.verify')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
|
||||
</div>
|
||||
<Button type="link" block style={{ marginTop: 8 }} onClick={() => { setTotpRequired(false); setTotpCode('') }}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!totpRequired && (
|
||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -381,6 +381,7 @@ function SettingsTab() {
|
||||
for (const i of sys ?? []) {
|
||||
if (i.ifname === 'lo') continue
|
||||
for (const a of i.addr_info ?? []) {
|
||||
if (a.local.startsWith('fe80:')) continue
|
||||
ipOptions.push({
|
||||
value: a.local,
|
||||
label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
|
||||
|
||||
@@ -39,6 +39,11 @@ interface VIPSettingsValues {
|
||||
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
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -839,6 +844,31 @@ export default function SettingsPage() {
|
||||
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}>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button, Form, Input, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message,
|
||||
Button, Form, Input, Modal, QRCode, Select, Space, Switch, Tag, Tooltip, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { KeyOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons'
|
||||
import { KeyOutlined, LockOutlined, PlusOutlined, SafetyCertificateOutlined, TeamOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -21,6 +21,7 @@ interface User {
|
||||
email: string
|
||||
role: string
|
||||
active: boolean
|
||||
totp_enabled: boolean
|
||||
last_login_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
@@ -45,9 +46,14 @@ export default function UsersPage() {
|
||||
|
||||
const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: listUsers })
|
||||
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState<User | null>(null)
|
||||
const [pwTarget, setPwTarget] = useState<User | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState<User | null>(null)
|
||||
const [pwTarget, setPwTarget] = useState<User | null>(null)
|
||||
const [totpTarget, setTotpTarget] = useState<User | null>(null)
|
||||
const [totpStep, setTotpStep] = useState(0)
|
||||
const [totpSecret, setTotpSecret] = useState('')
|
||||
const [totpUri, setTotpUri] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [createForm] = Form.useForm<CreateValues>()
|
||||
const [editForm] = Form.useForm<EditValues>()
|
||||
const [pwForm] = Form.useForm<PwValues>()
|
||||
@@ -80,6 +86,44 @@ export default function UsersPage() {
|
||||
onSuccess: invalidate,
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const disableTOTPMut = useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/users/${id}/totp`),
|
||||
onSuccess: () => { message.success(t('users.totp.disabled')); invalidate() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const openTOTPSetup = async (row: User) => {
|
||||
setTotpTarget(row)
|
||||
setTotpStep(0)
|
||||
setTotpCode('')
|
||||
if (row.email === me?.actor) {
|
||||
// own account — generate secret via self-service endpoint
|
||||
try {
|
||||
const r = await apiClient.post('/auth/totp/setup')
|
||||
if (isEnvelope(r.data)) {
|
||||
const d = r.data.data as { secret: string; uri: string }
|
||||
setTotpSecret(d.secret)
|
||||
setTotpUri(d.uri)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
message.error((e as Error).message)
|
||||
setTotpTarget(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const confirmTOTP = async () => {
|
||||
if (!totpTarget) return
|
||||
try {
|
||||
await apiClient.post('/auth/totp/confirm', { secret: totpSecret, code: totpCode })
|
||||
message.success(t('users.totp.enabled'))
|
||||
setTotpTarget(null)
|
||||
invalidate()
|
||||
} catch (e: unknown) {
|
||||
message.error((e as Error).message ?? t('auth.totp.invalidCode'))
|
||||
setTotpCode('')
|
||||
}
|
||||
}
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'admin', label: t('users.roleAdmin') },
|
||||
@@ -104,6 +148,12 @@ export default function UsersPage() {
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '2FA', key: 'totp', width: 70,
|
||||
render: (_, row) => row.totp_enabled
|
||||
? <Tag color="green" icon={<SafetyCertificateOutlined />}>{t('users.totp.on')}</Tag>
|
||||
: <Tag color="default">{t('users.totp.off')}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('users.active'), dataIndex: 'active', key: 'active', width: 80,
|
||||
render: (v: boolean, row: User) => (
|
||||
@@ -127,7 +177,7 @@ export default function UsersPage() {
|
||||
: <Text type="secondary" style={{ fontSize: 12 }}>{t('users.never')}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions', width: 120,
|
||||
title: t('common.actions'), key: 'actions', width: 160,
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('users.setPassword')}>
|
||||
@@ -135,6 +185,22 @@ export default function UsersPage() {
|
||||
disabled={isViewer}
|
||||
onClick={() => { setPwTarget(row); pwForm.resetFields() }} />
|
||||
</Tooltip>
|
||||
{/* 2FA button: setup for own account, disable for others */}
|
||||
{row.email === me?.actor ? (
|
||||
<Tooltip title={row.totp_enabled ? t('users.totp.manage') : t('users.totp.setup')}>
|
||||
<Button type="text" size="small"
|
||||
icon={<LockOutlined style={{ color: row.totp_enabled ? '#52c41a' : undefined }} />}
|
||||
onClick={() => void openTOTPSetup(row)} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
row.totp_enabled && !isViewer && (
|
||||
<Tooltip title={t('users.totp.disableFor', { email: row.email })}>
|
||||
<Button type="text" size="small" danger icon={<LockOutlined />}
|
||||
loading={disableTOTPMut.isPending && disableTOTPMut.variables === row.id}
|
||||
onClick={() => disableTOTPMut.mutate(row.id)} />
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
<ActionButtons
|
||||
onEdit={() => {
|
||||
setEditing(row)
|
||||
@@ -150,13 +216,11 @@ export default function UsersPage() {
|
||||
},
|
||||
]
|
||||
|
||||
const isSelfTOTP = totpTarget?.email === me?.actor
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
icon={<TeamOutlined />}
|
||||
title={t('users.title')}
|
||||
subtitle={t('users.intro')}
|
||||
/>
|
||||
<PageHeader icon={<TeamOutlined />} title={t('users.title')} subtitle={t('users.intro')} />
|
||||
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
@@ -195,16 +259,10 @@ export default function UsersPage() {
|
||||
/>
|
||||
|
||||
{/* Create modal */}
|
||||
<Modal
|
||||
title={t('users.addUser')}
|
||||
open={creating}
|
||||
<Modal title={t('users.addUser')} open={creating}
|
||||
onCancel={() => { setCreating(false); createForm.resetFields() }}
|
||||
onOk={() => void createForm.submit()}
|
||||
confirmLoading={createMut.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={createForm} layout="vertical"
|
||||
onFinish={(v) => createMut.mutate(v)}>
|
||||
onOk={() => void createForm.submit()} confirmLoading={createMut.isPending} destroyOnHidden>
|
||||
<Form form={createForm} layout="vertical" onFinish={(v) => createMut.mutate(v)}>
|
||||
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
||||
<Input autoFocus autoComplete="off" />
|
||||
</Form.Item>
|
||||
@@ -223,14 +281,9 @@ export default function UsersPage() {
|
||||
</Modal>
|
||||
|
||||
{/* Edit modal */}
|
||||
<Modal
|
||||
title={t('users.editUser')}
|
||||
open={editing !== null}
|
||||
<Modal title={t('users.editUser')} open={editing !== null}
|
||||
onCancel={() => { setEditing(null); editForm.resetFields() }}
|
||||
onOk={() => void editForm.submit()}
|
||||
confirmLoading={updateMut.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
onOk={() => void editForm.submit()} confirmLoading={updateMut.isPending} destroyOnHidden>
|
||||
<Form form={editForm} layout="vertical"
|
||||
onFinish={(v) => editing && updateMut.mutate({ id: editing.id, v })}>
|
||||
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
||||
@@ -246,14 +299,10 @@ export default function UsersPage() {
|
||||
</Modal>
|
||||
|
||||
{/* Set password modal */}
|
||||
<Modal
|
||||
title={`${t('users.setPasswordTitle')} — ${pwTarget?.email ?? ''}`}
|
||||
<Modal title={`${t('users.setPasswordTitle')} — ${pwTarget?.email ?? ''}`}
|
||||
open={pwTarget !== null}
|
||||
onCancel={() => { setPwTarget(null); pwForm.resetFields() }}
|
||||
onOk={() => void pwForm.submit()}
|
||||
confirmLoading={pwMut.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
onOk={() => void pwForm.submit()} confirmLoading={pwMut.isPending} destroyOnHidden>
|
||||
<Form form={pwForm} layout="vertical"
|
||||
onFinish={(v) => pwTarget && pwMut.mutate({ id: pwTarget.id, v })}>
|
||||
<Form.Item label={t('users.newPassword')} name="password"
|
||||
@@ -263,6 +312,61 @@ export default function UsersPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* TOTP setup modal (own account only) */}
|
||||
<Modal
|
||||
title={isSelfTOTP ? t('users.totp.setupTitle') : t('users.totp.manageTitle')}
|
||||
open={totpTarget !== null}
|
||||
onCancel={() => setTotpTarget(null)}
|
||||
footer={totpStep === 1
|
||||
? [
|
||||
<Button key="back" onClick={() => setTotpStep(0)}>{t('common.back')}</Button>,
|
||||
<Button key="confirm" type="primary" onClick={() => void confirmTOTP()}>{t('users.totp.confirm')}</Button>,
|
||||
]
|
||||
: [
|
||||
<Button key="cancel" onClick={() => setTotpTarget(null)}>{t('common.cancel')}</Button>,
|
||||
totpTarget?.totp_enabled
|
||||
? <Button key="disable" danger onClick={() => { if (totpTarget) { disableTOTPMut.mutate(totpTarget.id); setTotpTarget(null) } }}>{t('users.totp.disable')}</Button>
|
||||
: <Button key="next" type="primary" onClick={() => setTotpStep(1)}>{t('common.next')}</Button>,
|
||||
]
|
||||
}
|
||||
destroyOnHidden
|
||||
>
|
||||
{totpStep === 0 && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{totpTarget?.totp_enabled ? (
|
||||
<>
|
||||
<SafetyCertificateOutlined style={{ fontSize: 48, color: '#52c41a', marginBottom: 16 }} />
|
||||
<Typography.Paragraph>{t('users.totp.alreadyEnabled')}</Typography.Paragraph>
|
||||
<Typography.Paragraph type="secondary">{t('users.totp.disableHint')}</Typography.Paragraph>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Paragraph>{t('users.totp.scanHint')}</Typography.Paragraph>
|
||||
{totpUri && <QRCode value={totpUri} size={200} style={{ margin: '0 auto 16px' }} />}
|
||||
<Typography.Paragraph type="secondary" copyable={{ text: totpSecret }} style={{ fontFamily: 'monospace', fontSize: 13 }}>
|
||||
{totpSecret}
|
||||
</Typography.Paragraph>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{totpStep === 1 && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Typography.Paragraph>{t('users.totp.enterCode')}</Typography.Paragraph>
|
||||
<Input
|
||||
size="large"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
onPressEnter={() => void confirmTOTP()}
|
||||
style={{ textAlign: 'center', letterSpacing: 8, fontSize: 20, width: 200 }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user