- 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>
182 lines
7.0 KiB
TypeScript
182 lines
7.0 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import { eq, inArray, sql } from 'drizzle-orm';
|
|
import {
|
|
getDb,
|
|
users,
|
|
firms,
|
|
clients,
|
|
cases,
|
|
timeEntries,
|
|
invoices,
|
|
invoiceItems,
|
|
documents,
|
|
sessions,
|
|
} from '@lawdesk/db';
|
|
import { verifyPassword } from '../auth/password';
|
|
import { logAuditFromRequest } from '../lib/audit';
|
|
import { sendEmail, accountDeletedEmail } from '../lib/email';
|
|
import { deletePrefix, getSignedDownloadUrl } from '../lib/storage';
|
|
|
|
// Everything under /api/account acts on the whole firm, not just the caller's own rows, so it is
|
|
// owner-only. Without this an 'attorney'/'paralegal'/'staff' member could export every client
|
|
// file the firm holds, or delete the firm outright.
|
|
const OWNER_ONLY = ['owner'] as const;
|
|
|
|
const EXPORT_URL_TTL_SECONDS = 15 * 60;
|
|
|
|
export async function accountRoutes(app: FastifyInstance) {
|
|
app.addHook('preHandler', app.requireAuth);
|
|
|
|
// GDPR data export — full JSON dump of everything tied to the user's firm.
|
|
app.get(
|
|
'/api/account/export',
|
|
{
|
|
config: { rateLimit: { max: 5, timeWindow: '1 hour' } },
|
|
preHandler: app.requireRole(...OWNER_ONLY),
|
|
},
|
|
async (req, reply) => {
|
|
const userId = req.user!.id;
|
|
const firmId = req.user!.firmId;
|
|
const db = getDb();
|
|
|
|
const [profile] = await db
|
|
.select({
|
|
id: users.id,
|
|
email: users.email,
|
|
fullName: users.fullName,
|
|
role: users.role,
|
|
emailVerifiedAt: users.emailVerifiedAt,
|
|
totpEnabled: users.totpEnabled,
|
|
lastSeenAt: users.lastSeenAt,
|
|
createdAt: users.createdAt,
|
|
})
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1);
|
|
|
|
if (!profile) return reply.code(404).send({ error: 'profile_not_found' });
|
|
|
|
const dump: Record<string, unknown> = {
|
|
exportedAt: new Date().toISOString(),
|
|
profile,
|
|
};
|
|
|
|
if (firmId) {
|
|
const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1);
|
|
const firmClients = await db.select().from(clients).where(eq(clients.firmId, firmId));
|
|
const firmCases = await db.select().from(cases).where(eq(cases.firmId, firmId));
|
|
const firmTime = await db.select().from(timeEntries).where(eq(timeEntries.firmId, firmId));
|
|
const firmInvoices = await db.select().from(invoices).where(eq(invoices.firmId, firmId));
|
|
const invoiceIds = firmInvoices.map((i) => i.id);
|
|
const items = invoiceIds.length
|
|
? await db.select().from(invoiceItems).where(inArray(invoiceItems.invoiceId, invoiceIds))
|
|
: [];
|
|
const docs = await db.select().from(documents).where(eq(documents.firmId, firmId));
|
|
|
|
// GDPR portability covers the files themselves, not just their metadata — attach a
|
|
// time-limited presigned download URL per document. These are unauthenticated bearer URLs
|
|
// to privileged client material sitting inside a file the user may forward or archive, so
|
|
// the window is deliberately short (15 minutes): long enough to run the downloads straight
|
|
// after exporting, short enough that a leaked export is not a document breach. Re-export
|
|
// for fresh links.
|
|
const docsWithUrls = await Promise.all(
|
|
docs.map(async (d) => {
|
|
try {
|
|
const downloadUrl = await getSignedDownloadUrl(d.storageKey, d.name, EXPORT_URL_TTL_SECONDS);
|
|
return { ...d, downloadUrl, downloadUrlExpiresInMinutes: EXPORT_URL_TTL_SECONDS / 60 };
|
|
} catch {
|
|
return { ...d, downloadUrl: null, downloadUrlExpiresInMinutes: null };
|
|
}
|
|
}),
|
|
);
|
|
|
|
dump.firm = firm;
|
|
dump.clients = firmClients;
|
|
dump.cases = firmCases;
|
|
dump.timeEntries = firmTime;
|
|
dump.invoices = firmInvoices.map((i) => ({
|
|
...i,
|
|
items: items.filter((it) => it.invoiceId === i.id),
|
|
}));
|
|
dump.documents = docsWithUrls;
|
|
}
|
|
|
|
await logAuditFromRequest(req, 'account.export', {
|
|
firmId,
|
|
meta: { documentUrlsIssued: dump.documents ? (dump.documents as unknown[]).length : 0 },
|
|
});
|
|
|
|
reply
|
|
.header('Content-Type', 'application/json; charset=utf-8')
|
|
.header(
|
|
'Content-Disposition',
|
|
`attachment; filename="elegal-export-${new Date().toISOString().slice(0, 10)}.json"`,
|
|
);
|
|
return JSON.stringify(dump, null, 2);
|
|
});
|
|
|
|
// GDPR delete — password-confirmed. Solo firms cascade everything; multi-user firms must
|
|
// transfer ownership first (we'll add a transfer endpoint when we add team management).
|
|
app.post('/api/account/delete', { preHandler: app.requireRole(...OWNER_ONLY) }, async (req, reply) => {
|
|
const userId = req.user!.id;
|
|
const firmId = req.user!.firmId;
|
|
const body = z.object({ password: z.string().min(1) }).parse(req.body);
|
|
|
|
const db = getDb();
|
|
const [me] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
|
if (!me) return reply.code(404).send({ error: 'user_not_found' });
|
|
|
|
const ok = await verifyPassword(me.passwordHash, body.password);
|
|
if (!ok) return reply.code(401).send({ error: 'invalid_password' });
|
|
|
|
if (firmId) {
|
|
const countRows = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(users)
|
|
.where(eq(users.firmId, firmId));
|
|
const count = countRows[0]?.count ?? 0;
|
|
if (count > 1) {
|
|
return reply.code(409).send({
|
|
error: 'firm_has_other_users',
|
|
hint: 'Transfer firm ownership or remove other users before deleting this account.',
|
|
});
|
|
}
|
|
}
|
|
|
|
await logAuditFromRequest(req, 'account.delete', { firmId, meta: { email: me.email } });
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx.delete(sessions).where(eq(sessions.userId, userId));
|
|
// Deleting the firm cascades: clients → cases → time_entries / documents / invoices →
|
|
// invoice_items via the foreign-key onDelete:'cascade' chain. Audit log entries pointing
|
|
// to this user keep their row but null out user_id (set null).
|
|
if (firmId) await tx.delete(firms).where(eq(firms.id, firmId));
|
|
await tx.delete(users).where(eq(users.id, userId));
|
|
});
|
|
|
|
// GDPR erasure: the cascade above removed the document rows; now remove the files
|
|
// themselves. Storage keys are namespaced `${firmId}/...`, so a prefix delete catches
|
|
// everything, including any objects orphaned by earlier partial failures.
|
|
if (firmId) {
|
|
try {
|
|
const removed = await deletePrefix(`${firmId}/`);
|
|
app.log.info({ firmId, removed }, 'deleted firm storage on account deletion');
|
|
} catch (err) {
|
|
// The account is already gone — surface loudly so the sweep script can catch up.
|
|
app.log.error({ err, firmId }, 'FAILED to delete firm storage after account deletion');
|
|
}
|
|
}
|
|
|
|
// Deletion confirmation — the account row is gone, so use the details captured above.
|
|
const tpl = accountDeletedEmail(me.fullName);
|
|
sendEmail({ to: me.email, ...tpl }).catch((err) =>
|
|
app.log.warn({ err }, 'account deleted email failed'),
|
|
);
|
|
|
|
app.clearSessionCookie(reply);
|
|
app.clearCsrfCookie(reply);
|
|
return { ok: true };
|
|
});
|
|
}
|