Restructures frontend to a feature-based folder layout
Continuous Integration / config (pull_request) Successful in 11s
Continuous Integration / changes (pull_request) Successful in 21s
Continuous Integration / backend-build (pull_request) Skipped
Continuous Integration / backend-test (pull_request) Skipped
Continuous Integration / vulnerability-scan (pull_request) Skipped
Continuous Integration / frontend-prepare (pull_request) Successful in 1m37s
Continuous Integration / frontend-build (pull_request) Successful in 2m12s
Continuous Integration / frontend-test (pull_request) Successful in 4m42s
Continuous Integration / frontend-lint (pull_request) Successful in 2m2s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 6m51s
Continuous Integration / deploy-test (pull_request) Skipped

Groups auth, setup, invitation, profile, users, cms, availability and
system code (services/hooks, components, schemas, mocks, pages) under
src/features/<name> instead of splitting by technical layer (api/,
components/, lib/schemas/, mocks/, pages/). Renames the old
connection-oriented `api` layer to `services` per feature, and splits
the monolithic api/types.ts into per-feature types.ts files (with
ProblemDetails/ApiResult merged into lib/api-client.ts as shared
infra). Layout-agnostic code (ui primitives, app shell, i18n, test
utils, lib) stays at the top level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 22:30:37 +02:00
co-authored by Claude Sonnet 5
parent 650cf09cfa
commit 11ec08aac3
83 changed files with 249 additions and 254 deletions
@@ -0,0 +1,63 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import type { UserListItem } from '@/features/users/services/types';
interface DeleteUserDialogProps {
user: UserListItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isPending: boolean;
}
export function DeleteUserDialog({
user,
open,
onOpenChange,
onConfirm,
isPending,
}: DeleteUserDialogProps) {
const { t } = useTranslation();
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="delete-user-dialog">
<DialogHeader>
<DialogTitle>{t('users.delete.title')}</DialogTitle>
<DialogDescription>
{user.invitationPending
? t('users.delete.descriptionPending', { email: user.email })
: t('users.delete.description', { name: user.name, email: user.email })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
data-testid="delete-user-cancel"
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isPending}
data-testid="delete-user-confirm"
>
{isPending ? '...' : t('users.delete.confirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,118 @@
import { http, HttpResponse } from 'msw';
import type { UserListItem } from '@/features/users/services/types';
import { API_BASE, mockUser } from '@/features/auth/mocks/fixtures';
let mockUsers: UserListItem[] = [
{
...mockUser,
createdAt: '2026-01-01T00:00:00.000Z',
invitationPending: false,
inviteLink: null,
},
{
id: '22222222-2222-2222-2222-222222222222',
email: 'admin@example.com',
name: 'Admin User',
role: 'Administrator',
isActive: true,
createdAt: '2026-01-15T00:00:00.000Z',
invitationPending: false,
inviteLink: null,
},
{
id: '33333333-3333-3333-3333-333333333333',
email: 'pending@example.com',
name: 'pending@example.com',
role: 'User',
isActive: true,
createdAt: '2026-06-01T00:00:00.000Z',
invitationPending: true,
inviteLink: '/invite/complete?token=pending-mock-token',
},
];
export const resetMockUsers = () => {
mockUsers = [
{
...mockUser,
createdAt: '2026-01-01T00:00:00.000Z',
invitationPending: false,
inviteLink: null,
},
{
id: '22222222-2222-2222-2222-222222222222',
email: 'admin@example.com',
name: 'Admin User',
role: 'Administrator',
isActive: true,
createdAt: '2026-01-15T00:00:00.000Z',
invitationPending: false,
inviteLink: null,
},
{
id: '33333333-3333-3333-3333-333333333333',
email: 'pending@example.com',
name: 'pending@example.com',
role: 'User',
isActive: true,
createdAt: '2026-06-01T00:00:00.000Z',
invitationPending: true,
inviteLink: '/invite/complete?token=pending-mock-token',
},
];
};
export const getMockUsers = () => mockUsers;
export const userHandlers = [
http.get(`${API_BASE}/api/v1/Users`, () => HttpResponse.json(mockUsers)),
http.post(`${API_BASE}/api/v1/Users/invite`, async ({ request }) => {
const body = (await request.json()) as { email: string; role: string };
const token = `mock-token-${Date.now()}`;
const inviteLink = `/invite/complete?token=${token}`;
const newUser: UserListItem = {
id: crypto.randomUUID(),
email: body.email,
name: body.email,
role: body.role as UserListItem['role'],
isActive: true,
createdAt: new Date().toISOString(),
invitationPending: true,
inviteLink,
};
mockUsers = [...mockUsers, newUser];
return HttpResponse.json({ inviteLink });
}),
http.put(`${API_BASE}/api/v1/Users/:userId/role`, () => new HttpResponse(null, { status: 200 })),
http.put(`${API_BASE}/api/v1/Users/:userId/active`, () => new HttpResponse(null, { status: 200 })),
http.put(`${API_BASE}/api/v1/Users/me`, async ({ request }) => {
const body = (await request.json()) as { name: string; email: string };
const owner = mockUsers.find((u) => u.role === 'Owner') ?? mockUsers[0];
const updated: UserListItem = {
...owner,
name: body.name,
email: body.email,
};
mockUsers = mockUsers.map((u) => (u.id === owner.id ? updated : u));
return HttpResponse.json(updated);
}),
http.delete(`${API_BASE}/api/v1/Users/:userId`, ({ params }) => {
const { userId } = params as { userId: string };
const ownerUser = mockUsers.find((u) => u.role === 'Owner');
if (userId === ownerUser?.id) {
return HttpResponse.json(
{ title: 'Bad Request', detail: 'At least one Owner must remain.', status: 400 },
{ status: 400 },
);
}
mockUsers = mockUsers.filter((u) => u.id !== userId);
return new HttpResponse(null, { status: 204 });
}),
];
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { getMockUsers } from '@/features/users/mocks/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
});
const USERS_URL = `${API_BASE}/api/v1/Users`;
describe('UsersPage', () => {
it('renders the page title and invite button', async () => {
mockAuthenticated();
renderApp('/users');
expect(await screen.findByTestId('users-page', {}, { timeout: 10000 })).toBeInTheDocument();
expect(screen.getByTestId('users-invite-button')).toBeInTheDocument();
});
it('renders the users table with all mock users', async () => {
mockAuthenticated();
renderApp('/users');
const table = await screen.findByTestId('users-table');
expect(table).toBeInTheDocument();
for (const user of getMockUsers()) {
expect(screen.getByTestId(`user-row-${user.id}`)).toBeInTheDocument();
}
});
it('shows "Pending invite" badge for invitation-pending users', async () => {
mockAuthenticated();
renderApp('/users');
await screen.findByTestId('users-table');
const pendingUser = getMockUsers().find((u) => u.invitationPending)!;
const row = screen.getByTestId(`user-row-${pendingUser.id}`);
expect(within(row).getByText('Pending invite')).toBeInTheDocument();
});
it('shows copy invite link action for pending user', async () => {
mockAuthenticated();
renderApp('/users');
await screen.findByTestId('users-table');
const pendingUser = getMockUsers().find((u) => u.invitationPending)!;
const actionsBtn = screen.getByTestId(`user-actions-${pendingUser.id}`);
await userEvent.click(actionsBtn);
expect(screen.getByTestId(`user-copy-link-${pendingUser.id}`)).toBeInTheDocument();
});
it('shows an error message when the API fails', async () => {
mockAuthenticated();
server.use(http.get(USERS_URL, () => HttpResponse.json({}, { status: 500 })));
renderApp('/users');
await screen.findByTestId('users-page');
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
it('opens the invite dialog when the invite button is clicked', async () => {
mockAuthenticated();
renderApp('/users');
await screen.findByTestId('users-invite-button');
await userEvent.click(screen.getByTestId('users-invite-button'));
expect(screen.getByTestId('invite-dialog-email')).toBeInTheDocument();
});
});
@@ -0,0 +1,441 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { MoreHorizontal, Link as LinkIcon, Trash2, UserX, UserCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { InviteUserDialog } from '@/features/invitation/components/InviteUserDialog';
import { DeleteUserDialog } from '@/features/users/components/DeleteUserDialog';
import { RoleBadge } from '@/components/shared/RoleBadge';
import { useUsers, useChangeRole, useSetUserActive, useDeleteUser } from '@/features/users/services/useUsers';
import { useAuth } from '@/features/auth/context/auth-context';
import { Trans } from 'react-i18next';
import { ProblemDetailsError } from '@/lib/api-client';
import type { UserRole } from '@/features/auth/services/types';
import type { UserListItem } from '@/features/users/services/types';
function statusBadgeVariant(user: UserListItem) {
if (user.invitationPending) return 'outline';
if (!user.isActive) return 'destructive';
return 'secondary';
}
function statusLabel(user: UserListItem, t: (key: string) => string) {
if (user.invitationPending) return t('users.status.pendingInvite');
if (!user.isActive) return t('users.status.inactive');
return t('users.status.active');
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { dateStyle: 'medium' });
}
export function UsersPage() {
const { t } = useTranslation();
const { user: currentUser, logout } = useAuth();
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<UserListItem | null>(null);
const [selfRoleChangeTarget, setSelfRoleChangeTarget] = useState<{
userId: string;
newRole: UserRole;
} | null>(null);
const [ownerAssignTarget, setOwnerAssignTarget] = useState<{
userId: string;
userName: string;
} | null>(null);
const { data: users, isPending, isError } = useUsers();
const changeRole = useChangeRole();
const setUserActive = useSetUserActive();
const deleteUser = useDeleteUser();
const availableRolesFor = (user: UserListItem): UserRole[] => {
const isSelf = user.id === currentUser?.id;
if (currentUser?.role === 'Owner') return ['Owner', 'Administrator', 'User'];
if (currentUser?.role === 'Administrator') {
// Admins can demote themselves to User; they cannot touch other Admins or Owners
if (isSelf) return ['Administrator', 'User'];
if (user.role === 'User') return ['Administrator'];
}
return [];
};
const isOwnerAction = (user: UserListItem) =>
currentUser?.role === 'Owner' && user.id !== currentUser.id;
const canToggleActive = (user: UserListItem) =>
isOwnerAction(user) && !user.invitationPending;
const canDelete = (user: UserListItem) => isOwnerAction(user);
const hasAnyActions = (user: UserListItem) =>
user.invitationPending ||
availableRolesFor(user).length > 0 ||
canToggleActive(user) ||
canDelete(user);
const handleCopyInviteLink = async (user: UserListItem) => {
if (!user.inviteLink) return;
await navigator.clipboard.writeText(`${window.location.origin}${user.inviteLink}`);
toast.success(t('users.actions.linkCopied'));
};
const handleRoleChange = (user: UserListItem, newRole: UserRole) => {
if (user.id === currentUser?.id) {
setSelfRoleChangeTarget({ userId: user.id, newRole });
return;
}
if (newRole === 'Owner') {
setOwnerAssignTarget({ userId: user.id, userName: user.name });
return;
}
void executeRoleChange(user.id, newRole);
};
const executeRoleChange = async (userId: string, newRole: UserRole) => {
try {
await changeRole.mutateAsync({ userId, newRole });
} catch (err) {
if (err instanceof ProblemDetailsError) {
if (err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
} else if (err.status === 403) {
toast.error(t('users.errors.insufficientPermissions'));
} else {
toast.error(t('errors.generic'));
}
} else {
toast.error(t('errors.generic'));
}
}
};
const handleSelfRoleChangeConfirm = async () => {
if (!selfRoleChangeTarget) return;
try {
await changeRole.mutateAsync(selfRoleChangeTarget);
setSelfRoleChangeTarget(null);
await logout();
} catch (err) {
setSelfRoleChangeTarget(null);
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
} else {
toast.error(t('errors.generic'));
}
}
};
const handleOwnerAssignConfirm = async () => {
if (!ownerAssignTarget) return;
try {
await changeRole.mutateAsync({ userId: ownerAssignTarget.userId, newRole: 'Owner' });
setOwnerAssignTarget(null);
} catch (err) {
setOwnerAssignTarget(null);
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
} else if (err instanceof ProblemDetailsError && err.status === 403) {
toast.error(t('users.errors.insufficientPermissions'));
} else {
toast.error(t('errors.generic'));
}
}
};
const handleSetActive = async (user: UserListItem, isActive: boolean) => {
try {
await setUserActive.mutateAsync({ userId: user.id, isActive });
toast.success(
isActive
? t('users.active.successActivated', { name: user.name })
: t('users.active.successDeactivated', { name: user.name }),
);
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
} else {
toast.error(t('errors.generic'));
}
}
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
try {
await deleteUser.mutateAsync(deleteTarget.id);
toast.success(t('users.delete.success', { name: deleteTarget.name }));
setDeleteTarget(null);
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 400) {
toast.error(t('users.errors.lastOwnerRequired'));
setDeleteTarget(null);
} else {
toast.error(t('errors.generic'));
}
}
};
return (
<div className="space-y-4" data-testid="users-page">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold" data-testid="users-title">
{t('users.title')}
</h1>
<Button onClick={() => setInviteDialogOpen(true)} data-testid="users-invite-button">
{t('users.inviteButton')}
</Button>
</div>
{isPending && (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
)}
{isError && (
<p className="text-sm text-destructive">{t('errors.generic')}</p>
)}
{users && (
<Table data-testid="users-table">
<TableHeader>
<TableRow>
<TableHead>{t('users.table.name')}</TableHead>
<TableHead>{t('users.table.email')}</TableHead>
<TableHead>{t('users.table.role')}</TableHead>
<TableHead>{t('users.table.status')}</TableHead>
<TableHead>{t('users.table.createdAt')}</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => {
const roles = availableRolesFor(user);
const toggleActive = canToggleActive(user);
const deletable = canDelete(user);
const showActions = hasAnyActions(user);
return (
<TableRow key={user.id} data-testid={`user-row-${user.id}`}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<RoleBadge role={user.role} />
</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant(user)}>
{statusLabel(user, t)}
</Badge>
</TableCell>
<TableCell>{formatDate(user.createdAt)}</TableCell>
<TableCell>
{showActions && <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t('users.table.actions')}
data-testid={`user-actions-${user.id}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{t('users.table.actions')}
</DropdownMenuLabel>
{user.invitationPending && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleCopyInviteLink(user)}
data-testid={`user-copy-link-${user.id}`}
>
<LinkIcon className="mr-2 h-4 w-4" />
{t('users.actions.copyInviteLink')}
</DropdownMenuItem>
</>
)}
{roles.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger
data-testid={`user-change-role-${user.id}`}
>
{t('users.actions.changeRole')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{roles.map((role) => (
<DropdownMenuItem
key={role}
disabled={role === user.role}
onClick={() =>
handleRoleChange(user, role)
}
>
{t(`users.roles.${role}`)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
{toggleActive && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
handleSetActive(user, !user.isActive)
}
data-testid={`user-toggle-active-${user.id}`}
>
{user.isActive ? (
<>
<UserX className="mr-2 h-4 w-4" />
{t('users.actions.deactivate')}
</>
) : (
<>
<UserCheck className="mr-2 h-4 w-4" />
{t('users.actions.activate')}
</>
)}
</DropdownMenuItem>
</>
)}
{deletable && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteTarget(user)}
data-testid={`user-delete-${user.id}`}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('users.actions.delete')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
<InviteUserDialog open={inviteDialogOpen} onOpenChange={setInviteDialogOpen} />
<Dialog
open={selfRoleChangeTarget !== null}
onOpenChange={(open) => { if (!open) setSelfRoleChangeTarget(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.selfRoleChange.title')}</DialogTitle>
<DialogDescription asChild>
<div>
<Trans
i18nKey="users.selfRoleChange.description"
values={{
role: selfRoleChangeTarget
? t(`users.roles.${selfRoleChangeTarget.newRole}`)
: '',
}}
components={{ strong: <strong /> }}
/>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSelfRoleChangeTarget(null)}
>
{t('common.cancel')}
</Button>
<Button
onClick={handleSelfRoleChangeConfirm}
disabled={changeRole.isPending}
>
{t('users.selfRoleChange.confirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={ownerAssignTarget !== null}
onOpenChange={(open) => { if (!open) setOwnerAssignTarget(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.ownerAssign.title')}</DialogTitle>
<DialogDescription asChild>
<div>
<Trans
i18nKey="users.ownerAssign.description"
values={{ name: ownerAssignTarget?.userName ?? '' }}
components={{ strong: <strong /> }}
/>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOwnerAssignTarget(null)}
>
{t('common.cancel')}
</Button>
<Button
onClick={handleOwnerAssignConfirm}
disabled={changeRole.isPending}
>
{t('users.ownerAssign.confirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteUserDialog
user={deleteTarget}
open={deleteTarget !== null}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
onConfirm={handleDeleteConfirm}
isPending={deleteUser.isPending}
/>
</div>
);
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { inviteUserSchema } from './users';
describe('inviteUserSchema', () => {
it('accepts valid email and role', () => {
const result = inviteUserSchema.safeParse({ email: 'user@example.com', role: 'Administrator' });
expect(result.success).toBe(true);
});
it('rejects invalid email', () => {
const result = inviteUserSchema.safeParse({ email: 'not-an-email', role: 'User' });
expect(result.success).toBe(false);
});
it('rejects missing role', () => {
const result = inviteUserSchema.safeParse({ email: 'user@example.com' });
expect(result.success).toBe(false);
});
it('rejects invalid role value', () => {
const result = inviteUserSchema.safeParse({ email: 'user@example.com', role: 'Owner' });
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,8 @@
import { z } from 'zod';
export const inviteUserSchema = z.object({
email: z.string().email(),
role: z.enum(['Administrator', 'User']),
});
export type InviteUserFormData = z.infer<typeof inviteUserSchema>;
@@ -0,0 +1,20 @@
import type { User, UserRole } from '@/features/auth/services/types';
export interface UserListItem extends User {
createdAt: string;
invitationPending: boolean;
inviteLink: string | null;
}
export interface InviteUserPayload {
email: string;
role: Exclude<UserRole, 'Owner'>;
}
export interface InviteUserResponse {
inviteLink: string;
}
export interface ChangeRolePayload {
newRole: UserRole;
}
@@ -0,0 +1,168 @@
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { getMockUsers } from '@/features/users/mocks/handlers';
import { useUsers, useInviteUser, useChangeRole, useSetUserActive, useDeleteUser } from './useUsers';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
const USERS_URL = `${API_BASE}/api/v1/Users`;
describe('useUsers', () => {
it('starts in loading state', () => {
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
expect(result.current.isPending).toBe(true);
});
it('returns the users list on success', async () => {
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.isError).toBe(false);
expect(result.current.data).toHaveLength(getMockUsers().length);
expect(result.current.data?.[0].email).toBe(getMockUsers()[0].email);
});
it('enters error state on 5xx', async () => {
server.use(http.get(USERS_URL, () => HttpResponse.json({}, { status: 500 })));
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.isError).toBe(true);
});
});
describe('useInviteUser', () => {
it('returns inviteLink on successful mutation', async () => {
const { result } = renderHook(() => useInviteUser(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ email: 'new@example.com', role: 'User' });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.inviteLink).toContain('/invite/complete');
});
it('fails when server returns 400', async () => {
server.use(
http.post(`${USERS_URL}/invite`, () =>
HttpResponse.json({ detail: 'already exists' }, { status: 400 }),
),
);
const { result } = renderHook(() => useInviteUser(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ email: 'dup@example.com', role: 'User' }).catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useChangeRole', () => {
it('succeeds when server returns 200', async () => {
const { result } = renderHook(() => useChangeRole(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
userId: '22222222-2222-2222-2222-222222222222',
newRole: 'User',
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('fails when server returns 403', async () => {
server.use(
http.put(`${USERS_URL}/:userId/role`, () => new HttpResponse(null, { status: 403 })),
);
const { result } = renderHook(() => useChangeRole(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync({ userId: 'any-id', newRole: 'Administrator' })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useSetUserActive', () => {
it('succeeds when server returns 200', async () => {
const { result } = renderHook(() => useSetUserActive(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
userId: '22222222-2222-2222-2222-222222222222',
isActive: false,
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('fails when server returns 400', async () => {
server.use(
http.put(`${USERS_URL}/:userId/active`, () => new HttpResponse(null, { status: 400 })),
);
const { result } = renderHook(() => useSetUserActive(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync({ userId: 'any-id', isActive: false })
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useDeleteUser', () => {
it('succeeds when server returns 204', async () => {
const { result } = renderHook(() => useDeleteUser(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync('22222222-2222-2222-2222-222222222222');
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('fails when server returns 400 (last owner)', async () => {
server.use(
http.delete(`${USERS_URL}/:userId`, () =>
HttpResponse.json(
{ title: 'Bad Request', detail: 'At least one Owner must remain.', status: 400 },
{ status: 400 },
),
),
);
const { result } = renderHook(() => useDeleteUser(), { wrapper: createWrapper() });
await act(async () => {
await result.current
.mutateAsync('11111111-1111-1111-1111-111111111111')
.catch(() => {});
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
@@ -0,0 +1,49 @@
import { useQuery, useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { UserListItem, InviteUserPayload, InviteUserResponse, ChangeRolePayload } from './types';
import type { UserRole } from '@/features/auth/services/types';
export function useUsers() {
return useQuery<UserListItem[], Error>({
queryKey: ['users'],
queryFn: () => api.get<UserListItem[]>('/api/v1/Users'),
staleTime: 30_000,
});
}
export function useInviteUser(
options?: Pick<UseMutationOptions<InviteUserResponse, Error, InviteUserPayload>, 'onError'>,
) {
const queryClient = useQueryClient();
return useMutation<InviteUserResponse, Error, InviteUserPayload>({
mutationFn: (data) => api.post<InviteUserResponse>('/api/v1/Users/invite', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
...options,
});
}
export function useChangeRole() {
const queryClient = useQueryClient();
return useMutation<void, Error, { userId: string; newRole: UserRole }>({
mutationFn: ({ userId, newRole }: { userId: string; newRole: UserRole }) =>
api.put<void>(`/api/v1/Users/${userId}/role`, { newRole } satisfies ChangeRolePayload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}
export function useSetUserActive() {
const queryClient = useQueryClient();
return useMutation<void, Error, { userId: string; isActive: boolean }>({
mutationFn: ({ userId, isActive }) =>
api.put<void>(`/api/v1/Users/${userId}/active`, { isActive }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (userId: string) => api.delete<void>(`/api/v1/Users/${userId}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}