Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ee25d19
feat(admin): Users page to look up and deactivate a user
DavidCockerill Oct 22, 2026
027e427
fix(admin): keep the Users page and its result on a failed refresh
DavidCockerill Oct 22, 2026
2bd93bb
fix(admin): close the deactivate dialog on failure, and show an unrep…
DavidCockerill Oct 22, 2026
f0d3597
fix(admin): hold the deactivation dialog at page level
DavidCockerill Oct 22, 2026
0b0672b
feat(admin): user lookup by email or id, with a linkable details page
DavidCockerill Oct 26, 2026
8a181e3
fix(admin): close the open account on a failed lookup, and drop stale…
DavidCockerill Oct 26, 2026
77c6e0f
refactor(admin): drop two comments the lookup no longer needs
DavidCockerill Oct 26, 2026
65f78ad
feat(admin): give the Users page a proper look
DavidCockerill Oct 26, 2026
1df814f
fix(admin): keep user details apart from the API Tokens page's staff-…
DavidCockerill Oct 26, 2026
15034dc
refactor(admin): leave the deactivation split to the confirm dialog
DavidCockerill Oct 26, 2026
5479dde
feat(admin): move Deactivate into a settings menu on the user card
DavidCockerill Oct 26, 2026
f257668
style(admin): enlarge the user card's settings gear
DavidCockerill Oct 26, 2026
65d3667
feat(admin): type the account's email to confirm a deactivation
DavidCockerill Oct 26, 2026
582a578
feat(admin): offer to deactivate without leaving organizations
DavidCockerill Oct 26, 2026
4c25373
feat(admin): name removing a deactivated account from organizations a…
DavidCockerill Oct 26, 2026
2ba2124
fix(admin): report a status-only deactivation by what was sent and wh…
DavidCockerill Oct 26, 2026
dfc7723
fix(admin): drop an email lookup once any navigation has started
DavidCockerill Oct 26, 2026
4a76d81
fix(admin): drop an email lookup on any navigation or unmount, and fr…
DavidCockerill Oct 26, 2026
654946c
fix(admin): keep an email lookup through a router reload in place
DavidCockerill Oct 26, 2026
a9dc1d1
feat(admin): reactivate a deactivated account from the settings menu
DavidCockerill Oct 28, 2026
578bf3e
refactor(admin): say exactly what drops an email lookup
DavidCockerill Oct 28, 2026
56c345d
fix(admin): check lastAdmin, gate Users on the reads it makes, title …
DavidCockerill Oct 29, 2026
2adc23c
fix(admin): refuse a null membership in user details like any other m…
DavidCockerill Oct 29, 2026
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
160 changes: 160 additions & 0 deletions e2e/tests/admin-users.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { expect, type Page, test } from '@playwright/test';
import { rememberAppSignIn } from './signInMarkers';

const staff = {
id: 'usr-staff',
email: 'staff@harperdb.io',
firstname: 'Sky',
lastname: 'Admin',
roles: {},
fabricRole: 'fabric_admin',
staffPermissions: ['user:read', 'user:status', 'cluster:read', 'org:read'],
};

const pat = {
id: 'usr-0f8gtsvjqed6xwy9',
email: 'pat.target@example.test',
firstname: 'Pat',
lastname: 'Target',
status: 'ACTIVE',
isVerified: true,
createdAt: '2026-03-14T16:20:00.000Z',
lastLoginDate: '2026-10-24T09:12:00.000Z',
lastAccessedAt: '2026-10-26T18:41:00.000Z',
organizations: [
{
organizationId: 'org-shared',
organizationName: 'Shared Co',
roleNames: ['admin'],
lastAdmin: false,
clusters: [
{ id: 'clu-cm', name: 'Central Manager', status: 'RUNNING' },
{ id: 'clu-hm1', name: 'Host Manager 1', status: 'RUNNING' },
{ id: 'clu-hm2', name: 'Host Manager 2', status: 'STOPPED' },
{ id: 'clu-hm3', name: 'Host Manager 3', status: 'FAILED' },
{ id: 'clu-hm4', name: 'Host Manager 4', status: 'UPDATING' },
],
},
{ organizationId: 'org-solo', organizationName: 'Solo Co', roleNames: ['admin'], lastAdmin: true, clusters: [] },
{
organizationId: 'org-stale',
organizationName: 'Stale Co',
roleNames: ['admin', 'developer'],
lastAdmin: true,
clusters: [{ id: 'clu-stale', name: 'Legacy API', status: 'RUNNING' }],
},
{
organizationId: 'org-member',
organizationName: 'Member Co',
roleNames: ['developer'],
lastAdmin: false,
clusters: [],
},
],
};

async function mockCentralManager(page: Page) {
await rememberAppSignIn(page);
await page.route(
'**/*',
(route) =>
['fetch', 'xhr'].includes(route.request().resourceType())
? route.fulfill({ status: 404, json: {} })
: route.continue(),
);
await page.route('**/User/current', (route) => route.fulfill({ json: staff }));
await page.route(
/\/Admin\/User\/\?email=/,
(route) => route.fulfill({ json: [{ id: pat.id, email: pat.email, status: pat.status }] }),
);
await page.route(`**/Admin/User/${pat.id}?details=true`, (route) => route.fulfill({ json: pat }));
await page.routeWebSocket('**/*', (socket) => socket.close());
}

for (const colorScheme of ['light', 'dark'] as const) {
test(`user lookup and details read well on desktop and mobile in ${colorScheme} mode`, async ({ page }, testInfo) => {
await mockCentralManager(page);
await page.emulateMedia({ colorScheme });
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/#/admin/users');
await expect(page.getByLabel('Email or user ID')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath(`users-search-${colorScheme}.png`), fullPage: true });

await page.getByLabel('Email or user ID').fill('Pat.Target@example.test');
await page.getByRole('button', { name: 'Look up' }).click();
await expect(page).toHaveURL(new RegExp(`#/admin/users/${pat.id}$`));
await expect(page.getByText('Pat Target').first()).toBeVisible();
await page.screenshot({ path: testInfo.outputPath(`users-details-${colorScheme}.png`), fullPage: true });

await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath(`users-details-${colorScheme}-mobile.png`), fullPage: true });
});
}

test('deactivating confirms the split, then shows what the server did', async ({ page }, testInfo) => {
await mockCentralManager(page);
let patched: unknown;
await page.route(`**/Admin/User/${pat.id}`, async (route) => {
if (route.request().method() !== 'PATCH') { return route.fallback(); }
patched = route.request().postDataJSON();
await route.fulfill({
json: {
id: pat.id,
status: 'DEACTIVATED',
removedFrom: [pat.organizations[0], pat.organizations[3]],
retainedIn: [pat.organizations[1], pat.organizations[2]],
},
});
});
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto(`/#/admin/users/${pat.id}`);

await page.getByRole('button', { name: 'Account settings' }).click();
await expect(page.getByRole('menuitem', { name: /Deactivate/ })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('users-menu.png'), animations: 'disabled' });
await page.getByRole('menuitem', { name: /Deactivate/ }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Will stay in, as the last admin')).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Deactivate' })).toBeDisabled();
await page.screenshot({ path: testInfo.outputPath('users-confirm.png'), animations: 'disabled' });
await dialog.getByRole('radio', { name: /Only deactivate/ }).click();
await expect(dialog.getByText('They stay a member of all 4 organizations.')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('users-confirm-status-only.png'), animations: 'disabled' });
await dialog.getByRole('radio', { name: /leave organizations/ }).click();
await dialog.getByLabel(`Type ${pat.email} to confirm`).fill(pat.email);
await expect(dialog.getByRole('button', { name: 'Deactivate' })).toBeEnabled();

await dialog.getByRole('button', { name: 'Deactivate' }).click();
await expect(dialog.getByText('User deactivated')).toBeVisible();
expect(patched).toEqual({ status: 'DEACTIVATED' });
await page.screenshot({ path: testInfo.outputPath('users-result.png') });
});

test('reactivating sets a deactivated account back to active', async ({ page }, testInfo) => {
await mockCentralManager(page);
let current = { ...pat, status: 'DEACTIVATED' };
await page.route(`**/Admin/User/${pat.id}?details=true`, (route) => route.fulfill({ json: current }));
let patched: unknown;
await page.route(`**/Admin/User/${pat.id}`, async (route) => {
if (route.request().method() !== 'PATCH') { return route.fallback(); }
patched = route.request().postDataJSON();
current = { ...current, status: 'ACTIVE' };
await route.fulfill({ status: 204, body: '' });
});
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto(`/#/admin/users/${pat.id}`);

await page.getByRole('button', { name: 'Account settings' }).click();
await expect(page.getByRole('menuitem', { name: /Reactivate…/ })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('users-menu-deactivated.png'), animations: 'disabled' });
await page.getByRole('menuitem', { name: /Reactivate…/ }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText(`Reactivate ${pat.email}?`)).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('users-reactivate.png'), animations: 'disabled' });

await dialog.getByRole('button', { name: 'Reactivate' }).click();
await expect(dialog).toBeHidden();
expect(patched).toEqual({ status: 'ACTIVE' });
await expect(page.getByText('Active', { exact: true })).toBeVisible();
});
2 changes: 2 additions & 0 deletions src/features/admin/__tests__/adminRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ describe('admin section routes', () => {
['/admin/regions', '/regions'],
['/admin/hosts', '/hosts'],
['/admin/monitoring', '/monitoring'],
['/admin/users', '/users'],
['/admin/users/usr-1', '/users/$userId'],
['/admin/api-token', '/api-token'],
])('%s resolves to its own route', (path, expectedId) => {
expect(matchLeaf(path).routeId).toContain(expectedId);
Expand Down
17 changes: 15 additions & 2 deletions src/features/admin/__tests__/adminShellVisibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,23 @@ 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']);
});

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', 'cluster:read']))).toEqual([
'Monitoring',
'Users',
]);
expect(labels(user('fabric_readonly', ['user:read', 'cluster:read']))).toEqual(['Monitoring']);
});

it('hides Users from a holder of user:status who cannot also read the users and clusters it shows', () => {
expect(labels(user('fabric_admin', ['user:status', 'cluster:read']))).toEqual(['Monitoring']);
expect(labels(user('fabric_admin', ['user:status', 'user:read']))).toEqual([]);
});
});
20 changes: 17 additions & 3 deletions src/features/admin/components/AdminShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,37 @@ 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
* endpoints slot in as new items) plus the active page. Each item names the
* staff permission its page needs, plus any account carve-out; the rail shows
* staff permissions its page needs, plus any account carve-out; the rail shows
* only the pages the account holds, and holding any of them grants entry. The
* dashboard route guard already handles the unauthenticated redirect.
*/
// Order matters: /admin redirects to the first item's route (see adminIndexRoute);
// anyone not permitted there is bounced to their first visible page below.
const items: Array<
SubNavItem & { permission: StaffPermission; ssoAccountOnly?: boolean; needsPermissionsField?: boolean }
SubNavItem & {
permission: StaffPermission;
alsoNeeds?: StaffPermission[];
ssoAccountOnly?: boolean;
needsPermissionsField?: boolean;
}
> = [
{ to: '/admin/notifications', label: 'Notifications', icon: BellIcon, permission: 'systemStatus:write' },
{ 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, which keeps the page fabric_admin-only; the details lookup needs both reads.
{
to: '/admin/users',
label: 'Users',
icon: UsersIcon,
permission: 'user:status',
alsoNeeds: ['user:read', 'cluster:read'],
},
// Only an API that sends apiToken:self has the token endpoints this page drives (DESIGN.md);
// super_user cannot mint a staff credential.
{
Expand All @@ -37,6 +50,7 @@ export function visibleAdminItems(user: User | LocalUser | null) {
const hasPermissionsField = user !== null && 'staffPermissions' in user && user.staffPermissions !== undefined;
return items.filter((item) =>
hasStaffPermission(user, item.permission)
&& (item.alsoNeeds ?? []).every((permission) => hasStaffPermission(user, permission))
&& !(item.ssoAccountOnly && isSuperUser)
&& !(item.needsPermissionsField && !hasPermissionsField)
);
Expand Down
22 changes: 22 additions & 0 deletions src/features/admin/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,27 @@ 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'), 'UsersAdminLayout'),
});

const usersIndexRoute = createRoute({
getParentRoute: () => usersRoute,
path: '/',
component: lazyRouteComponent(async () => import('@/features/admin/users/index'), 'UsersSearchPrompt'),
});

// Keyed by id, never by email: page URLs reach RUM and third-party pixels.
const userDetailsRoute = createRoute({
getParentRoute: () => usersRoute,
path: '$userId',
head: () => ({ meta: [{ title: 'User — Harper Fabric' }] }),
component: lazyRouteComponent(async () => import('@/features/admin/users/UserDetails'), 'UserDetailsPage'),
});

const apiTokenRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: 'api-token',
Expand All @@ -62,5 +83,6 @@ export const adminRoutes = [
regionsRoute,
hostsRoute,
monitoringRoute,
usersRoute.addChildren([usersIndexRoute, userDetailsRoute]),
apiTokenRoute,
];
102 changes: 102 additions & 0 deletions src/features/admin/users/UserDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Skeleton } from '@/components/ui/skeleton';
import { DeactivateDialog } from '@/features/admin/users/components/Deactivation';
import { EmptyState } from '@/features/admin/users/components/EmptyState';
import { OrganizationCard } from '@/features/admin/users/components/OrganizationCard';
import { ReactivateDialog } from '@/features/admin/users/components/Reactivation';
import { UserProfileCard } from '@/features/admin/users/components/UserProfileCard';
import { AdminUser, userDetailsQueryOptions } from '@/features/admin/users/userAdminApi';
import { useQuery } from '@tanstack/react-query';
import { useParams } from '@tanstack/react-router';
import { Building2Icon, TriangleAlertIcon, UserXIcon } from 'lucide-react';
import { useState } from 'react';

export function UserDetailsPage() {
const { userId } = useParams({ strict: false }) as { userId: string };
// Keyed so moving to another account drops everything held for the last one, an open confirmation included.
return <UserDetailsView key={userId} userId={userId} />;
}

function UserDetailsView({ userId }: { userId: string }) {
const { data: user, isPending, isError, error } = useQuery(userDetailsQueryOptions(userId));
const [deactivating, setDeactivating] = useState<AdminUser | null>(null);
const [reactivating, setReactivating] = useState<AdminUser | null>(null);

return (
<>
{isPending
? <DetailsSkeleton />
: user
? (
<>
{isError && <Callout>Could not refresh this account; showing the last one loaded.</Callout>}
<UserDetails
user={user}
onDeactivate={() => setDeactivating(user)}
onReactivate={() => setReactivating(user)}
/>
</>
)
: isError
? <Callout>{error?.message || 'Could not load this account.'}</Callout>
: (
<EmptyState icon={<UserXIcon />} title="No such account">
{`No account has the ID ${userId}.`}
</EmptyState>
)}

{deactivating && <DeactivateDialog user={deactivating} onClose={() => setDeactivating(null)} />}
{reactivating && <ReactivateDialog user={reactivating} onClose={() => setReactivating(null)} />}
</>
);
}

function UserDetails({ user, onDeactivate, onReactivate }: {
user: AdminUser;
onDeactivate: () => void;
onReactivate: () => void;
}) {
return (
<div className="space-y-8">
<UserProfileCard user={user} onDeactivate={onDeactivate} onReactivate={onReactivate} />

<section>
<h2 className="mb-3 text-lg font-light">Organizations</h2>
{user.organizations.length
? (
<ul className="space-y-3">
{user.organizations.map((membership) => (
<OrganizationCard key={membership.organizationId} membership={membership} />
))}
</ul>
)
: (
<EmptyState icon={<Building2Icon />} title="No organizations">
Not a member of any organization.
</EmptyState>
)}
</section>
</div>
);
}

function Callout({ children }: { children: string }) {
return (
<p className="mb-4 flex items-center gap-2 rounded-xl border border-red-600/40 bg-red-600/5 px-4 py-3 text-sm text-red-700 dark:text-red-300">
<TriangleAlertIcon className="size-4 shrink-0" />
{children}
</p>
);
}

function DetailsSkeleton() {
return (
<div className="space-y-8" aria-busy aria-label="Loading account">
<Skeleton className="h-52 w-full rounded-xl" />
<div className="space-y-3">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</div>
</div>
);
}
Loading
Loading