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:
325
backend/src/modules/auth/admin-analytics.service.ts
Normal file
325
backend/src/modules/auth/admin-analytics.service.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAnalyticsService {
|
||||
private readonly logger = new Logger(AdminAnalyticsService.name);
|
||||
|
||||
constructor(private dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* Platform-wide metrics for the admin dashboard.
|
||||
*/
|
||||
async getPlatformMetrics() {
|
||||
const [
|
||||
userStats,
|
||||
orgStats,
|
||||
planBreakdown,
|
||||
statusBreakdown,
|
||||
newTenantsPerMonth,
|
||||
newUsersPerMonth,
|
||||
aiStats,
|
||||
activeUsers30d,
|
||||
] = await Promise.all([
|
||||
this.dataSource.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_users,
|
||||
COUNT(*) FILTER (WHERE is_superadmin = true) as superadmin_count,
|
||||
COUNT(*) FILTER (WHERE is_platform_owner = true) as platform_owner_count
|
||||
FROM shared.users
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_organizations,
|
||||
COUNT(*) FILTER (WHERE status = 'active') as active_count,
|
||||
COUNT(*) FILTER (WHERE status = 'archived') as archived_count,
|
||||
COUNT(*) FILTER (WHERE status = 'suspended') as suspended_count,
|
||||
COUNT(*) FILTER (WHERE status = 'trial') as trial_count
|
||||
FROM shared.organizations
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT plan_level, COUNT(*) as count
|
||||
FROM shared.organizations
|
||||
WHERE status != 'archived'
|
||||
GROUP BY plan_level
|
||||
ORDER BY count DESC
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM shared.organizations
|
||||
GROUP BY status
|
||||
ORDER BY count DESC
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT
|
||||
DATE_TRUNC('month', created_at) as month,
|
||||
COUNT(*) as count
|
||||
FROM shared.organizations
|
||||
WHERE created_at > NOW() - INTERVAL '6 months'
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
ORDER BY month DESC
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT
|
||||
DATE_TRUNC('month', created_at) as month,
|
||||
COUNT(*) as count
|
||||
FROM shared.users
|
||||
WHERE created_at > NOW() - INTERVAL '6 months'
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
ORDER BY month DESC
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COUNT(*) FILTER (WHERE status = 'success') as successful,
|
||||
ROUND(AVG(response_time_ms)) as avg_response_ms
|
||||
FROM shared.ai_recommendation_log
|
||||
WHERE requested_at > NOW() - INTERVAL '30 days'
|
||||
`),
|
||||
this.dataSource.query(`
|
||||
SELECT COUNT(DISTINCT user_id) as count
|
||||
FROM shared.login_history
|
||||
WHERE logged_in_at > NOW() - INTERVAL '30 days'
|
||||
`),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalUsers: parseInt(userStats[0]?.total_users || '0'),
|
||||
superadminCount: parseInt(userStats[0]?.superadmin_count || '0'),
|
||||
platformOwnerCount: parseInt(userStats[0]?.platform_owner_count || '0'),
|
||||
activeUsers30d: parseInt(activeUsers30d[0]?.count || '0'),
|
||||
totalOrganizations: parseInt(orgStats[0]?.total_organizations || '0'),
|
||||
activeOrganizations: parseInt(orgStats[0]?.active_count || '0'),
|
||||
archivedOrganizations: parseInt(orgStats[0]?.archived_count || '0'),
|
||||
suspendedOrganizations: parseInt(orgStats[0]?.suspended_count || '0'),
|
||||
trialOrganizations: parseInt(orgStats[0]?.trial_count || '0'),
|
||||
planBreakdown: planBreakdown.map((r: any) => ({
|
||||
plan: r.plan_level,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
statusBreakdown: statusBreakdown.map((r: any) => ({
|
||||
status: r.status,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
newTenantsPerMonth: newTenantsPerMonth.map((r: any) => ({
|
||||
month: r.month,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
newUsersPerMonth: newUsersPerMonth.map((r: any) => ({
|
||||
month: r.month,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
aiRequestsLast30d: parseInt(aiStats[0]?.total_requests || '0'),
|
||||
aiSuccessfulLast30d: parseInt(aiStats[0]?.successful || '0'),
|
||||
aiAvgResponseMs: parseInt(aiStats[0]?.avg_response_ms || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed analytics for a specific tenant/organization.
|
||||
*/
|
||||
async getTenantDetail(orgId: string) {
|
||||
const [orgInfo, loginStats, weeklyLogins, monthlyLogins, aiCount, memberCount] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT o.*, (SELECT MAX(logged_in_at) FROM shared.login_history WHERE organization_id = o.id) as last_login
|
||||
FROM shared.organizations o WHERE o.id = $1`,
|
||||
[orgId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE logged_in_at > NOW() - INTERVAL '7 days') as logins_this_week,
|
||||
COUNT(*) FILTER (WHERE logged_in_at > NOW() - INTERVAL '30 days') as logins_this_month,
|
||||
COUNT(DISTINCT user_id) FILTER (WHERE logged_in_at > NOW() - INTERVAL '30 days') as active_users_30d
|
||||
FROM shared.login_history WHERE organization_id = $1`,
|
||||
[orgId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT
|
||||
DATE_TRUNC('week', logged_in_at) as week,
|
||||
COUNT(*) as count
|
||||
FROM shared.login_history
|
||||
WHERE organization_id = $1 AND logged_in_at > NOW() - INTERVAL '4 weeks'
|
||||
GROUP BY DATE_TRUNC('week', logged_in_at)
|
||||
ORDER BY week DESC`,
|
||||
[orgId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT
|
||||
DATE_TRUNC('month', logged_in_at) as month,
|
||||
COUNT(*) as count
|
||||
FROM shared.login_history
|
||||
WHERE organization_id = $1 AND logged_in_at > NOW() - INTERVAL '6 months'
|
||||
GROUP BY DATE_TRUNC('month', logged_in_at)
|
||||
ORDER BY month DESC`,
|
||||
[orgId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM shared.ai_recommendation_log
|
||||
WHERE organization_id = $1 AND requested_at > NOW() - INTERVAL '30 days'`,
|
||||
[orgId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT COUNT(*) as count FROM shared.user_organizations WHERE organization_id = $1 AND is_active = true`,
|
||||
[orgId],
|
||||
),
|
||||
]);
|
||||
|
||||
const org = orgInfo[0];
|
||||
if (!org) return null;
|
||||
|
||||
// Cross-schema queries for tenant financial data
|
||||
let cashOnHand = 0;
|
||||
let hasBudget = false;
|
||||
let recentTransactions = 0;
|
||||
|
||||
try {
|
||||
const cashResult = await this.dataSource.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM "${org.schema_name}".accounts a
|
||||
JOIN "${org.schema_name}".journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN "${org.schema_name}".journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.account_type = 'asset' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
cashOnHand = parseFloat(cashResult[0]?.total || '0');
|
||||
|
||||
const budgetResult = await this.dataSource.query(
|
||||
`SELECT COUNT(*) as count FROM "${org.schema_name}".budgets WHERE fiscal_year = $1`,
|
||||
[new Date().getFullYear()],
|
||||
);
|
||||
hasBudget = parseInt(budgetResult[0]?.count || '0') > 0;
|
||||
|
||||
const txnResult = await this.dataSource.query(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "${org.schema_name}".journal_entries
|
||||
WHERE is_posted = true AND entry_date > NOW() - INTERVAL '30 days'
|
||||
`);
|
||||
recentTransactions = parseInt(txnResult[0]?.count || '0');
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to query tenant schema ${org.schema_name}: ${err.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
organization: org,
|
||||
lastLogin: org.last_login,
|
||||
loginsThisWeek: parseInt(loginStats[0]?.logins_this_week || '0'),
|
||||
loginsThisMonth: parseInt(loginStats[0]?.logins_this_month || '0'),
|
||||
activeUsers30d: parseInt(loginStats[0]?.active_users_30d || '0'),
|
||||
weeklyLogins: weeklyLogins.map((r: any) => ({
|
||||
week: r.week,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
monthlyLogins: monthlyLogins.map((r: any) => ({
|
||||
month: r.month,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
aiRecommendations30d: parseInt(aiCount[0]?.count || '0'),
|
||||
memberCount: parseInt(memberCount[0]?.count || '0'),
|
||||
cashOnHand,
|
||||
hasBudget,
|
||||
recentTransactions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All tenants with health scores for the Health & Adoption tab.
|
||||
*
|
||||
* Health Score (0-100):
|
||||
* Active users 30d ≥ 1 → 25pts
|
||||
* Has current-year budget → 25pts
|
||||
* Journal entries 30d ≥ 1 → 25pts
|
||||
* 2+ active members → 15pts
|
||||
* AI usage 30d ≥ 1 → 10pts
|
||||
*/
|
||||
async getAllTenantsHealth() {
|
||||
const orgs = await this.dataSource.query(`
|
||||
SELECT
|
||||
o.id, o.name, o.schema_name, o.status, o.plan_level, o.created_at,
|
||||
o.payment_date, o.renewal_date,
|
||||
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id AND is_active = true) as member_count,
|
||||
(SELECT MAX(lh.logged_in_at) FROM shared.login_history lh WHERE lh.organization_id = o.id) as last_login,
|
||||
(SELECT COUNT(DISTINCT lh.user_id) FROM shared.login_history lh WHERE lh.organization_id = o.id AND lh.logged_in_at > NOW() - INTERVAL '30 days') as active_users_30d,
|
||||
(SELECT COUNT(*) FROM shared.ai_recommendation_log ar WHERE ar.organization_id = o.id AND ar.requested_at > NOW() - INTERVAL '30 days') as ai_usage_30d
|
||||
FROM shared.organizations o
|
||||
WHERE o.status != 'archived'
|
||||
ORDER BY o.name
|
||||
`);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const results = [];
|
||||
|
||||
for (const org of orgs) {
|
||||
let cashOnHand = 0;
|
||||
let hasBudget = false;
|
||||
let journalEntries30d = 0;
|
||||
|
||||
try {
|
||||
const cashResult = await this.dataSource.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM "${org.schema_name}".accounts a
|
||||
JOIN "${org.schema_name}".journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN "${org.schema_name}".journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.account_type = 'asset' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
cashOnHand = parseFloat(cashResult[0]?.total || '0');
|
||||
|
||||
const budgetResult = await this.dataSource.query(
|
||||
`SELECT COUNT(*) as count FROM "${org.schema_name}".budgets WHERE fiscal_year = $1`,
|
||||
[currentYear],
|
||||
);
|
||||
hasBudget = parseInt(budgetResult[0]?.count || '0') > 0;
|
||||
|
||||
const jeResult = await this.dataSource.query(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "${org.schema_name}".journal_entries
|
||||
WHERE is_posted = true AND entry_date > NOW() - INTERVAL '30 days'
|
||||
`);
|
||||
journalEntries30d = parseInt(jeResult[0]?.count || '0');
|
||||
} catch (err) {
|
||||
// Schema may not exist yet (new tenant)
|
||||
this.logger.warn(`Health check skip for ${org.schema_name}: ${err.message}`);
|
||||
}
|
||||
|
||||
// Calculate health score
|
||||
const activeUsers = parseInt(org.active_users_30d) || 0;
|
||||
const memberCount = parseInt(org.member_count) || 0;
|
||||
const aiUsage = parseInt(org.ai_usage_30d) || 0;
|
||||
|
||||
let healthScore = 0;
|
||||
if (activeUsers >= 1) healthScore += 25;
|
||||
if (hasBudget) healthScore += 25;
|
||||
if (journalEntries30d >= 1) healthScore += 25;
|
||||
if (memberCount >= 2) healthScore += 15;
|
||||
if (aiUsage >= 1) healthScore += 10;
|
||||
|
||||
results.push({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
schemaName: org.schema_name,
|
||||
status: org.status,
|
||||
planLevel: org.plan_level,
|
||||
createdAt: org.created_at,
|
||||
paymentDate: org.payment_date,
|
||||
renewalDate: org.renewal_date,
|
||||
memberCount,
|
||||
lastLogin: org.last_login,
|
||||
activeUsers30d: activeUsers,
|
||||
aiUsage30d: aiUsage,
|
||||
cashOnHand,
|
||||
hasBudget,
|
||||
journalEntries30d,
|
||||
healthScore,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { OrganizationsService } from '../organizations/organizations.service';
|
||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
@ApiTags('admin')
|
||||
@@ -13,6 +14,7 @@ export class AdminController {
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private orgService: OrganizationsService,
|
||||
private analyticsService: AdminAnalyticsService,
|
||||
) {}
|
||||
|
||||
private async requireSuperadmin(req: any) {
|
||||
@@ -22,25 +24,76 @@ export class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Platform Metrics ──
|
||||
|
||||
@Get('metrics')
|
||||
async getPlatformMetrics(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
return this.analyticsService.getPlatformMetrics();
|
||||
}
|
||||
|
||||
// ── Users ──
|
||||
|
||||
@Get('users')
|
||||
async listUsers(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
const users = await this.usersService.findAllUsers();
|
||||
return users.map(u => ({
|
||||
id: u.id, email: u.email, firstName: u.firstName, lastName: u.lastName,
|
||||
isSuperadmin: u.isSuperadmin, lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
|
||||
isSuperadmin: u.isSuperadmin, isPlatformOwner: u.isPlatformOwner || false,
|
||||
lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
|
||||
organizations: u.userOrganizations?.map(uo => ({
|
||||
id: uo.organizationId, name: uo.organization?.name, role: uo.role,
|
||||
})) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Organizations ──
|
||||
|
||||
@Get('organizations')
|
||||
async listOrganizations(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
return this.usersService.findAllOrganizations();
|
||||
}
|
||||
|
||||
@Get('organizations/:id/detail')
|
||||
async getTenantDetail(@Req() req: any, @Param('id') id: string) {
|
||||
await this.requireSuperadmin(req);
|
||||
const detail = await this.analyticsService.getTenantDetail(id);
|
||||
if (!detail) {
|
||||
throw new BadRequestException('Organization not found');
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
@Put('organizations/:id/subscription')
|
||||
async updateSubscription(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string },
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const org = await this.orgService.updateSubscription(id, body);
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
|
||||
@Put('organizations/:id/status')
|
||||
async updateOrgStatus(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: string },
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
|
||||
if (!validStatuses.includes(body.status)) {
|
||||
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
||||
}
|
||||
const org = await this.orgService.updateStatus(id, body.status);
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
|
||||
// ── Superadmin Toggle ──
|
||||
|
||||
@Post('users/:id/superadmin')
|
||||
async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) {
|
||||
await this.requireSuperadmin(req);
|
||||
@@ -48,6 +101,16 @@ export class AdminController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Tenant Health ──
|
||||
|
||||
@Get('tenants-health')
|
||||
async getTenantsHealth(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
return this.analyticsService.getAllTenantsHealth();
|
||||
}
|
||||
|
||||
// ── Create Tenant ──
|
||||
|
||||
@Post('tenants')
|
||||
async createTenant(@Req() req: any, @Body() body: {
|
||||
orgName: string;
|
||||
@@ -105,19 +168,4 @@ export class AdminController {
|
||||
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
|
||||
@Put('organizations/:id/status')
|
||||
async updateOrgStatus(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: string },
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
|
||||
if (!validStatuses.includes(body.status)) {
|
||||
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
||||
}
|
||||
const org = await this.orgService.updateStatus(id, body.status);
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ export class AuthController {
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@UseGuards(AuthGuard('local'))
|
||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
||||
return this.authService.login(req.user);
|
||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||
const ua = req.headers['user-agent'];
|
||||
return this.authService.login(req.user, ip, ua);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@@ -45,6 +47,8 @@ export class AuthController {
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
||||
return this.authService.switchOrganization(req.user.sub, dto.organizationId);
|
||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||
const ua = req.headers['user-agent'];
|
||||
return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
@@ -25,7 +26,7 @@ import { OrganizationsModule } from '../organizations/organizations.module';
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController, AdminController],
|
||||
providers: [AuthService, JwtStrategy, LocalStrategy],
|
||||
providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { InvestmentPlanningService } from './investment-planning.service';
|
||||
@@ -24,7 +24,7 @@ export class InvestmentPlanningController {
|
||||
|
||||
@Post('recommendations')
|
||||
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
|
||||
getRecommendations() {
|
||||
return this.service.getAIRecommendations();
|
||||
getRecommendations(@Req() req: any) {
|
||||
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,8 +166,9 @@ export class InvestmentPlanningService {
|
||||
* 4. Call the AI API
|
||||
* 5. Parse and return structured recommendations
|
||||
*/
|
||||
async getAIRecommendations(): Promise<AIResponse> {
|
||||
async getAIRecommendations(userId?: string, orgId?: string): Promise<AIResponse> {
|
||||
this.debug('getAIRecommendations', 'Starting AI recommendation flow');
|
||||
const startTime = Date.now();
|
||||
|
||||
const [snapshot, cdRates, monthlyForecast] = await Promise.all([
|
||||
this.getFinancialSnapshot(),
|
||||
@@ -188,6 +189,7 @@ export class InvestmentPlanningService {
|
||||
|
||||
const messages = this.buildPromptMessages(snapshot, cdRates, monthlyForecast);
|
||||
const aiResponse = await this.callAI(messages);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
this.debug('final_response', {
|
||||
recommendation_count: aiResponse.recommendations.length,
|
||||
@@ -195,9 +197,33 @@ export class InvestmentPlanningService {
|
||||
risk_notes_count: aiResponse.risk_notes?.length || 0,
|
||||
});
|
||||
|
||||
// Log AI usage to shared.ai_recommendation_log (fire-and-forget)
|
||||
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
|
||||
|
||||
return aiResponse;
|
||||
}
|
||||
|
||||
private async logAIUsage(userId: string | undefined, orgId: string | undefined, response: AIResponse, elapsed: number) {
|
||||
try {
|
||||
const schema = this.tenant.getSchema();
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.ai_recommendation_log
|
||||
(tenant_schema, organization_id, user_id, recommendation_count, response_time_ms, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
schema || null,
|
||||
orgId || null,
|
||||
userId || null,
|
||||
response.recommendations?.length || 0,
|
||||
elapsed,
|
||||
response.recommendations?.length > 0 ? 'success' : 'empty',
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
// Non-critical — don't let logging failure break recommendations
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private: Tenant-Scoped Data Queries ──
|
||||
|
||||
private async getAccountBalances(): Promise<AccountBalance[]> {
|
||||
|
||||
@@ -61,6 +61,15 @@ export class Organization {
|
||||
@Column({ name: 'plan_level', default: 'standard' })
|
||||
planLevel: string;
|
||||
|
||||
@Column({ name: 'payment_date', type: 'date', nullable: true })
|
||||
paymentDate: Date;
|
||||
|
||||
@Column({ name: 'confirmation_number', nullable: true })
|
||||
confirmationNumber: string;
|
||||
|
||||
@Column({ name: 'renewal_date', type: 'date', nullable: true })
|
||||
renewalDate: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -62,6 +62,15 @@ export class OrganizationsService {
|
||||
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');
|
||||
if (data.paymentDate !== undefined) org.paymentDate = data.paymentDate ? new Date(data.paymentDate) : null;
|
||||
if (data.confirmationNumber !== undefined) org.confirmationNumber = data.confirmationNumber || null;
|
||||
if (data.renewalDate !== undefined) org.renewalDate = data.renewalDate ? new Date(data.renewalDate) : null;
|
||||
return this.orgRepository.save(org);
|
||||
}
|
||||
|
||||
async findByUser(userId: string) {
|
||||
const memberships = await this.userOrgRepository.find({
|
||||
where: { userId, isActive: true },
|
||||
|
||||
@@ -46,6 +46,9 @@ export class User {
|
||||
@Column({ name: 'is_superadmin', default: false })
|
||||
isSuperadmin: boolean;
|
||||
|
||||
@Column({ name: 'is_platform_owner', default: false })
|
||||
isPlatformOwner: boolean;
|
||||
|
||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||
lastLoginAt: Date;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
@@ -50,13 +50,19 @@ export class UsersService {
|
||||
const dataSource = this.usersRepository.manager.connection;
|
||||
return dataSource.query(`
|
||||
SELECT o.*,
|
||||
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count
|
||||
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count,
|
||||
(SELECT MAX(lh.logged_in_at) FROM shared.login_history lh WHERE lh.organization_id = o.id) as last_activity
|
||||
FROM shared.organizations o
|
||||
ORDER BY o.created_at DESC
|
||||
`);
|
||||
}
|
||||
|
||||
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
|
||||
// Protect platform owner from having superadmin removed
|
||||
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
||||
if (user?.isPlatformOwner) {
|
||||
throw new ForbiddenException('Cannot modify platform owner superadmin status');
|
||||
}
|
||||
await this.usersRepository.update(userId, { isSuperadmin });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ CREATE TABLE shared.organizations (
|
||||
email VARCHAR(255),
|
||||
tax_id VARCHAR(20),
|
||||
fiscal_year_start_month INTEGER DEFAULT 1 CHECK (fiscal_year_start_month BETWEEN 1 AND 12),
|
||||
payment_date DATE,
|
||||
confirmation_number VARCHAR(100),
|
||||
renewal_date DATE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -45,6 +48,7 @@ CREATE TABLE shared.users (
|
||||
oauth_provider_id VARCHAR(255),
|
||||
last_login_at TIMESTAMPTZ,
|
||||
is_superadmin BOOLEAN DEFAULT FALSE,
|
||||
is_platform_owner BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -86,6 +90,28 @@ CREATE TABLE shared.cd_rates (
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Login history (track logins/org-switches for platform analytics)
|
||||
CREATE TABLE shared.login_history (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
|
||||
logged_in_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
-- AI recommendation log (track AI usage per tenant)
|
||||
CREATE TABLE shared.ai_recommendation_log (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_schema VARCHAR(63),
|
||||
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
|
||||
user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
|
||||
recommendation_count INTEGER,
|
||||
response_time_ms INTEGER,
|
||||
status VARCHAR(20),
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_user_orgs_user ON shared.user_organizations(user_id);
|
||||
CREATE INDEX idx_user_orgs_org ON shared.user_organizations(organization_id);
|
||||
@@ -95,3 +121,8 @@ CREATE INDEX idx_invitations_token ON shared.invitations(token);
|
||||
CREATE INDEX idx_invitations_email ON shared.invitations(email);
|
||||
CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
|
||||
CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC);
|
||||
CREATE INDEX idx_login_history_org_time ON shared.login_history(organization_id, logged_in_at DESC);
|
||||
CREATE INDEX idx_login_history_user ON shared.login_history(user_id);
|
||||
CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC);
|
||||
CREATE INDEX idx_ai_rec_log_org ON shared.ai_recommendation_log(organization_id);
|
||||
CREATE INDEX idx_ai_rec_log_time ON shared.ai_recommendation_log(requested_at DESC);
|
||||
|
||||
52
db/migrations/006-admin-platform.sql
Normal file
52
db/migrations/006-admin-platform.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- ============================================================
|
||||
-- Migration 006: Platform Administration Features
|
||||
-- Adds: is_platform_owner, subscription fields, login_history, ai_recommendation_log
|
||||
-- ============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Add is_platform_owner to users
|
||||
ALTER TABLE shared.users
|
||||
ADD COLUMN IF NOT EXISTS is_platform_owner BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- 2. Add subscription fields to organizations
|
||||
ALTER TABLE shared.organizations
|
||||
ADD COLUMN IF NOT EXISTS payment_date DATE,
|
||||
ADD COLUMN IF NOT EXISTS confirmation_number VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS renewal_date DATE;
|
||||
|
||||
-- 3. Create login_history table
|
||||
CREATE TABLE IF NOT EXISTS shared.login_history (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
|
||||
logged_in_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_login_history_org_time
|
||||
ON shared.login_history(organization_id, logged_in_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_history_user
|
||||
ON shared.login_history(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_history_time
|
||||
ON shared.login_history(logged_in_at DESC);
|
||||
|
||||
-- 4. Create ai_recommendation_log table
|
||||
CREATE TABLE IF NOT EXISTS shared.ai_recommendation_log (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_schema VARCHAR(63),
|
||||
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
|
||||
user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
|
||||
recommendation_count INTEGER,
|
||||
response_time_ms INTEGER,
|
||||
status VARCHAR(20),
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_rec_log_org
|
||||
ON shared.ai_recommendation_log(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_rec_log_time
|
||||
ON shared.ai_recommendation_log(requested_at DESC);
|
||||
|
||||
COMMIT;
|
||||
@@ -16,6 +16,31 @@
|
||||
-- Enable UUID generation
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ============================================================
|
||||
-- 0. Create platform owner account (admin@hoaledgeriq.com)
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_platform_owner_id UUID;
|
||||
BEGIN
|
||||
SELECT id INTO v_platform_owner_id FROM shared.users WHERE email = 'admin@hoaledgeriq.com';
|
||||
IF v_platform_owner_id IS NULL THEN
|
||||
INSERT INTO shared.users (id, email, password_hash, first_name, last_name, is_superadmin, is_platform_owner)
|
||||
VALUES (
|
||||
uuid_generate_v4(),
|
||||
'admin@hoaledgeriq.com',
|
||||
-- bcrypt hash of platform owner password (cost 12)
|
||||
'$2b$12$QRJEJYsjy.24Va.57h13Te7UX7nMTN9hWhW19bwuCAkr1Dm0FWqrm',
|
||||
'Platform',
|
||||
'Admin',
|
||||
true,
|
||||
true
|
||||
) RETURNING id INTO v_platform_owner_id;
|
||||
END IF;
|
||||
-- Platform owner has NO org memberships — admin-only account
|
||||
RAISE NOTICE 'Platform Owner: admin@hoaledgeriq.com (id: %)', v_platform_owner_id;
|
||||
END $$;
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Create test user and organization
|
||||
-- ============================================================
|
||||
@@ -836,7 +861,42 @@ EXECUTE format('INSERT INTO %I.capital_projects (name, description, estimated_co
|
||||
(''Perimeter Fence Repair'', ''Replace damaged fence sections and repaint'', 8000, $1 + 4, 8, ''planned'', ''reserve'', 4)
|
||||
', v_schema) USING v_year;
|
||||
|
||||
-- Add subscription data to the organization
|
||||
UPDATE shared.organizations
|
||||
SET payment_date = (CURRENT_DATE - INTERVAL '15 days')::DATE,
|
||||
confirmation_number = 'PAY-2026-SVH-001',
|
||||
renewal_date = (CURRENT_DATE + INTERVAL '350 days')::DATE
|
||||
WHERE schema_name = v_schema;
|
||||
|
||||
-- ============================================================
|
||||
-- 13. Seed login_history for demo analytics
|
||||
-- ============================================================
|
||||
-- Admin user: regular logins over the past 30 days
|
||||
FOR v_month IN 0..29 LOOP
|
||||
INSERT INTO shared.login_history (user_id, organization_id, logged_in_at, ip_address, user_agent)
|
||||
VALUES (
|
||||
v_user_id,
|
||||
v_org_id,
|
||||
NOW() - (v_month || ' days')::INTERVAL - (random() * 8 || ' hours')::INTERVAL,
|
||||
'192.168.1.' || (10 + (random() * 50)::INT),
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
-- Viewer user: occasional logins (every 3-5 days)
|
||||
FOR v_month IN 0..9 LOOP
|
||||
INSERT INTO shared.login_history (user_id, organization_id, logged_in_at, ip_address, user_agent)
|
||||
VALUES (
|
||||
(SELECT id FROM shared.users WHERE email = 'viewer@sunrisevalley.org'),
|
||||
v_org_id,
|
||||
NOW() - ((v_month * 3) || ' days')::INTERVAL - (random() * 12 || ' hours')::INTERVAL,
|
||||
'10.0.0.' || (100 + (random() * 50)::INT),
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Seed data created successfully for Sunrise Valley HOA!';
|
||||
RAISE NOTICE 'Platform Owner: admin@hoaledgeriq.com (SuperAdmin + Platform Owner)';
|
||||
RAISE NOTICE 'Admin Login: admin@sunrisevalley.org / password123 (SuperAdmin + President)';
|
||||
RAISE NOTICE 'Viewer Login: viewer@sunrisevalley.org / password123 (Homeowner)';
|
||||
|
||||
|
||||
@@ -55,8 +55,14 @@ function SuperAdminRoute({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function AuthRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||
const organizations = useAuthStore((s) => s.organizations);
|
||||
if (token && currentOrg) return <Navigate to="/" replace />;
|
||||
// Platform owner / superadmin with no org memberships → admin panel
|
||||
if (token && user?.isSuperadmin && (!organizations || organizations.length === 0)) {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
if (token && !currentOrg) return <Navigate to="/select-org" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
IconChartAreaLine,
|
||||
IconClipboardCheck,
|
||||
IconSparkles,
|
||||
IconHeartRateMonitor,
|
||||
} from '@tabler/icons-react';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
@@ -87,12 +88,44 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||
const organizations = useAuthStore((s) => s.organizations);
|
||||
const isAdminOnly = location.pathname.startsWith('/admin') && !currentOrg;
|
||||
|
||||
const go = (path: string) => {
|
||||
navigate(path);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
// When on admin route with no org selected, show admin-only sidebar
|
||||
if (isAdminOnly && user?.isSuperadmin) {
|
||||
return (
|
||||
<ScrollArea p="sm">
|
||||
<Text size="xs" c="dimmed" fw={700} tt="uppercase" px="sm" pb={4}>
|
||||
Platform Administration
|
||||
</Text>
|
||||
<NavLink
|
||||
label="Admin Panel"
|
||||
leftSection={<IconCrown size={18} />}
|
||||
active={location.pathname === '/admin'}
|
||||
onClick={() => go('/admin')}
|
||||
color="red"
|
||||
/>
|
||||
{organizations && organizations.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<NavLink
|
||||
label="Switch to Tenant"
|
||||
leftSection={<IconBuildingBank size={18} />}
|
||||
onClick={() => go('/select-org')}
|
||||
variant="subtle"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea p="sm">
|
||||
{navSections.map((section, sIdx) => (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,8 +38,11 @@ export function LoginPage() {
|
||||
try {
|
||||
const { data } = await api.post('/auth/login', values);
|
||||
setAuth(data.accessToken, data.user, data.organizations);
|
||||
// Always go through org selection to ensure correct JWT with orgSchema
|
||||
if (data.organizations.length >= 1) {
|
||||
// Platform owner / superadmin with no orgs → admin panel
|
||||
if (data.user?.isSuperadmin && data.organizations.length === 0) {
|
||||
navigate('/admin');
|
||||
} else if (data.organizations.length >= 1) {
|
||||
// Always go through org selection to ensure correct JWT with orgSchema
|
||||
navigate('/select-org');
|
||||
} else {
|
||||
navigate('/');
|
||||
|
||||
@@ -14,6 +14,7 @@ interface User {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isSuperadmin?: boolean;
|
||||
isPlatformOwner?: boolean;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
|
||||
1945
scripts/package-lock.json
generated
Normal file
1945
scripts/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user