- Add superadmin impersonation: sessions.impersonated_by (migration 0003) is stamped onto audit rows so impersonated actions are attributable, with a persistent ImpersonationBanner in the app shell. - Harden auth and upload handling across routes (safe redirect targets, filename sanitization, checkout grant handling). - Update dependencies: Sentry 8 -> 10, @fastify/static 8 -> 10, react-router-dom 6.30.6; add find-my-way / fast-uri overrides. - Add tests for safe-next, checkout-grant, and upload-filename. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import { and, desc, eq, gte, ilike, isNull, or, sql } from 'drizzle-orm';
|
|
import {
|
|
getDb,
|
|
users,
|
|
firms,
|
|
clients,
|
|
cases,
|
|
invoices,
|
|
contactMessages,
|
|
auditLog,
|
|
toolUsage,
|
|
} from '@lawdesk/db';
|
|
import { createSession, destroySession, SESSION_COOKIE } from '../auth/sessions';
|
|
import { generateCsrfToken } from '../auth/csrf';
|
|
import { logAudit } from '../lib/audit';
|
|
|
|
const PLANS = ['starter', 'pro', 'lifetime'] as const;
|
|
|
|
const idParam = z.object({ id: z.string().uuid() });
|
|
|
|
export async function adminRoutes(app: FastifyInstance) {
|
|
app.addHook('preHandler', app.requireSuperadmin);
|
|
|
|
// ─────────────────────────── Stats ───────────────────────────
|
|
app.get('/api/admin/stats', async () => {
|
|
const db = getDb();
|
|
const since30 = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
|
|
const [counts] = await db
|
|
.select({
|
|
firms: sql<number>`(select count(*)::int from ${firms})`,
|
|
users: sql<number>`(select count(*)::int from ${users})`,
|
|
cases: sql<number>`(select count(*)::int from ${cases})`,
|
|
clients: sql<number>`(select count(*)::int from ${clients})`,
|
|
invoices: sql<number>`(select count(*)::int from ${invoices})`,
|
|
unresolvedContact: sql<number>`(select count(*)::int from ${contactMessages} where ${contactMessages.resolvedAt} is null)`,
|
|
})
|
|
.from(sql`(select 1) as one`);
|
|
|
|
const [paidTotalsRow] = await db
|
|
.select({
|
|
paidTotal: sql<string>`coalesce(sum(${invoices.total})::text, '0')`,
|
|
})
|
|
.from(invoices)
|
|
.where(eq(invoices.status, 'paid'));
|
|
|
|
const planRows = await db
|
|
.select({ plan: firms.plan, count: sql<number>`count(*)::int` })
|
|
.from(firms)
|
|
.groupBy(firms.plan);
|
|
|
|
const signups = await db
|
|
.select({
|
|
day: sql<string>`to_char(date_trunc('day', ${users.createdAt}), 'YYYY-MM-DD')`,
|
|
count: sql<number>`count(*)::int`,
|
|
})
|
|
.from(users)
|
|
.where(gte(users.createdAt, since30))
|
|
.groupBy(sql`date_trunc('day', ${users.createdAt})`)
|
|
.orderBy(sql`date_trunc('day', ${users.createdAt})`);
|
|
|
|
return {
|
|
counters: {
|
|
firms: counts?.firms ?? 0,
|
|
users: counts?.users ?? 0,
|
|
cases: counts?.cases ?? 0,
|
|
clients: counts?.clients ?? 0,
|
|
invoices: counts?.invoices ?? 0,
|
|
unresolvedContact: counts?.unresolvedContact ?? 0,
|
|
paidRevenueTotal: paidTotalsRow?.paidTotal ?? '0',
|
|
},
|
|
planDistribution: planRows,
|
|
signupsLast30Days: signups,
|
|
};
|
|
});
|
|
|
|
// ─────────────────────────── Firms ───────────────────────────
|
|
app.get('/api/admin/firms', async (req) => {
|
|
const q = z
|
|
.object({
|
|
q: z.string().max(160).optional(),
|
|
plan: z.enum(PLANS).optional(),
|
|
limit: z.coerce.number().int().positive().max(200).default(50),
|
|
offset: z.coerce.number().int().min(0).default(0),
|
|
})
|
|
.parse(req.query);
|
|
|
|
const db = getDb();
|
|
// Build where as raw SQL so we can use the aliased table name in the main query below.
|
|
const whereClauses: ReturnType<typeof sql>[] = [];
|
|
if (q.plan) whereClauses.push(sql`f.plan = ${q.plan}`);
|
|
if (q.q) whereClauses.push(sql`f.name ilike ${'%' + q.q + '%'}`);
|
|
const whereSql = whereClauses.length
|
|
? sql.join([sql`where`, sql.join(whereClauses, sql` and `)], sql` `)
|
|
: sql``;
|
|
|
|
// Raw SQL — Drizzle's `${firms.id}` interpolation inside `sql<T>` doesn't bind to the outer
|
|
// query's table reference inside correlated subqueries.
|
|
const result = await db.execute(sql`
|
|
select
|
|
f.id,
|
|
f.name,
|
|
f.plan,
|
|
f.watermark_enabled as "watermarkEnabled",
|
|
f.created_at as "createdAt",
|
|
coalesce((select count(*)::int from users u where u.firm_id = f.id), 0) as "userCount",
|
|
coalesce((select count(*)::int from cases c where c.firm_id = f.id), 0) as "caseCount",
|
|
coalesce((select count(*)::int from clients cl where cl.firm_id = f.id), 0) as "clientCount",
|
|
coalesce((select sum(total)::text from invoices i where i.firm_id = f.id and i.status = 'paid'), '0') as "paidTotal"
|
|
from firms f
|
|
${whereSql}
|
|
order by f.created_at desc
|
|
limit ${q.limit}
|
|
offset ${q.offset}
|
|
`);
|
|
|
|
const totalResult = await db.execute(sql`select count(*)::int as total from firms f ${whereSql}`);
|
|
const total = (totalResult.rows[0]?.total as number) ?? 0;
|
|
return { items: result.rows, total };
|
|
});
|
|
|
|
app.get('/api/admin/firms/:id', async (req, reply) => {
|
|
const { id } = idParam.parse(req.params);
|
|
const db = getDb();
|
|
|
|
const [firm] = await db.select().from(firms).where(eq(firms.id, id)).limit(1);
|
|
if (!firm) return reply.code(404).send({ error: 'not_found' });
|
|
|
|
const firmUsers = await db
|
|
.select({
|
|
id: users.id,
|
|
email: users.email,
|
|
fullName: users.fullName,
|
|
role: users.role,
|
|
isSuspended: users.isSuspended,
|
|
isSuperadmin: users.isSuperadmin,
|
|
createdAt: users.createdAt,
|
|
lastSeenAt: users.lastSeenAt,
|
|
})
|
|
.from(users)
|
|
.where(eq(users.firmId, id))
|
|
.orderBy(desc(users.createdAt));
|
|
|
|
const [counts] = await db
|
|
.select({
|
|
clients: sql<number>`(select count(*)::int from ${clients} where ${clients.firmId} = ${id})`,
|
|
cases: sql<number>`(select count(*)::int from ${cases} where ${cases.firmId} = ${id})`,
|
|
invoices: sql<number>`(select count(*)::int from ${invoices} where ${invoices.firmId} = ${id})`,
|
|
paidTotal: sql<string>`coalesce((select sum(${invoices.total})::text from ${invoices} where ${invoices.firmId} = ${id} and ${invoices.status} = 'paid'), '0')`,
|
|
})
|
|
.from(sql`(select 1) as one`);
|
|
|
|
return { firm, users: firmUsers, counts };
|
|
});
|
|
|
|
app.patch('/api/admin/firms/:id', async (req, reply) => {
|
|
const { id } = idParam.parse(req.params);
|
|
const body = z
|
|
.object({
|
|
plan: z.enum(PLANS).optional(),
|
|
watermarkEnabled: z.boolean().optional(),
|
|
name: z.string().min(1).max(160).optional(),
|
|
})
|
|
.parse(req.body);
|
|
if (!Object.keys(body).length) return reply.code(400).send({ error: 'empty_body' });
|
|
|
|
const [updated] = await getDb()
|
|
.update(firms)
|
|
.set({ ...body, updatedAt: new Date() })
|
|
.where(eq(firms.id, id))
|
|
.returning();
|
|
if (!updated) return reply.code(404).send({ error: 'not_found' });
|
|
|
|
await logAudit({
|
|
userId: req.user!.id,
|
|
firmId: id,
|
|
action: 'admin.firm.update',
|
|
meta: body,
|
|
ip: req.ip,
|
|
});
|
|
return updated;
|
|
});
|
|
|
|
// ─────────────────────────── Users ───────────────────────────
|
|
app.get('/api/admin/users', async (req) => {
|
|
const q = z
|
|
.object({
|
|
q: z.string().max(160).optional(),
|
|
suspended: z.enum(['true', 'false']).optional(),
|
|
limit: z.coerce.number().int().positive().max(200).default(50),
|
|
offset: z.coerce.number().int().min(0).default(0),
|
|
})
|
|
.parse(req.query);
|
|
|
|
const db = getDb();
|
|
const filters: Parameters<typeof and> = [];
|
|
if (q.q) filters.push(or(ilike(users.email, `%${q.q}%`), ilike(users.fullName, `%${q.q}%`))!);
|
|
if (q.suspended === 'true') filters.push(eq(users.isSuspended, true));
|
|
if (q.suspended === 'false') filters.push(eq(users.isSuspended, false));
|
|
const where = filters.length ? and(...filters) : undefined;
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: users.id,
|
|
email: users.email,
|
|
fullName: users.fullName,
|
|
role: users.role,
|
|
isSuperadmin: users.isSuperadmin,
|
|
isSuspended: users.isSuspended,
|
|
createdAt: users.createdAt,
|
|
lastSeenAt: users.lastSeenAt,
|
|
firmId: users.firmId,
|
|
firmName: firms.name,
|
|
})
|
|
.from(users)
|
|
.leftJoin(firms, eq(firms.id, users.firmId))
|
|
.where(where)
|
|
.orderBy(desc(users.createdAt))
|
|
.limit(q.limit)
|
|
.offset(q.offset);
|
|
|
|
const [count] = await db.select({ total: sql<number>`count(*)::int` }).from(users).where(where);
|
|
return { items: rows, total: count?.total ?? 0 };
|
|
});
|
|
|
|
app.patch('/api/admin/users/:id', async (req, reply) => {
|
|
const { id } = idParam.parse(req.params);
|
|
const body = z
|
|
.object({
|
|
isSuspended: z.boolean().optional(),
|
|
role: z.enum(['owner', 'attorney', 'paralegal', 'staff']).optional(),
|
|
})
|
|
.parse(req.body);
|
|
if (!Object.keys(body).length) return reply.code(400).send({ error: 'empty_body' });
|
|
|
|
if (req.user!.id === id && body.isSuspended === true) {
|
|
return reply.code(409).send({ error: 'cannot_suspend_self' });
|
|
}
|
|
|
|
const [updated] = await getDb()
|
|
.update(users)
|
|
.set({ ...body, updatedAt: new Date() })
|
|
.where(eq(users.id, id))
|
|
.returning({
|
|
id: users.id,
|
|
email: users.email,
|
|
role: users.role,
|
|
isSuspended: users.isSuspended,
|
|
});
|
|
if (!updated) return reply.code(404).send({ error: 'not_found' });
|
|
|
|
if (body.isSuspended) {
|
|
// Revoke all active sessions for this user
|
|
const { sessions } = await import('@lawdesk/db');
|
|
await getDb().delete(sessions).where(eq(sessions.userId, id));
|
|
}
|
|
|
|
await logAudit({
|
|
userId: req.user!.id,
|
|
action: 'admin.user.update',
|
|
meta: { targetUserId: id, patch: body },
|
|
ip: req.ip,
|
|
});
|
|
return updated;
|
|
});
|
|
|
|
// Impersonate: end the current session, start a short-lived one for the target user that is
|
|
// permanently stamped with the acting admin's id. Every audit row written from that session
|
|
// carries `impersonatedBy`, so support activity can never be mistaken for the customer's own.
|
|
app.post('/api/admin/users/:id/impersonate', async (req, reply) => {
|
|
const { id } = idParam.parse(req.params);
|
|
const db = getDb();
|
|
|
|
const [target] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
|
if (!target) return reply.code(404).send({ error: 'not_found' });
|
|
if (target.isSuspended) return reply.code(409).send({ error: 'target_suspended' });
|
|
if (target.id === req.user!.id) return reply.code(409).send({ error: 'cannot_impersonate_self' });
|
|
// Never let one platform admin borrow another's identity — that would launder an action
|
|
// between two accounts that both hold full platform authority.
|
|
if (target.isSuperadmin) return reply.code(409).send({ error: 'cannot_impersonate_superadmin' });
|
|
|
|
const adminId = req.user!.id;
|
|
const oldToken = req.cookies?.[SESSION_COOKIE];
|
|
if (oldToken) await destroySession(oldToken);
|
|
|
|
const { token, expiresAt } = await createSession({
|
|
userId: target.id,
|
|
ip: req.ip,
|
|
userAgent: req.headers['user-agent'] ?? null,
|
|
impersonatedBy: adminId,
|
|
});
|
|
app.setSessionCookie(reply, token, expiresAt);
|
|
app.setCsrfCookie(reply, generateCsrfToken());
|
|
|
|
await logAudit({
|
|
userId: adminId,
|
|
firmId: target.firmId,
|
|
action: 'admin.impersonate.start',
|
|
meta: { targetUserId: target.id, targetEmail: target.email, expiresAt: expiresAt.toISOString() },
|
|
ip: req.ip,
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
impersonating: { id: target.id, email: target.email, firmId: target.firmId },
|
|
expiresAt: expiresAt.toISOString(),
|
|
};
|
|
});
|
|
|
|
// ─────────────────────────── Contact inbox ───────────────────────────
|
|
app.get('/api/admin/contact-messages', async (req) => {
|
|
const q = z
|
|
.object({
|
|
resolved: z.enum(['true', 'false']).optional(),
|
|
limit: z.coerce.number().int().positive().max(200).default(100),
|
|
offset: z.coerce.number().int().min(0).default(0),
|
|
})
|
|
.parse(req.query);
|
|
|
|
const filters: Parameters<typeof and> = [];
|
|
if (q.resolved === 'true') filters.push(sql`${contactMessages.resolvedAt} is not null`);
|
|
if (q.resolved === 'false') filters.push(isNull(contactMessages.resolvedAt));
|
|
const where = filters.length ? and(...filters) : undefined;
|
|
|
|
const db = getDb();
|
|
const rows = await db
|
|
.select()
|
|
.from(contactMessages)
|
|
.where(where)
|
|
.orderBy(desc(contactMessages.createdAt))
|
|
.limit(q.limit)
|
|
.offset(q.offset);
|
|
const [count] = await db
|
|
.select({ total: sql<number>`count(*)::int` })
|
|
.from(contactMessages)
|
|
.where(where);
|
|
return { items: rows, total: count?.total ?? 0 };
|
|
});
|
|
|
|
app.patch('/api/admin/contact-messages/:id', async (req, reply) => {
|
|
const { id } = idParam.parse(req.params);
|
|
const body = z.object({ resolved: z.boolean() }).parse(req.body);
|
|
const [updated] = await getDb()
|
|
.update(contactMessages)
|
|
.set({ resolvedAt: body.resolved ? new Date() : null })
|
|
.where(eq(contactMessages.id, id))
|
|
.returning();
|
|
if (!updated) return reply.code(404).send({ error: 'not_found' });
|
|
return updated;
|
|
});
|
|
|
|
// ─────────────────────────── Audit log ───────────────────────────
|
|
app.get('/api/admin/audit-log', async (req) => {
|
|
const q = z
|
|
.object({
|
|
userId: z.string().uuid().optional(),
|
|
firmId: z.string().uuid().optional(),
|
|
action: z.string().max(120).optional(),
|
|
limit: z.coerce.number().int().positive().max(500).default(100),
|
|
offset: z.coerce.number().int().min(0).default(0),
|
|
})
|
|
.parse(req.query);
|
|
|
|
const filters: Parameters<typeof and> = [];
|
|
if (q.userId) filters.push(eq(auditLog.userId, q.userId));
|
|
if (q.firmId) filters.push(eq(auditLog.firmId, q.firmId));
|
|
if (q.action) filters.push(ilike(auditLog.action, `%${q.action}%`));
|
|
const where = filters.length ? and(...filters) : undefined;
|
|
|
|
const db = getDb();
|
|
const rows = await db
|
|
.select({
|
|
id: auditLog.id,
|
|
userId: auditLog.userId,
|
|
firmId: auditLog.firmId,
|
|
action: auditLog.action,
|
|
meta: auditLog.meta,
|
|
ip: auditLog.ip,
|
|
createdAt: auditLog.createdAt,
|
|
userEmail: users.email,
|
|
})
|
|
.from(auditLog)
|
|
.leftJoin(users, eq(users.id, auditLog.userId))
|
|
.where(where)
|
|
.orderBy(desc(auditLog.createdAt))
|
|
.limit(q.limit)
|
|
.offset(q.offset);
|
|
|
|
return { items: rows };
|
|
});
|
|
|
|
// ─────────────────────────── Tool usage analytics ───────────────────────────
|
|
app.get('/api/admin/tool-usage', async () => {
|
|
const db = getDb();
|
|
const since30 = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
const rows = await db
|
|
.select({
|
|
tool: toolUsage.tool,
|
|
count: sql<number>`count(*)::int`,
|
|
})
|
|
.from(toolUsage)
|
|
.where(gte(toolUsage.createdAt, since30))
|
|
.groupBy(toolUsage.tool)
|
|
.orderBy(sql`count(*) desc`);
|
|
return { items: rows };
|
|
});
|
|
}
|