diff --git a/apps/mana/apps/web/src/lib/api/services/admin.ts b/apps/mana/apps/web/src/lib/api/services/admin.ts index f4ebb4cd3..98808fbdf 100644 --- a/apps/mana/apps/web/src/lib/api/services/admin.ts +++ b/apps/mana/apps/web/src/lib/api/services/admin.ts @@ -146,10 +146,34 @@ export interface DeleteUserDataResponse { totalDeleted: number; } +/** + * Admin dashboard stats — aggregate counts from auth.users, + * auth.sessions, auth.login_attempts. Backed by GET /admin/stats. + */ +export interface AdminStats { + totalUsers: number; + newUsers7d: number; + newUsers30d: number; + activeSessions: number; + uniqueUsers24h: number; + loginSuccess7d: number; + loginFailed7d: number; + /** ISO timestamp of when the snapshot was generated server-side */ + generatedAt: string; +} + /** * Admin service for user data management */ export const adminService = { + /** + * Aggregate dashboard stats. Backed by a single mana-auth endpoint + * that runs seven counts against the auth schema in parallel. + */ + async getStats(): Promise> { + return getClient().get('/admin/stats'); + }, + /** * Get list of users with pagination and search */ diff --git a/apps/mana/apps/web/src/routes/(app)/admin/+page.svelte b/apps/mana/apps/web/src/routes/(app)/admin/+page.svelte index 930cb259d..1716c898a 100644 --- a/apps/mana/apps/web/src/routes/(app)/admin/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/admin/+page.svelte @@ -2,18 +2,9 @@ import { onMount } from 'svelte'; import StatCard from '$lib/components/admin/StatCard.svelte'; import QuickLinks from '$lib/components/admin/QuickLinks.svelte'; + import { adminService, type AdminStats } from '$lib/api/services/admin'; - interface Stats { - totalUsers: number; - newUsers7d: number; - newUsers30d: number; - activeSessions: number; - uniqueUsers24h: number; - loginSuccess7d: number; - loginFailed7d: number; - } - - let stats = $state(null); + let stats = $state(null); let loading = $state(true); let error = $state(null); @@ -45,27 +36,13 @@ ]; onMount(async () => { - try { - // TODO: Replace with actual API call to fetch admin stats - // const response = await fetch('/api/admin/stats'); - // stats = await response.json(); - - // Mock data for now - await new Promise((resolve) => setTimeout(resolve, 500)); - stats = { - totalUsers: 42, - newUsers7d: 8, - newUsers30d: 23, - activeSessions: 15, - uniqueUsers24h: 12, - loginSuccess7d: 156, - loginFailed7d: 3, - }; - } catch (e) { - error = 'Failed to load stats'; - } finally { - loading = false; + const result = await adminService.getStats(); + if (result.error) { + error = result.error; + } else { + stats = result.data; } + loading = false; }); let userGrowthPercent = $derived( @@ -131,9 +108,11 @@
Success Rate - {Math.round( - (stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100 - )}% + {stats.loginSuccess7d + stats.loginFailed7d > 0 + ? Math.round( + (stats.loginSuccess7d / (stats.loginSuccess7d + stats.loginFailed7d)) * 100 + ) + : '—'}%
diff --git a/services/mana-auth/src/routes/admin.ts b/services/mana-auth/src/routes/admin.ts index 3d34afbf9..2af1bf835 100644 --- a/services/mana-auth/src/routes/admin.ts +++ b/services/mana-auth/src/routes/admin.ts @@ -5,10 +5,11 @@ */ import { Hono } from 'hono'; -import { eq } from 'drizzle-orm'; +import { and, count, countDistinct, eq, gte, isNull } from 'drizzle-orm'; import type { AuthUser } from '../middleware/jwt-auth'; import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import { users } from '../db/schema/auth'; +import { users, sessions } from '../db/schema/auth'; +import { loginAttempts } from '../db/schema/login-attempts'; import type { UserDataService } from '../services/user-data'; const VALID_TIERS = ['guest', 'public', 'beta', 'alpha', 'founder'] as const; @@ -26,6 +27,75 @@ export function createAdminRoutes(db: PostgresJsDatabase, userDataService: await next(); }); + // ─── Aggregate stats for the admin dashboard ────────────── + // + // Replaces hardcoded mock data in apps/mana/apps/web/src/routes/ + // (app)/admin/+page.svelte. All seven values come from auth.users, + // auth.sessions and auth.login_attempts — no other service is + // involved, which keeps this endpoint a pure single-DB read. + + app.get('/stats', async (c) => { + const now = new Date(); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // One query per stat — Postgres handles them in parallel via + // the connection pool when wrapped in Promise.all. Each one is + // a single indexed count, so total latency is dominated by + // round-trip not query work. + const [ + [{ value: totalUsers }], + [{ value: newUsers7d }], + [{ value: newUsers30d }], + [{ value: activeSessions }], + [{ value: uniqueUsers24h }], + [{ value: loginSuccess7d }], + [{ value: loginFailed7d }], + ] = await Promise.all([ + db.select({ value: count() }).from(users).where(isNull(users.deletedAt)), + db + .select({ value: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.createdAt, sevenDaysAgo))), + db + .select({ value: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.createdAt, thirtyDaysAgo))), + db + .select({ value: count() }) + .from(sessions) + .where(and(gte(sessions.expiresAt, now), isNull(sessions.revokedAt))), + db + .select({ value: countDistinct(sessions.userId) }) + .from(sessions) + .where(and(isNull(sessions.revokedAt), gte(sessions.lastActivityAt, twentyFourHoursAgo))), + db + .select({ value: count() }) + .from(loginAttempts) + .where( + and(eq(loginAttempts.successful, true), gte(loginAttempts.attemptedAt, sevenDaysAgo)) + ), + db + .select({ value: count() }) + .from(loginAttempts) + .where( + and(eq(loginAttempts.successful, false), gte(loginAttempts.attemptedAt, sevenDaysAgo)) + ), + ]); + + return c.json({ + totalUsers, + newUsers7d, + newUsers30d, + activeSessions, + uniqueUsers24h, + loginSuccess7d, + loginFailed7d, + generatedAt: now.toISOString(), + }); + }); + // ─── List users with pagination and search ──────────────── app.get('/users', async (c) => {