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
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:
@@ -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'] }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user