From ee25d199514058f550a061871bc5a61ec8016fd0 Mon Sep 17 00:00:00 2001
From: David C
Date: Thu, 22 Oct 2026 19:32:25 -0400
Subject: [PATCH 01/23] feat(admin): Users page to look up and deactivate a
user
Staff holding user:status can find an account by email, see each of its
organizations marked "will be removed" or "kept: last admin" (central
manager's GET /Admin/User/{id}?previewMembershipRemoval=true), and deactivate
it behind a confirm dialog that then shows what the PATCH actually did.
Refs HarperFast/studio#1997
Co-Authored-By: Claude Opus 5.5
---
.../admin/__tests__/adminRoutes.test.ts | 1 +
.../__tests__/adminShellVisibility.test.ts | 10 +-
src/features/admin/components/AdminShell.tsx | 4 +-
src/features/admin/routes.ts | 8 +
src/features/admin/users/index.test.tsx | 149 ++++++++++++
src/features/admin/users/index.tsx | 223 ++++++++++++++++++
src/features/admin/users/userAdminApi.ts | 77 ++++++
src/integrations/api/api.patch.d.ts | 1 +
8 files changed, 470 insertions(+), 3 deletions(-)
create mode 100644 src/features/admin/users/index.test.tsx
create mode 100644 src/features/admin/users/index.tsx
create mode 100644 src/features/admin/users/userAdminApi.ts
diff --git a/src/features/admin/__tests__/adminRoutes.test.ts b/src/features/admin/__tests__/adminRoutes.test.ts
index a15556282..713684044 100644
--- a/src/features/admin/__tests__/adminRoutes.test.ts
+++ b/src/features/admin/__tests__/adminRoutes.test.ts
@@ -28,6 +28,7 @@ describe('admin section routes', () => {
['/admin/regions', '/regions'],
['/admin/hosts', '/hosts'],
['/admin/monitoring', '/monitoring'],
+ ['/admin/users', '/users'],
['/admin/api-token', '/api-token'],
])('%s resolves to its own route', (path, expectedId) => {
expect(matchLeaf(path).routeId).toContain(expectedId);
diff --git a/src/features/admin/__tests__/adminShellVisibility.test.ts b/src/features/admin/__tests__/adminShellVisibility.test.ts
index 2bdaaea00..b996b7a75 100644
--- a/src/features/admin/__tests__/adminShellVisibility.test.ts
+++ b/src/features/admin/__tests__/adminShellVisibility.test.ts
@@ -60,10 +60,16 @@ describe('admin section visibility', () => {
]);
expect(labels(withField)).toEqual(['Notifications', 'Regions', 'Hosts']);
// Legacy API without staffPermissions: same carve-out via the fallback.
- expect(labels(user('super_user'))).toEqual(['Notifications', 'Regions', 'Hosts', 'Monitoring']);
+ expect(labels(user('super_user'))).toEqual(['Notifications', 'Regions', 'Hosts', 'Monitoring', 'Users']);
});
it('keeps the older pages, not API Tokens, for a legacy-API fabric_admin', () => {
- expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'Hosts', 'Monitoring']);
+ expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'Hosts', 'Monitoring', 'Users']);
+ });
+
+ // Reading users (every staff role) is not enough to see the Users page.
+ it('shows Users only to holders of user:status', () => {
+ expect(labels(user('fabric_admin', ['user:read', 'user:status']))).toEqual(['Users']);
+ expect(labels(user('fabric_readonly', ['user:read']))).toEqual([]);
});
});
diff --git a/src/features/admin/components/AdminShell.tsx b/src/features/admin/components/AdminShell.tsx
index 058e5d01b..6e66e9160 100644
--- a/src/features/admin/components/AdminShell.tsx
+++ b/src/features/admin/components/AdminShell.tsx
@@ -2,7 +2,7 @@ import { SubNavItem, SubNavRail } from '@/components/SubNavRail';
import { hasStaffPermission, useCloudAuth } from '@/hooks/useAuth';
import { LocalUser, StaffPermission, User } from '@/integrations/api/api.patch';
import { Navigate, Outlet, useLocation } from '@tanstack/react-router';
-import { BellIcon, GlobeIcon, KeyRoundIcon, ServerIcon, SirenIcon } from 'lucide-react';
+import { BellIcon, GlobeIcon, KeyRoundIcon, ServerIcon, SirenIcon, UsersIcon } from 'lucide-react';
/**
* Shell for the Admin section: a responsive sub-nav rail (so future admin
@@ -20,6 +20,8 @@ const items: Array<
{ to: '/admin/regions', label: 'Regions', icon: GlobeIcon, permission: 'region:read' },
{ to: '/admin/hosts', label: 'Hosts', icon: ServerIcon, permission: 'host:read' },
{ to: '/admin/monitoring', label: 'Monitoring', icon: SirenIcon, permission: 'cluster:read' },
+ // Gated on deactivation's permission rather than user:read, which keeps the page fabric_admin-only.
+ { to: '/admin/users', label: 'Users', icon: UsersIcon, permission: 'user:status' },
// Only an API that sends apiToken:self has the token endpoints this page drives (DESIGN.md);
// super_user cannot mint a staff credential.
{
diff --git a/src/features/admin/routes.ts b/src/features/admin/routes.ts
index 0fd3cdaf1..b69cfc1ee 100644
--- a/src/features/admin/routes.ts
+++ b/src/features/admin/routes.ts
@@ -48,6 +48,13 @@ const monitoringRoute = createRoute({
component: lazyRouteComponent(async () => import('@/features/admin/monitoring/index'), 'MonitoringPolicyAdminIndex'),
});
+const usersRoute = createRoute({
+ getParentRoute: () => adminLayoutRoute,
+ path: 'users',
+ head: () => ({ meta: [{ title: 'Users — Harper Fabric' }] }),
+ component: lazyRouteComponent(async () => import('@/features/admin/users/index'), 'UsersAdminIndex'),
+});
+
const apiTokenRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: 'api-token',
@@ -62,5 +69,6 @@ export const adminRoutes = [
regionsRoute,
hostsRoute,
monitoringRoute,
+ usersRoute,
apiTokenRoute,
];
diff --git a/src/features/admin/users/index.test.tsx b/src/features/admin/users/index.test.tsx
new file mode 100644
index 000000000..520808ce3
--- /dev/null
+++ b/src/features/admin/users/index.test.tsx
@@ -0,0 +1,149 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { get, patch } = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn() }));
+
+vi.mock('@/config/apiClient', () => ({ apiClient: { get, patch } }));
+
+import { UsersAdminIndex } from './index';
+
+const USER = {
+ id: 'usr-1',
+ email: 'pat@example.com',
+ firstname: 'Pat',
+ lastname: 'Lee',
+ status: 'ACTIVE',
+ organizations: [
+ { organizationId: 'org-solo', organizationName: 'Solo Co', roleNames: ['admin'], lastAdmin: true },
+ {
+ organizationId: 'org-shared',
+ organizationName: 'Shared Co',
+ roleNames: ['admin', 'developer'],
+ lastAdmin: false,
+ },
+ ],
+};
+
+beforeAll(() => {
+ // Radix Dialog relies on DOM APIs jsdom doesn't implement.
+ Element.prototype.hasPointerCapture ??= () => false;
+ Element.prototype.setPointerCapture ??= () => undefined;
+ Element.prototype.releasePointerCapture ??= () => undefined;
+ Element.prototype.scrollIntoView ??= () => undefined;
+});
+
+let user: typeof USER;
+
+beforeEach(() => {
+ user = structuredClone(USER);
+ get.mockReset();
+ patch.mockReset();
+ get.mockImplementation(async (url: string) => {
+ if (url === '/Admin/User/?email=pat%40example.com') {
+ return { data: [{ id: 'usr-old', status: 'DELETED' }, { id: 'usr-1', status: user.status }] };
+ }
+ if (url === '/Admin/User/usr-1?previewMembershipRemoval=true') { return { data: user }; }
+ if (url.startsWith('/Admin/User/?email=')) { return { data: [] }; }
+ throw new Error(`unexpected GET ${url}`);
+ });
+ patch.mockImplementation(async () => {
+ user = { ...user, status: 'DEACTIVATED', organizations: [user.organizations[0]] };
+ return {
+ data: {
+ id: 'usr-1',
+ status: 'DEACTIVATED',
+ removedFrom: [{
+ organizationId: 'org-shared',
+ organizationName: 'Shared Co',
+ roleNames: ['admin', 'developer'],
+ }],
+ retainedIn: [{ organizationId: 'org-solo', organizationName: 'Solo Co', roleNames: ['admin'] }],
+ },
+ };
+ });
+});
+afterEach(cleanup);
+
+function renderPage() {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+async function lookUp(address: string) {
+ await act(async () => {
+ fireEvent.change(screen.getByLabelText('Email'), { target: { value: address } });
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Look up' }));
+ });
+}
+
+describe('UsersAdminIndex', () => {
+ it('looks up the live account by normalized address and previews each organization', async () => {
+ renderPage();
+ await lookUp(' Pat@Example.com ');
+
+ expect(await screen.findByText('Pat Lee')).toBeTruthy();
+ const solo = screen.getByText('Solo Co').closest('li')!;
+ expect(within(solo).getByText('Kept: last admin')).toBeTruthy();
+ const shared = screen.getByText('Shared Co').closest('li')!;
+ expect(within(shared).getByText('Will be removed')).toBeTruthy();
+ expect(within(shared).getByText('admin, developer')).toBeTruthy();
+ expect(get).toHaveBeenCalledWith('/Admin/User/usr-1?previewMembershipRemoval=true');
+ });
+
+ it('says so when no account uses the address', async () => {
+ renderPage();
+ await lookUp('nobody@example.com');
+
+ expect(await screen.findByText('No account uses nobody@example.com.')).toBeTruthy();
+ });
+
+ it('confirms, deactivates, and shows what actually happened', async () => {
+ renderPage();
+ await lookUp('pat@example.com');
+ await act(async () => {
+ fireEvent.click(await screen.findByRole('button', { name: 'Deactivate' }));
+ });
+
+ const confirm = screen.getByRole('dialog');
+ expect(within(confirm).getByText('Deactivate pat@example.com?')).toBeTruthy();
+ expect(within(within(confirm).getByText('Will be removed from').parentElement!).getByText('Shared Co'))
+ .toBeTruthy();
+ expect(within(within(confirm).getByText('Will stay in, as the last admin').parentElement!).getByText('Solo Co'))
+ .toBeTruthy();
+
+ await act(async () => {
+ fireEvent.click(within(confirm).getByRole('button', { name: 'Deactivate' }));
+ });
+
+ expect(patch).toHaveBeenCalledWith('/Admin/User/usr-1', { status: 'DEACTIVATED' });
+ const done = await screen.findByRole('dialog');
+ expect(within(done).getByText('User deactivated')).toBeTruthy();
+ expect(within(within(done).getByText('Removed from').parentElement!).getByText('Shared Co')).toBeTruthy();
+ expect(within(within(done).getByText('Kept in, as the last admin').parentElement!).getByText('Solo Co'))
+ .toBeTruthy();
+
+ await act(async () => {
+ fireEvent.click(within(done).getByRole('button', { name: 'Done' }));
+ });
+ expect(await screen.findByText('Deactivated, and in no organization it could leave.')).toBeTruthy();
+ });
+
+ it('still offers deactivation to an already-deactivated account that has memberships to remove', async () => {
+ user.status = 'DEACTIVATED';
+ renderPage();
+ await lookUp('pat@example.com');
+
+ expect(await screen.findByText(/Already deactivated/)).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Deactivate' })).toBeTruthy();
+ });
+});
diff --git a/src/features/admin/users/index.tsx b/src/features/admin/users/index.tsx
new file mode 100644
index 000000000..1503e0847
--- /dev/null
+++ b/src/features/admin/users/index.tsx
@@ -0,0 +1,223 @@
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Skeleton } from '@/components/ui/skeleton';
+import {
+ AdminUser,
+ adminUserQueryOptions,
+ DeactivateUserResult,
+ MembershipPreview,
+ normalizeEmail,
+ OrganizationMembership,
+ useDeactivateUserMutation,
+} from '@/features/admin/users/userAdminApi';
+import { useQuery } from '@tanstack/react-query';
+import { FormEvent, useState } from 'react';
+
+export function UsersAdminIndex() {
+ const [input, setInput] = useState('');
+ // Held in state, never in the URL: addresses must stay out of the page URL that RUM and third-party pixels read.
+ const [email, setEmail] = useState('');
+ const { data: user, isFetching, isError, error } = useQuery(adminUserQueryOptions(email));
+
+ const lookUp = (event: FormEvent) => {
+ event.preventDefault();
+ setEmail(normalizeEmail(input));
+ };
+
+ return (
+
+
Users
+
+ Deactivating an account ends its sessions and blocks sign-in. It also leaves every organization that keeps
+ another admin who can sign in; where it is the last one, its membership stays so the organization is never left
+ without an admin.
+
+
+ {organizationLabel(membership)}
+ {membership.roleNames.join(', ')}
+
+ {membership.lastAdmin
+ ? Kept: last admin
+ : Will be removed}
+
+ );
+}
+
+const organizationLabel = (membership: OrganizationMembership) =>
+ membership.organizationName || membership.organizationId;
+
+/** Mount only while open, so a result never outlives the session that produced it. */
+function DeactivateDialog({ user, onClose }: { user: AdminUser; onClose: () => void }) {
+ const mutation = useDeactivateUserMutation();
+ const [result, setResult] = useState(null);
+ const removed = user.organizations.filter((membership) => !membership.lastAdmin);
+ const kept = user.organizations.filter((membership) => membership.lastAdmin);
+
+ return (
+
+ );
+}
+
+function MembershipList({ title, memberships, empty }: {
+ title: string;
+ memberships: OrganizationMembership[];
+ empty: string;
+}) {
+ return (
+
+
{title}
+ {memberships.length
+ ? (
+
+ {memberships.map((membership) =>
{organizationLabel(membership)}
)}
+
+ )
+ :
{empty}
}
+
+ );
+}
diff --git a/src/features/admin/users/userAdminApi.ts b/src/features/admin/users/userAdminApi.ts
new file mode 100644
index 000000000..88946d9e7
--- /dev/null
+++ b/src/features/admin/users/userAdminApi.ts
@@ -0,0 +1,77 @@
+// Central manager's staff-only `/Admin/User` endpoint, hand-typed: the generated SDK carries neither the membership
+// preview nor the deactivation result, so URLs are cast as elsewhere. The endpoint enforces every permission the page
+// gates on.
+import { apiClient } from '@/config/apiClient';
+import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
+
+export interface OrganizationMembership {
+ organizationId: string;
+ organizationName: string | null;
+ roleNames: string[];
+}
+
+/** `lastAdmin`: the user is the organization's only admin who could sign in, so a deactivation keeps them there. */
+export interface MembershipPreview extends OrganizationMembership {
+ lastAdmin: boolean;
+}
+
+export interface AdminUser {
+ id: string;
+ email: string;
+ firstname?: string | null;
+ lastname?: string | null;
+ status?: string | null;
+ organizations: MembershipPreview[];
+}
+
+export interface DeactivateUserResult {
+ id: string;
+ status: string;
+ removedFrom: OrganizationMembership[];
+ retainedIn: OrganizationMembership[];
+}
+
+const BASE = '/Admin/User/';
+
+const userAdminQueryKey = ['fabric-admin', 'users'] as const;
+
+/** Matches how central manager stores addresses, so the exact-match lookup finds mixed-case input. */
+export const normalizeEmail = (email: string) => email.trim().toLowerCase();
+
+async function findUser(email: string): Promise {
+ const { data } = await apiClient.get(`${BASE}?email=${encodeURIComponent(email)}` as '/User/');
+ const live = (data as unknown as Array<{ id: string; status?: string }>).filter((user) => user.status !== 'DELETED');
+ if (live.length > 1) { throw new Error(`More than one account uses ${email}.`); }
+ if (!live.length) { return null; }
+
+ const { data: user } = await apiClient.get(
+ `${BASE}${encodeURIComponent(live[0].id)}?previewMembershipRemoval=true` as '/User/{id}',
+ );
+ return user as unknown as AdminUser;
+}
+
+/** Callers never send a blank address: the endpoint would answer every user instead of a match. */
+export function adminUserQueryOptions(email: string) {
+ return queryOptions({
+ queryKey: [...userAdminQueryKey, email],
+ queryFn: () => findUser(email),
+ enabled: email.length > 0,
+ });
+}
+
+/**
+ * PATCH the status to DEACTIVATED. Failures toast through the global MutationCache handler, which already words an
+ * unanswered write as "outcome unknown". Every settle refetches, because a failed answer can still be a committed write.
+ */
+export function useDeactivateUserMutation() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (userId: string) => {
+ const { data } = await apiClient.patch(`${BASE}${encodeURIComponent(userId)}` as '/User/{id}', {
+ status: 'DEACTIVATED',
+ });
+ return data as unknown as DeactivateUserResult;
+ },
+ onSettled: () => queryClient.invalidateQueries({ queryKey: userAdminQueryKey }),
+ });
+}
diff --git a/src/integrations/api/api.patch.d.ts b/src/integrations/api/api.patch.d.ts
index 1d73d19c4..be986c2c5 100644
--- a/src/integrations/api/api.patch.d.ts
+++ b/src/integrations/api/api.patch.d.ts
@@ -57,6 +57,7 @@ export type StaffPermission =
| 'region:read'
| 'region:write'
| 'monitoringPolicy:write'
+ | 'user:status'
| 'systemStatus:write'
| 'apiToken:create'
| 'apiToken:self';
From 027e427793e4333ce33e34397db92b1621321468 Mon Sep 17 00:00:00 2001
From: David C
Date: Thu, 22 Oct 2026 19:39:55 -0400
Subject: [PATCH 02/23] fix(admin): keep the Users page and its result on a
failed refresh
A failed refetch no longer replaces the loaded account, so a deactivation
result stays open. Looking up the same address again refetches. The lookup
refuses a central manager that answers without the membership preview, the
duplicate-account error no longer carries the address, and the calls use
the SDK's /Admin/User paths.
Refs HarperFast/studio#1997
Co-Authored-By: Claude Opus 5.5
---
.../__tests__/adminShellVisibility.test.ts | 3 +-
src/features/admin/users/index.test.tsx | 50 +++++++++++++++++++
src/features/admin/users/index.tsx | 21 ++++++--
src/features/admin/users/userAdminApi.ts | 23 +++++----
4 files changed, 82 insertions(+), 15 deletions(-)
diff --git a/src/features/admin/__tests__/adminShellVisibility.test.ts b/src/features/admin/__tests__/adminShellVisibility.test.ts
index b996b7a75..bc4ae740a 100644
--- a/src/features/admin/__tests__/adminShellVisibility.test.ts
+++ b/src/features/admin/__tests__/adminShellVisibility.test.ts
@@ -67,8 +67,7 @@ describe('admin section visibility', () => {
expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'Hosts', 'Monitoring', 'Users']);
});
- // Reading users (every staff role) is not enough to see the Users page.
- it('shows Users only to holders of user:status', () => {
+ it('shows Users only to holders of user:status, not to every role that can read users', () => {
expect(labels(user('fabric_admin', ['user:read', 'user:status']))).toEqual(['Users']);
expect(labels(user('fabric_readonly', ['user:read']))).toEqual([]);
});
diff --git a/src/features/admin/users/index.test.tsx b/src/features/admin/users/index.test.tsx
index 520808ce3..34a476e12 100644
--- a/src/features/admin/users/index.test.tsx
+++ b/src/features/admin/users/index.test.tsx
@@ -146,4 +146,54 @@ describe('UsersAdminIndex', () => {
expect(await screen.findByText(/Already deactivated/)).toBeTruthy();
expect(screen.getByRole('button', { name: 'Deactivate' })).toBeTruthy();
});
+
+ it('keeps the result open when the refetch after deactivating fails', async () => {
+ renderPage();
+ await lookUp('pat@example.com');
+ await act(async () => {
+ fireEvent.click(await screen.findByRole('button', { name: 'Deactivate' }));
+ });
+ get.mockRejectedValue(new Error('network down'));
+ await act(async () => {
+ fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Deactivate' }));
+ });
+
+ expect(await screen.findByText(/Could not refresh this account/)).toBeTruthy();
+ const done = screen.getByRole('dialog');
+ expect(within(done).getByText('User deactivated')).toBeTruthy();
+ expect(within(within(done).getByText('Removed from').parentElement!).getByText('Shared Co')).toBeTruthy();
+ });
+
+ it('looks the same address up again when asked', async () => {
+ const answer = get.getMockImplementation()!;
+ get.mockRejectedValueOnce(new Error('timeout'));
+ renderPage();
+ await lookUp('pat@example.com');
+ expect(await screen.findByText('timeout')).toBeTruthy();
+
+ get.mockImplementation(answer);
+ await lookUp('pat@example.com');
+ expect(await screen.findByText('Pat Lee')).toBeTruthy();
+ });
+
+ it('refuses a central manager that answers without the preview', async () => {
+ const { organizations: _omitted, ...plainRecord } = user;
+ get.mockImplementation(async (url: string) =>
+ url.includes('?email=') ? { data: [{ id: 'usr-1', status: 'ACTIVE' }] } : { data: plainRecord }
+ );
+ renderPage();
+ await lookUp('pat@example.com');
+
+ expect(await screen.findByText(/cannot preview organization memberships/)).toBeTruthy();
+ expect(screen.queryByRole('button', { name: 'Deactivate' })).toBeNull();
+ });
+
+ it('keeps the address out of the duplicate-account error', async () => {
+ get.mockResolvedValueOnce({ data: [{ id: 'usr-1', status: 'ACTIVE' }, { id: 'usr-2', status: 'ACTIVE' }] });
+ renderPage();
+ await lookUp('pat@example.com');
+
+ const message = await screen.findByText(/More than one live account/);
+ expect(message.textContent).not.toContain('pat@example.com');
+ });
});
diff --git a/src/features/admin/users/index.tsx b/src/features/admin/users/index.tsx
index 1503e0847..850566c6c 100644
--- a/src/features/admin/users/index.tsx
+++ b/src/features/admin/users/index.tsx
@@ -27,11 +27,14 @@ export function UsersAdminIndex() {
const [input, setInput] = useState('');
// Held in state, never in the URL: addresses must stay out of the page URL that RUM and third-party pixels read.
const [email, setEmail] = useState('');
- const { data: user, isFetching, isError, error } = useQuery(adminUserQueryOptions(email));
+ const { data: user, isFetching, isError, error, refetch } = useQuery(adminUserQueryOptions(email));
const lookUp = (event: FormEvent) => {
event.preventDefault();
- setEmail(normalizeEmail(input));
+ const next = normalizeEmail(input);
+ // The same address keeps the same query key, so it needs an explicit refetch to retry or refresh the preview.
+ if (next === email) { void refetch(); }
+ else { setEmail(next); }
};
return (
@@ -63,10 +66,20 @@ export function UsersAdminIndex() {
? null
: isFetching && user === undefined
?
+ : user
+ ? (
+ // A failed refetch keeps the loaded account, so an open deactivation result stays mounted.
+ <>
+ {isError && (
+
+ Could not refresh this account; showing the last one loaded.
+
+ )}
+
+ >
+ )
: isError
?
{error?.message || 'Could not look up that address.'}
- : user
- ?
:
No account uses {email}.
}
diff --git a/src/features/admin/users/userAdminApi.ts b/src/features/admin/users/userAdminApi.ts
index 88946d9e7..00c62d5a0 100644
--- a/src/features/admin/users/userAdminApi.ts
+++ b/src/features/admin/users/userAdminApi.ts
@@ -1,6 +1,5 @@
-// Central manager's staff-only `/Admin/User` endpoint, hand-typed: the generated SDK carries neither the membership
-// preview nor the deactivation result, so URLs are cast as elsewhere. The endpoint enforces every permission the page
-// gates on.
+// Central manager's staff-only `/Admin/User` endpoint. The generated SDK types the paths but not the membership preview
+// or the deactivation result, so those are hand-typed. The endpoint enforces every permission the page gates on.
import { apiClient } from '@/config/apiClient';
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -39,14 +38,20 @@ const userAdminQueryKey = ['fabric-admin', 'users'] as const;
export const normalizeEmail = (email: string) => email.trim().toLowerCase();
async function findUser(email: string): Promise {
- const { data } = await apiClient.get(`${BASE}?email=${encodeURIComponent(email)}` as '/User/');
+ const { data } = await apiClient.get(`${BASE}?email=${encodeURIComponent(email)}` as '/Admin/User/');
const live = (data as unknown as Array<{ id: string; status?: string }>).filter((user) => user.status !== 'DELETED');
- if (live.length > 1) { throw new Error(`More than one account uses ${email}.`); }
+ // The address stays out of error text: query errors reach the console, and from there RUM.
+ if (live.length > 1) { throw new Error('More than one live account uses this address.'); }
if (!live.length) { return null; }
const { data: user } = await apiClient.get(
- `${BASE}${encodeURIComponent(live[0].id)}?previewMembershipRemoval=true` as '/User/{id}',
+ `${BASE}${encodeURIComponent(live[0].id)}?previewMembershipRemoval=true` as '/Admin/User/{id}',
);
+ // A central manager without the preview answers the plain record; refuse it rather than offer a deactivation
+ // whose effect can't be shown.
+ if (!Array.isArray((user as unknown as Partial).organizations)) {
+ throw new Error('This central manager cannot preview organization memberships yet.');
+ }
return user as unknown as AdminUser;
}
@@ -60,14 +65,14 @@ export function adminUserQueryOptions(email: string) {
}
/**
- * PATCH the status to DEACTIVATED. Failures toast through the global MutationCache handler, which already words an
- * unanswered write as "outcome unknown". Every settle refetches, because a failed answer can still be a committed write.
+ * Failures toast through the global MutationCache handler, which already words an unanswered write as "outcome
+ * unknown". Every settle refetches, because a failed answer can still be a committed write.
*/
export function useDeactivateUserMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (userId: string) => {
- const { data } = await apiClient.patch(`${BASE}${encodeURIComponent(userId)}` as '/User/{id}', {
+ const { data } = await apiClient.patch(`${BASE}${encodeURIComponent(userId)}` as '/Admin/User/{id}', {
status: 'DEACTIVATED',
});
return data as unknown as DeactivateUserResult;
From 2bd93bbfedd5991bcac3016858b687b97f6e87ba Mon Sep 17 00:00:00 2001
From: David C
Date: Thu, 22 Oct 2026 19:44:05 -0400
Subject: [PATCH 03/23] fix(admin): close the deactivate dialog on failure, and
show an unreported result
A failed PATCH closes the confirm dialog: the toast reports the outcome and
the refetch shows the account as it now is. A successful answer that does
not list the organizations shows "Not reported" instead of breaking the
dialog.
Refs HarperFast/studio#1997
Co-Authored-By: Claude Opus 5.5
---
src/features/admin/users/index.test.tsx | 32 +++++++++++++++++++++++-
src/features/admin/users/index.tsx | 10 +++++---
src/features/admin/users/userAdminApi.ts | 16 ++++++------
3 files changed, 46 insertions(+), 12 deletions(-)
diff --git a/src/features/admin/users/index.test.tsx b/src/features/admin/users/index.test.tsx
index 34a476e12..3d0696895 100644
--- a/src/features/admin/users/index.test.tsx
+++ b/src/features/admin/users/index.test.tsx
@@ -9,7 +9,7 @@ const { get, patch } = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn() }));
vi.mock('@/config/apiClient', () => ({ apiClient: { get, patch } }));
-import { UsersAdminIndex } from './index';
+import { UsersAdminIndex } from '@/features/admin/users/index';
const USER = {
id: 'usr-1',
@@ -196,4 +196,34 @@ describe('UsersAdminIndex', () => {
const message = await screen.findByText(/More than one live account/);
expect(message.textContent).not.toContain('pat@example.com');
});
+
+ it('closes the confirm dialog when the PATCH fails', async () => {
+ patch.mockRejectedValueOnce(new Error('no answer'));
+ renderPage();
+ await lookUp('pat@example.com');
+ await act(async () => {
+ fireEvent.click(await screen.findByRole('button', { name: 'Deactivate' }));
+ });
+ await act(async () => {
+ fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Deactivate' }));
+ });
+
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('says so when a successful PATCH does not report the organizations', async () => {
+ patch.mockResolvedValueOnce({ data: '' });
+ renderPage();
+ await lookUp('pat@example.com');
+ await act(async () => {
+ fireEvent.click(await screen.findByRole('button', { name: 'Deactivate' }));
+ });
+ await act(async () => {
+ fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Deactivate' }));
+ });
+
+ const done = await screen.findByRole('dialog');
+ expect(within(done).getByText('User deactivated')).toBeTruthy();
+ expect(within(done).getAllByText('Not reported by the server.')).toHaveLength(2);
+ });
});
diff --git a/src/features/admin/users/index.tsx b/src/features/admin/users/index.tsx
index 850566c6c..037cdc4e1 100644
--- a/src/features/admin/users/index.tsx
+++ b/src/features/admin/users/index.tsx
@@ -32,7 +32,6 @@ export function UsersAdminIndex() {
const lookUp = (event: FormEvent) => {
event.preventDefault();
const next = normalizeEmail(input);
- // The same address keeps the same query key, so it needs an explicit refetch to retry or refresh the preview.
if (next === email) { void refetch(); }
else { setEmail(next); }
};
@@ -204,7 +203,8 @@ function DeactivateDialog({ user, onClose }: { user: AdminUser; onClose: () => v
@@ -218,13 +218,15 @@ function DeactivateDialog({ user, onClose }: { user: AdminUser; onClose: () => v
function MembershipList({ title, memberships, empty }: {
title: string;
- memberships: OrganizationMembership[];
+ memberships: OrganizationMembership[] | null;
empty: string;
}) {
return (
{title}
- {memberships.length
+ {memberships === null
+ ?
Not reported by the server.
+ : memberships.length
? (
{memberships.map((membership) =>
{organizationLabel(membership)}
)}
diff --git a/src/features/admin/users/userAdminApi.ts b/src/features/admin/users/userAdminApi.ts
index 00c62d5a0..0646a89a9 100644
--- a/src/features/admin/users/userAdminApi.ts
+++ b/src/features/admin/users/userAdminApi.ts
@@ -1,5 +1,4 @@
-// Central manager's staff-only `/Admin/User` endpoint. The generated SDK types the paths but not the membership preview
-// or the deactivation result, so those are hand-typed. The endpoint enforces every permission the page gates on.
+// The generated SDK types these paths but not the membership preview or the deactivation result, so those are typed here.
import { apiClient } from '@/config/apiClient';
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -23,11 +22,10 @@ export interface AdminUser {
organizations: MembershipPreview[];
}
+/** Null when the server's answer did not say, though the write went through. */
export interface DeactivateUserResult {
- id: string;
- status: string;
- removedFrom: OrganizationMembership[];
- retainedIn: OrganizationMembership[];
+ removedFrom: OrganizationMembership[] | null;
+ retainedIn: OrganizationMembership[] | null;
}
const BASE = '/Admin/User/';
@@ -75,7 +73,11 @@ export function useDeactivateUserMutation() {
const { data } = await apiClient.patch(`${BASE}${encodeURIComponent(userId)}` as '/Admin/User/{id}', {
status: 'DEACTIVATED',
});
- return data as unknown as DeactivateUserResult;
+ const result = data as unknown as Partial | undefined;
+ return {
+ removedFrom: Array.isArray(result?.removedFrom) ? result.removedFrom : null,
+ retainedIn: Array.isArray(result?.retainedIn) ? result.retainedIn : null,
+ };
},
onSettled: () => queryClient.invalidateQueries({ queryKey: userAdminQueryKey }),
});
From f0d359701fa2b2ea5ee8885507829880007b867f Mon Sep 17 00:00:00 2001
From: David C
Date: Thu, 22 Oct 2026 19:47:14 -0400
Subject: [PATCH 04/23] fix(admin): hold the deactivation dialog at page level
The dialog keeps the account as it was when Deactivate was pressed, so the
refetch after the PATCH can no longer unmount the result, whatever it finds.
Refs HarperFast/studio#1997
Co-Authored-By: Claude Opus 5.5
---
src/features/admin/users/index.test.tsx | 16 ++++++++++++++++
src/features/admin/users/index.tsx | 20 +++++++++++---------
2 files changed, 27 insertions(+), 9 deletions(-)
diff --git a/src/features/admin/users/index.test.tsx b/src/features/admin/users/index.test.tsx
index 3d0696895..c958632f6 100644
--- a/src/features/admin/users/index.test.tsx
+++ b/src/features/admin/users/index.test.tsx
@@ -226,4 +226,20 @@ describe('UsersAdminIndex', () => {
expect(within(done).getByText('User deactivated')).toBeTruthy();
expect(within(done).getAllByText('Not reported by the server.')).toHaveLength(2);
});
+
+ it('keeps the result open when the refetch finds no account any more', async () => {
+ renderPage();
+ await lookUp('pat@example.com');
+ await act(async () => {
+ fireEvent.click(await screen.findByRole('button', { name: 'Deactivate' }));
+ });
+ get.mockResolvedValue({ data: [] });
+ await act(async () => {
+ fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Deactivate' }));
+ });
+
+ expect(await screen.findByText('No account uses pat@example.com.')).toBeTruthy();
+ const done = screen.getByRole('dialog');
+ expect(within(within(done).getByText('Removed from').parentElement!).getByText('Shared Co')).toBeTruthy();
+ });
});
diff --git a/src/features/admin/users/index.tsx b/src/features/admin/users/index.tsx
index 037cdc4e1..b1c525acd 100644
--- a/src/features/admin/users/index.tsx
+++ b/src/features/admin/users/index.tsx
@@ -28,6 +28,7 @@ export function UsersAdminIndex() {
// Held in state, never in the URL: addresses must stay out of the page URL that RUM and third-party pixels read.
const [email, setEmail] = useState('');
const { data: user, isFetching, isError, error, refetch } = useQuery(adminUserQueryOptions(email));
+ const [deactivating, setDeactivating] = useState(null);
const lookUp = (event: FormEvent) => {
event.preventDefault();
@@ -67,26 +68,26 @@ export function UsersAdminIndex() {
?
: user
? (
- // A failed refetch keeps the loaded account, so an open deactivation result stays mounted.
<>
{isError && (
Could not refresh this account; showing the last one loaded.
)}
-
+ setDeactivating(user)} />
>
)
: isError
?
{error?.message || 'Could not look up that address.'}
:
No account uses {email}.
}
+
+ {deactivating && setDeactivating(null)} />}
);
}
-function UserDetails({ user }: { user: AdminUser }) {
- const [confirming, setConfirming] = useState(false);
+function UserDetails({ user, onDeactivate }: { user: AdminUser; onDeactivate: () => void }) {
const name = [user.firstname, user.lastname].filter(Boolean).join(' ');
const removable = user.organizations.filter((membership) => !membership.lastAdmin);
const deactivated = user.status === 'DEACTIVATED';
@@ -124,11 +125,9 @@ function UserDetails({ user }: { user: AdminUser }) {
Already deactivated: deactivating again removes the memberships marked for removal.
)}
-
+
)}
-
- {confirming && setConfirming(false)} />}
);
}
@@ -150,7 +149,10 @@ function MembershipRow({ membership }: { membership: MembershipPreview }) {
const organizationLabel = (membership: OrganizationMembership) =>
membership.organizationName || membership.organizationId;
-/** Mount only while open, so a result never outlives the session that produced it. */
+/**
+ * Mount only while open. It holds the account as it was when Deactivate was pressed, so the refetch that follows the
+ * PATCH can't change or unmount it, whatever that refetch finds.
+ */
function DeactivateDialog({ user, onClose }: { user: AdminUser; onClose: () => void }) {
const mutation = useDeactivateUserMutation();
const [result, setResult] = useState(null);
@@ -203,7 +205,7 @@ function DeactivateDialog({ user, onClose }: { user: AdminUser; onClose: () => v
+ {removable} removed on deactivation · {user.organizations.length - removable} kept as the last admin
+
+ )}
+
{user.organizations.length
? (
-
+
{user.organizations.map((membership) => (
-
+
))}
)
- :
Not a member of any organization.
}
-
-
-
-
Deactivate
-
- Deactivating an account ends its sessions and blocks sign-in. It also leaves every organization that keeps
- another admin who can sign in; where it is the last one, its membership stays so the organization is never
- left without an admin.
-
- {deleted
- ?
This account is deleted, so it cannot be deactivated.
- : deactivated && !removable.length
- ?
Deactivated, and in no organization it could leave.
: (
-
- {deactivated && (
-
- Already deactivated: deactivating again removes the memberships marked for removal.
-
- )}
- Deactivate
-
+ } title="No organizations">
+ Not a member of any organization.
+
)}
-
+ {deactivated
+ ? 'Already deactivated: deactivating again removes the memberships marked for removal.'
+ : 'Ends every session and blocks sign-in.'} Leaves {organizations(removable)}
+ {kept > 0 && `, and stays in ${organizations(kept)} as the last admin who can sign in`}.
+
+
+ Deactivate
+
+
+ );
+}
+
+/**
+ * Mount only while open. It holds the account as it was when Deactivate was pressed, so the refetch that follows the
+ * PATCH can't change or unmount it, whatever that refetch finds.
+ */
+export function DeactivateDialog({ user, onClose }: { user: AdminUser; onClose: () => void }) {
+ const mutation = useDeactivateUserMutation();
+ const [result, setResult] = useState(null);
+ const removed = user.organizations.filter((membership) => !membership.lastAdmin);
+ const kept = user.organizations.filter((membership) => membership.lastAdmin);
+
+ return (
+
+ );
+}
+
+const TONES: Record<'leave' | 'stay', { icon: ReactNode; box: string }> = {
+ leave: { icon: , box: 'bg-muted/40' },
+ stay: { icon: , box: 'border-yellow/40 bg-yellow/10' },
+};
+
+function MembershipList({ tone, title, memberships }: {
+ tone: 'leave' | 'stay';
+ title: string;
+ memberships: OrganizationMembership[] | null;
+}) {
+ return (
+
+
{TONES[tone].icon}{title}
+ {memberships === null
+ ?
Not reported by the server.
+ : memberships.length
+ ? (
+
+ {memberships.map((membership) =>
{organizationLabel(membership)}
)}
+
+ )
+ :
No organization.
}
+
+ );
+}
diff --git a/src/features/admin/users/components/EmptyState.tsx b/src/features/admin/users/components/EmptyState.tsx
new file mode 100644
index 000000000..bf7b8a989
--- /dev/null
+++ b/src/features/admin/users/components/EmptyState.tsx
@@ -0,0 +1,13 @@
+import { ReactNode } from 'react';
+
+export function EmptyState({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
+ return (
+