feat(rbac): RBAC-Audit abschliessen — Viewer-Sperre für alle verbleibenden Seiten (1.1.93)

Domains, Backends (inkl. ServerPanel), Backups/History und Alerts:
isViewer-Check + disabled Switches, Tooltip-wrapped Add-Buttons,
bedingte Delete/Restore-Render (Viewer → disabled Tooltip statt Popconfirm).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-24 16:39:24 +02:00
parent d3a3b93c1b
commit 0856c2fc10
8 changed files with 132 additions and 90 deletions

View File

@@ -1 +1 @@
1.1.92 1.1.93

View File

@@ -60,7 +60,7 @@ import (
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
) )
var version = "1.1.92" var version = "1.1.93"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -11,7 +11,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
) )
var version = "1.1.92" var version = "1.1.93"
const usage = `edgeguard-ctl — EdgeGuard CLI const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -35,7 +35,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
) )
var version = "1.1.92" var version = "1.1.93"
const ( const (
// renewTickInterval — how often we re-evaluate expiring certs. // renewTickInterval — how often we re-evaluate expiring certs.

View File

@@ -13,6 +13,7 @@ import dayjs from 'dayjs'
import apiClient, { isEnvelope } from '../../api/client' import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader' import PageHeader from '../../components/PageHeader'
import EmptyState from '../../components/EmptyState' import EmptyState from '../../components/EmptyState'
import { useAuthStore } from '../../stores/auth'
const { Text } = Typography const { Text } = Typography
@@ -66,6 +67,7 @@ function sevTag(s: AlertEvent['severity']) {
export default function AlertsPage() { export default function AlertsPage() {
const { t } = useTranslation() const { t } = useTranslation()
const qc = useQueryClient() const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const channels = useQuery({ const channels = useQuery({
queryKey: ['alerts', 'channels'], queryKey: ['alerts', 'channels'],
@@ -172,6 +174,7 @@ export default function AlertsPage() {
<Switch <Switch
size="small" size="small"
checked={v} checked={v}
disabled={isViewer}
loading={quickToggle.isPending && quickToggle.variables?.id === r.id} loading={quickToggle.isPending && quickToggle.variables?.id === r.id}
onChange={(checked) => quickToggle.mutate({ id: r.id, row: r, checked })} onChange={(checked) => quickToggle.mutate({ id: r.id, row: r, checked })}
/> />
@@ -181,7 +184,9 @@ export default function AlertsPage() {
title: t('common.actions'), key: 'a', width: 180, title: t('common.actions'), key: 'a', width: 180,
render: (_, r) => ( render: (_, r) => (
<Space size={4}> <Space size={4}>
<Button size="small" onClick={() => { <Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button size="small" disabled={isViewer} onClick={() => {
if (isViewer) return
setEdit(r) setEdit(r)
const settings = (r.settings ?? {}) as Record<string, unknown> const settings = (r.settings ?? {}) as Record<string, unknown>
form.setFieldsValue({ form.setFieldsValue({
@@ -194,10 +199,14 @@ export default function AlertsPage() {
use_tls: settings.use_tls as boolean, use_tls: settings.use_tls as boolean,
}) })
}}>{t('common.edit')}</Button> }}>{t('common.edit')}</Button>
<Popconfirm title={t('alerts.confirmDelete', { name: r.name })} </Tooltip>
{isViewer
? <Tooltip title={t('auth.viewerBadge')}><Button size="small" danger disabled>{t('common.delete')}</Button></Tooltip>
: <Popconfirm title={t('alerts.confirmDelete', { name: r.name })}
onConfirm={() => del.mutate(r.id)}> onConfirm={() => del.mutate(r.id)}>
<Button size="small" danger>{t('common.delete')}</Button> <Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm> </Popconfirm>
}
</Space> </Space>
), ),
}, },
@@ -284,10 +293,12 @@ export default function AlertsPage() {
label: t('alerts.tabs.channels'), label: t('alerts.tabs.channels'),
children: ( children: (
<Card size="small" extra={ <Card size="small" extra={
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" size="small" icon={<PlusOutlined />} <Button type="primary" size="small" icon={<PlusOutlined />}
onClick={openChannelCreate}> onClick={openChannelCreate} disabled={isViewer}>
{t('alerts.add')} {t('alerts.add')}
</Button> </Button>
</Tooltip>
}> }>
<Table size="small" rowKey="id" loading={channels.isFetching} <Table size="small" rowKey="id" loading={channels.isFetching}
dataSource={channels.data ?? []} columns={chanColumns} dataSource={channels.data ?? []} columns={chanColumns}
@@ -298,9 +309,11 @@ export default function AlertsPage() {
title={t('alerts.emptyChannelsTitle')} title={t('alerts.emptyChannelsTitle')}
description={t('alerts.emptyChannelsDesc')} description={t('alerts.emptyChannelsDesc')}
action={ action={
<Button type="primary" icon={<PlusOutlined />} onClick={openChannelCreate}> <Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} onClick={openChannelCreate} disabled={isViewer}>
{t('alerts.add')} {t('alerts.add')}
</Button> </Button>
</Tooltip>
} }
/> />
) }} /> ) }} />

View File

@@ -14,6 +14,7 @@ import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons' import ActionButtons from '../../components/ActionButtons'
import apiClient, { isEnvelope } from '../../api/client' import apiClient, { isEnvelope } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
const { Text } = Typography const { Text } = Typography
@@ -119,6 +120,7 @@ export default function BackendsPage() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const { data, isLoading } = useQuery({ queryKey: ['backends'], queryFn: listBackends }) const { data, isLoading } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains }) const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
@@ -286,6 +288,7 @@ export default function BackendsPage() {
<Switch <Switch
size="small" size="small"
checked={v} checked={v}
disabled={isViewer}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id} loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })} onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/> />
@@ -336,9 +339,11 @@ export default function BackendsPage() {
rowExpandable: (record) => !!record.id, rowExpandable: (record) => !!record.id,
}} }}
extraActions={ extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} disabled={isViewer}>
{t('backends.addBackend')} {t('backends.addBackend')}
</Button> </Button>
</Tooltip>
} }
emptyContent={ emptyContent={
<EmptyState <EmptyState
@@ -346,9 +351,11 @@ export default function BackendsPage() {
title={t('backends.emptyTitle')} title={t('backends.emptyTitle')}
description={t('backends.emptyDesc')} description={t('backends.emptyDesc')}
action={ action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} disabled={isViewer}>
{t('backends.addBackend')} {t('backends.addBackend')}
</Button> </Button>
</Tooltip>
} }
/> />
} }
@@ -396,6 +403,7 @@ export default function BackendsPage() {
function ServerPanel({ backendID }: { backendID: number }) { function ServerPanel({ backendID }: { backendID: number }) {
const { t } = useTranslation() const { t } = useTranslation()
const qc = useQueryClient() const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<BackendServer | null>(null) const [editing, setEditing] = useState<BackendServer | null>(null)
const [form] = Form.useForm<ServerFormValues>() const [form] = Form.useForm<ServerFormValues>()
@@ -462,6 +470,7 @@ function ServerPanel({ backendID }: { backendID: number }) {
<Switch <Switch
size="small" size="small"
checked={v} checked={v}
disabled={isViewer}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id} loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })} onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/> />
@@ -471,17 +480,23 @@ function ServerPanel({ backendID }: { backendID: number }) {
title: t('common.actions'), key: 'a', width: 160, title: t('common.actions'), key: 'a', width: 160,
render: (_, r) => ( render: (_, r) => (
<Space size={4}> <Space size={4}>
<Button size="small" onClick={() => { <Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button size="small" disabled={isViewer} onClick={() => {
if (isViewer) return
setEditing(r) setEditing(r)
form.setFieldsValue({ form.setFieldsValue({
name: r.name, address: r.address, port: r.port, name: r.name, address: r.address, port: r.port,
weight: r.weight, backup: r.backup, active: r.active, weight: r.weight, backup: r.backup, active: r.active,
}) })
}}>{t('common.edit')}</Button> }}>{t('common.edit')}</Button>
<Popconfirm title={t('backends.server.deleteConfirm', { name: r.name })} </Tooltip>
{isViewer
? <Tooltip title={t('auth.viewerBadge')}><Button size="small" danger disabled>{t('common.delete')}</Button></Tooltip>
: <Popconfirm title={t('backends.server.deleteConfirm', { name: r.name })}
onConfirm={() => del.mutate(r.id)}> onConfirm={() => del.mutate(r.id)}>
<Button size="small" danger>{t('common.delete')}</Button> <Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm> </Popconfirm>
}
</Space> </Space>
), ),
}, },
@@ -491,13 +506,15 @@ function ServerPanel({ backendID }: { backendID: number }) {
<div> <div>
<div className="mb-8" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="mb-8" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary">{t('backends.server.intro')}</Text> <Text type="secondary">{t('backends.server.intro')}</Text>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => { <Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button size="small" type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={() => {
setOpen(true) setOpen(true)
form.resetFields() form.resetFields()
form.setFieldsValue({ weight: 100, backup: false, active: true, port: 8080 }) form.setFieldsValue({ weight: 100, backup: false, active: true, port: 8080 })
}}> }}>
{t('backends.server.add')} {t('backends.server.add')}
</Button> </Button>
</Tooltip>
</div> </div>
<Table <Table
size="small" size="small"

View File

@@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import apiClient, { isEnvelope } from '../../api/client' import apiClient, { isEnvelope } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
const { Text } = Typography const { Text } = Typography
@@ -51,6 +52,7 @@ function fmtDuration(start: string, end: string): string {
export default function HistoryTab() { export default function HistoryTab() {
const { t } = useTranslation() const { t } = useTranslation()
const qc = useQueryClient() const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage() const [msg, msgCtx] = message.useMessage()
const list = useQuery({ const list = useQuery({
@@ -223,7 +225,9 @@ export default function HistoryTab() {
disabled={row.status !== 'success'} disabled={row.status !== 'success'}
>{t('backups.download')}</Button> >{t('backups.download')}</Button>
</Tooltip> </Tooltip>
<Popconfirm {isViewer
? <Tooltip title={t('auth.viewerBadge')}><Button size="small" icon={<UndoOutlined />} disabled>{t('backups.restore')}</Button></Tooltip>
: <Popconfirm
title={t('backups.confirmRestoreTitle')} title={t('backups.confirmRestoreTitle')}
description={t('backups.confirmRestoreDesc', { file: row.file })} description={t('backups.confirmRestoreDesc', { file: row.file })}
okText={t('backups.restoreOk')} okText={t('backups.restoreOk')}
@@ -232,21 +236,19 @@ export default function HistoryTab() {
onConfirm={() => startRestore(row)} onConfirm={() => startRestore(row)}
disabled={row.status !== 'success'} disabled={row.status !== 'success'}
> >
<Button <Button size="small" icon={<UndoOutlined />} disabled={row.status !== 'success'}>{t('backups.restore')}</Button>
size="small"
icon={<UndoOutlined />}
disabled={row.status !== 'success'}
>{t('backups.restore')}</Button>
</Popconfirm> </Popconfirm>
<Popconfirm }
{isViewer
? <Tooltip title={t('auth.viewerBadge')}><Button size="small" danger icon={<DeleteOutlined />} disabled>{t('common.delete')}</Button></Tooltip>
: <Popconfirm
title={t('backups.confirmDelete', { file: row.file })} title={t('backups.confirmDelete', { file: row.file })}
onConfirm={() => del.mutate(row.id)} onConfirm={() => del.mutate(row.id)}
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
> >
<Button size="small" danger icon={<DeleteOutlined />} loading={busyDelete === row.id}> <Button size="small" danger icon={<DeleteOutlined />} loading={busyDelete === row.id}>{t('common.delete')}</Button>
{t('common.delete')}
</Button>
</Popconfirm> </Popconfirm>
}
</Space> </Space>
), ),
}, },
@@ -320,14 +322,17 @@ export default function HistoryTab() {
{t('common.refresh')} {t('common.refresh')}
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button <Button
type="primary" type="primary"
icon={<CloudUploadOutlined />} icon={<CloudUploadOutlined />}
loading={trigger.isPending} loading={trigger.isPending}
disabled={isViewer}
onClick={() => trigger.mutate()} onClick={() => trigger.mutate()}
> >
{t('backups.runNow')} {t('backups.runNow')}
</Button> </Button>
</Tooltip>
</Space> </Space>
<Card className="mb-16"> <Card className="mb-16">

View File

@@ -12,6 +12,7 @@ import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot' import StatusDot from '../../components/StatusDot'
import apiClient, { isEnvelope } from '../../api/client' import apiClient, { isEnvelope } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
interface Domain { interface Domain {
id: number id: number
@@ -95,6 +96,7 @@ export default function DomainsPage() {
const { t } = useTranslation() const { t } = useTranslation()
const qc = useQueryClient() const qc = useQueryClient()
const navigate = useNavigate() const navigate = useNavigate()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['domains'], queryKey: ['domains'],
@@ -234,6 +236,7 @@ export default function DomainsPage() {
<Switch <Switch
size="small" size="small"
checked={v} checked={v}
disabled={isViewer}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id} loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })} onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/> />
@@ -322,9 +325,11 @@ export default function DomainsPage() {
dataSource={data ?? []} dataSource={data ?? []}
columns={columns} columns={columns}
extraActions={ extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} disabled={isViewer}>
{t('domains.addDomain')} {t('domains.addDomain')}
</Button> </Button>
</Tooltip>
} }
emptyContent={ emptyContent={
<EmptyState <EmptyState
@@ -332,9 +337,11 @@ export default function DomainsPage() {
title={t('domains.emptyTitle')} title={t('domains.emptyTitle')}
description={t('domains.emptyDesc')} description={t('domains.emptyDesc')}
action={ action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} disabled={isViewer}>
{t('domains.addDomain')} {t('domains.addDomain')}
</Button> </Button>
</Tooltip>
} }
/> />
} }