feat(users): Multi-User-Management — DB-backed Login-Accounts + UI
- internal/services/users: Repo mit CRUD, bcrypt (cost 12), Upsert für setup-store-Admin-Migration, RecordLogin - internal/handlers/users: GET/POST/PUT /users, POST /users/:id/password, DELETE /users/:id; Schutz gegen Selbst-Löschung - auth.go Login: DB-Nutzer first, Fallback auf setup-store-Admin; bei erfolgreichem Fallback wird der Admin per Upsert in die DB migriert (kein manueller Eingriff nötig) - management-ui: /users-Seite mit Tabelle, Anlegen-, Bearbeiten-, Passwort-setzen- und Löschen-Modals; "You"-Badge für eigenen Account - Sidebar + Route + i18n (de/en) ergänzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
)
|
||||
|
||||
var version = "1.0.78"
|
||||
@@ -267,7 +268,8 @@ func main() {
|
||||
systemHdl.WithAudit(auditRepo, nodeID)
|
||||
systemHdl.WithDB(pool)
|
||||
setupHdl.WithAudit(auditRepo, nodeID)
|
||||
authHdl.WithAudit(auditRepo, nodeID)
|
||||
usersRepo := usersvc.New(pool)
|
||||
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo)
|
||||
|
||||
haproxyReloader := func(ctx context.Context) error {
|
||||
return haproxy.New(pool).Render(ctx)
|
||||
@@ -276,6 +278,7 @@ func main() {
|
||||
authed := v1.Group("")
|
||||
authed.Use(requireAuth)
|
||||
setupHdl.RegisterAuthed(authed)
|
||||
handlers.NewUsersHandler(usersRepo, auditRepo, nodeID).Register(authed)
|
||||
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
|
||||
@@ -12,16 +12,20 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
)
|
||||
|
||||
// AuthHandler exposes login / me / logout. v1 verifies against the
|
||||
// setup-store (single admin); admin_users-table support comes when the
|
||||
// users repo lands.
|
||||
// AuthHandler exposes login / me / logout.
|
||||
// Login checks the DB users table first; falls back to the setup-store
|
||||
// admin for backwards compatibility. On a successful setup-store login
|
||||
// the account is auto-migrated into the DB (Upsert) so it shows up in
|
||||
// user management from that point on.
|
||||
type AuthHandler struct {
|
||||
Setup *setup.Store
|
||||
Signer *session.Signer
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Users *usersvc.Repo // optional — nil on first boot before DB is ready
|
||||
}
|
||||
|
||||
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
|
||||
@@ -36,6 +40,12 @@ func (h *AuthHandler) WithAudit(a *audit.Repo, nodeID string) *AuthHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithUsers injects the users repo so Login can verify against the DB.
|
||||
func (h *AuthHandler) WithUsers(u *usersvc.Repo) *AuthHandler {
|
||||
h.Users = u
|
||||
return h
|
||||
}
|
||||
|
||||
// Register mounts /auth/login + /logout (public) and /auth/me
|
||||
// (gated by requireAuth, passed in as a per-route middleware).
|
||||
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
||||
@@ -73,13 +83,44 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(st.AdminEmail, strings.TrimSpace(req.Email)) ||
|
||||
!st.VerifyAdminPassword(req.Password) {
|
||||
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||
return
|
||||
|
||||
email := strings.TrimSpace(req.Email)
|
||||
actor, role := "", "admin"
|
||||
|
||||
// 1. Try DB users table first.
|
||||
if h.Users != nil {
|
||||
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), email)
|
||||
if dbErr == nil {
|
||||
if !u.Active {
|
||||
response.Unauthorized(c, errors.New("account_disabled"))
|
||||
return
|
||||
}
|
||||
if !usersvc.VerifyPassword(hash, req.Password) {
|
||||
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||
return
|
||||
}
|
||||
actor = u.Email
|
||||
role = u.Role
|
||||
h.Users.RecordLogin(c.Request.Context(), u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
raw, tok, err := h.Signer.IssueWithRole(st.AdminEmail, "admin")
|
||||
// 2. Fallback: setup-store admin (backwards compat for pre-DB installs).
|
||||
if actor == "" {
|
||||
if !strings.EqualFold(st.AdminEmail, email) || !st.VerifyAdminPassword(req.Password) {
|
||||
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||
return
|
||||
}
|
||||
actor = st.AdminEmail
|
||||
role = "admin"
|
||||
// Auto-migrate: insert the setup-store admin into the DB so it
|
||||
// shows up in user management from this point on.
|
||||
if h.Users != nil {
|
||||
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
|
||||
}
|
||||
}
|
||||
|
||||
raw, tok, err := h.Signer.IssueWithRole(actor, role)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
|
||||
166
internal/handlers/users.go
Normal file
166
internal/handlers/users.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
)
|
||||
|
||||
// UsersHandler exposes:
|
||||
//
|
||||
// GET /api/v1/users — list all users
|
||||
// POST /api/v1/users — create user
|
||||
// PUT /api/v1/users/:id — update email/role/active (not password)
|
||||
// POST /api/v1/users/:id/password — set new password
|
||||
// DELETE /api/v1/users/:id — delete user
|
||||
type UsersHandler struct {
|
||||
Repo *users.Repo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewUsersHandler(repo *users.Repo, a *audit.Repo, nodeID string) *UsersHandler {
|
||||
return &UsersHandler{Repo: repo, Audit: a, NodeID: nodeID}
|
||||
}
|
||||
|
||||
func (h *UsersHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/users")
|
||||
g.GET("", h.List)
|
||||
g.POST("", h.Create)
|
||||
g.PUT("/:id", h.Update)
|
||||
g.POST("/:id/password", h.SetPassword)
|
||||
g.DELETE("/:id", h.Delete)
|
||||
}
|
||||
|
||||
func (h *UsersHandler) List(c *gin.Context) {
|
||||
out, err := h.Repo.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if out == nil {
|
||||
out = []users.User{}
|
||||
}
|
||||
response.OK(c, gin.H{"users": out})
|
||||
}
|
||||
|
||||
type createUserReq struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=12"`
|
||||
Role string `json:"role" binding:"required,oneof=admin viewer"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func (h *UsersHandler) Create(c *gin.Context) {
|
||||
var req createUserReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
u, err := h.Repo.Create(c.Request.Context(), req.Email, req.Password, req.Role, req.Active)
|
||||
if errors.Is(err, users.ErrEmailTaken) {
|
||||
response.Err(c, http.StatusConflict, err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.create", u.Email,
|
||||
gin.H{"role": u.Role, "active": u.Active}, h.NodeID)
|
||||
response.Created(c, u)
|
||||
}
|
||||
|
||||
type updateUserReq struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Role string `json:"role" binding:"required,oneof=admin viewer"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func (h *UsersHandler) Update(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updateUserReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
u, err := h.Repo.Update(c.Request.Context(), id, req.Email, req.Role, req.Active)
|
||||
if errors.Is(err, users.ErrNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, users.ErrEmailTaken) {
|
||||
response.Err(c, http.StatusConflict, err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.update", u.Email,
|
||||
gin.H{"role": u.Role, "active": u.Active}, h.NodeID)
|
||||
response.OK(c, u)
|
||||
}
|
||||
|
||||
type setPasswordReq struct {
|
||||
Password string `json:"password" binding:"required,min=12"`
|
||||
}
|
||||
|
||||
func (h *UsersHandler) SetPassword(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req setPasswordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Repo.SetPassword(c.Request.Context(), id, req.Password); err != nil {
|
||||
if errors.Is(err, users.ErrNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.password.set",
|
||||
c.Param("id"), nil, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *UsersHandler) Delete(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Prevent deleting the own account.
|
||||
tok := CurrentToken(c)
|
||||
if tok != nil {
|
||||
if u, _, err := h.Repo.FindByEmail(c.Request.Context(), tok.Actor); err == nil {
|
||||
if u.ID == id {
|
||||
response.Err(c, http.StatusBadRequest, errors.New("cannot delete own account"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := h.Repo.Delete(c.Request.Context(), id); err != nil {
|
||||
if errors.Is(err, users.ErrNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.delete",
|
||||
c.Param("id"), nil, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
181
internal/services/users/users.go
Normal file
181
internal/services/users/users.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Package users manages the admin users table (login accounts).
|
||||
// Passwords are bcrypt'd; minimum cost is 12 (≈300ms on modern hw).
|
||||
// The users table is the authoritative source for login; the legacy
|
||||
// setup-store admin is migrated into this table on first login.
|
||||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const passwordCost = 12
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("user not found")
|
||||
ErrEmailTaken = errors.New("email already in use")
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Active bool `json:"active"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} }
|
||||
|
||||
const selectCols = `id, email, role, active, last_login_at, created_at, updated_at`
|
||||
|
||||
func scan(row pgx.Row) (User, error) {
|
||||
var u User
|
||||
err := row.Scan(&u.ID, &u.Email, &u.Role, &u.Active,
|
||||
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt)
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (r *Repo) List(ctx context.Context) ([]User, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT `+selectCols+` FROM users ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
u, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// FindByEmail returns the user + bcrypt hash. ErrNotFound if absent.
|
||||
func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, error) {
|
||||
var u User
|
||||
var hash string
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT `+selectCols+`, password_hash FROM users WHERE lower(email)=lower($1)`,
|
||||
email).Scan(&u.ID, &u.Email, &u.Role, &u.Active,
|
||||
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, &hash)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return u, "", ErrNotFound
|
||||
}
|
||||
return u, hash, err
|
||||
}
|
||||
|
||||
func (r *Repo) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *Repo) Create(ctx context.Context, email, password, role string, active bool) (User, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), passwordCost)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
u, err := scan(r.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (email, password_hash, role, active)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
RETURNING `+selectCols,
|
||||
email, string(hash), role, active))
|
||||
if isUnique(err) {
|
||||
return User{}, ErrEmailTaken
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
// Upsert: insert or update — used to migrate the legacy setup-store admin
|
||||
// into the DB on first successful login without clobbering an existing row.
|
||||
func (r *Repo) Upsert(ctx context.Context, email, password, role string, active bool) (User, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), passwordCost)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
u, err := scan(r.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (email, password_hash, role, active)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (email) DO NOTHING
|
||||
RETURNING `+selectCols,
|
||||
email, string(hash), role, active))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// row already existed — return what's there without overwriting
|
||||
u, _, err = r.FindByEmail(ctx, email)
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (r *Repo) Update(ctx context.Context, id int64, email, role string, active bool) (User, error) {
|
||||
u, err := scan(r.pool.QueryRow(ctx,
|
||||
`UPDATE users SET email=$1, role=$2, active=$3, updated_at=NOW()
|
||||
WHERE id=$4 RETURNING `+selectCols,
|
||||
email, role, active, id))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return u, ErrNotFound
|
||||
}
|
||||
if isUnique(err) {
|
||||
return u, ErrEmailTaken
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (r *Repo) SetPassword(ctx context.Context, id int64, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), passwordCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`UPDATE users SET password_hash=$1, updated_at=NOW() WHERE id=$2`,
|
||||
string(hash), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||
tag, err := r.pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) RecordLogin(ctx context.Context, id int64) {
|
||||
_, _ = r.pool.Exec(ctx, `UPDATE users SET last_login_at=NOW() WHERE id=$1`, id)
|
||||
}
|
||||
|
||||
// VerifyPassword is a constant-time bcrypt compare.
|
||||
func VerifyPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func isUnique(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "23505") || strings.Contains(s, "unique")
|
||||
}
|
||||
@@ -36,6 +36,7 @@ const DiagnosticsPage = lazy(() => import('./pages/Diagnostics'))
|
||||
const AlertsPage = lazy(() => import('./pages/Alerts'))
|
||||
const LicensePage = lazy(() => import('./pages/License'))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||
const UsersPage = lazy(() => import('./pages/Users'))
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -128,6 +129,7 @@ export default function App() {
|
||||
<Route path="/diagnostics" element={<DiagnosticsPage />} />
|
||||
<Route path="/alerts" element={<AlertsPage />} />
|
||||
<Route path="/license" element={<LicensePage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/diagnostics': 'nav.diagnostics',
|
||||
'/alerts': 'nav.alerts',
|
||||
'/license': 'nav.license',
|
||||
'/users': 'nav.users',
|
||||
'/settings': 'nav.settings',
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
NodeIndexOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
ToolOutlined,
|
||||
} from '@ant-design/icons'
|
||||
@@ -85,6 +86,7 @@ const NAV: NavSection[] = [
|
||||
{ path: '/alerts', labelKey: 'nav.alerts', icon: <BellOutlined /> },
|
||||
{ path: '/backups', labelKey: 'nav.backups', icon: <DatabaseOutlined /> },
|
||||
{ path: '/license', labelKey: 'nav.license', icon: <CrownOutlined /> },
|
||||
{ path: '/users', labelKey: 'nav.users', icon: <TeamOutlined /> },
|
||||
{ path: '/settings', labelKey: 'nav.settings', icon: <SettingOutlined /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"audit": "Audit-Log",
|
||||
"backups": "Backups",
|
||||
"diagnostics": "Diagnose",
|
||||
"users": "Benutzer",
|
||||
"alerts": "Alarme",
|
||||
"license": "Lizenz",
|
||||
"settings": "Einstellungen",
|
||||
@@ -1136,6 +1137,29 @@
|
||||
"rule": "Rule-ID"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"title": "Benutzerverwaltung",
|
||||
"intro": "Login-Accounts verwalten. Alle hier gelisteten Accounts können sich über die Login-Seite authentifizieren.",
|
||||
"addUser": "Benutzer anlegen",
|
||||
"editUser": "Benutzer bearbeiten",
|
||||
"email": "E-Mail",
|
||||
"role": "Rolle",
|
||||
"active": "Aktiv",
|
||||
"lastLogin": "Letzter Login",
|
||||
"created": "Angelegt",
|
||||
"setPassword": "Passwort setzen",
|
||||
"setPasswordTitle": "Neues Passwort setzen",
|
||||
"newPassword": "Neues Passwort",
|
||||
"newPasswordHint": "Mindestens 12 Zeichen.",
|
||||
"roleAdmin": "Admin",
|
||||
"roleViewer": "Betrachter (nur lesen)",
|
||||
"deleteConfirm": "Benutzer {{email}} löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"emptyTitle": "Noch keine Benutzer",
|
||||
"emptyDesc": "Oben den ersten zusätzlichen Benutzer anlegen.",
|
||||
"errorEmailTaken": "Diese E-Mail-Adresse wird bereits verwendet.",
|
||||
"cannotDeleteSelf": "Das eigene Konto kann nicht gelöscht werden.",
|
||||
"never": "Nie"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit-Log",
|
||||
"intro": "Wer hat was wann geändert. Jede Mutation in der API (Domain anlegen, Backend deaktivieren, Cert ausstellen, …) ist hier nachvollziehbar.",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"audit": "Audit log",
|
||||
"backups": "Backups",
|
||||
"diagnostics": "Diagnostics",
|
||||
"users": "Users",
|
||||
"alerts": "Alerts",
|
||||
"license": "License",
|
||||
"settings": "Settings",
|
||||
@@ -1136,6 +1137,29 @@
|
||||
"rule": "Rule ID"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"title": "User Management",
|
||||
"intro": "Manage login accounts. Every account listed here can authenticate via the login page.",
|
||||
"addUser": "Add User",
|
||||
"editUser": "Edit User",
|
||||
"email": "Email",
|
||||
"role": "Role",
|
||||
"active": "Active",
|
||||
"lastLogin": "Last Login",
|
||||
"created": "Created",
|
||||
"setPassword": "Set Password",
|
||||
"setPasswordTitle": "Set New Password",
|
||||
"newPassword": "New Password",
|
||||
"newPasswordHint": "Minimum 12 characters.",
|
||||
"roleAdmin": "Admin",
|
||||
"roleViewer": "Viewer (read-only)",
|
||||
"deleteConfirm": "Delete user {{email}}? This cannot be undone.",
|
||||
"emptyTitle": "No users yet",
|
||||
"emptyDesc": "Add the first additional user above.",
|
||||
"errorEmailTaken": "This email address is already in use.",
|
||||
"cannotDeleteSelf": "You cannot delete your own account.",
|
||||
"never": "Never"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit log",
|
||||
"intro": "Who changed what, when. Every mutation through the API (domain create, backend disable, cert issue, …) is recorded here.",
|
||||
|
||||
247
management-ui/src/pages/Users/index.tsx
Normal file
247
management-ui/src/pages/Users/index.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button, Form, Input, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { KeyOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
email: string
|
||||
role: string
|
||||
active: boolean
|
||||
last_login_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface CreateValues { email: string; password: string; role: string; active: boolean }
|
||||
interface EditValues { email: string; role: string; active: boolean }
|
||||
interface PwValues { password: string }
|
||||
|
||||
async function listUsers(): Promise<User[]> {
|
||||
const r = await apiClient.get('/users')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { users?: User[] }).users ?? []
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = { admin: 'blue', viewer: 'default' }
|
||||
|
||||
export default function UsersPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const me = useAuthStore((s) => s.user)
|
||||
|
||||
const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: listUsers })
|
||||
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState<User | null>(null)
|
||||
const [pwTarget, setPwTarget] = useState<User | null>(null)
|
||||
const [createForm] = Form.useForm<CreateValues>()
|
||||
const [editForm] = Form.useForm<EditValues>()
|
||||
const [pwForm] = Form.useForm<PwValues>()
|
||||
|
||||
const invalidate = () => void qc.invalidateQueries({ queryKey: ['users'] })
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (v: CreateValues) => apiClient.post('/users', v),
|
||||
onSuccess: () => { message.success(t('common.save')); setCreating(false); createForm.resetFields(); invalidate() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, v }: { id: number; v: EditValues }) => apiClient.put(`/users/${id}`, v),
|
||||
onSuccess: () => { message.success(t('common.save')); setEditing(null); editForm.resetFields(); invalidate() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const pwMut = useMutation({
|
||||
mutationFn: ({ id, v }: { id: number; v: PwValues }) => apiClient.post(`/users/${id}/password`, v),
|
||||
onSuccess: () => { message.success(t('common.save')); setPwTarget(null); pwForm.resetFields() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const delMut = useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/users/${id}`),
|
||||
onSuccess: invalidate,
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'admin', label: t('users.roleAdmin') },
|
||||
{ value: 'viewer', label: t('users.roleViewer') },
|
||||
]
|
||||
|
||||
const columns: ColumnsType<User> = [
|
||||
{
|
||||
title: t('users.email'), dataIndex: 'email', key: 'email',
|
||||
render: (s: string, row) => (
|
||||
<Space size={6}>
|
||||
<span>{s}</span>
|
||||
{me?.actor === s && <Tag style={{ fontSize: 11, padding: '0 4px' }}>You</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('users.role'), dataIndex: 'role', key: 'role',
|
||||
render: (r: string) => (
|
||||
<Tag color={ROLE_COLORS[r] ?? 'default'}>
|
||||
{r === 'admin' ? t('users.roleAdmin') : t('users.roleViewer')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('users.active'), dataIndex: 'active', key: 'active', width: 70,
|
||||
render: (v: boolean) => <StatusDot active={v} />,
|
||||
},
|
||||
{
|
||||
title: t('users.lastLogin'), key: 'lastLogin', width: 160,
|
||||
render: (_, row) => row.last_login_at
|
||||
? <Tooltip title={new Date(row.last_login_at).toLocaleString()}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{new Date(row.last_login_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
: <Text type="secondary" style={{ fontSize: 12 }}>{t('users.never')}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions', width: 120,
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title={t('users.setPassword')}>
|
||||
<Button type="text" size="small" icon={<KeyOutlined />}
|
||||
onClick={() => { setPwTarget(row); pwForm.resetFields() }} />
|
||||
</Tooltip>
|
||||
<ActionButtons
|
||||
onEdit={() => {
|
||||
setEditing(row)
|
||||
editForm.setFieldsValue({ email: row.email, role: row.role, active: row.active })
|
||||
}}
|
||||
onDelete={() => delMut.mutate(row.id)}
|
||||
deleteConfirm={t('users.deleteConfirm', { email: row.email })}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
icon={<TeamOutlined />}
|
||||
title={t('users.title')}
|
||||
subtitle={t('users.intro')}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={users ?? []}
|
||||
columns={columns}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true)
|
||||
createForm.resetFields()
|
||||
createForm.setFieldsValue({ role: 'admin', active: true })
|
||||
}}>
|
||||
{t('users.addUser')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<TeamOutlined />}
|
||||
title={t('users.emptyTitle')}
|
||||
description={t('users.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true)
|
||||
createForm.resetFields()
|
||||
createForm.setFieldsValue({ role: 'admin', active: true })
|
||||
}}>
|
||||
{t('users.addUser')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Create modal */}
|
||||
<Modal
|
||||
title={t('users.addUser')}
|
||||
open={creating}
|
||||
onCancel={() => { setCreating(false); createForm.resetFields() }}
|
||||
onOk={() => void createForm.submit()}
|
||||
confirmLoading={createMut.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={createForm} layout="vertical"
|
||||
onFinish={(v) => createMut.mutate(v)}>
|
||||
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
||||
<Input autoFocus autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('users.newPassword')} name="password"
|
||||
extra={t('users.newPasswordHint')}
|
||||
rules={[{ required: true, min: 12, message: t('users.newPasswordHint') }]}>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('users.role')} name="role" rules={[{ required: true }]}>
|
||||
<Select options={roleOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('users.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit modal */}
|
||||
<Modal
|
||||
title={t('users.editUser')}
|
||||
open={editing !== null}
|
||||
onCancel={() => { setEditing(null); editForm.resetFields() }}
|
||||
onOk={() => void editForm.submit()}
|
||||
confirmLoading={updateMut.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={editForm} layout="vertical"
|
||||
onFinish={(v) => editing && updateMut.mutate({ id: editing.id, v })}>
|
||||
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
||||
<Input autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('users.role')} name="role" rules={[{ required: true }]}>
|
||||
<Select options={roleOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('users.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Set password modal */}
|
||||
<Modal
|
||||
title={`${t('users.setPasswordTitle')} — ${pwTarget?.email ?? ''}`}
|
||||
open={pwTarget !== null}
|
||||
onCancel={() => { setPwTarget(null); pwForm.resetFields() }}
|
||||
onOk={() => void pwForm.submit()}
|
||||
confirmLoading={pwMut.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={pwForm} layout="vertical"
|
||||
onFinish={(v) => pwTarget && pwMut.mutate({ id: pwTarget.id, v })}>
|
||||
<Form.Item label={t('users.newPassword')} name="password"
|
||||
extra={t('users.newPasswordHint')}
|
||||
rules={[{ required: true, min: 12, message: t('users.newPasswordHint') }]}>
|
||||
<Input.Password autoFocus autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user