diff --git a/VERSION b/VERSION
index b30a005..2733a3e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.57
+1.1.58
diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go
index 87173d9..2ca316e 100644
--- a/cmd/edgeguard-api/main.go
+++ b/cmd/edgeguard-api/main.go
@@ -276,7 +276,7 @@ func main() {
}
authed := v1.Group("")
- authed.Use(requireAuth)
+ authed.Use(requireAuth, handlers.RequireAdminForMutations())
setupHdl.RegisterAuthed(authed)
handlers.NewUsersHandler(usersRepo, auditRepo, nodeID).Register(authed)
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
diff --git a/internal/handlers/middleware.go b/internal/handlers/middleware.go
index f88c81d..fe70f84 100644
--- a/internal/handlers/middleware.go
+++ b/internal/handlers/middleware.go
@@ -117,4 +117,27 @@ func tokenFromRequest(c *gin.Context) string {
return ""
}
+// RequireAdminForMutations blocks non-GET/HEAD requests from non-admin
+// users. GET and HEAD are always allowed for authenticated users so
+// read-only ("viewer") accounts can browse all data. Every state-
+// changing request (POST, PUT, PATCH, DELETE) requires role="admin".
+//
+// Must be mounted AFTER RequireAuth so the token is already in context.
+func RequireAdminForMutations() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ m := c.Request.Method
+ if m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions {
+ c.Next()
+ return
+ }
+ tok := CurrentToken(c)
+ if tok == nil || tok.Role != "admin" {
+ response.Err(c, http.StatusForbidden, errors.New("admin_required"))
+ c.Abort()
+ return
+ }
+ c.Next()
+ }
+}
+
func ptr[T any](v T) *T { return &v }
diff --git a/management-ui/src/api/client.ts b/management-ui/src/api/client.ts
index 52bc9e3..cd68d6b 100644
--- a/management-ui/src/api/client.ts
+++ b/management-ui/src/api/client.ts
@@ -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 &&
diff --git a/management-ui/src/components/Layout/Header.tsx b/management-ui/src/components/Layout/Header.tsx
index b6f2318..fa72c7e 100644
--- a/management-ui/src/components/Layout/Header.tsx
+++ b/management-ui/src/components/Layout/Header.tsx
@@ -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 && (
- ,
- label: t('auth.logout'),
- onClick: onLogout,
- },
- ],
- }}
- placement="bottomRight"
- >
-
-
+ <>
+ {user.role === 'viewer' && (
+
+ } color="default" style={{ marginRight: 4 }}>
+ {t('auth.viewerBadge')}
+
+
+ )}
+ ,
+ label: t('auth.logout'),
+ onClick: onLogout,
+ },
+ ],
+ }}
+ placement="bottomRight"
+ >
+
+
+ >
)}
diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json
index b05a931..6dc85b3 100644
--- a/management-ui/src/i18n/locales/de/common.json
+++ b/management-ui/src/i18n/locales/de/common.json
@@ -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",
diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json
index 1391083..f68cf48 100644
--- a/management-ui/src/i18n/locales/en/common.json
+++ b/management-ui/src/i18n/locales/en/common.json
@@ -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",