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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user