Add admin enhancements: impersonation, plan management, org status enforcement

Enhancement 1 - Block suspended/archived org access:
- Add org status check in switchOrganization() (auth.service.ts)
- Filter suspended/archived orgs from login response (generateTokenResponse)
- Add org status guard with 60s cache in TenantMiddleware
- Frontend: filter orgs in SelectOrgPage, add 403 handler in api.ts

Enhancement 2 - Change tenant plan level:
- Add updatePlanLevel() to organizations.service.ts
- Add PUT /admin/organizations/:id/plan endpoint
- Frontend: clickable plan dropdown in Organizations table + confirmation modal
- Plan level Select in tenant detail drawer

Enhancement 3 - User impersonation:
- Add impersonateUser() to auth.service.ts with impersonatedBy JWT claim
- Add POST /admin/impersonate/:userId endpoint
- Frontend: Impersonate button in Users tab (disabled for admins)
- Impersonation state management in authStore (start/stop/persist)
- Orange impersonation banner in AppLayout header with stop button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 13:21:59 -05:00
parent e156cf7c87
commit d9bb9363dd
10 changed files with 345 additions and 18 deletions

View File

@@ -11,10 +11,12 @@ import {
IconCrown, IconPlus, IconArchive, IconChevronDown,
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
IconCurrencyDollar, IconClipboardCheck, IconLogin,
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
interface AdminUser {
id: string; email: string; firstName: string; lastName: string;
@@ -115,10 +117,13 @@ export function AdminPage() {
const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false);
const [form, setForm] = useState<CreateTenantForm>(initialFormState);
const [statusConfirm, setStatusConfirm] = useState<{ orgId: string; orgName: string; newStatus: string } | null>(null);
const [planConfirm, setPlanConfirm] = useState<{ orgId: string; orgName: string; newPlan: string } | null>(null);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [drawerOpened, { open: openDrawer, close: closeDrawer }] = useDisclosure(false);
const [subForm, setSubForm] = useState({ paymentDate: '', confirmationNumber: '', renewalDate: '' });
const queryClient = useQueryClient();
const navigate = useNavigate();
const { startImpersonation } = useAuthStore();
// ── Queries ──
@@ -193,6 +198,34 @@ export function AdminPage() {
},
});
const changeOrgPlan = useMutation({
mutationFn: async ({ orgId, planLevel }: { orgId: string; planLevel: string }) => {
await api.put(`/admin/organizations/${orgId}/plan`, { planLevel });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
queryClient.invalidateQueries({ queryKey: ['admin-metrics'] });
queryClient.invalidateQueries({ queryKey: ['admin-tenants-health'] });
queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] });
setPlanConfirm(null);
},
});
const impersonateUser = useMutation({
mutationFn: async (userId: string) => {
const { data } = await api.post(`/admin/impersonate/${userId}`);
return data;
},
onSuccess: (data) => {
startImpersonation(data.accessToken, data.user, data.organizations);
if (data.organizations.length > 0) {
navigate('/select-org');
} else {
navigate('/');
}
},
});
// ── Helpers ──
const updateField = <K extends keyof CreateTenantForm>(key: K, value: CreateTenantForm[K]) => {
@@ -434,9 +467,34 @@ export function AdminPage() {
</Menu>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={planBadgeColor[o.plan_level] || 'gray'}>
{o.plan_level}
</Badge>
<Menu shadow="md" width={180} position="bottom-start">
<Menu.Target>
<Badge
size="sm" variant="light"
color={planBadgeColor[o.plan_level] || 'gray'}
style={{ cursor: 'pointer' }}
rightSection={<IconChevronDown size={10} />}
onClick={(e) => e.stopPropagation()}
>
{o.plan_level}
</Badge>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Change plan</Menu.Label>
{['standard', 'premium', 'enterprise']
.filter(p => p !== o.plan_level)
.map(p => (
<Menu.Item key={p}
color={planBadgeColor[p]}
onClick={(e) => {
e.stopPropagation();
setPlanConfirm({ orgId: o.id, orgName: o.name, newPlan: p });
}}>
{p.charAt(0).toUpperCase() + p.slice(1)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</Table.Td>
<Table.Td ta="center">
<Badge variant="light" size="sm">{o.member_count}</Badge>
@@ -483,6 +541,7 @@ export function AdminPage() {
<Table.Th>Organizations</Table.Th>
<Table.Th>Last Login</Table.Th>
<Table.Th ta="center">SuperAdmin</Table.Th>
<Table.Th ta="center">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -538,6 +597,20 @@ export function AdminPage() {
/>
)}
</Table.Td>
<Table.Td ta="center">
<Tooltip label={u.isPlatformOwner || u.isSuperadmin ? 'Cannot impersonate admins' : `View app as ${u.firstName}`}>
<Button
variant="subtle"
size="xs"
leftSection={<IconEye size={14} />}
disabled={u.isPlatformOwner || u.isSuperadmin}
loading={impersonateUser.isPending}
onClick={() => impersonateUser.mutate(u.id)}
>
Impersonate
</Button>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
@@ -657,7 +730,21 @@ export function AdminPage() {
<Text size="xs" c="dimmed">Status</Text>
<Badge size="xs" variant="light" color={statusColor[tenantDetail.organization.status]}>{tenantDetail.organization.status}</Badge>
<Text size="xs" c="dimmed">Plan</Text>
<Badge size="xs" variant="light" color={planBadgeColor[tenantDetail.organization.plan_level]}>{tenantDetail.organization.plan_level}</Badge>
<Select
size="xs"
data={[
{ value: 'standard', label: 'Standard' },
{ value: 'premium', label: 'Premium' },
{ value: 'enterprise', label: 'Enterprise' },
]}
value={tenantDetail.organization.plan_level}
onChange={(val) => {
if (val && selectedOrgId && val !== tenantDetail.organization.plan_level) {
changeOrgPlan.mutate({ orgId: selectedOrgId, planLevel: val });
}
}}
styles={{ root: { maxWidth: 140 } }}
/>
<Text size="xs" c="dimmed">Contract #</Text>
<Text size="xs">{tenantDetail.organization.contract_number || '\u2014'}</Text>
<Text size="xs" c="dimmed">Members</Text>
@@ -837,6 +924,36 @@ export function AdminPage() {
</Stack>
)}
</Modal>
{/* ── Plan Change Confirmation Modal ── */}
<Modal
opened={planConfirm !== null}
onClose={() => setPlanConfirm(null)}
title="Confirm Plan Change"
size="sm"
centered
>
{planConfirm && (
<Stack>
<Text size="sm">
Change <Text span fw={700}>{planConfirm.orgName}</Text> plan to{' '}
<Badge size="sm" variant="light" color={planBadgeColor[planConfirm.newPlan] || 'gray'}>
{planConfirm.newPlan}
</Badge>?
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => setPlanConfirm(null)}>Cancel</Button>
<Button
color={planBadgeColor[planConfirm.newPlan] || 'blue'}
onClick={() => changeOrgPlan.mutate({ orgId: planConfirm.orgId, planLevel: planConfirm.newPlan })}
loading={changeOrgPlan.isPending}
>
Confirm
</Button>
</Group>
</Stack>
)}
</Modal>
</Stack>
);
}

View File

@@ -49,6 +49,11 @@ export function SelectOrgPage() {
},
});
// Filter out suspended/archived organizations (defense in depth)
const activeOrganizations = (organizations || []).filter(
(org: any) => !org.status || !['suspended', 'archived'].includes(org.status),
);
const handleSelect = async (org: any) => {
try {
const { data } = await api.post('/auth/switch-org', {
@@ -90,8 +95,15 @@ export function SelectOrgPage() {
Choose an HOA to manage or create a new one
</Text>
<Stack mt={30}>
{organizations.map((org) => (
{/* Filter out suspended/archived orgs (defense in depth — backend also filters) */}
{organizations.length > activeOrganizations.length && (
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light" mt="md">
Some organizations are currently suspended or archived and are not shown.
</Alert>
)}
<Stack mt={organizations.length > activeOrganizations.length ? 'sm' : 30}>
{activeOrganizations.map((org) => (
<Card
key={org.id}
shadow="sm"