diff --git a/VERSION b/VERSION index 6d4b6e1..439ba98 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.55 +1.1.56 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index c860081..87173d9 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -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) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index f1838aa..7e7ced6 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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 diff --git a/internal/handlers/users.go b/internal/handlers/users.go new file mode 100644 index 0000000..95601ae --- /dev/null +++ b/internal/handlers/users.go @@ -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}) +} diff --git a/internal/services/users/users.go b/internal/services/users/users.go new file mode 100644 index 0000000..d49bcc4 --- /dev/null +++ b/internal/services/users/users.go @@ -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") +} diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index c2f20fe..ee5471c 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/management-ui/src/components/Layout/AppLayout.tsx b/management-ui/src/components/Layout/AppLayout.tsx index 2eecdea..5510668 100644 --- a/management-ui/src/components/Layout/AppLayout.tsx +++ b/management-ui/src/components/Layout/AppLayout.tsx @@ -26,6 +26,7 @@ const PAGE_TITLES: Record = { '/diagnostics': 'nav.diagnostics', '/alerts': 'nav.alerts', '/license': 'nav.license', + '/users': 'nav.users', '/settings': 'nav.settings', } diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index a856fb5..b96dda5 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -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: }, { path: '/backups', labelKey: 'nav.backups', icon: }, { path: '/license', labelKey: 'nav.license', icon: }, + { path: '/users', labelKey: 'nav.users', icon: }, { path: '/settings', labelKey: 'nav.settings', icon: }, ], }, diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 58a46a5..2f681a4 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -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.", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index ed5f30b..e20f744 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -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.", diff --git a/management-ui/src/pages/Users/index.tsx b/management-ui/src/pages/Users/index.tsx new file mode 100644 index 0000000..34f7a83 --- /dev/null +++ b/management-ui/src/pages/Users/index.tsx @@ -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 { + const r = await apiClient.get('/users') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { users?: User[] }).users ?? [] +} + +const ROLE_COLORS: Record = { 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(null) + const [pwTarget, setPwTarget] = useState(null) + const [createForm] = Form.useForm() + const [editForm] = Form.useForm() + const [pwForm] = Form.useForm() + + 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 = [ + { + title: t('users.email'), dataIndex: 'email', key: 'email', + render: (s: string, row) => ( + + {s} + {me?.actor === s && You} + + ), + }, + { + title: t('users.role'), dataIndex: 'role', key: 'role', + render: (r: string) => ( + + {r === 'admin' ? t('users.roleAdmin') : t('users.roleViewer')} + + ), + }, + { + title: t('users.active'), dataIndex: 'active', key: 'active', width: 70, + render: (v: boolean) => , + }, + { + title: t('users.lastLogin'), key: 'lastLogin', width: 160, + render: (_, row) => row.last_login_at + ? + + {new Date(row.last_login_at).toLocaleDateString()} + + + : {t('users.never')}, + }, + { + title: t('common.actions'), key: 'actions', width: 120, + render: (_, row) => ( + + + + } + emptyContent={ + } + title={t('users.emptyTitle')} + description={t('users.emptyDesc')} + action={ + + } + /> + } + /> + + {/* Create modal */} + { setCreating(false); createForm.resetFields() }} + onOk={() => void createForm.submit()} + confirmLoading={createMut.isPending} + destroyOnHidden + > +
createMut.mutate(v)}> + + + + + + + + + + +