feat(rbac): Viewer-Rolle durchsetzen — alle Mutations nur für Admins

- middleware.go: RequireAdminForMutations() blockiert POST/PUT/DELETE
  für Nicht-Admins (GET/HEAD passieren immer durch)
- main.go: Middleware in den authed-Gruppe eingehängt — wirkt für
  alle ~30 Resource-Handler gleichzeitig
- api/client.ts: 403 → AntD-Notification "Nur Lesezugriff" statt
  stiller Fehler
- Header: "Nur lesen"-Badge + Tooltip wenn role=viewer
- i18n de+en: viewerBadge + viewerHint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-23 16:57:17 +02:00
parent 4e01b4569c
commit 386972366b
7 changed files with 66 additions and 23 deletions

View File

@@ -1,4 +1,5 @@
import axios, { type AxiosError } from 'axios'
import { message } from 'antd'
import { useAuthStore } from '../stores/auth'
@@ -38,6 +39,12 @@ apiClient.interceptors.response.use(
window.location.replace('/login')
}
// 403 admin_required → viewer account tried to mutate; show a
// one-time notification and let the calling mutation handle the rest.
if (error.response?.status === 403) {
message.error('Keine Berechtigung — dieser Account hat nur Lesezugriff.', 4)
}
// 503 setup_required → kick to /setup so the wizard takes over.
if (
error.response?.status === 503 &&

View File

@@ -1,5 +1,5 @@
import { Button, Dropdown, Select, Space } from 'antd'
import { GlobalOutlined, LogoutOutlined, MenuOutlined, UserOutlined } from '@ant-design/icons'
import { Button, Dropdown, Select, Space, Tag, Tooltip } from 'antd'
import { EyeOutlined, GlobalOutlined, LogoutOutlined, MenuOutlined, UserOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
@@ -55,23 +55,32 @@ export default function Header({ pageTitle, onMenuToggle }: HeaderProps) {
popupMatchSelectWidth={false}
/>
{user && (
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: t('auth.logout'),
onClick: onLogout,
},
],
}}
placement="bottomRight"
>
<Button type="text" className="header-user">
<Space><UserOutlined />{user.actor}</Space>
</Button>
</Dropdown>
<>
{user.role === 'viewer' && (
<Tooltip title={t('auth.viewerHint')}>
<Tag icon={<EyeOutlined />} color="default" style={{ marginRight: 4 }}>
{t('auth.viewerBadge')}
</Tag>
</Tooltip>
)}
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: t('auth.logout'),
onClick: onLogout,
},
],
}}
placement="bottomRight"
>
<Button type="text" className="header-user">
<Space><UserOutlined />{user.actor}</Space>
</Button>
</Dropdown>
</>
)}
</div>
</header>

View File

@@ -198,7 +198,9 @@
"logout": "Abmelden",
"loginFailed": "Anmeldung fehlgeschlagen",
"loggedInAs": "Angemeldet als",
"forgotPassword": "Passwort vergessen?"
"forgotPassword": "Passwort vergessen?",
"viewerBadge": "Nur lesen",
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen."
},
"reset": {
"title": "Admin-Passwort zurücksetzen",

View File

@@ -198,7 +198,9 @@
"logout": "Sign out",
"loginFailed": "Sign-in failed",
"loggedInAs": "Signed in as",
"forgotPassword": "Forgot your password?"
"forgotPassword": "Forgot your password?",
"viewerBadge": "Read-only",
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role."
},
"reset": {
"title": "Reset admin password",