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;