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

@@ -1,4 +1,4 @@
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar } from '@mantine/core';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconLogout,
@@ -7,6 +7,7 @@ import {
IconSettings,
IconUserCog,
IconUsersGroup,
IconEyeOff,
} from '@tabler/icons-react';
import { Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
@@ -15,25 +16,53 @@ import logoSrc from '../../assets/logo.svg';
export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout } = useAuthStore();
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
const navigate = useNavigate();
const isImpersonating = !!impersonationOriginal;
const handleLogout = () => {
logout();
navigate('/login');
};
const handleStopImpersonation = () => {
stopImpersonation();
navigate('/admin');
};
// Tenant admins (president role) can manage org members
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
return (
<AppShell
header={{ height: 60 }}
header={{ height: isImpersonating ? 100 : 60 }}
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="md"
>
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
{isImpersonating && (
<Group
h={40}
px="md"
justify="center"
gap="xs"
style={{ backgroundColor: 'var(--mantine-color-orange-6)' }}
>
<Text size="sm" fw={600} c="white">
Impersonating {user?.firstName} {user?.lastName} ({user?.email})
</Text>
<Button
size="xs"
variant="white"
color="orange"
leftSection={<IconEyeOff size={14} />}
onClick={handleStopImpersonation}
>
Stop Impersonating
</Button>
</Group>
)}
<Group h={60} px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} />
@@ -46,7 +75,7 @@ export function AppLayout() {
<Menu.Target>
<UnstyledButton>
<Group gap="xs">
<Avatar size="sm" radius="xl" color="blue">
<Avatar size="sm" radius="xl" color={isImpersonating ? 'orange' : 'blue'}>
{user?.firstName?.[0]}{user?.lastName?.[0]}
</Avatar>
<Text size="sm">{user?.firstName} {user?.lastName}</Text>
@@ -55,6 +84,18 @@ export function AppLayout() {
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{isImpersonating && (
<>
<Menu.Item
color="orange"
leftSection={<IconEyeOff size={14} />}
onClick={handleStopImpersonation}
>
Stop Impersonating
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Label>Account</Menu.Label>
<Menu.Item
leftSection={<IconUserCog size={14} />}