diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
index e5e86585..6aa341d8 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useLocation } from 'react-router';
-import { useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
+import { ImpersonationBanner, useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
import { usePermissions } from 'hooks/usePermissions';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback';
@@ -46,6 +46,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
return (
+ {/* ABOVE the header, and outside everything that can be hidden. A fullscreen panel hides the header;
+ it must not be able to hide the fact that you are somebody else. Renders nothing when you are not. */}
+
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx
index 4fa4d258..06441dbb 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx
@@ -1,7 +1,17 @@
+import { startImpersonation } from 'officerdev';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
-import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, SquareTerminal as TerminalIcon } from 'lucide-react';
+import {
+ Copy,
+ Crown,
+ KeyRound,
+ Loader2,
+ RotateCcw,
+ SquareTerminal as TerminalIcon,
+ Trash2,
+ UserCog,
+} from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -128,6 +138,25 @@ export const UsersSection = () => {
}
};
+ /**
+ * Sign in as this person, for an hour.
+ *
+ * No confirmation dialog, deliberately: it is reversible from the banner in one click, it destroys
+ * nothing, and it is a support action taken while somebody is waiting. The dialogs beside it guard a
+ * new password and a deletion, which are neither.
+ */
+ const impersonate = async (user: ManagedUser) => {
+ setPendingId(user.id);
+ try {
+ const { token } = await client.post<{ token: string }>(`/users/${user.id}/impersonate`, {});
+ // Swaps the token and reloads — nothing after this line runs.
+ startImpersonation(token);
+ } catch (ex) {
+ toast.error(ex instanceof Error ? ex.message : 'Could not sign in as that user');
+ setPendingId(null);
+ }
+ };
+
const remove = async (user: ManagedUser) => {
setPendingId(user.id);
setConfirmDelete(null);
@@ -263,6 +292,22 @@ export const UsersSection = () => {
)}
+ {/* Not for the owner: impersonating yourself is refused by the route, and the row that
+ offers it would be the one row where it cannot work. */}
+ {!user.isOwner && (
+
+ )}
+