From 15edcf9ba5ff25f54938f3e8ef6401fc3d27856f Mon Sep 17 00:00:00 2001 From: Alexey <177283+dexion@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:40:37 +0300 Subject: [PATCH] feat(users): add a settings page for managing users and their roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PentAGI ships user management as REST endpoints only. Everything else about an installation is administrable from the web UI, but to see who has an account — let alone change what they may do — an administrator has to call /api/v1/users by hand. Accounts created by an OAuth provider on first login are especially invisible: they simply appear in the database with the User role. This adds Settings -> Users, listing every account with its sign-in method (local or the OAuth provider it came from), role, status and creation time, and offering the administrative actions the API already supports: change a role, block or unblock, delete, and create a local account. The page is gated by the same privileges the backend enforces. It is only reachable, and only shown in the settings sidebar, for accounts holding users.view; the role selector appears with users.edit, the row actions with users.edit or users.delete, and the create action with users.create. Without users.view the page explains that the account lacks administrator privileges rather than rendering an empty table — the backend narrows the list to the caller in that case, so an empty-looking table would be misleading. Changing roles needed a small backend addition: PatchUser updated name, status and password, so a role could not be changed through the API at all. It now accepts role_id under three conditions, mirroring what CreateUser already does: - the caller holds users.edit, - the target role grants no privilege the caller lacks, so the endpoint cannot be used to escalate, - and the caller is not changing their own role, which would let the last administrator demote themselves and leave the installation without one. The same reasoning is reflected in the page: your own row shows the role as a plain badge and has no row actions. --- backend/pkg/server/services/users.go | 50 +- backend/pkg/server/services/users_test.go | 153 +++++ frontend/e2e/route-manifest.unit.test.ts | 1 + frontend/src/app.tsx | 6 + .../settings/settings-sidebar.test.tsx | 28 +- .../layouts/settings/settings-sidebar.tsx | 17 +- frontend/src/lib/route-titles/index.ts | 3 + frontend/src/lib/routes.ts | 1 + .../pages/settings/settings-users.test.tsx | 175 ++++++ .../src/pages/settings/settings-users.tsx | 577 ++++++++++++++++++ 10 files changed, 996 insertions(+), 15 deletions(-) create mode 100644 frontend/src/pages/settings/settings-users.test.tsx create mode 100644 frontend/src/pages/settings/settings-users.tsx diff --git a/backend/pkg/server/services/users.go b/backend/pkg/server/services/users.go index bdf7c75ef..23b96f875 100644 --- a/backend/pkg/server/services/users.go +++ b/backend/pkg/server/services/users.go @@ -652,6 +652,12 @@ func (s *UserService) PatchUser(c *gin.Context) { return } + // Use map to update fields to avoid GORM ignoring zero values (false for bool) + updates := map[string]any{ + "name": user.Name, + "status": user.Status, + } + if user.Password != "" { var encPassword []byte encPassword, err = rdb.EncryptPassword(user.Password) @@ -660,22 +666,42 @@ func (s *UserService) PatchUser(c *gin.Context) { response.Error(c, response.ErrInternal, err) return } - // Use map to update fields to avoid GORM ignoring zero values (false for bool) - updates := map[string]any{ - "name": user.Name, - "status": user.Status, - "password": string(encPassword), - "password_change_required": false, + updates["password"] = string(encPassword) + updates["password_change_required"] = false + } + + // Role changes are an administrative action: they require users.edit, they may + // not grant privileges the caller does not hold, and nobody may change their + // own role, which would otherwise let the last administrator demote themselves. + if user.RoleID != 0 && user.RoleID != existingUser.RoleID { + if !slices.Contains(privs, "users.edit") || uhash == hash { + logger.FromContext(c).Errorf("error changing user role: permission not found") + response.Error(c, response.ErrNotPermitted, nil) + return } - err = s.db.Model(&existingUser).Updates(updates).Error - } else { - updates := map[string]any{ - "name": user.Name, - "status": user.Status, + + var privsCurrentUser, privsTargetRole []string + if privsCurrentUser, err = s.GetUserPrivileges(c, c.GetUint64("rid")); err != nil { + logger.FromContext(c).WithError(err).Errorf("error getting current user privileges") + response.Error(c, response.ErrInternal, err) + return + } + if privsTargetRole, err = s.GetUserPrivileges(c, user.RoleID); err != nil { + logger.FromContext(c).WithError(err).Errorf("error getting target role privileges") + response.Error(c, response.ErrInternal, err) + return } - err = s.db.Model(&existingUser).Updates(updates).Error + if !s.CheckPrivilege(c, privsCurrentUser, privsTargetRole) { + logger.FromContext(c).Errorf("error checking target role privileges") + response.Error(c, response.ErrNotPermitted, nil) + return + } + + updates["role_id"] = user.RoleID } + err = s.db.Model(&existingUser).Updates(updates).Error + if err != nil { logger.FromContext(c).WithError(err).Errorf("error updating user by hash '%s'", hash) response.Error(c, response.ErrInternal, err) diff --git a/backend/pkg/server/services/users_test.go b/backend/pkg/server/services/users_test.go index 0f6f6440f..4ff02fdab 100644 --- a/backend/pkg/server/services/users_test.go +++ b/backend/pkg/server/services/users_test.go @@ -553,3 +553,156 @@ func TestIsUniqueViolation(t *testing.T) { assert.False(t, isUniqueViolation(errors.New("connection refused"))) assert.False(t, isUniqueViolation(nil)) } + +// patchUserContext builds a request context for PatchUser as the caller identified +// by callerHash with the given role and privileges. +func patchUserContext( + t *testing.T, + target models.User, + roleID uint64, + callerID uint64, + callerHash string, + callerRoleID uint64, + privs []string, +) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + c.Set("uid", callerID) + c.Set("rid", callerRoleID) + c.Set("uhash", callerHash) + c.Set("prm", privs) + c.Params = gin.Params{{Key: "hash", Value: target.Hash}} + + payload := models.UserPassword{User: models.User{ + Hash: target.Hash, + ID: target.ID, + Mail: target.Mail, + Name: target.Name, + RoleID: roleID, + Status: target.Status, + Type: target.Type, + }} + + body, err := json.Marshal(payload) + require.NoError(t, err) + + c.Request, _ = http.NewRequest("PUT", "/users/"+target.Hash, bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + return c, w +} + +// seedPatchUser inserts a user the tests can then patch. +func seedPatchUser(t *testing.T, db *gorm.DB, hash, mail string, roleID uint64) models.User { + t.Helper() + + user := models.User{ + Hash: hash, + Mail: mail, + Name: "Target User", + RoleID: roleID, + Status: models.UserStatusActive, + Type: models.UserTypeLocal, + } + require.NoError(t, db.Create(&user).Error) + + return user +} + +func TestPatchUser_ChangesRoleWithEditPrivilege(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + service := NewUserService(db, auth.NewUserCache(db)) + target := seedPatchUser(t, db, "aa000000000000000000000000000001", "target-a@test.com", 2) + + c, w := patchUserContext(t, target, 1, 1, "bb000000000000000000000000000001", 1, []string{"users.edit"}) + service.PatchUser(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var updated models.User + require.NoError(t, db.Where("hash = ?", target.Hash).First(&updated).Error) + assert.Equal(t, uint64(1), updated.RoleID, "role should be updated to Admin") +} + +func TestPatchUser_RejectsRoleChangeWithoutEditPrivilege(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + service := NewUserService(db, auth.NewUserCache(db)) + target := seedPatchUser(t, db, "aa000000000000000000000000000002", "target-b@test.com", 2) + + // The caller may patch itself, but that does not extend to role changes. + c, w := patchUserContext(t, target, 1, target.ID, target.Hash, 2, []string{}) + service.PatchUser(c) + + assert.Equal(t, http.StatusForbidden, w.Code) + + var updated models.User + require.NoError(t, db.Where("hash = ?", target.Hash).First(&updated).Error) + assert.Equal(t, uint64(2), updated.RoleID, "role must stay unchanged") +} + +func TestPatchUser_RejectsSelfRoleChange(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + service := NewUserService(db, auth.NewUserCache(db)) + admin := seedPatchUser(t, db, "bb000000000000000000000000000002", "admin-self@test.com", 1) + + c, w := patchUserContext(t, admin, 2, admin.ID, admin.Hash, 1, []string{"users.edit"}) + service.PatchUser(c) + + assert.Equal(t, http.StatusForbidden, w.Code, "an administrator must not demote themselves") + + var updated models.User + require.NoError(t, db.Where("hash = ?", admin.Hash).First(&updated).Error) + assert.Equal(t, uint64(1), updated.RoleID) +} + +func TestPatchUser_RejectsPrivilegeEscalation(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // A role that can edit users but holds fewer privileges than Admin. + require.NoError(t, db.Exec("INSERT INTO roles (id, name) VALUES (3, 'Operator')").Error) + require.NoError(t, db.Exec(`INSERT INTO privileges (role_id, name) VALUES + (3, 'users.view'), (3, 'users.edit'), (3, 'roles.view')`).Error) + + service := NewUserService(db, auth.NewUserCache(db)) + target := seedPatchUser(t, db, "aa000000000000000000000000000003", "target-c@test.com", 2) + + // Operator tries to promote the target to Admin, which holds privileges the + // operator does not have. + c, w := patchUserContext(t, target, 1, 1, "cc000000000000000000000000000001", 3, []string{"users.edit"}) + service.PatchUser(c) + + assert.Equal(t, http.StatusForbidden, w.Code) + + var updated models.User + require.NoError(t, db.Where("hash = ?", target.Hash).First(&updated).Error) + assert.Equal(t, uint64(2), updated.RoleID, "privilege escalation must be refused") +} + +func TestPatchUser_KeepsRoleWhenUnchanged(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + service := NewUserService(db, auth.NewUserCache(db)) + target := seedPatchUser(t, db, "aa000000000000000000000000000004", "target-d@test.com", 2) + + c, w := patchUserContext(t, target, 2, 1, "bb000000000000000000000000000003", 1, []string{"users.edit"}) + service.PatchUser(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var updated models.User + require.NoError(t, db.Where("hash = ?", target.Hash).First(&updated).Error) + assert.Equal(t, uint64(2), updated.RoleID) + assert.Equal(t, "Target User", updated.Name) +} diff --git a/frontend/e2e/route-manifest.unit.test.ts b/frontend/e2e/route-manifest.unit.test.ts index d13807937..ca85ed835 100644 --- a/frontend/e2e/route-manifest.unit.test.ts +++ b/frontend/e2e/route-manifest.unit.test.ts @@ -25,6 +25,7 @@ const EXCLUDED: Record = { '/oauth/result': 'OAuth popup landing; only meaningful mid-OAuth-roundtrip', '/settings': 'redirects to /settings/account', '/settings/account': 'needs an account cassette + visual baseline before joining the sweep', + '/settings/users': 'needs a users cassette + visual baseline before joining the sweep', '/templates/new': 'create-mode variant of the template detail page', }; diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 1f078b94b..eb31429f1 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -48,6 +48,7 @@ const Templates = lazy(() => import('@/pages/templates/templates')); const OAuthResult = lazy(() => import('@/pages/oauth-result')); const SettingsAccount = lazy(() => import('@/pages/settings/settings-account')); const SettingsAPITokens = lazy(() => import('@/pages/settings/settings-api-tokens')); +const SettingsUsers = lazy(() => import('@/pages/settings/settings-users')); const SettingsPrompt = lazy(() => import('@/pages/settings/settings-prompt')); const SettingsPrompts = lazy(() => import('@/pages/settings/settings-prompts')); const SettingsProvider = lazy(() => import('@/pages/settings/settings-provider')); @@ -231,6 +232,11 @@ const router = createBrowserRouter( handle={routeTitles.apiTokens} path="api-tokens" /> + } + handle={routeTitles.users} + path="users" + /> ({ authState: { privileges: [] as string[] } })); + +vi.mock('@/providers/user-provider', () => ({ + useUser: () => ({ authInfo: { privileges: authState.privileges, type: 'user' } }), +})); + import { SettingsSidebar } from './settings-sidebar'; +beforeEach(() => { + authState.privileges = []; +}); + function renderSidebar(entry: { pathname: string; state?: unknown }) { return render( @@ -51,3 +61,19 @@ describe('SettingsSidebar "Back to App"', () => { expect(backToApp()).toHaveAttribute('href', '/dashboard'); }); }); + +describe('SettingsSidebar privileged items', () => { + it('hides Users from accounts without the users.view privilege', () => { + renderSidebar({ pathname: '/settings/account' }); + + expect(screen.queryByRole('link', { name: /Users/ })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Account/ })).toBeInTheDocument(); + }); + + it('shows Users once the account may view them', () => { + authState.privileges = ['users.view']; + renderSidebar({ pathname: '/settings/account' }); + + expect(screen.getByRole('link', { name: /Users/ })).toHaveAttribute('href', '/settings/users'); + }); +}); diff --git a/frontend/src/components/layouts/settings/settings-sidebar.tsx b/frontend/src/components/layouts/settings/settings-sidebar.tsx index 1ea570f22..c6ada8d6b 100644 --- a/frontend/src/components/layouts/settings/settings-sidebar.tsx +++ b/frontend/src/components/layouts/settings/settings-sidebar.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; -import { ArrowLeft, FileText, Key, Plug, Settings as SettingsIcon, User } from 'lucide-react'; +import { ArrowLeft, FileText, Key, Plug, Settings as SettingsIcon, User, Users } from 'lucide-react'; import { useState } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; @@ -17,11 +17,14 @@ import { } from '@/components/ui/sidebar'; import { routes } from '@/lib/routes'; import { getSafeReturnUrl } from '@/lib/utils/auth'; +import { useUser } from '@/providers/user-provider'; interface MenuItem { icon?: ReactNode; id: string; path: string; + // Privilege the account must hold for the item to be shown at all. + privilege?: string; title: string; } @@ -54,10 +57,20 @@ const menuItems: readonly MenuItem[] = [ path: routes.settings.apiTokens, title: 'API Tokens', }, + { + icon: , + id: 'users', + path: routes.settings.users, + privilege: 'users.view', + title: 'Users', + }, ] as const; export function SettingsSidebar() { const location = useLocation(); + const { authInfo } = useUser(); + const privileges = authInfo?.privileges ?? []; + const visibleItems = menuItems.filter((item) => !item.privilege || privileges.includes(item.privilege)); const [returnUrl] = useState(() => getSafeReturnUrl((location.state as null | { from?: string })?.from ?? null, routes.flows), ); @@ -80,7 +93,7 @@ export function SettingsSidebar() { - {menuItems.map((item) => ( + {visibleItems.map((item) => ( ; diff --git a/frontend/src/lib/routes.ts b/frontend/src/lib/routes.ts index 707bc4545..efe44c57b 100644 --- a/frontend/src/lib/routes.ts +++ b/frontend/src/lib/routes.ts @@ -44,6 +44,7 @@ export const routes = { provider: (id: string) => `/settings/providers/${id}`, providers: '/settings/providers', root: '/settings', + users: '/settings/users', }, template: (id: number | string) => `/templates/${id}`, diff --git a/frontend/src/pages/settings/settings-users.test.tsx b/frontend/src/pages/settings/settings-users.test.tsx new file mode 100644 index 000000000..284bcb440 --- /dev/null +++ b/frontend/src/pages/settings/settings-users.test.tsx @@ -0,0 +1,175 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SidebarProvider } from '@/components/ui/sidebar'; + +const { api, authState } = vi.hoisted(() => ({ + api: { + delete: vi.fn(), + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + }, + authState: { value: null as unknown }, +})); + +vi.mock('@/lib/axios', async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, api }; +}); +vi.mock('@/providers/user-provider', () => ({ useUser: () => ({ authInfo: authState.value }) })); + +import SettingsUsers from './settings-users'; + +// The page renders inside the settings shell, which supplies the sidebar and router context. +const renderPage = () => + render( + + + + + , + ); + +const admin = { + hash: 'bb000000000000000000000000000001', + id: 1, + mail: 'admin@pentagi.com', + name: 'admin', + password_change_required: false, + role_id: 1, + status: 'active' as const, + type: 'local' as const, +}; + +const ssoUser = { + created_at: '2026-08-25T10:00:00Z', + hash: 'aa000000000000000000000000000001', + id: 2, + mail: 'sso-user@example.com', + name: 'sso-user', + password_change_required: false, + provider: 'oidc', + role_id: 2, + status: 'active' as const, + type: 'oauth' as const, +}; + +const roles = [ + { id: 1, name: 'Admin' }, + { id: 2, name: 'User' }, +]; + +const setAuth = (privileges: string[]) => { + authState.value = { + privileges, + role: roles[0], + type: 'user', + user: { ...admin, created_at: '2026-08-01T00:00:00Z' }, + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + api.get.mockImplementation((url: string) => + url.startsWith('/users/') + ? Promise.resolve({ + data: { total: 2, users: [{ ...admin, created_at: '2026-08-01T00:00:00Z' }, ssoUser] }, + status: 'success', + }) + : Promise.resolve({ data: { roles, total: 2 }, status: 'success' }), + ); + api.put.mockResolvedValue({ data: ssoUser, status: 'success' }); + api.delete.mockResolvedValue({ data: {}, status: 'success' }); +}); + +describe('SettingsUsers access control', () => { + it('refuses to show anything without the users.view privilege', async () => { + setAuth(['flows.view']); + renderPage(); + + expect(await screen.findByText('Not available')).toBeInTheDocument(); + expect(api.get).not.toHaveBeenCalled(); + }); + + it('lists users for an account holding users.view', async () => { + setAuth(['users.view']); + renderPage(); + + expect(await screen.findByText('sso-user@example.com')).toBeInTheDocument(); + expect(screen.getByText('admin@pentagi.com')).toBeInTheDocument(); + // The sign-in column shows where the account comes from. + expect(screen.getByText('oidc')).toBeInTheDocument(); + }); + + it('keeps roles read-only without users.edit', async () => { + setAuth(['users.view']); + renderPage(); + + await screen.findByText('sso-user@example.com'); + + expect(screen.queryByRole('combobox', { name: 'Role of sso-user@example.com' })).not.toBeInTheDocument(); + expect(screen.getAllByText('User').length).toBeGreaterThan(0); + }); +}); + +describe('SettingsUsers role management', () => { + it('offers a role selector for other users and saves the change', async () => { + const user = userEvent.setup(); + setAuth(['users.view', 'users.edit']); + renderPage(); + + await screen.findByText('sso-user@example.com'); + + const roleSelect = screen.getByRole('combobox', { name: 'Role of sso-user@example.com' }); + await user.click(roleSelect); + await user.click(await screen.findByRole('option', { name: 'Admin' })); + + await waitFor(() => + expect(api.put).toHaveBeenCalledWith( + `/users/${ssoUser.hash}`, + expect.objectContaining({ hash: ssoUser.hash, role_id: 1 }), + ), + ); + }); + + it('never offers to change your own role', async () => { + setAuth(['users.view', 'users.edit']); + renderPage(); + + await screen.findByText('admin@pentagi.com'); + + expect(screen.queryByRole('combobox', { name: 'Role of admin@pentagi.com' })).not.toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Role of sso-user@example.com' })).toBeInTheDocument(); + }); + + it('hides row actions when the account may neither edit nor delete', async () => { + setAuth(['users.view']); + renderPage(); + + await screen.findByText('sso-user@example.com'); + + expect(screen.queryByRole('button', { name: 'Actions for sso-user@example.com' })).not.toBeInTheDocument(); + }); + + it('hides the create action without users.create', async () => { + setAuth(['users.view', 'users.edit']); + renderPage(); + + await screen.findByText('sso-user@example.com'); + + expect(screen.queryByRole('button', { name: /add user/i })).not.toBeInTheDocument(); + }); + + it('shows the create action for users.create', async () => { + setAuth(['users.view', 'users.create']); + renderPage(); + + await screen.findByText('sso-user@example.com'); + + expect(screen.getByRole('button', { name: /add user/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/settings/settings-users.tsx b/frontend/src/pages/settings/settings-users.tsx new file mode 100644 index 000000000..48f495447 --- /dev/null +++ b/frontend/src/pages/settings/settings-users.tsx @@ -0,0 +1,577 @@ +import type { ColumnDef } from '@tanstack/react-table'; + +import { Ellipsis, Plus, ShieldOff, Trash, Users as UsersIcon } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import * as z from 'zod'; + +import type { Role } from '@/models/info'; +import type { User } from '@/models/user'; + +import { + AppHeader, + AppHeaderAction, + AppHeaderActions, + AppHeaderContent, + AppHeaderTitle, +} from '@/components/layouts/app/app-header'; +import ConfirmationDialog from '@/components/shared/confirmation-dialog'; +import { ErrorState } from '@/components/shared/error-state'; +import { LoadingState } from '@/components/shared/loading-state'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { DataTable, DataTableColumnHeader } from '@/components/ui/data-table'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { FormSubmitButton } from '@/components/ui/form-submit-button'; +import { Input } from '@/components/ui/input'; +import { InputPassword } from '@/components/ui/input-password'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useAppForm } from '@/hooks/use-app-form'; +import { api, getApiErrorMessage, unwrapApiResponse } from '@/lib/axios'; +import { formatDate } from '@/lib/utils/format'; +import { useUser } from '@/providers/user-provider'; + +// The list endpoints are paginated; PentAGI installations hold few accounts, so +// one generous page keeps the table simple and still shows everyone. +const PAGE_SIZE = 1000; +const LIST_QUERY = `?page=1&pageSize=${PAGE_SIZE}&type=init`; + +interface CreateUserDialogProps { + onCreated: () => void; + onOpenChange: (open: boolean) => void; + open: boolean; + roles: Role[]; +} + +interface RolesResponse { + roles: Role[]; + total: number; +} + +interface UsersResponse { + total: number; + users: User[]; +} + +const createUserSchema = z.object({ + mail: z.string().min(1, { message: 'Email is required' }).email({ message: 'Invalid email' }), + name: z.string().min(1, { message: 'Name is required' }).max(70, { message: 'Name is too long' }), + password: z.string().min(8, { message: 'Password must be at least 8 characters' }), + role_id: z.string().min(1, { message: 'Role is required' }), +}); + +type CreateUserValues = z.infer; + +const statusVariants: Record = { + active: 'default', + blocked: 'destructive', + created: 'secondary', +}; + +function CreateUserDialog({ onCreated, onOpenChange, open, roles }: CreateUserDialogProps) { + const form = useAppForm({ + defaultValues: { mail: '', name: '', password: '', role_id: '' }, + schema: createUserSchema, + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (values: CreateUserValues) => { + setIsSubmitting(true); + + try { + const response = await api.post('/users/', { + mail: values.mail, + name: values.name, + password: values.password, + role_id: Number(values.role_id), + status: 'active', + type: 'local', + }); + + unwrapApiResponse(response); + toast.success(`User ${values.mail} created`); + form.reset(); + onOpenChange(false); + onCreated(); + } catch (err) { + toast.error( + getApiErrorMessage(err, 'Failed to create the user', { + 403: 'You are not allowed to create users with this role', + }), + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + Add user + + Creates a local account. Accounts signing in through an identity provider appear here on their + first login. + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Role + + + + )} + /> + Create + + +
+
+ ); +} + +function SettingsUsers() { + const { authInfo } = useUser(); + const privileges = useMemo(() => authInfo?.privileges ?? [], [authInfo?.privileges]); + const canView = privileges.includes('users.view'); + const canEdit = privileges.includes('users.edit'); + const canCreate = privileges.includes('users.create'); + const canDelete = privileges.includes('users.delete'); + const currentUserHash = authInfo?.user?.hash; + + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [isLoading, setIsLoading] = useState(canView); + const [error, setError] = useState(null); + const [pendingHash, setPendingHash] = useState(null); + const [userToDelete, setUserToDelete] = useState(null); + const [isCreateOpen, setIsCreateOpen] = useState(false); + // Bumped by the retry button and after a user is created, to refetch both lists. + const [reloadToken, setReloadToken] = useState(0); + + const reload = useCallback(() => { + setIsLoading(true); + setError(null); + setReloadToken((token) => token + 1); + }, []); + + useEffect(() => { + if (!canView) { + return; + } + + let isCancelled = false; + + const load = async () => { + try { + const [usersResponse, rolesResponse] = await Promise.all([ + api.get(`/users/${LIST_QUERY}`), + api.get(`/roles/${LIST_QUERY}`), + ]); + + if (isCancelled) { + return; + } + + setUsers(unwrapApiResponse(usersResponse).users ?? []); + setRoles(unwrapApiResponse(rolesResponse).roles ?? []); + } catch (err) { + if (!isCancelled) { + setError(getApiErrorMessage(err, 'Failed to load users')); + } + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + }; + + void load(); + + return () => { + isCancelled = true; + }; + }, [canView, reloadToken]); + + const roleName = useCallback( + (roleId: number) => roles.find((role) => role.id === roleId)?.name ?? `Role #${roleId}`, + [roles], + ); + + const patchUser = useCallback( + async (user: User, changes: Partial>, successMessage: string) => { + setPendingHash(user.hash); + + try { + const response = await api.put(`/users/${user.hash}`, { ...user, ...changes }); + + unwrapApiResponse(response); + setUsers((current) => + current.map((item) => (item.hash === user.hash ? { ...item, ...changes } : item)), + ); + toast.success(successMessage); + } catch (err) { + toast.error( + getApiErrorMessage(err, 'Failed to update the user', { + 403: 'You are not allowed to make this change', + }), + ); + } finally { + setPendingHash(null); + } + }, + [], + ); + + const deleteUser = useCallback(async (user: User) => { + setPendingHash(user.hash); + + try { + const response = await api.delete(`/users/${user.hash}`); + + unwrapApiResponse(response); + setUsers((current) => current.filter((item) => item.hash !== user.hash)); + toast.success(`User ${user.mail} deleted`); + } catch (err) { + toast.error( + getApiErrorMessage(err, 'Failed to delete the user', { + 403: 'You are not allowed to delete this user', + }), + ); + } finally { + setPendingHash(null); + setUserToDelete(null); + } + }, []); + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'name', + cell: ({ row }) => {row.original.name || '—'}, + header: ({ column }) => ( + + ), + }, + { + accessorKey: 'mail', + header: ({ column }) => ( + + ), + }, + { + accessorKey: 'type', + cell: ({ row }) => ( + + {row.original.type === 'oauth' ? (row.original.provider ?? 'oauth') : 'local'} + + ), + header: ({ column }) => ( + + ), + }, + { + accessorKey: 'role_id', + cell: ({ row }) => { + const user = row.original; + // Changing your own role is refused by the API — it would let the + // last administrator lock everyone out — so it is not offered here. + const isSelf = user.hash === currentUserHash; + + if (!canEdit || isSelf) { + return {roleName(user.role_id)}; + } + + return ( + + ); + }, + header: ({ column }) => ( + + ), + }, + { + accessorKey: 'status', + cell: ({ row }) => {row.original.status}, + header: ({ column }) => ( + + ), + }, + { + accessorKey: 'created_at', + cell: ({ row }) => ( + {formatDate(new Date(row.original.created_at))} + ), + header: ({ column }) => ( + + ), + }, + { + cell: ({ row }) => { + const user = row.original; + const isSelf = user.hash === currentUserHash; + const canBlock = canEdit && !isSelf; + const canRemove = canDelete && !isSelf; + + if (!canBlock && !canRemove) { + return null; + } + + return ( + + + + + + {canBlock && ( + + void patchUser( + user, + { status: user.status === 'blocked' ? 'active' : 'blocked' }, + user.status === 'blocked' + ? `${user.mail} unblocked` + : `${user.mail} blocked`, + ) + } + > + + {user.status === 'blocked' ? 'Unblock' : 'Block'} + + )} + {canRemove && ( + setUserToDelete(user)} + variant="destructive" + > + + Delete + + )} + + + ); + }, + id: 'actions', + }, + ], + [canDelete, canEdit, currentUserHash, patchUser, pendingHash, roleName, roles], + ); + + if (!canView) { + return ( + <> + + + Users + + + + + + + + Not available + + Managing users requires administrator privileges on this account. + + + + + ); + } + + return ( + <> + + + Users + + {canCreate && ( + + } + label="Add User" + onClick={() => setIsCreateOpen(true)} + /> + + )} + + + {isLoading && } + {!isLoading && error && ( + + )} + {!isLoading && !error && ( +
+ +
+ )} + + + + (userToDelete ? deleteUser(userToDelete) : undefined)} + handleOpenChange={(isOpen: boolean) => !isOpen && setUserToDelete(null)} + isOpen={!!userToDelete} + title="Delete user?" + /> + + ); +} + +export default SettingsUsers;