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:
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import * as jwt from 'jsonwebtoken';
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
@@ -12,9 +13,16 @@ export interface TenantRequest extends Request {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantMiddleware implements NestMiddleware {
|
export class TenantMiddleware implements NestMiddleware {
|
||||||
constructor(private configService: ConfigService) {}
|
// In-memory cache for org status to avoid DB hit per request
|
||||||
|
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
|
||||||
|
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||||
|
|
||||||
use(req: TenantRequest, _res: Response, next: NextFunction) {
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async use(req: TenantRequest, res: Response, next: NextFunction) {
|
||||||
// Try to extract tenant info from Authorization header JWT
|
// Try to extract tenant info from Authorization header JWT
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
@@ -23,6 +31,18 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
const secret = this.configService.get<string>('JWT_SECRET');
|
const secret = this.configService.get<string>('JWT_SECRET');
|
||||||
const decoded = jwt.verify(token, secret!) as any;
|
const decoded = jwt.verify(token, secret!) as any;
|
||||||
if (decoded?.orgSchema) {
|
if (decoded?.orgSchema) {
|
||||||
|
// Check if the org is still active (catches post-JWT suspension)
|
||||||
|
if (decoded.orgId) {
|
||||||
|
const status = await this.getOrgStatus(decoded.orgId);
|
||||||
|
if (status && ['suspended', 'archived'].includes(status)) {
|
||||||
|
res.status(403).json({
|
||||||
|
statusCode: 403,
|
||||||
|
message: `This organization has been ${status}. Please contact your administrator.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
req.tenantSchema = decoded.orgSchema;
|
req.tenantSchema = decoded.orgSchema;
|
||||||
req.orgId = decoded.orgId;
|
req.orgId = decoded.orgId;
|
||||||
req.userId = decoded.sub;
|
req.userId = decoded.sub;
|
||||||
@@ -34,4 +54,24 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getOrgStatus(orgId: string): Promise<string | null> {
|
||||||
|
const cached = this.orgStatusCache.get(orgId);
|
||||||
|
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
||||||
|
return cached.status;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await this.dataSource.query(
|
||||||
|
`SELECT status FROM shared.organizations WHERE id = $1`,
|
||||||
|
[orgId],
|
||||||
|
);
|
||||||
|
if (result.length > 0) {
|
||||||
|
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
|
||||||
|
return result[0].status;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical — don't block requests on cache miss errors
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards, Req, ForbiddenException, BadRequestException } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards, Req, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { OrganizationsService } from '../organizations/organizations.service';
|
import { OrganizationsService } from '../organizations/organizations.service';
|
||||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||||
@@ -12,6 +13,7 @@ import * as bcrypt from 'bcryptjs';
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private orgService: OrganizationsService,
|
private orgService: OrganizationsService,
|
||||||
private analyticsService: AdminAnalyticsService,
|
private analyticsService: AdminAnalyticsService,
|
||||||
@@ -92,6 +94,23 @@ export class AdminController {
|
|||||||
return { success: true, organization: org };
|
return { success: true, organization: org };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Plan Level ──
|
||||||
|
|
||||||
|
@Put('organizations/:id/plan')
|
||||||
|
async updateOrgPlan(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { planLevel: string },
|
||||||
|
) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const validPlans = ['standard', 'premium', 'enterprise'];
|
||||||
|
if (!validPlans.includes(body.planLevel)) {
|
||||||
|
throw new BadRequestException(`Invalid plan. Must be one of: ${validPlans.join(', ')}`);
|
||||||
|
}
|
||||||
|
const org = await this.orgService.updatePlanLevel(id, body.planLevel);
|
||||||
|
return { success: true, organization: org };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Superadmin Toggle ──
|
// ── Superadmin Toggle ──
|
||||||
|
|
||||||
@Post('users/:id/superadmin')
|
@Post('users/:id/superadmin')
|
||||||
@@ -101,6 +120,15 @@ export class AdminController {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── User Impersonation ──
|
||||||
|
|
||||||
|
@Post('impersonate/:userId')
|
||||||
|
async impersonateUser(@Req() req: any, @Param('userId') userId: string) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const adminUserId = req.user.userId || req.user.sub;
|
||||||
|
return this.authService.impersonateUser(adminUserId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tenant Health ──
|
// ── Tenant Health ──
|
||||||
|
|
||||||
@Get('tenants-health')
|
@Get('tenants-health')
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
@@ -104,6 +106,14 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Not a member of this organization');
|
throw new UnauthorizedException('Not a member of this organization');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block access to suspended/archived organizations
|
||||||
|
const orgStatus = membership.organization?.status;
|
||||||
|
if (orgStatus && ['suspended', 'archived'].includes(orgStatus)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`This organization has been ${orgStatus}. Please contact your administrator.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -142,8 +152,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateTokenResponse(user: User) {
|
private generateTokenResponse(user: User, impersonatedBy?: string) {
|
||||||
const orgs = user.userOrganizations || [];
|
const allOrgs = user.userOrganizations || [];
|
||||||
|
// Filter out suspended/archived organizations
|
||||||
|
const orgs = allOrgs.filter(
|
||||||
|
(uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status),
|
||||||
|
);
|
||||||
const defaultOrg = orgs[0];
|
const defaultOrg = orgs[0];
|
||||||
|
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
@@ -152,6 +166,10 @@ export class AuthService {
|
|||||||
isSuperadmin: user.isSuperadmin || false,
|
isSuperadmin: user.isSuperadmin || false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (impersonatedBy) {
|
||||||
|
payload.impersonatedBy = impersonatedBy;
|
||||||
|
}
|
||||||
|
|
||||||
if (defaultOrg) {
|
if (defaultOrg) {
|
||||||
payload.orgId = defaultOrg.organizationId;
|
payload.orgId = defaultOrg.organizationId;
|
||||||
payload.orgSchema = defaultOrg.organization?.schemaName;
|
payload.orgSchema = defaultOrg.organization?.schemaName;
|
||||||
@@ -172,8 +190,20 @@ export class AuthService {
|
|||||||
id: uo.organizationId,
|
id: uo.organizationId,
|
||||||
name: uo.organization?.name,
|
name: uo.organization?.name,
|
||||||
schemaName: uo.organization?.schemaName,
|
schemaName: uo.organization?.schemaName,
|
||||||
|
status: uo.organization?.status,
|
||||||
role: uo.role,
|
role: uo.role,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async impersonateUser(adminUserId: string, targetUserId: string) {
|
||||||
|
const targetUser = await this.usersService.findByIdWithOrgs(targetUserId);
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
if (targetUser.isSuperadmin) {
|
||||||
|
throw new ForbiddenException('Cannot impersonate another superadmin');
|
||||||
|
}
|
||||||
|
return this.generateTokenResponse(targetUser, adminUserId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
orgSchema: payload.orgSchema,
|
orgSchema: payload.orgSchema,
|
||||||
role: payload.role,
|
role: payload.role,
|
||||||
isSuperadmin: payload.isSuperadmin || false,
|
isSuperadmin: payload.isSuperadmin || false,
|
||||||
|
impersonatedBy: payload.impersonatedBy || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ export class OrganizationsService {
|
|||||||
return this.orgRepository.save(org);
|
return this.orgRepository.save(org);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updatePlanLevel(id: string, planLevel: string) {
|
||||||
|
const org = await this.orgRepository.findOne({ where: { id } });
|
||||||
|
if (!org) throw new NotFoundException('Organization not found');
|
||||||
|
org.planLevel = planLevel;
|
||||||
|
return this.orgRepository.save(org);
|
||||||
|
}
|
||||||
|
|
||||||
async updateSubscription(id: string, data: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string }) {
|
async updateSubscription(id: string, data: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string }) {
|
||||||
const org = await this.orgRepository.findOne({ where: { id } });
|
const org = await this.orgRepository.findOne({ where: { id } });
|
||||||
if (!org) throw new NotFoundException('Organization not found');
|
if (!org) throw new NotFoundException('Organization not found');
|
||||||
|
|||||||
@@ -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 { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconLogout,
|
IconLogout,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IconSettings,
|
IconSettings,
|
||||||
IconUserCog,
|
IconUserCog,
|
||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
|
IconEyeOff,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Outlet, useNavigate } from 'react-router-dom';
|
import { Outlet, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
@@ -15,25 +16,53 @@ import logoSrc from '../../assets/logo.svg';
|
|||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [opened, { toggle, close }] = useDisclosure();
|
const [opened, { toggle, close }] = useDisclosure();
|
||||||
const { user, currentOrg, logout } = useAuthStore();
|
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isImpersonating = !!impersonationOriginal;
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStopImpersonation = () => {
|
||||||
|
stopImpersonation();
|
||||||
|
navigate('/admin');
|
||||||
|
};
|
||||||
|
|
||||||
// Tenant admins (president role) can manage org members
|
// Tenant admins (president role) can manage org members
|
||||||
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
|
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 60 }}
|
header={{ height: isImpersonating ? 100 : 60 }}
|
||||||
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
||||||
padding="md"
|
padding="md"
|
||||||
>
|
>
|
||||||
<AppShell.Header>
|
<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>
|
<Group>
|
||||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} />
|
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} />
|
||||||
@@ -46,7 +75,7 @@ export function AppLayout() {
|
|||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
<Group gap="xs">
|
<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]}
|
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text size="sm">{user?.firstName} {user?.lastName}</Text>
|
<Text size="sm">{user?.firstName} {user?.lastName}</Text>
|
||||||
@@ -55,6 +84,18 @@ export function AppLayout() {
|
|||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<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.Label>Account</Menu.Label>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconUserCog size={14} />}
|
leftSection={<IconUserCog size={14} />}
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import {
|
|||||||
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
||||||
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
||||||
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
||||||
IconCurrencyDollar, IconClipboardCheck, IconLogin,
|
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
interface AdminUser {
|
interface AdminUser {
|
||||||
id: string; email: string; firstName: string; lastName: string;
|
id: string; email: string; firstName: string; lastName: string;
|
||||||
@@ -115,10 +117,13 @@ export function AdminPage() {
|
|||||||
const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false);
|
const [createModalOpened, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false);
|
||||||
const [form, setForm] = useState<CreateTenantForm>(initialFormState);
|
const [form, setForm] = useState<CreateTenantForm>(initialFormState);
|
||||||
const [statusConfirm, setStatusConfirm] = useState<{ orgId: string; orgName: string; newStatus: string } | null>(null);
|
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 [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
||||||
const [drawerOpened, { open: openDrawer, close: closeDrawer }] = useDisclosure(false);
|
const [drawerOpened, { open: openDrawer, close: closeDrawer }] = useDisclosure(false);
|
||||||
const [subForm, setSubForm] = useState({ paymentDate: '', confirmationNumber: '', renewalDate: '' });
|
const [subForm, setSubForm] = useState({ paymentDate: '', confirmationNumber: '', renewalDate: '' });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { startImpersonation } = useAuthStore();
|
||||||
|
|
||||||
// ── Queries ──
|
// ── 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 ──
|
// ── Helpers ──
|
||||||
|
|
||||||
const updateField = <K extends keyof CreateTenantForm>(key: K, value: CreateTenantForm[K]) => {
|
const updateField = <K extends keyof CreateTenantForm>(key: K, value: CreateTenantForm[K]) => {
|
||||||
@@ -434,9 +467,34 @@ export function AdminPage() {
|
|||||||
</Menu>
|
</Menu>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge size="sm" variant="light" color={planBadgeColor[o.plan_level] || 'gray'}>
|
<Menu shadow="md" width={180} position="bottom-start">
|
||||||
{o.plan_level}
|
<Menu.Target>
|
||||||
</Badge>
|
<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>
|
||||||
<Table.Td ta="center">
|
<Table.Td ta="center">
|
||||||
<Badge variant="light" size="sm">{o.member_count}</Badge>
|
<Badge variant="light" size="sm">{o.member_count}</Badge>
|
||||||
@@ -483,6 +541,7 @@ export function AdminPage() {
|
|||||||
<Table.Th>Organizations</Table.Th>
|
<Table.Th>Organizations</Table.Th>
|
||||||
<Table.Th>Last Login</Table.Th>
|
<Table.Th>Last Login</Table.Th>
|
||||||
<Table.Th ta="center">SuperAdmin</Table.Th>
|
<Table.Th ta="center">SuperAdmin</Table.Th>
|
||||||
|
<Table.Th ta="center">Actions</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -538,6 +597,20 @@ export function AdminPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</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.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
@@ -657,7 +730,21 @@ export function AdminPage() {
|
|||||||
<Text size="xs" c="dimmed">Status</Text>
|
<Text size="xs" c="dimmed">Status</Text>
|
||||||
<Badge size="xs" variant="light" color={statusColor[tenantDetail.organization.status]}>{tenantDetail.organization.status}</Badge>
|
<Badge size="xs" variant="light" color={statusColor[tenantDetail.organization.status]}>{tenantDetail.organization.status}</Badge>
|
||||||
<Text size="xs" c="dimmed">Plan</Text>
|
<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" c="dimmed">Contract #</Text>
|
||||||
<Text size="xs">{tenantDetail.organization.contract_number || '\u2014'}</Text>
|
<Text size="xs">{tenantDetail.organization.contract_number || '\u2014'}</Text>
|
||||||
<Text size="xs" c="dimmed">Members</Text>
|
<Text size="xs" c="dimmed">Members</Text>
|
||||||
@@ -837,6 +924,36 @@ export function AdminPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</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>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
const handleSelect = async (org: any) => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/switch-org', {
|
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
|
Choose an HOA to manage or create a new one
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Stack mt={30}>
|
{/* Filter out suspended/archived orgs (defense in depth — backend also filters) */}
|
||||||
{organizations.map((org) => (
|
{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
|
<Card
|
||||||
key={org.id}
|
key={org.id}
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ api.interceptors.response.use(
|
|||||||
useAuthStore.getState().logout();
|
useAuthStore.getState().logout();
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
// Handle org suspended/archived — redirect to org selection
|
||||||
|
if (
|
||||||
|
error.response?.status === 403 &&
|
||||||
|
typeof error.response?.data?.message === 'string' &&
|
||||||
|
error.response.data.message.includes('has been')
|
||||||
|
) {
|
||||||
|
const store = useAuthStore.getState();
|
||||||
|
store.setCurrentOrg({ id: '', name: '', role: '' }); // Clear current org
|
||||||
|
window.location.href = '/select-org';
|
||||||
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface Organization {
|
|||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
schemaName?: string;
|
schemaName?: string;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -17,23 +18,34 @@ interface User {
|
|||||||
isPlatformOwner?: boolean;
|
isPlatformOwner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImpersonationOriginal {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
organizations: Organization[];
|
||||||
|
currentOrg: Organization | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
organizations: Organization[];
|
organizations: Organization[];
|
||||||
currentOrg: Organization | null;
|
currentOrg: Organization | null;
|
||||||
|
impersonationOriginal: ImpersonationOriginal | null;
|
||||||
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
||||||
setCurrentOrg: (org: Organization, token?: string) => void;
|
setCurrentOrg: (org: Organization, token?: string) => void;
|
||||||
|
startImpersonation: (token: string, user: User, organizations: Organization[]) => void;
|
||||||
|
stopImpersonation: () => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
currentOrg: null,
|
currentOrg: null,
|
||||||
|
impersonationOriginal: null,
|
||||||
setAuth: (token, user, organizations) =>
|
setAuth: (token, user, organizations) =>
|
||||||
set({
|
set({
|
||||||
token,
|
token,
|
||||||
@@ -47,22 +59,51 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
currentOrg: org,
|
currentOrg: org,
|
||||||
token: token || state.token,
|
token: token || state.token,
|
||||||
})),
|
})),
|
||||||
|
startImpersonation: (token, user, organizations) => {
|
||||||
|
const state = get();
|
||||||
|
set({
|
||||||
|
impersonationOriginal: {
|
||||||
|
token: state.token!,
|
||||||
|
user: state.user!,
|
||||||
|
organizations: state.organizations,
|
||||||
|
currentOrg: state.currentOrg,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
organizations,
|
||||||
|
currentOrg: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
stopImpersonation: () => {
|
||||||
|
const { impersonationOriginal } = get();
|
||||||
|
if (impersonationOriginal) {
|
||||||
|
set({
|
||||||
|
token: impersonationOriginal.token,
|
||||||
|
user: impersonationOriginal.user,
|
||||||
|
organizations: impersonationOriginal.organizations,
|
||||||
|
currentOrg: impersonationOriginal.currentOrg,
|
||||||
|
impersonationOriginal: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
logout: () =>
|
logout: () =>
|
||||||
set({
|
set({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
currentOrg: null,
|
currentOrg: null,
|
||||||
|
impersonationOriginal: null,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ledgeriq-auth',
|
name: 'ledgeriq-auth',
|
||||||
version: 3,
|
version: 4,
|
||||||
migrate: () => ({
|
migrate: () => ({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
currentOrg: null,
|
currentOrg: null,
|
||||||
|
impersonationOriginal: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user