From e3c0ea525803a7a2149c7eccaafa03fd8353fe23 Mon Sep 17 00:00:00 2001 From: Anthony Geourjon Date: Wed, 5 Aug 2026 09:58:52 +0200 Subject: [PATCH] feat(backoffice): add user impersonation UI for admins Adds a superuser-only "impersonate" action to the backoffice users table, backed by the new backend token-swap endpoint (POST /api/v1/auth/management/users/{id}/impersonate/): - lib/auth-token-cookie.ts: second cookie pair (archetype_impersonator_token) to stash the admin's original token while impersonating. - contexts/auth-context.tsx: isImpersonating state plus startImpersonation/stopImpersonation, restored on mount and cleared on logout. - services/backoffice/users.ts: impersonateUser() bespoke POST call. - components/impersonation-banner.tsx: app-wide "Stop impersonating" banner mounted in the root layout, inside AuthProvider. - app/backoffice/users/page.tsx: row action gated to non-self, non-staff, non-superuser targets (mirrors the backend's own restrictions), with a confirm dialog before switching sessions. Includes en/fr translations and tests for the context, banner, and page-level gating/mutation flow. Co-Authored-By: Claude Sonnet 5 --- app/backoffice/users/page.test.tsx | 166 +++++++++++++++++++++++ app/backoffice/users/page.tsx | 74 +++++++++- app/layout.tsx | 2 + components/impersonation-banner.test.tsx | 53 ++++++++ components/impersonation-banner.tsx | 52 +++++++ contexts/auth-context.test.tsx | 156 +++++++++++++++++++++ contexts/auth-context.tsx | 58 +++++++- lib/auth-token-cookie.ts | 34 +++++ messages/en.json | 12 ++ messages/fr.json | 12 ++ services/backoffice/users.ts | 20 ++- 11 files changed, 632 insertions(+), 7 deletions(-) create mode 100644 app/backoffice/users/page.test.tsx create mode 100644 components/impersonation-banner.test.tsx create mode 100644 components/impersonation-banner.tsx create mode 100644 contexts/auth-context.test.tsx diff --git a/app/backoffice/users/page.test.tsx b/app/backoffice/users/page.test.tsx new file mode 100644 index 00000000..dfa20f95 --- /dev/null +++ b/app/backoffice/users/page.test.tsx @@ -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 { + 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 { + return { count: results.length, next: null, previous: null, results }; +} + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + ); +} + +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(); + }); +}); diff --git a/app/backoffice/users/page.tsx b/app/backoffice/users/page.tsx index fda0c4a6..dc3194b3 100644 --- a/app/backoffice/users/page.tsx +++ b/app/backoffice/users/page.tsx @@ -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 { @@ -12,6 +13,7 @@ import { Plus, Pencil, Trash2, + VenetianMask, Eye, EyeOff, CheckCircle, @@ -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'; @@ -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(); @@ -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(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [impersonateTarget, setImpersonateTarget] = useState(null); const [bulkDeleteIds, setBulkDeleteIds] = useState([]); const [createForm, setCreateForm] = useState({ ...emptyCreate }); @@ -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. @@ -727,6 +757,34 @@ export default function UsersPage() {
+ {(() => { + const impersonable = canImpersonate(u, user?.id); + const reasonKey = + u.id === user?.id + ? 'users.tooltipImpersonateSelf' + : 'users.tooltipImpersonateProtected'; + return ( + + + + + + + + {impersonable ? t('users.tooltipImpersonateUser') : t(reasonKey)} + + + ); + })()} +
+ + ); +} diff --git a/contexts/auth-context.test.tsx b/contexts/auth-context.test.tsx new file mode 100644 index 00000000..6ce3e521 --- /dev/null +++ b/contexts/auth-context.test.tsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const push = vi.fn(); +vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) })); + +const getUserProfile = vi.fn(); +const logoutUser = vi.fn(); +vi.mock('@/utils/api', () => ({ + getUserProfile: (...args: unknown[]) => getUserProfile(...args), + logoutUser: (...args: unknown[]) => logoutUser(...args), +})); + +import { AuthProvider, useAuth } from './auth-context'; +import { + clearAuthTokenCookie, + clearImpersonatorTokenCookie, + getAuthTokenCookie, + getImpersonatorTokenCookie, + setAuthTokenCookie, + setImpersonatorTokenCookie, +} from '@/lib/auth-token-cookie'; +import type { UserProfile } from '@/types'; + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +function fakeProfile(username: string): UserProfile { + return { + id: 1, + email: `${username}@example.com`, + username, + first_name: '', + last_name: '', + is_staff: false, + is_superuser: false, + }; +} + +beforeEach(() => { + push.mockClear(); + getUserProfile.mockReset(); + logoutUser.mockReset(); + logoutUser.mockResolvedValue(undefined); + clearAuthTokenCookie(); + clearImpersonatorTokenCookie(); +}); + +afterEach(() => { + clearAuthTokenCookie(); + clearImpersonatorTokenCookie(); +}); + +describe('AuthProvider impersonation', () => { + it('startImpersonation stashes the current token and switches to the new one', async () => { + getUserProfile.mockImplementation(async (token: string) => fakeProfile(`user-${token}`)); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => expect(result.current.isReady).toBe(true)); + + act(() => result.current.setToken('admin-token')); + await waitFor(() => expect(result.current.token).toBe('admin-token')); + + act(() => result.current.startImpersonation('target-token')); + + await waitFor(() => expect(result.current.token).toBe('target-token')); + expect(result.current.isImpersonating).toBe(true); + expect(getImpersonatorTokenCookie()).toBe('admin-token'); + expect(getAuthTokenCookie()).toBe('target-token'); + }); + + it('a second startImpersonation call does not clobber the original stashed token', async () => { + getUserProfile.mockImplementation(async (token: string) => fakeProfile(`user-${token}`)); + + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isReady).toBe(true)); + + act(() => result.current.setToken('admin-token')); + await waitFor(() => expect(result.current.token).toBe('admin-token')); + + act(() => result.current.startImpersonation('target-token-1')); + await waitFor(() => expect(result.current.token).toBe('target-token-1')); + + // Nested impersonation: switching again while already impersonating must + // NOT overwrite the stashed original admin token. + act(() => result.current.startImpersonation('target-token-2')); + await waitFor(() => expect(result.current.token).toBe('target-token-2')); + + expect(result.current.isImpersonating).toBe(true); + expect(getImpersonatorTokenCookie()).toBe('admin-token'); + }); + + it('stopImpersonation restores the stashed token and clears the stash', async () => { + getUserProfile.mockImplementation(async (token: string) => fakeProfile(`user-${token}`)); + + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isReady).toBe(true)); + + act(() => result.current.setToken('admin-token')); + await waitFor(() => expect(result.current.token).toBe('admin-token')); + + act(() => result.current.startImpersonation('target-token')); + await waitFor(() => expect(result.current.token).toBe('target-token')); + + act(() => result.current.stopImpersonation()); + + await waitFor(() => expect(result.current.token).toBe('admin-token')); + expect(result.current.isImpersonating).toBe(false); + expect(getImpersonatorTokenCookie()).toBeNull(); + }); + + it('restores isImpersonating on initial mount when an impersonator cookie is already present', async () => { + setAuthTokenCookie('target-token'); + setImpersonatorTokenCookie('admin-token'); + getUserProfile.mockImplementation(async (token: string) => fakeProfile(`user-${token}`)); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => expect(result.current.isReady).toBe(true)); + expect(result.current.isImpersonating).toBe(true); + expect(result.current.token).toBe('target-token'); + }); + + it('does not report isImpersonating on a normal (non-impersonated) mount', async () => { + setAuthTokenCookie('admin-token'); + getUserProfile.mockImplementation(async (token: string) => fakeProfile(`user-${token}`)); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => expect(result.current.isReady).toBe(true)); + expect(result.current.isImpersonating).toBe(false); + }); + + it('logout clears the stashed impersonator token', async () => { + getUserProfile.mockImplementation(async (token: string) => fakeProfile(`user-${token}`)); + + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(result.current.isReady).toBe(true)); + + act(() => result.current.setToken('admin-token')); + await waitFor(() => expect(result.current.token).toBe('admin-token')); + + act(() => result.current.startImpersonation('target-token')); + await waitFor(() => expect(result.current.token).toBe('target-token')); + + act(() => result.current.logout()); + + await waitFor(() => expect(result.current.token).toBeNull()); + expect(result.current.isImpersonating).toBe(false); + expect(getImpersonatorTokenCookie()).toBeNull(); + expect(push).toHaveBeenCalledWith('/login'); + }); +}); diff --git a/contexts/auth-context.tsx b/contexts/auth-context.tsx index a06a6fe3..d2238348 100644 --- a/contexts/auth-context.tsx +++ b/contexts/auth-context.tsx @@ -5,8 +5,11 @@ import { useRouter } from 'next/navigation'; import { getUserProfile, logoutUser } from '@/utils/api'; import { clearAuthTokenCookie, + clearImpersonatorTokenCookie, getAuthTokenCookie, + getImpersonatorTokenCookie, setAuthTokenCookie, + setImpersonatorTokenCookie, } from '@/lib/auth-token-cookie'; import type { UserProfile } from '@/types'; @@ -14,7 +17,10 @@ interface AuthContextType { token: string | null; user: UserProfile | null; isReady: boolean; + isImpersonating: boolean; setToken: (token: string | null) => void; + startImpersonation: (newToken: string) => void; + stopImpersonation: () => void; logout: () => void; } @@ -24,6 +30,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [token, setToken] = useState(null); const [user, setUser] = useState(null); const [isReady, setIsReady] = useState(false); + const [isImpersonating, setIsImpersonating] = useState(false); const router = useRouter(); const setAuthToken = useCallback((nextToken: string | null) => { @@ -37,6 +44,29 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { clearAuthTokenCookie(); }, []); + const startImpersonation = useCallback( + (newToken: string) => { + // Only stash the current token if we're not already impersonating — + // otherwise a nested impersonation would clobber the real original + // admin token with an already-impersonated one, making it unrecoverable. + if (!isImpersonating && token) { + setImpersonatorTokenCookie(token); + } + setIsImpersonating(true); + setAuthToken(newToken); + }, + [isImpersonating, token, setAuthToken] + ); + + const stopImpersonation = useCallback(() => { + const originalToken = getImpersonatorTokenCookie(); + if (originalToken) { + setAuthToken(originalToken); + clearImpersonatorTokenCookie(); + } + setIsImpersonating(false); + }, [setAuthToken]); + const logout = useCallback(() => { // Revoke the server-side token so a captured token can't be reused after // logout. Fire-and-forget: a network/HTTP failure must not block the local @@ -45,6 +75,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { void logoutUser(token).catch(() => {}); } setAuthToken(null); + // Don't leave a stale stashed original token around if the user fully + // logs out while impersonating. + clearImpersonatorTokenCookie(); + setIsImpersonating(false); setUser(null); setIsReady(true); router.push('/login'); @@ -52,6 +86,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const storedToken = getAuthTokenCookie(); + const storedImpersonatorToken = getImpersonatorTokenCookie(); + queueMicrotask(() => setIsImpersonating(!!storedImpersonatorToken)); if (storedToken) { queueMicrotask(() => setAuthToken(storedToken)); @@ -97,8 +133,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, [token, setAuthToken]); const value = useMemo( - () => ({ token, user, isReady, setToken: setAuthToken, logout }), - [token, user, isReady, setAuthToken, logout] + () => ({ + token, + user, + isReady, + isImpersonating, + setToken: setAuthToken, + startImpersonation, + stopImpersonation, + logout, + }), + [ + token, + user, + isReady, + isImpersonating, + setAuthToken, + startImpersonation, + stopImpersonation, + logout, + ] ); return {children}; diff --git a/lib/auth-token-cookie.ts b/lib/auth-token-cookie.ts index a0a00be8..16f3ac4c 100644 --- a/lib/auth-token-cookie.ts +++ b/lib/auth-token-cookie.ts @@ -1,6 +1,12 @@ const AUTH_TOKEN_COOKIE_NAME = 'archetype_auth_token'; const AUTH_TOKEN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days +// Stashes the ORIGINAL admin's token while impersonating another user, so it +// can be restored when impersonation ends. Same shape/lifetime as the main +// auth token cookie. +const IMPERSONATOR_TOKEN_COOKIE_NAME = 'archetype_impersonator_token'; +const IMPERSONATOR_TOKEN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days + function secureSuffix(): string { if (typeof window === 'undefined') { return ''; @@ -33,3 +39,31 @@ export function getAuthTokenCookie(): string | null { } export const AUTH_TOKEN_COOKIE = AUTH_TOKEN_COOKIE_NAME; + +export function setImpersonatorTokenCookie(token: string): void { + if (typeof document === 'undefined') { + return; + } + document.cookie = + `${IMPERSONATOR_TOKEN_COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; Max-Age=${IMPERSONATOR_TOKEN_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax` + + secureSuffix(); +} + +export function clearImpersonatorTokenCookie(): void { + if (typeof document === 'undefined') { + return; + } + document.cookie = `${IMPERSONATOR_TOKEN_COOKIE_NAME}=; Path=/; Max-Age=0; SameSite=Lax${secureSuffix()}`; +} + +export function getImpersonatorTokenCookie(): string | null { + if (typeof document === 'undefined') { + return null; + } + const match = document.cookie.match( + new RegExp(`(?:^|; )${IMPERSONATOR_TOKEN_COOKIE_NAME}=([^;]*)`) + ); + return match ? decodeURIComponent(match[1]) : null; +} + +export const IMPERSONATOR_TOKEN_COOKIE = IMPERSONATOR_TOKEN_COOKIE_NAME; diff --git a/messages/en.json b/messages/en.json index 914b4ad2..d3d0bed1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1837,6 +1837,9 @@ "statusInactive": "Inactive", "tooltipEditUser": "Edit user", "tooltipDeleteUser": "Delete user", + "tooltipImpersonateUser": "Impersonate user", + "tooltipImpersonateSelf": "You cannot impersonate yourself", + "tooltipImpersonateProtected": "Staff and superuser accounts cannot be impersonated", "createDialogTitle": "New User", "createDialogDesc": "Set up a new account. The password will be securely hashed.", "editDialogMemberSince": "Member since {date}", @@ -1872,12 +1875,17 @@ "bulkDeleteTitle": "Delete {count} user(s)?", "bulkDeleteDesc": "This action cannot be undone. All selected users will be permanently removed.", "bulkDeleteConfirm": "Delete All", + "impersonateDialogTitle": "Impersonate \"{username}\"?", + "impersonateDialogDesc": "This action is audited. Your active session will switch to this user's account so you can browse the site as them. Use \"Stop impersonating\" to return to your own account.", + "impersonateDialogConfirm": "Impersonate", "toastUserCreated": "User created", "toastFailedCreate": "Failed to create user", "toastUserUpdated": "User updated", "toastFailedUpdate": "Failed to update user", "toastUserDeleted": "User deleted", "toastFailedDelete": "Failed to delete user", + "toastImpersonating": "Now browsing as {username}", + "toastFailedImpersonate": "Failed to impersonate user", "failedLoad": "Failed to load users", "relativeNever": "Never", "relativeJustNow": "Just now" @@ -2551,6 +2559,10 @@ "showPassword": "Show password", "signingIn": "Signing in…" }, + "impersonation": { + "bannerMessage": "Viewing the site as {username}", + "stopButton": "Stop impersonating" + }, "hand": { "breadcrumb": "Hands", "namePrefix": "Hand: ", diff --git a/messages/fr.json b/messages/fr.json index f0818440..cb33bb29 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1837,6 +1837,9 @@ "statusInactive": "Inactif", "tooltipEditUser": "Modifier l'utilisateur", "tooltipDeleteUser": "Supprimer l'utilisateur", + "tooltipImpersonateUser": "Emprunter l'identité de l'utilisateur", + "tooltipImpersonateSelf": "Vous ne pouvez pas emprunter votre propre identité", + "tooltipImpersonateProtected": "Impossible d'emprunter l'identité d'un compte personnel ou superutilisateur", "createDialogTitle": "Nouvel utilisateur", "createDialogDesc": "Configurer un nouveau compte. Le mot de passe sera stocké de façon sécurisée (hashé).", "editDialogMemberSince": "Membre depuis {date}", @@ -1872,12 +1875,17 @@ "bulkDeleteTitle": "Supprimer {count} utilisateur(s) ?", "bulkDeleteDesc": "Cette action est irréversible. Tous les utilisateurs sélectionnés seront définitivement supprimés.", "bulkDeleteConfirm": "Tout supprimer", + "impersonateDialogTitle": "Emprunter l'identité de « {username} » ?", + "impersonateDialogDesc": "Cette action est journalisée (audit). Votre session active basculera vers le compte de cet utilisateur pour vous permettre de parcourir le site à sa place. Utilisez « Arrêter l'emprunt d'identité » pour revenir à votre propre compte.", + "impersonateDialogConfirm": "Emprunter l'identité", "toastUserCreated": "Utilisateur créé", "toastFailedCreate": "Échec de la création de l'utilisateur", "toastUserUpdated": "Utilisateur mis à jour", "toastFailedUpdate": "Échec de la mise à jour de l'utilisateur", "toastUserDeleted": "Utilisateur supprimé", "toastFailedDelete": "Échec de la suppression de l'utilisateur", + "toastImpersonating": "Vous naviguez maintenant en tant que {username}", + "toastFailedImpersonate": "Échec de l'emprunt d'identité", "failedLoad": "Échec du chargement des utilisateurs", "relativeNever": "Jamais", "relativeJustNow": "À l'instant" @@ -2551,6 +2559,10 @@ "showPassword": "Afficher le mot de passe", "signingIn": "Connexion en cours…" }, + "impersonation": { + "bannerMessage": "Vous consultez le site en tant que {username}", + "stopButton": "Arrêter l'emprunt d'identité" + }, "hand": { "breadcrumb": "Mains", "namePrefix": "Main : ", diff --git a/services/backoffice/users.ts b/services/backoffice/users.ts index e8f00c72..4051ce50 100644 --- a/services/backoffice/users.ts +++ b/services/backoffice/users.ts @@ -1,12 +1,26 @@ import { createCrudService } from './crud-factory'; +import { backofficePost } from './api-client'; import type { PaginatedResponse, UserListItem, UserDetail } from '@/types/backoffice'; -const usersCrud = createCrudService, UserDetail>( - '/api/v1/auth/management/users/' -); +const USERS_BASE_PATH = '/api/v1/auth/management/users/'; + +const usersCrud = createCrudService, UserDetail>(USERS_BASE_PATH); export const getUsers = usersCrud.list; export const getUser = usersCrud.get; export const createUser = usersCrud.create; export const updateUser = usersCrud.update; export const deleteUser = usersCrud.remove; + +/** + * Support-style impersonation: mint (or reuse) the target user's own auth + * token so the caller can swap its stored bearer token and browse as them. + * Superuser-only; the backend 400s for self-impersonation and 403s for + * staff/superuser targets (see apps.users.services.impersonate_user). + */ +export function impersonateUser( + token: string, + id: UserListItem['id'] +): Promise<{ auth_token: string }> { + return backofficePost<{ auth_token: string }>(`${USERS_BASE_PATH}${id}/impersonate/`, token, {}); +}