Add comprehensive platform administration panel

- Database: Add login_history, ai_recommendation_log tables; is_platform_owner
  column on users; subscription fields on organizations (payment_date,
  confirmation_number, renewal_date)
- Backend: New AdminAnalyticsService with platform metrics, tenant detail, and
  health score calculations (0-100 based on activity, budget, transactions,
  members, AI usage)
- Backend: Login/org-switch now records to login_history; AI recommendations
  logged to ai_recommendation_log; platform owner protected from superadmin toggle
- Frontend: 4-tab admin panel (Dashboard, Organizations, Users, Tenant Health)
  with tenant detail drawer, subscription management, health scoring visualization
- Platform owner account (admin@hoaledgeriq.com) auto-redirects to admin panel
- Seed data includes platform owner account and sample login history

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 08:51:39 -05:00
parent 0bd30a0eb8
commit a32d4cc179
20 changed files with 3183 additions and 317 deletions

View File

@@ -4,6 +4,7 @@ import {
ConflictException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { UsersService } from '../users/users.service';
import { RegisterDto } from './dto/register.dto';
@@ -14,6 +15,7 @@ export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private dataSource: DataSource,
) {}
async register(dto: RegisterDto) {
@@ -47,7 +49,7 @@ export class AuthService {
return user;
}
async login(user: User) {
async login(user: User, ipAddress?: string, userAgent?: string) {
await this.usersService.updateLastLogin(user.id);
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
const u = fullUser || user;
@@ -65,6 +67,9 @@ export class AuthService {
}
}
// Record login in history (org_id is null at initial login)
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
return this.generateTokenResponse(u);
}
@@ -86,7 +91,7 @@ export class AuthService {
};
}
async switchOrganization(userId: string, organizationId: string) {
async switchOrganization(userId: string, organizationId: string, ipAddress?: string, userAgent?: string) {
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) {
throw new UnauthorizedException('User not found');
@@ -107,6 +112,9 @@ export class AuthService {
role: membership.role,
};
// Record org switch in login history
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
return {
accessToken: this.jwtService.sign(payload),
organization: {
@@ -117,6 +125,23 @@ export class AuthService {
};
}
private async recordLoginHistory(
userId: string,
organizationId: string | null,
ipAddress?: string,
userAgent?: string,
) {
try {
await this.dataSource.query(
`INSERT INTO shared.login_history (user_id, organization_id, ip_address, user_agent)
VALUES ($1, $2, $3, $4)`,
[userId, organizationId, ipAddress || null, userAgent || null],
);
} catch (err) {
// Non-critical — don't let login history failure block auth
}
}
private generateTokenResponse(user: User) {
const orgs = user.userOrganizations || [];
const defaultOrg = orgs[0];
@@ -141,6 +166,7 @@ export class AuthService {
firstName: user.firstName,
lastName: user.lastName,
isSuperadmin: user.isSuperadmin || false,
isPlatformOwner: user.isPlatformOwner || false,
},
organizations: orgs.map((uo) => ({
id: uo.organizationId,