diff --git a/backend/src/database/tenant.middleware.ts b/backend/src/database/tenant.middleware.ts index e91ae1a..5a489c1 100644 --- a/backend/src/database/tenant.middleware.ts +++ b/backend/src/database/tenant.middleware.ts @@ -1,5 +1,6 @@ import { Injectable, NestMiddleware } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; import { Request, Response, NextFunction } from 'express'; import * as jwt from 'jsonwebtoken'; @@ -12,9 +13,16 @@ export interface TenantRequest extends Request { @Injectable() 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(); + 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 const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { @@ -23,6 +31,18 @@ export class TenantMiddleware implements NestMiddleware { const secret = this.configService.get('JWT_SECRET'); const decoded = jwt.verify(token, secret!) as any; 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.orgId = decoded.orgId; req.userId = decoded.sub; @@ -34,4 +54,24 @@ export class TenantMiddleware implements NestMiddleware { } next(); } + + private async getOrgStatus(orgId: string): Promise { + 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; + } } diff --git a/backend/src/modules/auth/admin.controller.ts b/backend/src/modules/auth/admin.controller.ts index 5012b37..4a4a68e 100644 --- a/backend/src/modules/auth/admin.controller.ts +++ b/backend/src/modules/auth/admin.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Put, Body, Param, UseGuards, Req, ForbiddenException, BadRequestException } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; import { OrganizationsService } from '../organizations/organizations.service'; import { AdminAnalyticsService } from './admin-analytics.service'; @@ -12,6 +13,7 @@ import * as bcrypt from 'bcryptjs'; @UseGuards(JwtAuthGuard) export class AdminController { constructor( + private authService: AuthService, private usersService: UsersService, private orgService: OrganizationsService, private analyticsService: AdminAnalyticsService, @@ -92,6 +94,23 @@ export class AdminController { 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 ── @Post('users/:id/superadmin') @@ -101,6 +120,15 @@ export class AdminController { 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 ── @Get('tenants-health') diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 17fbd2b..5f591e8 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -2,6 +2,8 @@ import { Injectable, UnauthorizedException, ConflictException, + ForbiddenException, + NotFoundException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { DataSource } from 'typeorm'; @@ -104,6 +106,14 @@ export class AuthService { 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 = { sub: user.id, email: user.email, @@ -142,8 +152,12 @@ export class AuthService { } } - private generateTokenResponse(user: User) { - const orgs = user.userOrganizations || []; + private generateTokenResponse(user: User, impersonatedBy?: string) { + 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 payload: Record = { @@ -152,6 +166,10 @@ export class AuthService { isSuperadmin: user.isSuperadmin || false, }; + if (impersonatedBy) { + payload.impersonatedBy = impersonatedBy; + } + if (defaultOrg) { payload.orgId = defaultOrg.organizationId; payload.orgSchema = defaultOrg.organization?.schemaName; @@ -172,8 +190,20 @@ export class AuthService { id: uo.organizationId, name: uo.organization?.name, schemaName: uo.organization?.schemaName, + status: uo.organization?.status, 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); + } } diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts index d837316..cf0d863 100644 --- a/backend/src/modules/auth/strategies/jwt.strategy.ts +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -21,6 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { orgSchema: payload.orgSchema, role: payload.role, isSuperadmin: payload.isSuperadmin || false, + impersonatedBy: payload.impersonatedBy || null, }; } } diff --git a/backend/src/modules/organizations/organizations.service.ts b/backend/src/modules/organizations/organizations.service.ts index 8723d0e..653ae4f 100644 --- a/backend/src/modules/organizations/organizations.service.ts +++ b/backend/src/modules/organizations/organizations.service.ts @@ -62,6 +62,13 @@ export class OrganizationsService { 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 }) { const org = await this.orgRepository.findOne({ where: { id } }); if (!org) throw new NotFoundException('Organization not found'); diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 92d7dfc..5a5928f 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -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 ( - + {isImpersonating && ( + + + Impersonating {user?.firstName} {user?.lastName} ({user?.email}) + + + + )} + HOA LedgerIQ @@ -46,7 +75,7 @@ export function AppLayout() { - + {user?.firstName?.[0]}{user?.lastName?.[0]} {user?.firstName} {user?.lastName} @@ -55,6 +84,18 @@ export function AppLayout() { + {isImpersonating && ( + <> + } + onClick={handleStopImpersonation} + > + Stop Impersonating + + + + )} Account } diff --git a/frontend/src/pages/admin/AdminPage.tsx b/frontend/src/pages/admin/AdminPage.tsx index c2b7633..c04f5ca 100644 --- a/frontend/src/pages/admin/AdminPage.tsx +++ b/frontend/src/pages/admin/AdminPage.tsx @@ -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(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(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 = (key: K, value: CreateTenantForm[K]) => { @@ -434,9 +467,34 @@ export function AdminPage() { - - {o.plan_level} - + + + } + onClick={(e) => e.stopPropagation()} + > + {o.plan_level} + + + + Change plan + {['standard', 'premium', 'enterprise'] + .filter(p => p !== o.plan_level) + .map(p => ( + { + e.stopPropagation(); + setPlanConfirm({ orgId: o.id, orgName: o.name, newPlan: p }); + }}> + {p.charAt(0).toUpperCase() + p.slice(1)} + + ))} + + {o.member_count} @@ -483,6 +541,7 @@ export function AdminPage() { Organizations Last Login SuperAdmin + Actions @@ -538,6 +597,20 @@ export function AdminPage() { /> )} + + + + + ))} @@ -657,7 +730,21 @@ export function AdminPage() { Status {tenantDetail.organization.status} Plan - {tenantDetail.organization.plan_level} +