diff --git a/backend/src/admin/admin-users.controller.ts b/backend/src/admin/admin-users.controller.ts new file mode 100644 index 0000000..a6d0c7f --- /dev/null +++ b/backend/src/admin/admin-users.controller.ts @@ -0,0 +1,71 @@ +import { + Controller, + Get, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { AdminUsersService } from './admin-users.service'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { UserRole } from '../common/enums'; + +/** + * Super-admin "User Detail" dashboard API. Read-only aggregation of everything + * about a single user. ADMIN only — wallet/revenue data is too sensitive for + * technical/sales staff. + */ +@ApiTags('Admin') +@ApiBearerAuth() +@Controller('admin') +@UseGuards(AuthGuard('jwt'), RolesGuard) +@Roles(UserRole.ADMIN) +export class AdminUsersController { + constructor(private readonly adminUsers: AdminUsersService) {} + + @Get('users/:id') + @ApiOperation({ summary: 'User overview: profile, status, wallet, revenue, counts' }) + getOverview(@Param('id') id: string) { + return this.adminUsers.getOverview(id); + } + + @Get('users/:id/wallet/transactions') + @ApiOperation({ summary: 'Wallet transaction history' }) + @ApiQuery({ name: 'limit', required: false }) + getTransactions(@Param('id') id: string, @Query('limit') limit?: string) { + const n = limit ? Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200) : 50; + return this.adminUsers.getTransactions(id, n); + } + + @Get('users/:id/applications') + @ApiOperation({ summary: 'All applications (incl. deleted/docked) with restore status' }) + getApplications(@Param('id') id: string) { + return this.adminUsers.getApplications(id); + } + + @Get('users/:id/errors') + @ApiOperation({ summary: 'Build/deploy failures across the user applications' }) + getErrors(@Param('id') id: string) { + return this.adminUsers.getErrors(id); + } + + @Get('users/:id/tickets') + @ApiOperation({ summary: 'Support tickets with conversation' }) + getTickets(@Param('id') id: string) { + return this.adminUsers.getTickets(id); + } + + @Get('users/:id/activity') + @ApiOperation({ summary: 'Composite activity timeline' }) + getActivity(@Param('id') id: string) { + return this.adminUsers.getActivity(id); + } + + @Get('deployments/:deploymentId/logs') + @ApiOperation({ summary: 'Build/deploy logs for a single deployment' }) + getDeploymentLogs(@Param('deploymentId') deploymentId: string) { + return this.adminUsers.getDeploymentLogs(deploymentId); + } +} diff --git a/backend/src/admin/admin-users.service.ts b/backend/src/admin/admin-users.service.ts new file mode 100644 index 0000000..a17a421 --- /dev/null +++ b/backend/src/admin/admin-users.service.ts @@ -0,0 +1,333 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { UsersService } from '../users/users.service'; +import { BillingService } from '../billing/billing.service'; +import { ApplicationsService } from '../applications/applications.service'; +import { TicketsService } from '../tickets/tickets.service'; +import { DeploymentsService } from '../deployments/deployments.service'; +import { + AppLifecycleStatus, + DeploymentStatus, + ProductType, + TicketStatus, +} from '../common/enums'; +import { Application } from '../applications/entities/application.entity'; +import { Ticket } from '../tickets/entities/ticket.entity'; + +/** How a non-active application can be brought back, for the admin UI. */ +type RestoreEligibility = 'restorable' | 'recoverable' | 'none'; + +/** + * Read-only aggregation for the super-admin "User Detail" dashboard. Pulls + * everything about a single user from the existing domain services so the panel + * can show profile, wallet, apps, errors, tickets and an activity timeline — + * without duplicating per-module endpoints. + */ +@Injectable() +export class AdminUsersService { + constructor( + private readonly usersService: UsersService, + private readonly billingService: BillingService, + private readonly applicationsService: ApplicationsService, + private readonly ticketsService: TicketsService, + private readonly deploymentsService: DeploymentsService, + ) {} + + /** Overview tab: profile + account status + wallet + revenue + summary counts. */ + async getOverview(userId: string) { + const user = await this.usersService.findById(userId); + if (!user) { + throw new NotFoundException('User not found'); + } + const { password, ...profile } = user; + + // Ensure the wallet row exists before the parallel reads below — otherwise + // getBalance and getRevenueSummary both lazily create it at once and race on + // the unique (userId) constraint. + await this.billingService.getOrCreateWallet(userId); + + const [balance, revenue, apps, tickets, recentTx] = await Promise.all([ + this.billingService.getBalance(userId), + this.billingService.getRevenueSummary(userId), + this.applicationsService.findAllByUser(userId), + this.ticketsService.findMyTickets(userId), + this.billingService.getTransactions(userId, 10), + ]); + + return { + profile, + wallet: { balance: balance.balance }, + revenue, + counts: this.computeCounts(apps, tickets), + recentTransactions: recentTx.map((t) => this.txPublic(t)), + }; + } + + /** Wallet transaction history. */ + async getTransactions(userId: string, limit = 50) { + await this.assertUserExists(userId); + const txs = await this.billingService.getTransactions(userId, limit); + return txs.map((t) => this.txPublic(t)); + } + + /** All applications including deleted/docked ones, with restore eligibility. */ + async getApplications(userId: string) { + await this.assertUserExists(userId); + const apps = await this.applicationsService.findAllByUser(userId); + return apps.map((a) => this.appPublic(a)); + } + + /** Build/deploy failures across all of the user's applications. */ + async getErrors(userId: string) { + await this.assertUserExists(userId); + const apps = await this.applicationsService.findAllByUser(userId); + const failures: Array> = []; + + for (const app of apps) { + const deps = [...(app.deployments ?? [])].sort( + (a, b) => this.time(b.createdAt) - this.time(a.createdAt), + ); + for (const d of deps) { + if ( + d.status !== DeploymentStatus.BUILD_FAILED && + d.status !== DeploymentStatus.FAILED + ) { + continue; + } + // Resolved once a later successful (running) deployment exists for the app. + const resolved = deps.some( + (o) => + o.status === DeploymentStatus.RUNNING && + this.time(o.createdAt) > this.time(d.createdAt), + ); + failures.push({ + deploymentId: d.id, + applicationId: app.id, + applicationName: app.name, + status: d.status, + errorMessage: d.errorMessage ?? null, + createdAt: d.createdAt, + finishedAt: d.finishedAt ?? null, + resolved, + }); + } + } + + return failures.sort( + (a, b) => this.time(b.createdAt as Date) - this.time(a.createdAt as Date), + ); + } + + /** Build/deploy logs for a single deployment (admin can read any user's). */ + async getDeploymentLogs(deploymentId: string) { + const d = await this.deploymentsService.findOne(deploymentId); + return { + id: d.id, + status: d.status, + errorMessage: d.errorMessage ?? null, + buildLog: d.buildLog ?? null, + deployLog: d.deployLog ?? null, + createdAt: d.createdAt, + finishedAt: d.finishedAt ?? null, + }; + } + + /** Support tickets with their full conversation (sensitive sender data stripped). */ + async getTickets(userId: string) { + await this.assertUserExists(userId); + const tickets = await this.ticketsService.findMyTickets(userId); + return tickets.map((t) => ({ + id: t.id, + subject: t.subject, + department: t.department, + status: t.status, + priority: t.priority, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + closedAt: t.closedAt ?? null, + messageCount: t.messages?.length ?? 0, + messages: [...(t.messages ?? [])] + .sort((a, b) => this.time(a.createdAt) - this.time(b.createdAt)) + .map((m) => ({ + id: m.id, + message: m.message, + senderRole: m.senderRole, + senderName: m.sender + ? `${m.sender.firstName ?? ''} ${m.sender.lastName ?? ''}`.trim() + : null, + createdAt: m.createdAt, + })), + })); + } + + /** Composite activity timeline built from existing data (works retroactively). */ + async getActivity(userId: string) { + await this.assertUserExists(userId); + const [apps, txs, tickets] = await Promise.all([ + this.applicationsService.findAllByUser(userId), + this.billingService.getTransactions(userId, 100), + this.ticketsService.findMyTickets(userId), + ]); + + const events: Array<{ + type: string; + title: string; + at: Date; + meta: Record; + }> = []; + + for (const a of apps) { + events.push({ + type: 'app_created', + title: a.name, + at: a.createdAt, + meta: { productType: a.productType }, + }); + if (a.dockedAt) { + events.push({ type: 'app_docked', title: a.name, at: a.dockedAt, meta: {} }); + } + if (a.lifecycleStatus === AppLifecycleStatus.DELETED && a.scheduledDeletionAt) { + events.push({ + type: 'app_deleted', + title: a.name, + at: a.scheduledDeletionAt, + meta: {}, + }); + } + for (const d of a.deployments ?? []) { + events.push({ + type: 'deployment', + title: a.name, + at: d.createdAt, + meta: { status: d.status }, + }); + } + } + + for (const t of txs) { + events.push({ + type: 'transaction', + title: t.description || t.type, + at: t.createdAt, + meta: { txType: t.type, amount: Number(t.amount) }, + }); + } + + for (const t of tickets) { + events.push({ + type: 'ticket', + title: t.subject, + at: t.createdAt, + meta: { status: t.status, department: t.department }, + }); + } + + return events + .sort((a, b) => this.time(b.at) - this.time(a.at)) + .slice(0, 100); + } + + // ── helpers ────────────────────────────────────────── + + private async assertUserExists(userId: string): Promise { + const user = await this.usersService.findById(userId); + if (!user) { + throw new NotFoundException('User not found'); + } + } + + private computeCounts(apps: Application[], tickets: Ticket[]) { + const isManaged = (a: Application) => + !!a.productType && a.productType !== ProductType.APPLICATION; + return { + appsTotal: apps.length, + appsActive: apps.filter((a) => a.lifecycleStatus === AppLifecycleStatus.ACTIVE) + .length, + appsDeleted: apps.filter( + (a) => + a.lifecycleStatus === AppLifecycleStatus.DELETED || + a.lifecycleStatus === AppLifecycleStatus.DOCKED, + ).length, + managedServices: apps.filter(isManaged).length, + optionalServices: apps.reduce( + (n, a) => + n + + (a.enableRedis ? 1 : 0) + + (a.enableRabbitmq ? 1 : 0) + + (a.enableElasticsearch ? 1 : 0), + 0, + ), + deployments: apps.reduce((n, a) => n + (a.deployments?.length ?? 0), 0), + ticketsTotal: tickets.length, + ticketsOpen: tickets.filter((t) => t.status !== TicketStatus.CLOSED).length, + }; + } + + private appPublic(a: Application) { + // findAllByUser orders deployments newest-first, so [0] is the latest. + const latest = a.deployments?.[0]; + return { + id: a.id, + name: a.name, + productType: a.productType, + runtime: a.runtime, + databaseType: a.databaseType, + lifecycleStatus: a.lifecycleStatus, + billingCycle: a.billingCycle ?? null, + subdomain: a.subdomain ?? null, + customDomain: a.customDomain ?? null, + enableRedis: a.enableRedis, + enableRabbitmq: a.enableRabbitmq, + enableElasticsearch: a.enableElasticsearch, + cpuLimit: a.cpuLimit, + memoryLimit: a.memoryLimit, + replicas: a.replicas, + createdAt: a.createdAt, + planExpiresAt: a.planExpiresAt ?? null, + scheduledDeletionAt: a.scheduledDeletionAt ?? null, + dockedAt: a.dockedAt ?? null, + restorable: this.restoreEligibility(a), + latestDeployment: latest + ? { id: latest.id, status: latest.status, createdAt: latest.createdAt } + : null, + }; + } + + private restoreEligibility(a: Application): RestoreEligibility { + if (a.lifecycleStatus === AppLifecycleStatus.DOCKED && a.dockSnapshotId) { + return 'restorable'; + } + if ( + a.lifecycleStatus === AppLifecycleStatus.SUSPENDED || + a.lifecycleStatus === AppLifecycleStatus.PENDING_DELETION + ) { + return 'recoverable'; + } + return 'none'; + } + + private txPublic(t: { + id: string; + type: unknown; + amount: number; + balanceAfter: number; + description?: string | null; + applicationId?: string | null; + invoiceId?: string | null; + createdAt: Date; + }) { + return { + id: t.id, + type: t.type, + amount: Number(t.amount), + balanceAfter: Number(t.balanceAfter), + description: t.description ?? null, + applicationId: t.applicationId ?? null, + invoiceId: t.invoiceId ?? null, + createdAt: t.createdAt, + }; + } + + private time(d: Date | string): number { + return new Date(d).getTime(); + } +} diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts new file mode 100644 index 0000000..0c3f137 --- /dev/null +++ b/backend/src/admin/admin.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { AdminUsersController } from './admin-users.controller'; +import { AdminUsersService } from './admin-users.service'; +import { UsersModule } from '../users/users.module'; +import { BillingModule } from '../billing/billing.module'; +import { ApplicationsModule } from '../applications/applications.module'; +import { DeploymentsModule } from '../deployments/deployments.module'; +import { TicketsModule } from '../tickets/tickets.module'; + +/** + * Super-admin aggregation module. Imports the domain modules (which export + * their services) so the admin controller can read everything about a user + * without re-implementing per-module logic. + */ +@Module({ + imports: [ + UsersModule, + BillingModule, + ApplicationsModule, + DeploymentsModule, + TicketsModule, + ], + controllers: [AdminUsersController], + providers: [AdminUsersService], +}) +export class AdminModule {} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index c1a400b..a114504 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -14,6 +14,7 @@ import { BillingModule } from './billing/billing.module'; import { SnapshotsModule } from './snapshots/snapshots.module'; import { LifecycleModule } from './lifecycle/lifecycle.module'; import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module'; +import { AdminModule } from './admin/admin.module'; import configuration from './config/configuration'; @Module({ @@ -66,6 +67,7 @@ import configuration from './config/configuration'; SnapshotsModule, LifecycleModule, ApplicationMigrationsModule, + AdminModule, ], }) export class AppModule {} diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index 24275fc..7455b62 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -167,6 +167,35 @@ export class BillingService { }); } + /** + * Aggregate wallet movement for a user, grouped by transaction type. Used by + * the admin user-detail dashboard. "revenue" is what the user actually spent + * on services (deductions + direct gateway payments) — i.e. platform income — + * as opposed to "charged" which is just money topped up into the wallet. + */ + async getRevenueSummary( + userId: string, + ): Promise<{ revenue: number; charged: number; refunded: number }> { + const wallet = await this.getOrCreateWallet(userId); + const rows = await this.txRepo + .createQueryBuilder('tx') + .select('tx.type', 'type') + .addSelect('COALESCE(SUM(tx.amount), 0)', 'total') + .where('tx.walletId = :walletId', { walletId: wallet.id }) + .groupBy('tx.type') + .getRawMany<{ type: TransactionType; total: string }>(); + + const totals = new Map(rows.map((r) => [r.type, Number(r.total)])); + const revenue = + (totals.get(TransactionType.DEDUCTION) ?? 0) + + (totals.get(TransactionType.GATEWAY_PAYMENT) ?? 0); + return { + revenue, + charged: totals.get(TransactionType.CHARGE) ?? 0, + refunded: totals.get(TransactionType.REFUND) ?? 0, + }; + } + async recordGatewayPayment( userId: string, amount: number, diff --git a/frontend/src/app/[lang]/dashboard/admin/users/[id]/page.tsx b/frontend/src/app/[lang]/dashboard/admin/users/[id]/page.tsx new file mode 100644 index 0000000..0110946 --- /dev/null +++ b/frontend/src/app/[lang]/dashboard/admin/users/[id]/page.tsx @@ -0,0 +1,638 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useParams } from 'next/navigation'; +import { useLocalizedRouter } from '@/i18n/navigation'; +import { useT, useLocale } from '@/i18n/I18nProvider'; +import api from '@/lib/api'; +import type { + AdminUserOverview, + AdminUserApplication, + AdminUserError, + AdminUserTicket, + AdminActivityEvent, + AdminDeploymentLogs, +} from '@/types'; +import { + ArrowLeft, + Wallet, + TrendingUp, + Boxes, + AlertTriangle, + Ticket as TicketIcon, + Activity, + Server, + ChevronDown, + ChevronUp, + Layers, + CreditCard, +} from 'lucide-react'; + +type TabKey = 'overview' | 'applications' | 'activity' | 'errors' | 'tickets'; +type Det = ReturnType['dashboard']['users']['detail']; + +const lifecycleBadge: Record = { + active: 'badge-green', + suspended: 'bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs font-medium', + pending_deletion: 'bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full text-xs font-medium', + docked: 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium', + deleted: 'badge-red', +}; + +const deployBadge: Record = { + running: 'badge-green', + build_failed: 'badge-red', + failed: 'badge-red', + building: 'badge-blue', + deploying: 'badge-blue', + pending: 'badge-gray', + stopped: 'badge-gray', + cancelled: 'badge-gray', + deleting: 'badge-gray', +}; + +const restoreBadge: Record = { + restorable: 'badge-green', + recoverable: 'badge-blue', + none: 'badge-gray', +}; + +const ticketStatusBadge: Record = { + open: 'bg-yellow-100 text-yellow-700', + waiting: 'bg-orange-100 text-orange-700', + answered: 'bg-green-100 text-green-700', + closed: 'bg-gray-100 text-gray-500', +}; + +function lookup(map: Record, key?: string | null): string { + if (!key) return '—'; + return map[key] ?? key; +} + +export default function AdminUserDetailPage() { + const t = useT(); + const det = t.dashboard.users.detail; + const roleLabels = t.dashboard.users.roles as Record; + const locale = useLocale(); + const { id } = useParams<{ id: string }>(); + const router = useLocalizedRouter(); + const [tab, setTab] = useState('overview'); + + const money = (n: number | string) => + `${Number(n || 0).toLocaleString('en-US')} ${t.common.currencyShort}`; + + const { data: overview, isLoading } = useQuery({ + queryKey: ['admin-user', id], + queryFn: () => api.get(`/admin/users/${id}`).then((r) => r.data), + }); + + const apps = useQuery({ + queryKey: ['admin-user', id, 'applications'], + queryFn: () => api.get(`/admin/users/${id}/applications`).then((r) => r.data), + enabled: tab === 'applications', + }); + + const activity = useQuery({ + queryKey: ['admin-user', id, 'activity'], + queryFn: () => api.get(`/admin/users/${id}/activity`).then((r) => r.data), + enabled: tab === 'activity', + }); + + const errors = useQuery({ + queryKey: ['admin-user', id, 'errors'], + queryFn: () => api.get(`/admin/users/${id}/errors`).then((r) => r.data), + enabled: tab === 'errors', + }); + + const tickets = useQuery({ + queryKey: ['admin-user', id, 'tickets'], + queryFn: () => api.get(`/admin/users/${id}/tickets`).then((r) => r.data), + enabled: tab === 'tickets', + }); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!overview) { + return
{det.notFound}
; + } + + const p = overview.profile; + const tabs: { key: TabKey; label: string; icon: React.ReactNode; badge?: number }[] = [ + { key: 'overview', label: det.tabOverview, icon: }, + { key: 'applications', label: det.tabApplications, icon: , badge: overview.counts.appsTotal }, + { key: 'activity', label: det.tabActivity, icon: }, + { key: 'errors', label: det.tabErrors, icon: }, + { key: 'tickets', label: det.tabTickets, icon: , badge: overview.counts.ticketsOpen }, + ]; + + return ( +
+ {/* Header */} +
+ +
+

+ {p.firstName} {p.lastName} +

+ + {lookup(roleLabels, p.role)} + + + {p.isActive ? t.dashboard.users.active : t.dashboard.users.inactive} + +
+

+ {p.phone || p.email || '—'} +

+
+ + {/* Tabs */} +
+ {tabs.map((tb) => ( + + ))} +
+ + {/* ── Overview ── */} + {tab === 'overview' && ( +
+
+ {/* Basic info */} +
+

{det.basicInfo}

+
+ + + + +
+
+ + {/* Wallet */} +
+

+ {det.walletBalance} +

+

{money(overview.wallet.balance)}

+
+ + +
+
+ + {/* Revenue */} +
+

+ {det.revenue} +

+

{money(overview.revenue.revenue)}

+
+
+ + {/* Counts */} +
+

{det.countsTitle}

+
+ + + + + + +
+
+ + {/* Recent transactions */} +
+

+ {det.recentTransactions} +

+ {overview.recentTransactions.length === 0 ? ( +
{det.noTransactions}
+ ) : ( +
+ + + + + + + + + + + + {overview.recentTransactions.map((tx) => ( + + + + + + + + ))} + +
{det.txType}{det.txAmount}{det.txBalance}{det.txDesc}{det.txDate}
+ {lookup(det.txTypes as Record, tx.type)} + {money(tx.amount)}{money(tx.balanceAfter)}{tx.description || '—'} + {new Date(tx.createdAt).toLocaleDateString(locale)} +
+
+ )} +
+
+ )} + + {/* ── Applications ── */} + {tab === 'applications' && ( + + {(list) => { + const active = list.filter( + (a) => a.lifecycleStatus !== 'docked' && a.lifecycleStatus !== 'deleted', + ); + const removed = list.filter( + (a) => a.lifecycleStatus === 'docked' || a.lifecycleStatus === 'deleted', + ); + return ( +
+ + +
+ ); + }} +
+ )} + + {/* ── Activity ── */} + {tab === 'activity' && ( + + {(events) => + events.length === 0 ? ( +
{det.noActivity}
+ ) : ( +
    + {events.map((e, i) => ( +
  1. + +
    + + {lookup(det.activityTypes as Record, e.type)} + + {e.title} + {e.meta?.status && ( + + {String(e.meta.status)} + + )} + {typeof e.meta?.amount === 'number' && ( + {money(e.meta.amount)} + )} +
    + +
  2. + ))} +
+ ) + } +
+ )} + + {/* ── Errors ── */} + {tab === 'errors' && ( + + {(list) => + list.length === 0 ? ( +
{det.noErrors}
+ ) : ( +
+ {list.map((err) => ( + + ))} +
+ ) + } +
+ )} + + {/* ── Tickets ── */} + {tab === 'tickets' && ( + + {(list) => + list.length === 0 ? ( +
{det.noTickets}
+ ) : ( +
+ {list.map((tk) => ( + + ))} +
+ ) + } +
+ )} +
+ ); +} + +/* ── small presentational helpers ── */ + +function Row({ label, value, ltr }: { label: string; value: string; ltr?: boolean }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function StatBox({ label, value }: { label: string; value: number }) { + return ( +
+

{value}

+

{label}

+
+ ); +} + +function Th({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** Renders loading / content states for a lazily-fetched tab query. */ +function TabState({ + query, + children, +}: { + query: { isLoading: boolean; data: T | undefined }; + children: (data: T) => React.ReactNode; +}) { + if (query.isLoading || !query.data) { + return ( +
+
+
+ ); + } + return <>{children(query.data)}; +} + +function remainingLabel(det: Det, iso?: string | null): { label: string; expired: boolean } | null { + if (!iso) return null; + const ms = new Date(iso).getTime() - Date.now(); + if (ms <= 0) return { label: det.expired, expired: true }; + const days = Math.floor(ms / 86_400_000); + if (days >= 1) return { label: det.remainingDays.replace('{n}', String(days)), expired: false }; + const hours = Math.max(1, Math.ceil(ms / 3_600_000)); + return { label: det.remainingHours.replace('{n}', String(hours)), expired: false }; +} + +function AppTable({ + title, + apps, + det, + locale, + showRestore, +}: { + title: string; + apps: AdminUserApplication[]; + det: Det; + locale: string; + showRestore?: boolean; +}) { + return ( +
+

+ {title} + ({apps.length}) +

+ {apps.length === 0 ? ( +
{det.noApps}
+ ) : ( +
+ + + + + + + + + + + + {apps.map((a) => { + const rem = remainingLabel(det, a.planExpiresAt); + return ( + + + + + + + + ); + })} + +
{det.appName}{det.appType}{det.appStatus}{det.appExpires}{det.appCreated}
+
{a.name}
+ {a.subdomain && ( +
{a.subdomain}
+ )} +
+
{a.productType || 'application'}
+
{a.runtime}
+
+ + {lookup(det.lifecycle as Record, a.lifecycleStatus)} + + {showRestore && ( +
+ + {lookup(det.restore as Record, a.restorable)} + +
+ )} +
+ {rem ? ( + {rem.label} + ) : ( + + )} + + {new Date(a.createdAt).toLocaleDateString(locale)} +
+
+ )} +
+ ); +} + +function ErrorRow({ err, det, locale }: { err: AdminUserError; det: Det; locale: string }) { + const [open, setOpen] = useState(false); + const logs = useQuery({ + queryKey: ['admin-deploy-logs', err.deploymentId], + queryFn: () => api.get(`/admin/deployments/${err.deploymentId}/logs`).then((r) => r.data), + enabled: open, + }); + + return ( +
+
+
+
+ + {err.applicationName} + {err.status} + + {err.resolved ? det.errorResolved : det.errorOpen} + +
+

+ {err.errorMessage || det.noErrorMessage} +

+

{new Date(err.createdAt).toLocaleString(locale)}

+
+ +
+ + {open && ( +
+ {logs.isLoading ? ( +
+
+
+ ) : ( + <> + + + + )} +
+ )} +
+ ); +} + +function LogBlock({ title, content, empty }: { title: string; content?: string | null; empty: string }) { + return ( +
+

{title}

+
+        {content?.trim() || empty}
+      
+
+ ); +} + +function TicketRow({ ticket, det, locale }: { ticket: AdminUserTicket; det: Det; locale: string }) { + const [open, setOpen] = useState(false); + return ( +
+ + + {open && ( +
+ {ticket.messages.map((m) => { + const isStaff = m.senderRole !== 'user'; + return ( +
+
+

+ {m.senderName || '—'} + + {lookup(det.senderRoles as Record, m.senderRole)} + +

+

{m.message}

+

+ {new Date(m.createdAt).toLocaleString(locale)} +

+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/app/[lang]/dashboard/admin/users/page.tsx b/frontend/src/app/[lang]/dashboard/admin/users/page.tsx index c643893..30688e0 100644 --- a/frontend/src/app/[lang]/dashboard/admin/users/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/users/page.tsx @@ -9,6 +9,7 @@ import { notify } from '@/lib/notify'; import type { AdminUser } from '@/types'; import { Users, Search, X, Clock, KeyRound } from 'lucide-react'; import { Select } from '@/components/ui/select'; +import { Link } from '@/i18n/Link'; export default function AdminUsersPage() { const t = useT(); @@ -250,7 +251,16 @@ export default function AdminUsersPage() { {users.map((user) => ( - {user.firstName} {user.lastName} + {isAdmin ? ( + + {user.firstName} {user.lastName} + + ) : ( + <>{user.firstName} {user.lastName} + )} {user.phone || '—'} {user.email || '—'} @@ -320,7 +330,16 @@ export default function AdminUsersPage() {
-

{user.firstName} {user.lastName}

+ {isAdmin ? ( + + {user.firstName} {user.lastName} + + ) : ( +

{user.firstName} {user.lastName}

+ )}

{user.phone || user.email || '—'}

diff --git a/frontend/src/i18n/dictionaries/en.ts b/frontend/src/i18n/dictionaries/en.ts index 1309e76..6a5bfb2 100644 --- a/frontend/src/i18n/dictionaries/en.ts +++ b/frontend/src/i18n/dictionaries/en.ts @@ -794,6 +794,82 @@ const en: Dictionary = { roleUpdated: 'Role updated', passwordUpdated: 'Password updated', passwordFailed: 'Failed to update password', + detail: { + back: 'Back', + notFound: 'User not found', + loading: 'Loading…', + viewDetails: 'View details', + tabOverview: 'Overview', + tabApplications: 'Applications', + tabActivity: 'Activity', + tabErrors: 'Errors', + tabTickets: 'Tickets', + accountStatus: 'Account status', + basicInfo: 'Basic info', + walletBalance: 'Wallet balance', + revenue: 'Revenue generated', + totalCharged: 'Total charged', + totalRefunded: 'Total refunded', + joinedAt: 'Joined', + namespace: 'Namespace', + recentTransactions: 'Recent transactions', + noTransactions: 'No transactions yet.', + countsTitle: 'Summary', + appsTotal: 'Total applications', + appsActive: 'Active apps', + appsDeleted: 'Deleted / archived apps', + managedServices: 'Managed services', + optionalServices: 'Add-on services', + deploymentsCount: 'Deployments', + ticketsOpenCount: 'Open tickets', + txType: 'Type', + txAmount: 'Amount', + txBalance: 'Balance after', + txDesc: 'Description', + txDate: 'Date', + txTypes: { charge: 'Charge', deduction: 'Deduction', refund: 'Refund', gateway_payment: 'Gateway payment' }, + appName: 'Name', + appType: 'Type', + appStatus: 'Status', + appExpires: 'Service expiry', + appCreated: 'Created', + appResources: 'Resources', + activeApps: 'Active apps', + deletedApps: 'Deleted / archived apps', + noApps: 'No applications.', + expired: 'Expired', + remainingDays: '{n} days left', + remainingHours: '{n} hours left', + lifecycle: { active: 'Active', suspended: 'Suspended', pending_deletion: 'Pending deletion', docked: 'Archived', deleted: 'Deleted' }, + restore: { restorable: 'Restorable', recoverable: 'Recoverable with payment', none: 'Not restorable' }, + errorApp: 'Application', + errorReason: 'Error reason', + errorTime: 'Time', + errorState: 'State', + errorResolved: 'Resolved', + errorOpen: 'Open', + noErrors: 'No build/deploy errors.', + noErrorMessage: 'No error message recorded.', + viewLogs: 'View logs', + hideLogs: 'Hide logs', + buildLog: 'Build log', + deployLog: 'Deploy log', + noLog: 'No log available.', + noActivity: 'No activity recorded.', + activityTypes: { app_created: 'Application created', app_docked: 'Application archived', app_deleted: 'Application deleted', deployment: 'Deployment', transaction: 'Wallet transaction', ticket: 'Support ticket' }, + ticketSubject: 'Subject', + ticketStatus: 'Status', + ticketDept: 'Department', + ticketPriority: 'Priority', + ticketMessages: '{n} messages', + noTickets: 'No tickets.', + viewConversation: 'View conversation', + hideConversation: 'Hide conversation', + ticketStatuses: { open: 'Open', answered: 'Answered', waiting: 'Waiting', closed: 'Closed' }, + ticketDepts: { technical: 'Technical', sales: 'Sales' }, + ticketPriorities: { low: 'Low', medium: 'Medium', high: 'High' }, + senderRoles: { user: 'User', admin: 'Admin', technical: 'Technical support', sales: 'Sales' }, + }, }, pools: { title: 'Cluster Pools', diff --git a/frontend/src/i18n/dictionaries/fa.ts b/frontend/src/i18n/dictionaries/fa.ts index b4117bc..38d70c5 100644 --- a/frontend/src/i18n/dictionaries/fa.ts +++ b/frontend/src/i18n/dictionaries/fa.ts @@ -793,6 +793,82 @@ const fa = { roleUpdated: 'نقش به‌روزرسانی شد', passwordUpdated: 'رمز عبور به‌روزرسانی شد', passwordFailed: 'به‌روزرسانی رمز ناموفق بود', + detail: { + back: 'بازگشت', + notFound: 'کاربر پیدا نشد', + loading: 'در حال بارگذاری…', + viewDetails: 'مشاهدهٔ جزئیات', + tabOverview: 'نمای کلی', + tabApplications: 'اپلیکیشن‌ها', + tabActivity: 'تاریخچه فعالیت', + tabErrors: 'خطاها', + tabTickets: 'تیکت‌ها', + accountStatus: 'وضعیت حساب', + basicInfo: 'اطلاعات پایه', + walletBalance: 'موجودی کیف پول', + revenue: 'درآمد ایجادشده', + totalCharged: 'مجموع شارژ', + totalRefunded: 'مجموع بازگشت', + joinedAt: 'تاریخ عضویت', + namespace: 'فضای‌نام', + recentTransactions: 'آخرین تراکنش‌ها', + noTransactions: 'تراکنشی ثبت نشده.', + countsTitle: 'آمار خلاصه', + appsTotal: 'کل اپلیکیشن‌ها', + appsActive: 'اپ‌های فعال', + appsDeleted: 'اپ‌های حذف/بایگانی‌شده', + managedServices: 'سرویس‌های مدیریت‌شده', + optionalServices: 'سرویس‌های جانبی', + deploymentsCount: 'دیپلوی‌ها', + ticketsOpenCount: 'تیکت‌های باز', + txType: 'نوع', + txAmount: 'مبلغ', + txBalance: 'موجودی پس از', + txDesc: 'توضیح', + txDate: 'تاریخ', + txTypes: { charge: 'شارژ', deduction: 'کسر', refund: 'بازگشت', gateway_payment: 'پرداخت درگاه' }, + appName: 'نام', + appType: 'نوع', + appStatus: 'وضعیت', + appExpires: 'انقضای سرویس', + appCreated: 'تاریخ ایجاد', + appResources: 'منابع', + activeApps: 'اپ‌های فعال', + deletedApps: 'اپ‌های حذف/بایگانی‌شده', + noApps: 'اپلیکیشنی نیست.', + expired: 'منقضی‌شده', + remainingDays: '{n} روز باقی‌مانده', + remainingHours: '{n} ساعت باقی‌مانده', + lifecycle: { active: 'فعال', suspended: 'معلق', pending_deletion: 'در انتظار حذف', docked: 'بایگانی‌شده', deleted: 'حذف‌شده' }, + restore: { restorable: 'قابل بازگردانی', recoverable: 'با پرداخت قابل احیا', none: 'غیرقابل بازگردانی' }, + errorApp: 'اپلیکیشن', + errorReason: 'علت خطا', + errorTime: 'زمان', + errorState: 'وضعیت', + errorResolved: 'حل‌شده', + errorOpen: 'باز', + noErrors: 'خطای Build/Deploy ثبت نشده.', + noErrorMessage: 'پیام خطایی ثبت نشده.', + viewLogs: 'مشاهدهٔ لاگ', + hideLogs: 'بستن لاگ', + buildLog: 'لاگ Build', + deployLog: 'لاگ Deploy', + noLog: 'لاگی موجود نیست.', + noActivity: 'فعالیتی ثبت نشده.', + activityTypes: { app_created: 'ساخت اپلیکیشن', app_docked: 'بایگانی اپلیکیشن', app_deleted: 'حذف اپلیکیشن', deployment: 'دیپلوی', transaction: 'تراکنش کیف پول', ticket: 'تیکت پشتیبانی' }, + ticketSubject: 'موضوع', + ticketStatus: 'وضعیت', + ticketDept: 'دپارتمان', + ticketPriority: 'اولویت', + ticketMessages: '{n} پیام', + noTickets: 'تیکتی ثبت نشده.', + viewConversation: 'مشاهدهٔ گفتگو', + hideConversation: 'بستن گفتگو', + ticketStatuses: { open: 'باز', answered: 'پاسخ داده‌شده', waiting: 'در انتظار پاسخ', closed: 'بسته' }, + ticketDepts: { technical: 'فنی', sales: 'فروش' }, + ticketPriorities: { low: 'کم', medium: 'متوسط', high: 'زیاد' }, + senderRoles: { user: 'کاربر', admin: 'مدیر', technical: 'پشتیبان فنی', sales: 'کارشناس فروش' }, + }, }, pools: { title: 'پول‌های کلاستر', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c12a528..b1a1fbb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -172,7 +172,7 @@ export type DeploymentStatus = | 'stopped' | 'deleting'; -export type AppLifecycleStatus = 'active' | 'suspended' | 'pending_deletion' | 'deleted'; +export type AppLifecycleStatus = 'active' | 'suspended' | 'pending_deletion' | 'docked' | 'deleted'; export interface ResourceCredit { id: string; @@ -432,6 +432,142 @@ export interface AdminUser extends User { appCount?: number; } +// ─── Admin User Detail dashboard ──────────────────── + +export interface AdminUserProfile { + id: string; + phone?: string | null; + email: string | null; + firstName: string; + lastName: string; + role: 'user' | 'admin' | 'technical' | 'sales'; + isActive: boolean; + phoneVerified?: boolean; + namespace?: string; + createdAt: string; + updatedAt?: string; +} + +export interface AdminUserCounts { + appsTotal: number; + appsActive: number; + appsDeleted: number; + managedServices: number; + optionalServices: number; + deployments: number; + ticketsTotal: number; + ticketsOpen: number; +} + +export interface AdminRevenueSummary { + /** What the user actually spent on services (platform income). */ + revenue: number; + /** Total topped up into the wallet. */ + charged: number; + refunded: number; +} + +export interface AdminUserTransaction { + id: string; + type: TransactionType; + amount: number; + balanceAfter: number; + description?: string | null; + applicationId?: string | null; + invoiceId?: string | null; + createdAt: string; +} + +export interface AdminUserOverview { + profile: AdminUserProfile; + wallet: { balance: number }; + revenue: AdminRevenueSummary; + counts: AdminUserCounts; + recentTransactions: AdminUserTransaction[]; +} + +export type RestoreEligibility = 'restorable' | 'recoverable' | 'none'; + +export interface AdminUserApplication { + id: string; + name: string; + productType?: ProductType; + runtime: string; + databaseType: string; + lifecycleStatus?: AppLifecycleStatus; + billingCycle?: BillingCycle | null; + subdomain?: string | null; + customDomain?: string | null; + enableRedis: boolean; + enableRabbitmq: boolean; + enableElasticsearch: boolean; + cpuLimit: string; + memoryLimit: string; + replicas: number; + createdAt: string; + planExpiresAt?: string | null; + scheduledDeletionAt?: string | null; + dockedAt?: string | null; + restorable: RestoreEligibility; + latestDeployment?: { id: string; status: DeploymentStatus; createdAt: string } | null; +} + +export interface AdminUserError { + deploymentId: string; + applicationId: string; + applicationName: string; + status: DeploymentStatus; + errorMessage?: string | null; + createdAt: string; + finishedAt?: string | null; + resolved: boolean; +} + +export interface AdminDeploymentLogs { + id: string; + status: DeploymentStatus; + errorMessage?: string | null; + buildLog?: string | null; + deployLog?: string | null; + createdAt: string; + finishedAt?: string | null; +} + +export interface AdminUserTicketMessage { + id: string; + message: string; + senderRole: 'user' | 'admin' | 'technical' | 'sales'; + senderName?: string | null; + createdAt: string; +} + +export interface AdminUserTicket { + id: string; + subject: string; + department: TicketDepartment; + status: TicketStatus; + priority: TicketPriority; + createdAt: string; + updatedAt: string; + closedAt?: string | null; + messageCount: number; + messages: AdminUserTicketMessage[]; +} + +export interface AdminActivityEvent { + type: + | 'app_created' + | 'app_docked' + | 'app_deleted' + | 'deployment' + | 'transaction' + | 'ticket' + | string; + title: string; + at: string; + meta: Record; +} + export interface ClusterNode { name: string; status: string;