Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions app/backoffice/users/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import * as React from 'react';
import { render, screen, within, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, it, expect, vi, beforeEach } from 'vitest';

import type { PaginatedResponse, UserListItem } from '@/types/backoffice';

const push = vi.fn();
vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) }));

const startImpersonation = vi.fn();
let mockAuthUser: { id: number; username: string } | null = { id: 1, username: 'admin' };
vi.mock('@/contexts/auth-context', () => ({
useAuth: () => ({
token: 'admin-token',
user: mockAuthUser,
startImpersonation,
}),
}));

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

const getUsers = vi.fn();
const createUser = vi.fn();
const updateUser = vi.fn();
const deleteUser = vi.fn();
const impersonateUser = vi.fn();
vi.mock('@/services/backoffice/users', () => ({
getUsers: (...args: unknown[]) => getUsers(...args),
createUser: (...args: unknown[]) => createUser(...args),
updateUser: (...args: unknown[]) => updateUser(...args),
deleteUser: (...args: unknown[]) => deleteUser(...args),
impersonateUser: (...args: unknown[]) => impersonateUser(...args),
}));

import { toast } from 'sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import UsersPage from './page';

function baseUser(overrides: Partial<UserListItem>): UserListItem {
return {
id: 0,
username: '',
email: '',
first_name: '',
last_name: '',
is_staff: false,
is_superuser: false,
is_active: true,
date_joined: '2024-01-01T00:00:00Z',
last_login: null,
...overrides,
};
}

const ADMIN = baseUser({ id: 1, username: 'admin' });
const STAFF = baseUser({ id: 2, username: 'staffer', is_staff: true });
const SUPERUSER = baseUser({ id: 3, username: 'superadmin', is_superuser: true });
const REGULAR = baseUser({ id: 4, username: 'regular' });

function usersResponse(results: UserListItem[]): PaginatedResponse<UserListItem> {
return { count: results.length, next: null, previous: null, results };
}

function renderPage() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<TooltipProvider>
<UsersPage />
</TooltipProvider>
</QueryClientProvider>
);
}

function rowFor(username: string): HTMLElement {
return screen.getByText(username).closest('tr') as HTMLElement;
}

beforeEach(() => {
push.mockClear();
startImpersonation.mockClear();
vi.mocked(toast.success).mockClear();
vi.mocked(toast.error).mockClear();
getUsers.mockReset();
createUser.mockReset();
updateUser.mockReset();
deleteUser.mockReset();
impersonateUser.mockReset();
mockAuthUser = { id: 1, username: 'admin' };
getUsers.mockResolvedValue(usersResponse([ADMIN, STAFF, SUPERUSER, REGULAR]));
});

describe('UsersPage impersonation action', () => {
it("disables the impersonate button for the signed-in admin's own row", async () => {
renderPage();
await screen.findByText('admin');

const button = within(rowFor('admin')).getByLabelText(/impersonate user/i);
expect((button as HTMLButtonElement).disabled).toBe(true);
});

it('disables the impersonate button for staff rows', async () => {
renderPage();
await screen.findByText('staffer');

const button = within(rowFor('staffer')).getByLabelText(/impersonate user/i);
expect((button as HTMLButtonElement).disabled).toBe(true);
});

it('disables the impersonate button for superuser rows', async () => {
renderPage();
await screen.findByText('superadmin');

const button = within(rowFor('superadmin')).getByLabelText(/impersonate user/i);
expect((button as HTMLButtonElement).disabled).toBe(true);
});

it('enables the impersonate button for a regular, non-staff, non-self row', async () => {
renderPage();
await screen.findByText('regular');

const button = within(rowFor('regular')).getByLabelText(/impersonate user/i);
expect((button as HTMLButtonElement).disabled).toBe(false);
});

it('opens a confirmation dialog naming the target user when clicked', async () => {
renderPage();
await screen.findByText('regular');

fireEvent.click(within(rowFor('regular')).getByLabelText(/impersonate user/i));

expect(await screen.findByText(/impersonate "regular"/i)).toBeTruthy();
});

it('on confirm, calls impersonateUser, then startImpersonation + navigates home on success', async () => {
impersonateUser.mockResolvedValue({ auth_token: 'target-token' });
renderPage();
await screen.findByText('regular');

fireEvent.click(within(rowFor('regular')).getByLabelText(/impersonate user/i));
await screen.findByText(/impersonate "regular"/i);

fireEvent.click(screen.getByRole('button', { name: /^impersonate$/i }));

await waitFor(() => expect(impersonateUser).toHaveBeenCalledWith('admin-token', 4));
await waitFor(() => expect(startImpersonation).toHaveBeenCalledWith('target-token'));
expect(push).toHaveBeenCalledWith('/');
expect(toast.success).toHaveBeenCalled();
});

it('shows an error toast and does not impersonate on failure', async () => {
impersonateUser.mockRejectedValue(new Error('boom'));
renderPage();
await screen.findByText('regular');

fireEvent.click(within(rowFor('regular')).getByLabelText(/impersonate user/i));
await screen.findByText(/impersonate "regular"/i);

fireEvent.click(screen.getByRole('button', { name: /^impersonate$/i }));

await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(startImpersonation).not.toHaveBeenCalled();
expect(push).not.toHaveBeenCalled();
});
});
74 changes: 72 additions & 2 deletions app/backoffice/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/contexts/auth-context';
import {
Expand All @@ -12,6 +13,7 @@ import {
Plus,
Pencil,
Trash2,
VenetianMask,
Eye,
EyeOff,
CheckCircle,
Expand Down Expand Up @@ -50,7 +52,13 @@ import {
BackofficeErrorState,
BackofficeLoadingState,
} from '@/components/backoffice/common/query-state';
import { getUsers, createUser, updateUser, deleteUser } from '@/services/backoffice/users';
import {
getUsers,
createUser,
updateUser,
deleteUser,
impersonateUser,
} from '@/services/backoffice/users';
import { backofficeKeys } from '@/lib/backoffice/query-keys';
import { formatApiError } from '@/lib/backoffice/format-api-error';
import { runBulkAction } from '@/lib/backoffice/bulk-action';
Expand All @@ -70,6 +78,13 @@ function fullName(user: UserListItem): string {
return [user.first_name, user.last_name].filter(Boolean).join(' ');
}

// Mirrors the backend's own restrictions (apps.users.services.impersonate_user):
// never yourself, never a staff/superuser account. No point offering an action
// that is guaranteed to 400/403.
function canImpersonate(row: UserListItem, currentUserId: number | undefined): boolean {
return row.id !== currentUserId && !row.is_staff && !row.is_superuser;
}

function relativeTime(dateStr: string | null, t: (key: string) => string): string {
if (!dateStr) return t('users.relativeNever');
const diff = Date.now() - new Date(dateStr).getTime();
Expand Down Expand Up @@ -181,13 +196,15 @@ function SortHeader({

export default function UsersPage() {
const t = useTranslations('backoffice');
const { token } = useAuth();
const { token, user, startImpersonation } = useAuth();
const queryClient = useQueryClient();
const router = useRouter();

// Dialog / mutation targets
const [createOpen, setCreateOpen] = useState(false);
const [editTarget, setEditTarget] = useState<UserListItem | null>(null);
const [deleteTarget, setDeleteTarget] = useState<UserListItem | null>(null);
const [impersonateTarget, setImpersonateTarget] = useState<UserListItem | null>(null);
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);

const [createForm, setCreateForm] = useState<UserCreatePayload>({ ...emptyCreate });
Expand Down Expand Up @@ -367,6 +384,19 @@ export default function UsersPage() {
},
});

const impersonateMut = useMutation({
mutationFn: (target: UserListItem) => impersonateUser(token!, target.id),
onSuccess: (data, target) => {
startImpersonation(data.auth_token);
toast.success(t('users.toastImpersonating', { username: target.username }));
setImpersonateTarget(null);
router.push('/');
},
onError: (err) => {
toast.error(t('users.toastFailedImpersonate'), { description: formatApiError(err) });
},
});

// Use `runBulkAction` so a single failed delete doesn't black-hole the
// cache invalidation — successful deletes still need to be reflected in
// the table even when one row 4xx/5xxs.
Expand Down Expand Up @@ -727,6 +757,34 @@ export default function UsersPage() {
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{(() => {
const impersonable = canImpersonate(u, user?.id);
const reasonKey =
u.id === user?.id
? 'users.tooltipImpersonateSelf'
: 'users.tooltipImpersonateProtected';
return (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
disabled={!impersonable}
aria-label={t('users.tooltipImpersonateUser')}
onClick={() => setImpersonateTarget(u)}
>
<VenetianMask className="h-3.5 w-3.5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{impersonable ? t('users.tooltipImpersonateUser') : t(reasonKey)}
</TooltipContent>
</Tooltip>
);
})()}
<Tooltip>
<TooltipTrigger asChild>
<Button
Expand Down Expand Up @@ -1092,6 +1150,18 @@ export default function UsersPage() {
</DialogContent>
</Dialog>

{/* ── Impersonate confirmation ────────────────────────────────── */}
<ConfirmDialog
open={!!impersonateTarget}
onOpenChange={(open) => !open && setImpersonateTarget(null)}
title={t('users.impersonateDialogTitle', { username: impersonateTarget?.username ?? '' })}
description={t('users.impersonateDialogDesc')}
confirmLabel={t('users.impersonateDialogConfirm')}
variant="default"
loading={impersonateMut.isPending}
onConfirm={() => impersonateTarget && impersonateMut.mutate(impersonateTarget)}
/>

{/* ── Single delete confirmation ──────────────────────────────── */}
<ConfirmDialog
open={!!deleteTarget}
Expand Down
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import './globals.css';
import { AuthProvider } from '@/contexts/auth-context';
import { ImpersonationBanner } from '@/components/impersonation-banner';
import { CollectionProvider } from '@/contexts/collection-context';
import { SearchProvider } from '@/contexts/search-context';
import { SiteFeaturesProvider } from '@/contexts/site-features-context';
Expand Down Expand Up @@ -95,6 +96,7 @@ export default async function RootLayout({
>
<NextIntlClientProvider locale={locale} messages={messages}>
<AuthProvider>
<ImpersonationBanner />
<SiteFeaturesProvider initialConfig={siteFeaturesConfig}>
<ModelLabelsProvider initialConfig={modelLabelsConfig}>
<AppQueryProvider>
Expand Down
53 changes: 53 additions & 0 deletions components/impersonation-banner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';

const push = vi.fn();
const refresh = vi.fn();
vi.mock('next/navigation', () => ({ useRouter: () => ({ push, refresh }) }));

const stopImpersonation = vi.fn();
let mockAuth: {
isImpersonating: boolean;
user: { username: string } | null;
stopImpersonation: () => void;
};
vi.mock('@/contexts/auth-context', () => ({
useAuth: () => mockAuth,
}));

import { ImpersonationBanner } from './impersonation-banner';

beforeEach(() => {
push.mockClear();
refresh.mockClear();
stopImpersonation.mockClear();
mockAuth = { isImpersonating: false, user: null, stopImpersonation };
});

describe('ImpersonationBanner', () => {
it('renders nothing when not impersonating', () => {
mockAuth.isImpersonating = false;
const { container } = render(<ImpersonationBanner />);
expect(container.firstChild).toBeNull();
});

it('renders the banner with the impersonated username when impersonating', () => {
mockAuth.isImpersonating = true;
mockAuth.user = { username: 'jdoe' };
render(<ImpersonationBanner />);
expect(screen.getByRole('alert').textContent).toContain('jdoe');
expect(screen.getByRole('button', { name: /stop impersonating/i })).not.toBeNull();
});

it('calls stopImpersonation and navigates home when the button is clicked', () => {
mockAuth.isImpersonating = true;
mockAuth.user = { username: 'jdoe' };
render(<ImpersonationBanner />);

fireEvent.click(screen.getByRole('button', { name: /stop impersonating/i }));

expect(stopImpersonation).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledWith('/');
expect(refresh).toHaveBeenCalledTimes(1);
});
});
Loading