fix(rbac): Viewer-Rolle in Detail-Pages + ActionButtons — Edit ebenfalls sperren

- ActionButtons: Edit-Button wird für Viewer wie Delete gesperrt (Tooltip zeigt Reason)
- Domains/Detail: isViewer-Flag an alle Sub-Panels weitergegeben; Save-, TLS-Cert-,
  Routing-Rules- und Headers-Buttons für Viewer disabled
- Backends/Detail: Save-Button + ServerPanel Add-Button für Viewer disabled
- i18n: domains.backendUp/backendDown Keys (waren noch hardkodiert)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-24 13:10:29 +02:00
parent 9708e4441b
commit 2d027b3044
9 changed files with 133 additions and 86 deletions

View File

@@ -1 +1 @@
1.1.88
1.1.89

View File

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

View File

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

View File

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

View File

@@ -9,9 +9,9 @@ import { useAuthStore } from '../stores/auth'
//
// Either prop may be omitted to suppress that button — useful for
// rows that aren't editable (e.g. builtin services).
// Viewer-role accounts automatically get delete disabled — mutations
// are blocked at the API level too, but hiding the button avoids
// confusing "access denied" errors for read-only users.
// Viewer-role accounts get both buttons disabled — mutations are
// blocked at the API level too, but disabling here avoids confusing
// "access denied" errors for read-only users.
interface ActionButtonsProps {
onEdit?: () => void
onDelete?: () => void
@@ -35,20 +35,23 @@ export default function ActionButtons({
const role = useAuthStore((s) => s.user?.role)
const isViewer = role === 'viewer'
const viewerReason = isViewer ? t('auth.viewerBadge') : undefined
const editDis = editDisabled || isViewer
const editDisReason = isViewer ? viewerReason : editDisabledReason
const delDisabled = deleteDisabled || isViewer
const delDisabledReason = isViewer
? t('auth.viewerBadge')
: deleteDisabledReason
const delDisabledReason = isViewer ? viewerReason : deleteDisabledReason
return (
<Space size={4}>
{onEdit && (
<Tooltip title={editDisabled ? editDisabledReason : (editTooltip ?? t('common.edit'))}>
<Tooltip title={editDis ? editDisReason : (editTooltip ?? t('common.edit'))}>
<Button
type="text"
size="small"
icon={<EditOutlined />}
disabled={editDisabled}
disabled={editDis}
onClick={onEdit}
/>
</Tooltip>

View File

@@ -322,7 +322,9 @@
"rateLimitTag": "Rate-Limit",
"routingRulesTitle": "Routing-Regeln — {{name}}",
"routingRulesHint": "Pfad-Präfix → Backend-Zuordnungen für diese Domain. Niedrigste Prioritätszahl gewinnt; nicht gematchte Anfragen gehen an das Primary-Backend.",
"routingRulesEmpty": "Keine Routing-Regeln — alle Anfragen gehen an das Primary-Backend."
"routingRulesEmpty": "Keine Routing-Regeln — alle Anfragen gehen an das Primary-Backend.",
"backendUp": "Backend UP",
"backendDown": "Backend DOWN"
},
"backends": {
"title": "Backends",

View File

@@ -322,7 +322,9 @@
"rateLimitTag": "Rate limited",
"routingRulesTitle": "Routing rules — {{name}}",
"routingRulesHint": "Path-prefix → backend mappings for this domain. Lowest priority number wins; unmatched requests go to the primary backend.",
"routingRulesEmpty": "No routing rules — all requests go to the primary backend."
"routingRulesEmpty": "No routing rules — all requests go to the primary backend.",
"backendUp": "backend UP",
"backendDown": "backend DOWN"
},
"backends": {
"title": "Backends",

View File

@@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import { useAuthStore } from '../../stores/auth'
const { Text } = Typography
@@ -90,6 +91,7 @@ export default function BackendDetailPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const backendID = Number(id)
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const { data: backend, isLoading } = useQuery({
queryKey: ['backend', backendID],
@@ -213,9 +215,11 @@ export default function BackendDetailPage() {
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={update.isPending}>
{t('common.save')}
</Button>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" htmlType="submit" loading={update.isPending} disabled={isViewer}>
{t('common.save')}
</Button>
</Tooltip>
</Form.Item>
</Form>
</Card>
@@ -223,7 +227,7 @@ export default function BackendDetailPage() {
<Col xs={24} lg={14}>
<Card size="small" title={t('backends.serversIn', { name: backend.name })}>
<ServerPanel backendID={backendID} haproxyStats={haproxyStats ?? []} />
<ServerPanel backendID={backendID} haproxyStats={haproxyStats ?? []} isViewer={isViewer} />
</Card>
</Col>
</Row>
@@ -231,7 +235,7 @@ export default function BackendDetailPage() {
)
}
function ServerPanel({ backendID, haproxyStats }: { backendID: number; haproxyStats: HAProxyStat[] }) {
function ServerPanel({ backendID, haproxyStats, isViewer }: { backendID: number; haproxyStats: HAProxyStat[]; isViewer: boolean }) {
const { t } = useTranslation()
const qc = useQueryClient()
const [open, setOpen] = useState(false)
@@ -347,12 +351,14 @@ function ServerPanel({ backendID, haproxyStats }: { backendID: number; haproxySt
return (
<>
<div className="mb-8" style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<PlusOutlined />} size="small" onClick={() => {
setOpen(true); form.resetFields()
form.setFieldsValue({ weight: 100, backup: false, active: true, port: 8080 })
}}>
{t('backends.server.add')}
</Button>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} size="small" disabled={isViewer} onClick={() => {
setOpen(true); form.resetFields()
form.setFieldsValue({ weight: 100, backup: false, active: true, port: 8080 })
}}>
{t('backends.server.add')}
</Button>
</Tooltip>
</div>
<Table
size="small" rowKey="id" loading={isLoading}

View File

@@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
import StatusDot from '../../components/StatusDot'
import { useAuthStore } from '../../stores/auth'
const { Text } = Typography
@@ -109,6 +110,7 @@ export default function DomainDetailPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const domainID = Number(id)
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const { data: domain, isLoading } = useQuery({
queryKey: ['domain', domainID],
@@ -193,8 +195,8 @@ export default function DomainDetailPage() {
<Space size={6}>
<StatusDot active={domain.active} />
{certBadge()}
{backendHealth === 'UP' && <Tag color="green" style={{ margin: 0 }}>backend UP</Tag>}
{backendHealth === 'DOWN' && <Tag color="red" style={{ margin: 0 }}>backend DOWN</Tag>}
{backendHealth === 'UP' && <Tag color="green" style={{ margin: 0 }}>{t('domains.backendUp')}</Tag>}
{backendHealth === 'DOWN' && <Tag color="red" style={{ margin: 0 }}>{t('domains.backendDown')}</Tag>}
</Space>
}
extra={
@@ -205,23 +207,30 @@ export default function DomainDetailPage() {
description={t('ssl.renewConfirmDesc', { domain: domain.name })}
onConfirm={() => issueCert.mutate(domain.name)}
okText={t('common.yes')} cancelText={t('common.no')}
disabled={isViewer}
>
<Button
icon={<SafetyCertificateOutlined />}
loading={issueCert.isPending}
>
{t('ssl.renewBtn')}
</Button>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
icon={<SafetyCertificateOutlined />}
loading={issueCert.isPending}
disabled={isViewer}
>
{t('ssl.renewBtn')}
</Button>
</Tooltip>
</Popconfirm>
) : (
<Button
icon={<SafetyCertificateOutlined />}
type="primary"
loading={issueCert.isPending}
onClick={() => issueCert.mutate(domain.name)}
>
{t('ssl.issueButton')}
</Button>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
icon={<SafetyCertificateOutlined />}
type="primary"
loading={issueCert.isPending}
disabled={isViewer}
onClick={() => issueCert.mutate(domain.name)}
>
{t('ssl.issueButton')}
</Button>
</Tooltip>
)}
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/domains')}>
{t('domains.backToList')}
@@ -232,7 +241,7 @@ export default function DomainDetailPage() {
<Row gutter={[24, 0]}>
<Col xs={24}>
<RoutingRulesPanel domainID={domainID} domainName={domain.name} />
<RoutingRulesPanel domainID={domainID} domainName={domain.name} isViewer={isViewer} />
</Col>
</Row>
@@ -347,16 +356,18 @@ export default function DomainDetailPage() {
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={update.isPending}>
{t('common.save')}
</Button>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" htmlType="submit" loading={update.isPending} disabled={isViewer}>
{t('common.save')}
</Button>
</Tooltip>
</Form.Item>
</Form>
</Card>
</Col>
<Col xs={24} lg={12}>
<HeadersPanel domainID={domainID} domainName={domain.name} />
<HeadersPanel domainID={domainID} domainName={domain.name} isViewer={isViewer} />
</Col>
</Row>
</div>
@@ -365,7 +376,7 @@ export default function DomainDetailPage() {
// ── Routing Rules Panel ─────────────────────────────────────────────────
function RoutingRulesPanel({ domainID, domainName }: { domainID: number; domainName: string }) {
function RoutingRulesPanel({ domainID, domainName, isViewer }: { domainID: number; domainName: string; isViewer: boolean }) {
const { t } = useTranslation()
const qc = useQueryClient()
const [open, setOpen] = useState(false)
@@ -428,6 +439,7 @@ function RoutingRulesPanel({ domainID, domainName }: { domainID: number; domainN
<Switch
size="small"
checked={v}
disabled={isViewer}
loading={quickToggle.isPending && quickToggle.variables?.id === r.id}
onChange={(checked) => quickToggle.mutate({ id: r.id, row: r, checked })}
/>
@@ -437,19 +449,28 @@ function RoutingRulesPanel({ domainID, domainName }: { domainID: number; domainN
title: t('common.actions'), key: 'a', width: 90,
render: (_, r) => (
<Space size={4}>
<Button type="text" size="small" icon={<EditOutlined />}
onClick={() => {
setEditing(r)
rForm.setFieldsValue({ path_prefix: r.path_prefix, backend_id: r.backend_id, priority: r.priority, active: r.active })
}}
/>
<Popconfirm
title={t('routing.deleteConfirm')}
okText={t('common.yes')} cancelText={t('common.no')}
onConfirm={() => del.mutate(r.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
<Button type="text" size="small" icon={<EditOutlined />}
disabled={isViewer}
onClick={() => {
setEditing(r)
rForm.setFieldsValue({ path_prefix: r.path_prefix, backend_id: r.backend_id, priority: r.priority, active: r.active })
}}
/>
</Tooltip>
{isViewer ? (
<Tooltip title={t('auth.viewerBadge')}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled />
</Tooltip>
) : (
<Popconfirm
title={t('routing.deleteConfirm')}
okText={t('common.yes')} cancelText={t('common.no')}
onConfirm={() => del.mutate(r.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
@@ -462,12 +483,14 @@ function RoutingRulesPanel({ domainID, domainName }: { domainID: number; domainN
title={t('domains.routingRulesTitle', { name: domainName })}
className="mb-16"
extra={
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => {
setOpen(true); rForm.resetFields()
rForm.setFieldsValue({ path_prefix: '/', priority: 100, active: true })
}}>
{t('routing.addRule')}
</Button>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" size="small" icon={<PlusOutlined />} disabled={isViewer} onClick={() => {
setOpen(true); rForm.resetFields()
rForm.setFieldsValue({ path_prefix: '/', priority: 100, active: true })
}}>
{t('routing.addRule')}
</Button>
</Tooltip>
}
>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
@@ -517,7 +540,7 @@ function RoutingRulesPanel({ domainID, domainName }: { domainID: number; domainN
// ── Response Headers Panel ──────────────────────────────────────────────
function HeadersPanel({ domainID, domainName }: { domainID: number; domainName: string }) {
function HeadersPanel({ domainID, domainName, isViewer }: { domainID: number; domainName: string; isViewer: boolean }) {
const { t } = useTranslation()
const qc = useQueryClient()
const [open, setOpen] = useState(false)
@@ -566,19 +589,28 @@ function HeadersPanel({ domainID, domainName }: { domainID: number; domainName:
title: t('common.actions'), key: 'a', width: 80,
render: (_, r) => (
<Space size={4}>
<Button type="text" size="small" icon={<EditOutlined />}
onClick={() => {
setEditing(r)
hForm.setFieldsValue({ name: r.name, value: r.value, position: r.position })
}}
/>
<Popconfirm
title={t('domains.headerDeleteConfirm', { name: r.name })}
okText={t('common.yes')} cancelText={t('common.no')}
onConfirm={() => del.mutate(r.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
<Button type="text" size="small" icon={<EditOutlined />}
disabled={isViewer}
onClick={() => {
setEditing(r)
hForm.setFieldsValue({ name: r.name, value: r.value, position: r.position })
}}
/>
</Tooltip>
{isViewer ? (
<Tooltip title={t('auth.viewerBadge')}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled />
</Tooltip>
) : (
<Popconfirm
title={t('domains.headerDeleteConfirm', { name: r.name })}
okText={t('common.yes')} cancelText={t('common.no')}
onConfirm={() => del.mutate(r.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
@@ -590,12 +622,14 @@ function HeadersPanel({ domainID, domainName }: { domainID: number; domainName:
size="small"
title={t('domains.headersTitle', { name: domainName })}
extra={
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => {
setOpen(true); hForm.resetFields()
hForm.setFieldsValue({ name: '', value: '', position: headers?.length ?? 0 })
}}>
{t('domains.addHeader')}
</Button>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" size="small" icon={<PlusOutlined />} disabled={isViewer} onClick={() => {
setOpen(true); hForm.resetFields()
hForm.setFieldsValue({ name: '', value: '', position: headers?.length ?? 0 })
}}>
{t('domains.addHeader')}
</Button>
</Tooltip>
}
>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>