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 { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
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 * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@@ -13,6 +14,7 @@ export class AdminController {
|
|||||||
constructor(
|
constructor(
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private orgService: OrganizationsService,
|
private orgService: OrganizationsService,
|
||||||
|
private analyticsService: AdminAnalyticsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async requireSuperadmin(req: any) {
|
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')
|
@Get('users')
|
||||||
async listUsers(@Req() req: any) {
|
async listUsers(@Req() req: any) {
|
||||||
await this.requireSuperadmin(req);
|
await this.requireSuperadmin(req);
|
||||||
const users = await this.usersService.findAllUsers();
|
const users = await this.usersService.findAllUsers();
|
||||||
return users.map(u => ({
|
return users.map(u => ({
|
||||||
id: u.id, email: u.email, firstName: u.firstName, lastName: u.lastName,
|
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 => ({
|
organizations: u.userOrganizations?.map(uo => ({
|
||||||
id: uo.organizationId, name: uo.organization?.name, role: uo.role,
|
id: uo.organizationId, name: uo.organization?.name, role: uo.role,
|
||||||
})) || [],
|
})) || [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Organizations ──
|
||||||
|
|
||||||
@Get('organizations')
|
@Get('organizations')
|
||||||
async listOrganizations(@Req() req: any) {
|
async listOrganizations(@Req() req: any) {
|
||||||
await this.requireSuperadmin(req);
|
await this.requireSuperadmin(req);
|
||||||
return this.usersService.findAllOrganizations();
|
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')
|
@Post('users/:id/superadmin')
|
||||||
async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) {
|
async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) {
|
||||||
await this.requireSuperadmin(req);
|
await this.requireSuperadmin(req);
|
||||||
@@ -48,6 +101,16 @@ export class AdminController {
|
|||||||
return { success: true };
|
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')
|
@Post('tenants')
|
||||||
async createTenant(@Req() req: any, @Body() body: {
|
async createTenant(@Req() req: any, @Body() body: {
|
||||||
orgName: string;
|
orgName: string;
|
||||||
@@ -105,19 +168,4 @@ export class AdminController {
|
|||||||
|
|
||||||
return { success: true, organization: org };
|
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' })
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
@UseGuards(AuthGuard('local'))
|
@UseGuards(AuthGuard('local'))
|
||||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
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')
|
@Get('profile')
|
||||||
@@ -45,6 +47,8 @@ export class AuthController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
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 { AuthController } from './auth.controller';
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { LocalStrategy } from './strategies/local.strategy';
|
import { LocalStrategy } from './strategies/local.strategy';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
@@ -25,7 +26,7 @@ import { OrganizationsModule } from '../organizations/organizations.module';
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController, AdminController],
|
controllers: [AuthController, AdminController],
|
||||||
providers: [AuthService, JwtStrategy, LocalStrategy],
|
providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
@@ -14,6 +15,7 @@ export class AuthService {
|
|||||||
constructor(
|
constructor(
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
|
private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(dto: RegisterDto) {
|
async register(dto: RegisterDto) {
|
||||||
@@ -47,7 +49,7 @@ export class AuthService {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(user: User) {
|
async login(user: User, ipAddress?: string, userAgent?: string) {
|
||||||
await this.usersService.updateLastLogin(user.id);
|
await this.usersService.updateLastLogin(user.id);
|
||||||
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
|
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
|
||||||
const u = fullUser || user;
|
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);
|
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);
|
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('User not found');
|
throw new UnauthorizedException('User not found');
|
||||||
@@ -107,6 +112,9 @@ export class AuthService {
|
|||||||
role: membership.role,
|
role: membership.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Record org switch in login history
|
||||||
|
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: this.jwtService.sign(payload),
|
accessToken: this.jwtService.sign(payload),
|
||||||
organization: {
|
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) {
|
private generateTokenResponse(user: User) {
|
||||||
const orgs = user.userOrganizations || [];
|
const orgs = user.userOrganizations || [];
|
||||||
const defaultOrg = orgs[0];
|
const defaultOrg = orgs[0];
|
||||||
@@ -141,6 +166,7 @@ export class AuthService {
|
|||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
isSuperadmin: user.isSuperadmin || false,
|
isSuperadmin: user.isSuperadmin || false,
|
||||||
|
isPlatformOwner: user.isPlatformOwner || false,
|
||||||
},
|
},
|
||||||
organizations: orgs.map((uo) => ({
|
organizations: orgs.map((uo) => ({
|
||||||
id: uo.organizationId,
|
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 { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { InvestmentPlanningService } from './investment-planning.service';
|
import { InvestmentPlanningService } from './investment-planning.service';
|
||||||
@@ -24,7 +24,7 @@ export class InvestmentPlanningController {
|
|||||||
|
|
||||||
@Post('recommendations')
|
@Post('recommendations')
|
||||||
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
|
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
|
||||||
getRecommendations() {
|
getRecommendations(@Req() req: any) {
|
||||||
return this.service.getAIRecommendations();
|
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,8 +166,9 @@ export class InvestmentPlanningService {
|
|||||||
* 4. Call the AI API
|
* 4. Call the AI API
|
||||||
* 5. Parse and return structured recommendations
|
* 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');
|
this.debug('getAIRecommendations', 'Starting AI recommendation flow');
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
const [snapshot, cdRates, monthlyForecast] = await Promise.all([
|
const [snapshot, cdRates, monthlyForecast] = await Promise.all([
|
||||||
this.getFinancialSnapshot(),
|
this.getFinancialSnapshot(),
|
||||||
@@ -188,6 +189,7 @@ export class InvestmentPlanningService {
|
|||||||
|
|
||||||
const messages = this.buildPromptMessages(snapshot, cdRates, monthlyForecast);
|
const messages = this.buildPromptMessages(snapshot, cdRates, monthlyForecast);
|
||||||
const aiResponse = await this.callAI(messages);
|
const aiResponse = await this.callAI(messages);
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
this.debug('final_response', {
|
this.debug('final_response', {
|
||||||
recommendation_count: aiResponse.recommendations.length,
|
recommendation_count: aiResponse.recommendations.length,
|
||||||
@@ -195,9 +197,33 @@ export class InvestmentPlanningService {
|
|||||||
risk_notes_count: aiResponse.risk_notes?.length || 0,
|
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;
|
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: Tenant-Scoped Data Queries ──
|
||||||
|
|
||||||
private async getAccountBalances(): Promise<AccountBalance[]> {
|
private async getAccountBalances(): Promise<AccountBalance[]> {
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ export class Organization {
|
|||||||
@Column({ name: 'plan_level', default: 'standard' })
|
@Column({ name: 'plan_level', default: 'standard' })
|
||||||
planLevel: string;
|
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' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ export class OrganizationsService {
|
|||||||
return this.orgRepository.save(org);
|
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) {
|
async findByUser(userId: string) {
|
||||||
const memberships = await this.userOrgRepository.find({
|
const memberships = await this.userOrgRepository.find({
|
||||||
where: { userId, isActive: true },
|
where: { userId, isActive: true },
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export class User {
|
|||||||
@Column({ name: 'is_superadmin', default: false })
|
@Column({ name: 'is_superadmin', default: false })
|
||||||
isSuperadmin: boolean;
|
isSuperadmin: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_platform_owner', default: false })
|
||||||
|
isPlatformOwner: boolean;
|
||||||
|
|
||||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||||
lastLoginAt: Date;
|
lastLoginAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { User } from './entities/user.entity';
|
import { User } from './entities/user.entity';
|
||||||
@@ -50,13 +50,19 @@ export class UsersService {
|
|||||||
const dataSource = this.usersRepository.manager.connection;
|
const dataSource = this.usersRepository.manager.connection;
|
||||||
return dataSource.query(`
|
return dataSource.query(`
|
||||||
SELECT o.*,
|
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
|
FROM shared.organizations o
|
||||||
ORDER BY o.created_at DESC
|
ORDER BY o.created_at DESC
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
|
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 });
|
await this.usersRepository.update(userId, { isSuperadmin });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ CREATE TABLE shared.organizations (
|
|||||||
email VARCHAR(255),
|
email VARCHAR(255),
|
||||||
tax_id VARCHAR(20),
|
tax_id VARCHAR(20),
|
||||||
fiscal_year_start_month INTEGER DEFAULT 1 CHECK (fiscal_year_start_month BETWEEN 1 AND 12),
|
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(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -45,6 +48,7 @@ CREATE TABLE shared.users (
|
|||||||
oauth_provider_id VARCHAR(255),
|
oauth_provider_id VARCHAR(255),
|
||||||
last_login_at TIMESTAMPTZ,
|
last_login_at TIMESTAMPTZ,
|
||||||
is_superadmin BOOLEAN DEFAULT FALSE,
|
is_superadmin BOOLEAN DEFAULT FALSE,
|
||||||
|
is_platform_owner BOOLEAN DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -86,6 +90,28 @@ CREATE TABLE shared.cd_rates (
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
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
|
-- Indexes
|
||||||
CREATE INDEX idx_user_orgs_user ON shared.user_organizations(user_id);
|
CREATE INDEX idx_user_orgs_user ON shared.user_organizations(user_id);
|
||||||
CREATE INDEX idx_user_orgs_org ON shared.user_organizations(organization_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_invitations_email ON shared.invitations(email);
|
||||||
CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
|
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_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
|
-- Enable UUID generation
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
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
|
-- 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)
|
(''Perimeter Fence Repair'', ''Replace damaged fence sections and repaint'', 8000, $1 + 4, 8, ''planned'', ''reserve'', 4)
|
||||||
', v_schema) USING v_year;
|
', 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 '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 'Admin Login: admin@sunrisevalley.org / password123 (SuperAdmin + President)';
|
||||||
RAISE NOTICE 'Viewer Login: viewer@sunrisevalley.org / password123 (Homeowner)';
|
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 }) {
|
function AuthRoute({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
|
const organizations = useAuthStore((s) => s.organizations);
|
||||||
if (token && currentOrg) return <Navigate to="/" replace />;
|
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 />;
|
if (token && !currentOrg) return <Navigate to="/select-org" replace />;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
IconChartAreaLine,
|
IconChartAreaLine,
|
||||||
IconClipboardCheck,
|
IconClipboardCheck,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
|
IconHeartRateMonitor,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
@@ -87,12 +88,44 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const user = useAuthStore((s) => s.user);
|
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) => {
|
const go = (path: string) => {
|
||||||
navigate(path);
|
navigate(path);
|
||||||
onNavigate?.();
|
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 (
|
return (
|
||||||
<ScrollArea p="sm">
|
<ScrollArea p="sm">
|
||||||
{navSections.map((section, sIdx) => (
|
{navSections.map((section, sIdx) => (
|
||||||
|
|||||||
@@ -2,20 +2,23 @@ import { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
||||||
ThemeIcon, Tabs, Switch, TextInput, Avatar, Modal, Button, PasswordInput,
|
ThemeIcon, Tabs, Switch, TextInput, Avatar, Modal, Button, PasswordInput,
|
||||||
Select, NumberInput, Menu, Divider,
|
Select, NumberInput, Menu, Divider, Progress, Tooltip, Drawer, RingProgress,
|
||||||
|
Paper,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconUsers, IconBuilding, IconShieldLock, IconSearch,
|
IconUsers, IconBuilding, IconShieldLock, IconSearch,
|
||||||
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
||||||
IconCircleCheck, IconBan, IconArchiveOff,
|
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
||||||
|
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
||||||
|
IconCurrencyDollar, IconClipboardCheck, IconLogin,
|
||||||
} 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 api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
interface AdminUser {
|
interface AdminUser {
|
||||||
id: string; email: string; firstName: string; lastName: string;
|
id: string; email: string; firstName: string; lastName: string;
|
||||||
isSuperadmin: boolean; lastLoginAt: string; createdAt: string;
|
isSuperadmin: boolean; isPlatformOwner?: boolean; lastLoginAt: string; createdAt: string;
|
||||||
organizations: { id: string; name: string; role: string }[];
|
organizations: { id: string; name: string; role: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,62 +26,107 @@ interface AdminOrg {
|
|||||||
id: string; name: string; schema_name: string; status: string;
|
id: string; name: string; schema_name: string; status: string;
|
||||||
email: string; phone: string; member_count: string; created_at: string;
|
email: string; phone: string; member_count: string; created_at: string;
|
||||||
contract_number: string; plan_level: string;
|
contract_number: string; plan_level: string;
|
||||||
|
payment_date: string; confirmation_number: string; renewal_date: string;
|
||||||
|
last_activity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlatformMetrics {
|
||||||
|
totalUsers: number;
|
||||||
|
superadminCount: number;
|
||||||
|
platformOwnerCount: number;
|
||||||
|
activeUsers30d: number;
|
||||||
|
totalOrganizations: number;
|
||||||
|
activeOrganizations: number;
|
||||||
|
archivedOrganizations: number;
|
||||||
|
suspendedOrganizations: number;
|
||||||
|
trialOrganizations: number;
|
||||||
|
planBreakdown: { plan: string; count: number }[];
|
||||||
|
statusBreakdown: { status: string; count: number }[];
|
||||||
|
newTenantsPerMonth: { month: string; count: number }[];
|
||||||
|
newUsersPerMonth: { month: string; count: number }[];
|
||||||
|
aiRequestsLast30d: number;
|
||||||
|
aiSuccessfulLast30d: number;
|
||||||
|
aiAvgResponseMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantHealth {
|
||||||
|
id: string; name: string; schemaName: string; status: string;
|
||||||
|
planLevel: string; createdAt: string; paymentDate: string; renewalDate: string;
|
||||||
|
memberCount: number; lastLogin: string; activeUsers30d: number;
|
||||||
|
aiUsage30d: number; cashOnHand: number; hasBudget: boolean;
|
||||||
|
journalEntries30d: number; healthScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantDetail {
|
||||||
|
organization: any;
|
||||||
|
lastLogin: string;
|
||||||
|
loginsThisWeek: number;
|
||||||
|
loginsThisMonth: number;
|
||||||
|
activeUsers30d: number;
|
||||||
|
weeklyLogins: { week: string; count: number }[];
|
||||||
|
monthlyLogins: { month: string; count: number }[];
|
||||||
|
aiRecommendations30d: number;
|
||||||
|
memberCount: number;
|
||||||
|
cashOnHand: number;
|
||||||
|
hasBudget: boolean;
|
||||||
|
recentTransactions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateTenantForm {
|
interface CreateTenantForm {
|
||||||
orgName: string;
|
orgName: string; email: string; phone: string; addressLine1: string;
|
||||||
email: string;
|
city: string; state: string; zipCode: string; contractNumber: string;
|
||||||
phone: string;
|
planLevel: string; fiscalYearStartMonth: number | '';
|
||||||
addressLine1: string;
|
adminEmail: string; adminPassword: string; adminFirstName: string; adminLastName: string;
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
zipCode: string;
|
|
||||||
contractNumber: string;
|
|
||||||
planLevel: string;
|
|
||||||
fiscalYearStartMonth: number | '';
|
|
||||||
adminEmail: string;
|
|
||||||
adminPassword: string;
|
|
||||||
adminFirstName: string;
|
|
||||||
adminLastName: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialFormState: CreateTenantForm = {
|
const initialFormState: CreateTenantForm = {
|
||||||
orgName: '',
|
orgName: '', email: '', phone: '', addressLine1: '', city: '', state: '', zipCode: '',
|
||||||
email: '',
|
contractNumber: '', planLevel: 'standard', fiscalYearStartMonth: 1,
|
||||||
phone: '',
|
adminEmail: '', adminPassword: '', adminFirstName: '', adminLastName: '',
|
||||||
addressLine1: '',
|
|
||||||
city: '',
|
|
||||||
state: '',
|
|
||||||
zipCode: '',
|
|
||||||
contractNumber: '',
|
|
||||||
planLevel: 'standard',
|
|
||||||
fiscalYearStartMonth: 1,
|
|
||||||
adminEmail: '',
|
|
||||||
adminPassword: '',
|
|
||||||
adminFirstName: '',
|
|
||||||
adminLastName: '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const planBadgeColor: Record<string, string> = {
|
const planBadgeColor: Record<string, string> = { standard: 'blue', premium: 'violet', enterprise: 'orange' };
|
||||||
standard: 'blue',
|
const statusColor: Record<string, string> = { active: 'green', trial: 'yellow', suspended: 'red', archived: 'gray' };
|
||||||
premium: 'violet',
|
|
||||||
enterprise: 'orange',
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
function healthScoreColor(score: number): string {
|
||||||
active: 'green',
|
if (score >= 75) return 'green';
|
||||||
trial: 'yellow',
|
if (score >= 50) return 'yellow';
|
||||||
suspended: 'red',
|
if (score >= 25) return 'orange';
|
||||||
archived: 'gray',
|
return 'red';
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return '\u2014';
|
||||||
|
return new Date(dateStr).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<string | null>('dashboard');
|
||||||
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 [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
||||||
|
const [drawerOpened, { open: openDrawer, close: closeDrawer }] = useDisclosure(false);
|
||||||
|
const [subForm, setSubForm] = useState({ paymentDate: '', confirmationNumber: '', renewalDate: '' });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// ── Queries ──
|
||||||
|
|
||||||
|
const { data: metrics, isLoading: metricsLoading } = useQuery<PlatformMetrics>({
|
||||||
|
queryKey: ['admin-metrics'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/admin/metrics'); return data; },
|
||||||
|
});
|
||||||
|
|
||||||
const { data: users, isLoading: usersLoading } = useQuery<AdminUser[]>({
|
const { data: users, isLoading: usersLoading } = useQuery<AdminUser[]>({
|
||||||
queryKey: ['admin-users'],
|
queryKey: ['admin-users'],
|
||||||
queryFn: async () => { const { data } = await api.get('/admin/users'); return data; },
|
queryFn: async () => { const { data } = await api.get('/admin/users'); return data; },
|
||||||
@@ -89,6 +137,20 @@ export function AdminPage() {
|
|||||||
queryFn: async () => { const { data } = await api.get('/admin/organizations'); return data; },
|
queryFn: async () => { const { data } = await api.get('/admin/organizations'); return data; },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: tenantsHealth, isLoading: healthLoading } = useQuery<TenantHealth[]>({
|
||||||
|
queryKey: ['admin-tenants-health'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/admin/tenants-health'); return data; },
|
||||||
|
enabled: activeTab === 'health',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tenantDetail, isLoading: detailLoading } = useQuery<TenantDetail>({
|
||||||
|
queryKey: ['admin-tenant-detail', selectedOrgId],
|
||||||
|
queryFn: async () => { const { data } = await api.get(`/admin/organizations/${selectedOrgId}/detail`); return data; },
|
||||||
|
enabled: !!selectedOrgId && drawerOpened,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mutations ──
|
||||||
|
|
||||||
const toggleSuperadmin = useMutation({
|
const toggleSuperadmin = useMutation({
|
||||||
mutationFn: async ({ userId, isSuperadmin }: { userId: string; isSuperadmin: boolean }) => {
|
mutationFn: async ({ userId, isSuperadmin }: { userId: string; isSuperadmin: boolean }) => {
|
||||||
await api.post(`/admin/users/${userId}/superadmin`, { isSuperadmin });
|
await api.post(`/admin/users/${userId}/superadmin`, { isSuperadmin });
|
||||||
@@ -104,6 +166,7 @@ export function AdminPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-metrics'] });
|
||||||
setForm(initialFormState);
|
setForm(initialFormState);
|
||||||
closeCreateModal();
|
closeCreateModal();
|
||||||
},
|
},
|
||||||
@@ -115,10 +178,23 @@ export function AdminPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-metrics'] });
|
||||||
setStatusConfirm(null);
|
setStatusConfirm(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateSubscription = useMutation({
|
||||||
|
mutationFn: async ({ orgId, data: subData }: { orgId: string; data: any }) => {
|
||||||
|
await api.put(`/admin/organizations/${orgId}/subscription`, subData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
const updateField = <K extends keyof CreateTenantForm>(key: K, value: CreateTenantForm[K]) => {
|
const updateField = <K extends keyof CreateTenantForm>(key: K, value: CreateTenantForm[K]) => {
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
@@ -135,7 +211,18 @@ export function AdminPage() {
|
|||||||
o.schema_name.toLowerCase().includes(search.toLowerCase())
|
o.schema_name.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
const archivedCount = (orgs || []).filter(o => o.status === 'archived').length;
|
const openOrgDetail = (orgId: string) => {
|
||||||
|
setSelectedOrgId(orgId);
|
||||||
|
const org = (orgs || []).find(o => o.id === orgId);
|
||||||
|
if (org) {
|
||||||
|
setSubForm({
|
||||||
|
paymentDate: org.payment_date ? org.payment_date.split('T')[0] : '',
|
||||||
|
confirmationNumber: org.confirmation_number || '',
|
||||||
|
renewalDate: org.renewal_date ? org.renewal_date.split('T')[0] : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
openDrawer();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -143,12 +230,9 @@ export function AdminPage() {
|
|||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
<div>
|
<div>
|
||||||
<Title order={2}>Platform Administration</Title>
|
<Title order={2}>Platform Administration</Title>
|
||||||
<Text c="dimmed" size="sm">SuperUser Admin Panel — Manage tenants and users</Text>
|
<Text c="dimmed" size="sm">HOA LedgerIQ SaaS Management Console</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button leftSection={<IconPlus size={16} />} onClick={openCreateModal}>
|
||||||
leftSection={<IconPlus size={16} />}
|
|
||||||
onClick={openCreateModal}
|
|
||||||
>
|
|
||||||
Create Tenant
|
Create Tenant
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -157,12 +241,42 @@ export function AdminPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search users, organizations, or tenants..."
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="dashboard" leftSection={<IconDashboard size={16} />}>
|
||||||
|
Dashboard
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="orgs" leftSection={<IconBuilding size={16} />}>
|
||||||
|
Organizations ({filteredOrgs.length})
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
|
||||||
|
Users ({filteredUsers.length})
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="health" leftSection={<IconHeartRateMonitor size={16} />}>
|
||||||
|
Tenant Health
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
{/* ── TAB 1: Dashboard ── */}
|
||||||
|
<Tabs.Panel value="dashboard" pt="md">
|
||||||
|
{metricsLoading ? (
|
||||||
|
<Center h={300}><Loader /></Center>
|
||||||
|
) : metrics ? (
|
||||||
|
<Stack>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
||||||
<Card withBorder padding="lg">
|
<Card withBorder padding="lg">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Users</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Users</Text>
|
||||||
<Text fw={700} size="xl">{users?.length || 0}</Text>
|
<Text fw={700} size="xl">{metrics.totalUsers}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{metrics.activeUsers30d} active (30d)</Text>
|
||||||
</div>
|
</div>
|
||||||
<ThemeIcon color="blue" variant="light" size={48} radius="md">
|
<ThemeIcon color="blue" variant="light" size={48} radius="md">
|
||||||
<IconUsers size={28} />
|
<IconUsers size={28} />
|
||||||
@@ -173,7 +287,8 @@ export function AdminPage() {
|
|||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Organizations</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Organizations</Text>
|
||||||
<Text fw={700} size="xl">{orgs?.length || 0}</Text>
|
<Text fw={700} size="xl">{metrics.totalOrganizations}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{metrics.activeOrganizations} active</Text>
|
||||||
</div>
|
</div>
|
||||||
<ThemeIcon color="green" variant="light" size={48} radius="md">
|
<ThemeIcon color="green" variant="light" size={48} radius="md">
|
||||||
<IconBuilding size={28} />
|
<IconBuilding size={28} />
|
||||||
@@ -183,44 +298,178 @@ export function AdminPage() {
|
|||||||
<Card withBorder padding="lg">
|
<Card withBorder padding="lg">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>SuperAdmins</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>AI Requests (30d)</Text>
|
||||||
<Text fw={700} size="xl">{(users || []).filter(u => u.isSuperadmin).length}</Text>
|
<Text fw={700} size="xl">{metrics.aiRequestsLast30d}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{metrics.aiAvgResponseMs ? `Avg ${(metrics.aiAvgResponseMs / 1000).toFixed(1)}s` : 'No data'}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<ThemeIcon color="red" variant="light" size={48} radius="md">
|
<ThemeIcon color="violet" variant="light" size={48} radius="md">
|
||||||
<IconShieldLock size={28} />
|
<IconSparkles size={28} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder padding="lg">
|
<Card withBorder padding="lg">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Archived</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>SuperAdmins</Text>
|
||||||
<Text fw={700} size="xl">{archivedCount}</Text>
|
<Text fw={700} size="xl">{metrics.superadminCount}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{metrics.suspendedOrganizations} suspended, {metrics.archivedOrganizations} archived</Text>
|
||||||
</div>
|
</div>
|
||||||
<ThemeIcon color="gray" variant="light" size={48} radius="md">
|
<ThemeIcon color="red" variant="light" size={48} radius="md">
|
||||||
<IconArchive size={28} />
|
<IconShieldLock size={28} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<TextInput
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
placeholder="Search users or organizations..."
|
<Card withBorder>
|
||||||
leftSection={<IconSearch size={16} />}
|
<Text fw={600} mb="sm">Plan Distribution</Text>
|
||||||
value={search}
|
<Stack gap="xs">
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
{metrics.planBreakdown.map((p) => (
|
||||||
|
<Group key={p.plan} justify="space-between">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge size="sm" variant="light" color={planBadgeColor[p.plan] || 'gray'}>{p.plan}</Badge>
|
||||||
|
<Text size="sm">{p.count} tenant{p.count !== 1 ? 's' : ''}</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress
|
||||||
|
value={metrics.totalOrganizations > 0 ? (p.count / metrics.totalOrganizations) * 100 : 0}
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
color={planBadgeColor[p.plan] || 'gray'}
|
||||||
|
style={{ width: '50%' }}
|
||||||
/>
|
/>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="sm">Status Breakdown</Text>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{metrics.statusBreakdown.map((s) => (
|
||||||
|
<Group key={s.status} justify="space-between">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge size="sm" variant="light" color={statusColor[s.status] || 'gray'}>{s.status}</Badge>
|
||||||
|
<Text size="sm">{s.count} org{s.count !== 1 ? 's' : ''}</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress
|
||||||
|
value={metrics.totalOrganizations > 0 ? (s.count / metrics.totalOrganizations) * 100 : 0}
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
color={statusColor[s.status] || 'gray'}
|
||||||
|
style={{ width: '50%' }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs defaultValue="users">
|
{/* ── TAB 2: Organizations ── */}
|
||||||
<Tabs.List>
|
<Tabs.Panel value="orgs" pt="md">
|
||||||
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
|
{orgsLoading ? (
|
||||||
Users ({filteredUsers.length})
|
<Center h={200}><Loader /></Center>
|
||||||
</Tabs.Tab>
|
) : (
|
||||||
<Tabs.Tab value="orgs" leftSection={<IconBuilding size={16} />}>
|
<Card withBorder>
|
||||||
Organizations ({filteredOrgs.length})
|
<Table striped highlightOnHover>
|
||||||
</Tabs.Tab>
|
<Table.Thead>
|
||||||
</Tabs.List>
|
<Table.Tr>
|
||||||
|
<Table.Th>Organization</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Plan</Table.Th>
|
||||||
|
<Table.Th ta="center">Members</Table.Th>
|
||||||
|
<Table.Th>Last Activity</Table.Th>
|
||||||
|
<Table.Th>Subscription</Table.Th>
|
||||||
|
<Table.Th>Created</Table.Th>
|
||||||
|
<Table.Th></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{filteredOrgs.map((o) => (
|
||||||
|
<Table.Tr key={o.id} style={{ cursor: 'pointer' }} onClick={() => openOrgDetail(o.id)}>
|
||||||
|
<Table.Td>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>{o.name}</Text>
|
||||||
|
<Text size="xs" ff="monospace" c="dimmed">{o.schema_name}</Text>
|
||||||
|
</div>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Menu shadow="md" width={180} position="bottom-start">
|
||||||
|
<Menu.Target>
|
||||||
|
<Badge
|
||||||
|
size="sm" variant="light"
|
||||||
|
color={statusColor[o.status] || 'gray'}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
rightSection={<IconChevronDown size={10} />}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{o.status}
|
||||||
|
</Badge>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Label>Change status</Menu.Label>
|
||||||
|
{o.status !== 'active' && (
|
||||||
|
<Menu.Item leftSection={<IconCircleCheck size={14} />} color="green"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'active' }); }}>
|
||||||
|
Set Active
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{o.status !== 'suspended' && (
|
||||||
|
<Menu.Item leftSection={<IconBan size={14} />} color="red"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'suspended' }); }}>
|
||||||
|
Suspend
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{o.status !== 'archived' && (
|
||||||
|
<Menu.Item leftSection={<IconArchiveOff size={14} />} color="gray"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'archived' }); }}>
|
||||||
|
Archive
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" variant="light" color={planBadgeColor[o.plan_level] || 'gray'}>
|
||||||
|
{o.plan_level}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Badge variant="light" size="sm">{o.member_count}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" c="dimmed">{formatDateTime(o.last_activity)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{o.renewal_date ? (
|
||||||
|
<Tooltip label={`Paid: ${formatDate(o.payment_date)} | Conf: ${o.confirmation_number || 'N/A'}`}>
|
||||||
|
<Text size="xs">Renews {formatDate(o.renewal_date)}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed">Not set</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" c="dimmed">{formatDate(o.created_at)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Button variant="subtle" size="xs" onClick={(e) => { e.stopPropagation(); openOrgDetail(o.id); }}>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* ── TAB 3: Users ── */}
|
||||||
<Tabs.Panel value="users" pt="md">
|
<Tabs.Panel value="users" pt="md">
|
||||||
{usersLoading ? (
|
{usersLoading ? (
|
||||||
<Center h={200}><Loader /></Center>
|
<Center h={200}><Loader /></Center>
|
||||||
@@ -241,10 +490,15 @@ export function AdminPage() {
|
|||||||
<Table.Tr key={u.id}>
|
<Table.Tr key={u.id}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Avatar size="sm" radius="xl" color={u.isSuperadmin ? 'red' : 'blue'}>
|
<Avatar size="sm" radius="xl" color={u.isPlatformOwner ? 'orange' : u.isSuperadmin ? 'red' : 'blue'}>
|
||||||
{u.firstName?.[0]}{u.lastName?.[0]}
|
{u.firstName?.[0]}{u.lastName?.[0]}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
<div>
|
||||||
<Text size="sm" fw={500}>{u.firstName} {u.lastName}</Text>
|
<Text size="sm" fw={500}>{u.firstName} {u.lastName}</Text>
|
||||||
|
{u.isPlatformOwner && (
|
||||||
|
<Badge size="xs" variant="filled" color="orange">Platform Owner</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -268,6 +522,11 @@ export function AdminPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="center">
|
<Table.Td ta="center">
|
||||||
|
{u.isPlatformOwner ? (
|
||||||
|
<Tooltip label="Platform owner - cannot modify">
|
||||||
|
<Switch checked={true} disabled size="sm" color="orange" />
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
<Switch
|
<Switch
|
||||||
checked={u.isSuperadmin}
|
checked={u.isSuperadmin}
|
||||||
onChange={() => toggleSuperadmin.mutate({
|
onChange={() => toggleSuperadmin.mutate({
|
||||||
@@ -277,6 +536,7 @@ export function AdminPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
color="red"
|
color="red"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
@@ -286,99 +546,88 @@ export function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="orgs" pt="md">
|
{/* ── TAB 4: Tenant Health ── */}
|
||||||
{orgsLoading ? (
|
<Tabs.Panel value="health" pt="md">
|
||||||
|
{healthLoading ? (
|
||||||
<Center h={200}><Loader /></Center>
|
<Center h={200}><Loader /></Center>
|
||||||
) : (
|
) : (
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Organization</Table.Th>
|
<Table.Th>Tenant</Table.Th>
|
||||||
<Table.Th>Schema</Table.Th>
|
<Table.Th>Health Score</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th ta="center">Active Users</Table.Th>
|
||||||
<Table.Th>Contract #</Table.Th>
|
<Table.Th>Last Login</Table.Th>
|
||||||
<Table.Th>Plan</Table.Th>
|
<Table.Th ta="center">Budget</Table.Th>
|
||||||
<Table.Th ta="center">Members</Table.Th>
|
<Table.Th ta="center">Transactions (30d)</Table.Th>
|
||||||
<Table.Th>Contact</Table.Th>
|
<Table.Th>Cash on Hand</Table.Th>
|
||||||
<Table.Th>Created</Table.Th>
|
<Table.Th ta="center">AI Usage (30d)</Table.Th>
|
||||||
|
<Table.Th>Renewal</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{filteredOrgs.map((o) => (
|
{(tenantsHealth || [])
|
||||||
<Table.Tr key={o.id}>
|
.filter(t => !search || t.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
<Table.Td fw={500}>{o.name}</Table.Td>
|
.map((t) => (
|
||||||
|
<Table.Tr key={t.id} style={{ cursor: 'pointer' }} onClick={() => openOrgDetail(t.id)}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs" ff="monospace" c="dimmed">{o.schema_name}</Text>
|
<div>
|
||||||
|
<Text size="sm" fw={500}>{t.name}</Text>
|
||||||
|
<Group gap={4}>
|
||||||
|
<Badge size="xs" variant="light" color={statusColor[t.status]}>{t.status}</Badge>
|
||||||
|
<Badge size="xs" variant="light" color={planBadgeColor[t.planLevel]}>{t.planLevel}</Badge>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Menu shadow="md" width={180} position="bottom-start">
|
<Group gap="xs">
|
||||||
<Menu.Target>
|
<RingProgress
|
||||||
<Badge
|
size={36}
|
||||||
size="sm"
|
thickness={4}
|
||||||
variant="light"
|
sections={[{ value: t.healthScore, color: healthScoreColor(t.healthScore) }]}
|
||||||
color={statusColor[o.status] || 'gray'}
|
label={
|
||||||
style={{ cursor: 'pointer' }}
|
<Text size="xs" ta="center" fw={700}>{t.healthScore}</Text>
|
||||||
rightSection={<IconChevronDown size={10} />}
|
}
|
||||||
>
|
/>
|
||||||
{o.status}
|
<Text size="xs" c={healthScoreColor(t.healthScore)} fw={600}>
|
||||||
</Badge>
|
{t.healthScore >= 75 ? 'Healthy' : t.healthScore >= 50 ? 'Fair' : t.healthScore >= 25 ? 'At Risk' : 'Critical'}
|
||||||
</Menu.Target>
|
</Text>
|
||||||
<Menu.Dropdown>
|
</Group>
|
||||||
<Menu.Label>Change status</Menu.Label>
|
</Table.Td>
|
||||||
{o.status !== 'active' && (
|
<Table.Td ta="center">
|
||||||
<Menu.Item
|
<Text size="sm">{t.activeUsers30d} / {t.memberCount}</Text>
|
||||||
leftSection={<IconCircleCheck size={14} />}
|
|
||||||
color="green"
|
|
||||||
onClick={() => setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'active' })}
|
|
||||||
>
|
|
||||||
Set Active
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{o.status !== 'suspended' && (
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconBan size={14} />}
|
|
||||||
color="red"
|
|
||||||
onClick={() => setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'suspended' })}
|
|
||||||
>
|
|
||||||
Suspend
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
{o.status !== 'archived' && (
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconArchiveOff size={14} />}
|
|
||||||
color="gray"
|
|
||||||
onClick={() => setStatusConfirm({ orgId: o.id, orgName: o.name, newStatus: 'archived' })}
|
|
||||||
>
|
|
||||||
Archive
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs" ff="monospace">{o.contract_number || '\u2014'}</Text>
|
<Text size="xs" c="dimmed">{formatDateTime(t.lastLogin)}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td ta="center">
|
||||||
{o.plan_level ? (
|
{t.hasBudget ? (
|
||||||
<Badge size="sm" variant="light" color={planBadgeColor[o.plan_level] || 'gray'}>
|
<ThemeIcon color="green" variant="light" size="sm" radius="xl">
|
||||||
{o.plan_level}
|
<IconClipboardCheck size={14} />
|
||||||
</Badge>
|
</ThemeIcon>
|
||||||
) : (
|
) : (
|
||||||
<Text size="xs" c="dimmed">{'\u2014'}</Text>
|
<ThemeIcon color="red" variant="light" size="sm" radius="xl">
|
||||||
|
<IconBan size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="center">
|
<Table.Td ta="center">
|
||||||
<Badge variant="light" size="sm">{o.member_count}</Badge>
|
<Text size="sm">{t.journalEntries30d}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs">{o.email || 'N/A'}</Text>
|
<Text size="sm" fw={500} c={t.cashOnHand > 0 ? undefined : 'red'}>
|
||||||
</Table.Td>
|
{formatCurrency(t.cashOnHand)}
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{new Date(o.created_at).toLocaleDateString()}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Badge size="sm" variant="light" color={t.aiUsage30d > 0 ? 'violet' : 'gray'}>
|
||||||
|
{t.aiUsage30d}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" c="dimmed">{formatDate(t.renewalDate)}</Text>
|
||||||
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
@@ -388,7 +637,109 @@ export function AdminPage() {
|
|||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Create Tenant Modal */}
|
{/* ── Tenant Detail Drawer ── */}
|
||||||
|
<Drawer
|
||||||
|
opened={drawerOpened}
|
||||||
|
onClose={closeDrawer}
|
||||||
|
title={<Text fw={600}>Tenant Details</Text>}
|
||||||
|
position="right"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{detailLoading ? (
|
||||||
|
<Center h={300}><Loader /></Center>
|
||||||
|
) : tenantDetail ? (
|
||||||
|
<Stack>
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="xs">{tenantDetail.organization.name}</Text>
|
||||||
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
|
<Text size="xs" c="dimmed">Schema</Text>
|
||||||
|
<Text size="xs" ff="monospace">{tenantDetail.organization.schema_name}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Status</Text>
|
||||||
|
<Badge size="xs" variant="light" color={statusColor[tenantDetail.organization.status]}>{tenantDetail.organization.status}</Badge>
|
||||||
|
<Text size="xs" c="dimmed">Plan</Text>
|
||||||
|
<Badge size="xs" variant="light" color={planBadgeColor[tenantDetail.organization.plan_level]}>{tenantDetail.organization.plan_level}</Badge>
|
||||||
|
<Text size="xs" c="dimmed">Contract #</Text>
|
||||||
|
<Text size="xs">{tenantDetail.organization.contract_number || '\u2014'}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Members</Text>
|
||||||
|
<Text size="xs">{tenantDetail.memberCount}</Text>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="xs">Activity</Text>
|
||||||
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
|
<Text size="xs" c="dimmed">Last Login</Text>
|
||||||
|
<Text size="xs">{formatDateTime(tenantDetail.lastLogin)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Logins This Week</Text>
|
||||||
|
<Text size="xs" fw={500}>{tenantDetail.loginsThisWeek}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Logins This Month</Text>
|
||||||
|
<Text size="xs" fw={500}>{tenantDetail.loginsThisMonth}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Active Users (30d)</Text>
|
||||||
|
<Text size="xs" fw={500}>{tenantDetail.activeUsers30d}</Text>
|
||||||
|
<Text size="xs" c="dimmed">AI Recommendations (30d)</Text>
|
||||||
|
<Badge size="xs" variant="light" color="violet">{tenantDetail.aiRecommendations30d}</Badge>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="xs">Setup Health</Text>
|
||||||
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
|
<Text size="xs" c="dimmed">Cash on Hand</Text>
|
||||||
|
<Text size="xs" fw={500}>{formatCurrency(tenantDetail.cashOnHand)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Has Budget</Text>
|
||||||
|
<Badge size="xs" color={tenantDetail.hasBudget ? 'green' : 'red'}>
|
||||||
|
{tenantDetail.hasBudget ? 'Yes' : 'No'}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" c="dimmed">Recent Transactions (30d)</Text>
|
||||||
|
<Text size="xs">{tenantDetail.recentTransactions}</Text>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="xs">Subscription</Text>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<TextInput
|
||||||
|
label="Payment Date"
|
||||||
|
type="date"
|
||||||
|
size="xs"
|
||||||
|
value={subForm.paymentDate}
|
||||||
|
onChange={(e) => setSubForm(p => ({ ...p, paymentDate: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Confirmation Number"
|
||||||
|
size="xs"
|
||||||
|
placeholder="PAY-2026-..."
|
||||||
|
value={subForm.confirmationNumber}
|
||||||
|
onChange={(e) => setSubForm(p => ({ ...p, confirmationNumber: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Renewal Date"
|
||||||
|
type="date"
|
||||||
|
size="xs"
|
||||||
|
value={subForm.renewalDate}
|
||||||
|
onChange={(e) => setSubForm(p => ({ ...p, renewalDate: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
loading={updateSubscription.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedOrgId) {
|
||||||
|
updateSubscription.mutate({ orgId: selectedOrgId, data: subForm });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save Subscription
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Text c="dimmed">No data available</Text>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* ── Create Tenant Modal ── */}
|
||||||
<Modal
|
<Modal
|
||||||
opened={createModalOpened}
|
opened={createModalOpened}
|
||||||
onClose={() => { closeCreateModal(); setForm(initialFormState); }}
|
onClose={() => { closeCreateModal(); setForm(initialFormState); }}
|
||||||
@@ -397,130 +748,63 @@ export function AdminPage() {
|
|||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text fw={600} size="sm" c="dimmed" tt="uppercase">Organization Details</Text>
|
<Text fw={600} size="sm" c="dimmed" tt="uppercase">Organization Details</Text>
|
||||||
<TextInput
|
<TextInput label="Organization Name" placeholder="Sunset Ridge HOA" required
|
||||||
label="Organization Name"
|
value={form.orgName} onChange={(e) => updateField('orgName', e.currentTarget.value)} />
|
||||||
placeholder="Sunset Ridge HOA"
|
|
||||||
required
|
|
||||||
value={form.orgName}
|
|
||||||
onChange={(e) => updateField('orgName', e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<TextInput label="Email" placeholder="contact@sunsetridge.org"
|
||||||
label="Email"
|
value={form.email} onChange={(e) => updateField('email', e.currentTarget.value)} />
|
||||||
placeholder="contact@sunsetridge.org"
|
<TextInput label="Phone" placeholder="(555) 123-4567"
|
||||||
value={form.email}
|
value={form.phone} onChange={(e) => updateField('phone', e.currentTarget.value)} />
|
||||||
onChange={(e) => updateField('email', e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Phone"
|
|
||||||
placeholder="(555) 123-4567"
|
|
||||||
value={form.phone}
|
|
||||||
onChange={(e) => updateField('phone', e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
<TextInput
|
<TextInput label="Address Line 1" placeholder="123 Main Street"
|
||||||
label="Address Line 1"
|
value={form.addressLine1} onChange={(e) => updateField('addressLine1', e.currentTarget.value)} />
|
||||||
placeholder="123 Main Street"
|
|
||||||
value={form.addressLine1}
|
|
||||||
onChange={(e) => updateField('addressLine1', e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<TextInput label="City" placeholder="Springfield"
|
||||||
label="City"
|
value={form.city} onChange={(e) => updateField('city', e.currentTarget.value)} />
|
||||||
placeholder="Springfield"
|
<TextInput label="State" placeholder="CA"
|
||||||
value={form.city}
|
value={form.state} onChange={(e) => updateField('state', e.currentTarget.value)} />
|
||||||
onChange={(e) => updateField('city', e.currentTarget.value)}
|
<TextInput label="Zip Code" placeholder="90210"
|
||||||
/>
|
value={form.zipCode} onChange={(e) => updateField('zipCode', e.currentTarget.value)} />
|
||||||
<TextInput
|
|
||||||
label="State"
|
|
||||||
placeholder="CA"
|
|
||||||
value={form.state}
|
|
||||||
onChange={(e) => updateField('state', e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Zip Code"
|
|
||||||
placeholder="90210"
|
|
||||||
value={form.zipCode}
|
|
||||||
onChange={(e) => updateField('zipCode', e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<TextInput label="Contract Number" placeholder="HOA-2026-001"
|
||||||
label="Contract Number"
|
value={form.contractNumber} onChange={(e) => updateField('contractNumber', e.currentTarget.value)} />
|
||||||
placeholder="HOA-2026-001"
|
<Select label="Plan Level"
|
||||||
value={form.contractNumber}
|
|
||||||
onChange={(e) => updateField('contractNumber', e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Plan Level"
|
|
||||||
data={[
|
data={[
|
||||||
{ value: 'standard', label: 'Standard' },
|
{ value: 'standard', label: 'Standard' },
|
||||||
{ value: 'premium', label: 'Premium' },
|
{ value: 'premium', label: 'Premium' },
|
||||||
{ value: 'enterprise', label: 'Enterprise' },
|
{ value: 'enterprise', label: 'Enterprise' },
|
||||||
]}
|
]}
|
||||||
value={form.planLevel}
|
value={form.planLevel}
|
||||||
onChange={(val) => updateField('planLevel', val || 'standard')}
|
onChange={(val) => updateField('planLevel', val || 'standard')} />
|
||||||
/>
|
<NumberInput label="Fiscal Year Start Month" placeholder="1" min={1} max={12}
|
||||||
<NumberInput
|
|
||||||
label="Fiscal Year Start Month"
|
|
||||||
placeholder="1"
|
|
||||||
min={1}
|
|
||||||
max={12}
|
|
||||||
value={form.fiscalYearStartMonth}
|
value={form.fiscalYearStartMonth}
|
||||||
onChange={(val) => updateField('fiscalYearStartMonth', val as number | '')}
|
onChange={(val) => updateField('fiscalYearStartMonth', val as number | '')} />
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Divider my="xs" />
|
<Divider my="xs" />
|
||||||
|
|
||||||
<Text fw={600} size="sm" c="dimmed" tt="uppercase">Admin User</Text>
|
<Text fw={600} size="sm" c="dimmed" tt="uppercase">Admin User</Text>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<TextInput label="Email" placeholder="admin@sunsetridge.org" required
|
||||||
label="Email"
|
value={form.adminEmail} onChange={(e) => updateField('adminEmail', e.currentTarget.value)} />
|
||||||
placeholder="admin@sunsetridge.org"
|
<PasswordInput label="Password" placeholder="Strong password" required
|
||||||
required
|
value={form.adminPassword} onChange={(e) => updateField('adminPassword', e.currentTarget.value)} />
|
||||||
value={form.adminEmail}
|
|
||||||
onChange={(e) => updateField('adminEmail', e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label="Password"
|
|
||||||
placeholder="Strong password"
|
|
||||||
required
|
|
||||||
value={form.adminPassword}
|
|
||||||
onChange={(e) => updateField('adminPassword', e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<TextInput label="First Name" placeholder="Jane"
|
||||||
label="First Name"
|
value={form.adminFirstName} onChange={(e) => updateField('adminFirstName', e.currentTarget.value)} />
|
||||||
placeholder="Jane"
|
<TextInput label="Last Name" placeholder="Doe"
|
||||||
value={form.adminFirstName}
|
value={form.adminLastName} onChange={(e) => updateField('adminLastName', e.currentTarget.value)} />
|
||||||
onChange={(e) => updateField('adminFirstName', e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Last Name"
|
|
||||||
placeholder="Doe"
|
|
||||||
value={form.adminLastName}
|
|
||||||
onChange={(e) => updateField('adminLastName', e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
<Button variant="default" onClick={() => { closeCreateModal(); setForm(initialFormState); }}>
|
<Button variant="default" onClick={() => { closeCreateModal(); setForm(initialFormState); }}>Cancel</Button>
|
||||||
Cancel
|
<Button onClick={() => createTenant.mutate(form)} loading={createTenant.isPending} disabled={!canSubmitCreate}>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => createTenant.mutate(form)}
|
|
||||||
loading={createTenant.isPending}
|
|
||||||
disabled={!canSubmitCreate}
|
|
||||||
>
|
|
||||||
Create Tenant
|
Create Tenant
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Status Change Confirmation Modal */}
|
{/* ── Status Change Confirmation Modal ── */}
|
||||||
<Modal
|
<Modal
|
||||||
opened={statusConfirm !== null}
|
opened={statusConfirm !== null}
|
||||||
onClose={() => setStatusConfirm(null)}
|
onClose={() => setStatusConfirm(null)}
|
||||||
@@ -535,19 +819,13 @@ export function AdminPage() {
|
|||||||
to <Badge size="sm" variant="light" color={statusColor[statusConfirm.newStatus] || 'gray'}>{statusConfirm.newStatus}</Badge>?
|
to <Badge size="sm" variant="light" color={statusColor[statusConfirm.newStatus] || 'gray'}>{statusConfirm.newStatus}</Badge>?
|
||||||
</Text>
|
</Text>
|
||||||
{statusConfirm.newStatus === 'archived' && (
|
{statusConfirm.newStatus === 'archived' && (
|
||||||
<Text size="xs" c="red">
|
<Text size="xs" c="red">Archiving an organization will disable access for all its members.</Text>
|
||||||
Archiving an organization will disable access for all its members.
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
{statusConfirm.newStatus === 'suspended' && (
|
{statusConfirm.newStatus === 'suspended' && (
|
||||||
<Text size="xs" c="red">
|
<Text size="xs" c="red">Suspending an organization will temporarily disable access for all its members.</Text>
|
||||||
Suspending an organization will temporarily disable access for all its members.
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
<Button variant="default" onClick={() => setStatusConfirm(null)}>
|
<Button variant="default" onClick={() => setStatusConfirm(null)}>Cancel</Button>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
color={statusColor[statusConfirm.newStatus] || 'gray'}
|
color={statusColor[statusConfirm.newStatus] || 'gray'}
|
||||||
onClick={() => changeOrgStatus.mutate({ orgId: statusConfirm.orgId, status: statusConfirm.newStatus })}
|
onClick={() => changeOrgStatus.mutate({ orgId: statusConfirm.orgId, status: statusConfirm.newStatus })}
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ export function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/login', values);
|
const { data } = await api.post('/auth/login', values);
|
||||||
setAuth(data.accessToken, data.user, data.organizations);
|
setAuth(data.accessToken, data.user, data.organizations);
|
||||||
|
// 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
|
// Always go through org selection to ensure correct JWT with orgSchema
|
||||||
if (data.organizations.length >= 1) {
|
|
||||||
navigate('/select-org');
|
navigate('/select-org');
|
||||||
} else {
|
} else {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface User {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
isSuperadmin?: boolean;
|
isSuperadmin?: boolean;
|
||||||
|
isPlatformOwner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
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