From 97e1d4c60b8b9a558666256689c81fb118263c4a Mon Sep 17 00:00:00 2001 From: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:18:10 -0400 Subject: [PATCH] =?UTF-8?q?Storage=E2=86=92Spaces,=20security=20hardening,?= =?UTF-8?q?=20production-blocker=20fixes,=20tests=20+=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage - Migrate document/media storage from local disk to DigitalOcean Spaces (S3); lib/storage.ts now streams via the S3 SDK; SPACES_* env vars required. - Add scripts/migrate-storage-to-spaces.ts (idempotent, one-time). Security hardening (all report findings) - DB pool fails closed in production when the CA cert is missing (no more silent unverified TLS); warns in dev. - trustProxy: 1 (was true) so X-Forwarded-For can't be spoofed to evade rate limits. - Login lockout keyed by (email, ip) so an attacker can't lock out a victim. - Superadmin auto-grant now requires a verified email. - CSRF tokens HMAC-signed; exact-path exemptions; logout no longer exempt. - Upload content-sniffing (magic bytes) rejects spoofed MIME types. - create-admin.ts reads creds from env/argv; seed-demo.ts guarded behind ALLOW_SEED. Production-blocker fixes - SPA deep-link/refresh no longer 500s (decorateReply fix); index.html served no-cache. - Invoice numbering is transaction-safe (per-firm advisory lock + max sequence), eliminating concurrent collisions and delete-reuse — no schema change. - Checkout guards against double-billing a firm already on a paid plan. - Fix render-loop in CreateInvoiceDrawer / ManualEntryDrawer (unstable effect deps). Honesty / trust - Remove fabricated testimonials, stats, strikethrough "was" prices, contact SLA, and the login-panel stats; replace with non-fabricated copy. - Fix cookie-policy consent-key mismatch. (Legal pages still need lawyer review.) Quality - Add Vitest unit tests (file-signature, password hashing) and GitHub Actions CI. Co-Authored-By: Claude Fable 5 --- .env.example | 22 +- .github/workflows/ci.yml | 31 + apps/api/package.json | 10 +- apps/api/src/auth/csrf.ts | 52 +- apps/api/src/auth/plugin.ts | 1 + apps/api/src/auth/superadmin.ts | 35 +- apps/api/src/env.ts | 13 +- apps/api/src/lib/email.ts | 443 +++++-- apps/api/src/lib/file-signature.ts | 55 + apps/api/src/lib/invoice-numbering.ts | 30 +- apps/api/src/lib/storage.ts | 166 ++- apps/api/src/routes/account.ts | 39 +- apps/api/src/routes/auth.ts | 137 ++- apps/api/src/routes/billing.ts | 9 + apps/api/src/routes/cases.ts | 7 + apps/api/src/routes/clients.ts | 18 +- apps/api/src/routes/contact.ts | 15 +- apps/api/src/routes/documents.ts | 26 +- apps/api/src/routes/invoices.ts | 32 +- apps/api/src/routes/webhooks-stripe.ts | 50 +- apps/api/src/server.ts | 21 +- apps/api/test/file-signature.test.ts | 70 ++ apps/api/test/password.test.ts | 33 + apps/api/vitest.config.ts | 11 + apps/web/src/App.tsx | 12 + .../components/app/CreateInvoiceDrawer.tsx | 6 +- .../src/components/app/ManualEntryDrawer.tsx | 7 +- apps/web/src/components/auth/AuthLayout.tsx | 12 +- apps/web/src/components/marketing/Contact.tsx | 8 +- .../web/src/components/marketing/Features.tsx | 4 +- apps/web/src/components/marketing/Footer.tsx | 23 +- apps/web/src/components/marketing/Pricing.tsx | 3 - apps/web/src/components/marketing/Stats.tsx | 31 +- .../src/components/marketing/Testimonials.tsx | 106 +- .../web/src/pages/legal/AcceptableUsePage.tsx | 84 ++ apps/web/src/pages/legal/CookiesPage.tsx | 4 +- apps/web/src/pages/legal/DisclaimerPage.tsx | 70 ++ apps/web/src/pages/legal/DmcaPage.tsx | 89 ++ apps/web/src/pages/legal/DpaPage.tsx | 118 ++ apps/web/src/pages/legal/LegalIndexPage.tsx | 83 ++ apps/web/src/pages/legal/LegalLayout.tsx | 22 +- apps/web/src/pages/legal/PrivacyPage.tsx | 183 ++- apps/web/src/pages/legal/RefundsPage.tsx | 85 ++ apps/web/src/pages/legal/TermsPage.tsx | 357 ++++-- package-lock.json | 1089 +++++++++++++---- package.json | 3 +- packages/db/src/index.ts | 27 +- scripts/create-admin.ts | 71 ++ scripts/migrate-storage-to-spaces.ts | 122 ++ scripts/seed-demo.ts | 268 ++++ scripts/send-overdue-reminders.ts | 87 ++ 51 files changed, 3640 insertions(+), 660 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 apps/api/src/lib/file-signature.ts create mode 100644 apps/api/test/file-signature.test.ts create mode 100644 apps/api/test/password.test.ts create mode 100644 apps/api/vitest.config.ts create mode 100644 apps/web/src/pages/legal/AcceptableUsePage.tsx create mode 100644 apps/web/src/pages/legal/DisclaimerPage.tsx create mode 100644 apps/web/src/pages/legal/DmcaPage.tsx create mode 100644 apps/web/src/pages/legal/DpaPage.tsx create mode 100644 apps/web/src/pages/legal/LegalIndexPage.tsx create mode 100644 apps/web/src/pages/legal/RefundsPage.tsx create mode 100644 scripts/create-admin.ts create mode 100644 scripts/migrate-storage-to-spaces.ts create mode 100644 scripts/seed-demo.ts create mode 100644 scripts/send-overdue-reminders.ts diff --git a/.env.example b/.env.example index 57ad6c9..d2c0903 100644 --- a/.env.example +++ b/.env.example @@ -22,16 +22,28 @@ DATABASE_URL=postgresql://doadmin:password@db-postgresql-nyc1-xxxxx.b.db.ondigit DATABASE_CA_CERT_PATH=./certs/do-ca.crt # ───────────────────────────────────────────── -# Local file storage -# Absolute path where uploaded documents are stored (outside web root). -# Production example: /var/www/vhosts/elegalsoftware.com/storage +# Object storage — DigitalOcean Spaces (S3-compatible). Sole storage backend (required). +# Create a Space + access keys in the DO console. Endpoint is the REGION endpoint +# (no bucket prefix); the SDK adds the bucket as a virtual host. # ───────────────────────────────────────────── +SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com +SPACES_REGION=nyc3 +SPACES_BUCKET=elegalsoftware +SPACES_KEY= +SPACES_SECRET= +# Optional CDN/base URL for public objects; leave blank to serve everything through the API. +SPACES_PUBLIC_BASE= + +# Legacy local path — only read by scripts/migrate-storage-to-spaces.ts during a one-time migration. STORAGE_PATH=./storage # ───────────────────────────────────────────── -# Email (Resend) +# Email (SMTP2GO) — sends all platform email via the HTTP API. +# Create an API key in the SMTP2GO dashboard (Settings → API Keys). +# EMAIL_FROM's domain must be a verified sender domain in SMTP2GO. +# Leave the key blank in dev to log emails instead of sending. # ───────────────────────────────────────────── -RESEND_API_KEY= +SMTP2GO_API_KEY= EMAIL_FROM="eLegal Software " # ───────────────────────────────────────────── diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5b0dd14 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: ['**'] + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Typecheck (all workspaces) + run: npm run typecheck + + - name: Test (all workspaces) + run: npm test + + - name: Build web + run: npm run build diff --git a/apps/api/package.json b/apps/api/package.json index 7e1694c..e411e38 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,9 +8,13 @@ "dev": "tsx watch src/server.ts", "build": "tsc -p tsconfig.json --noEmit", "start": "tsx src/server.ts", + "test": "vitest run", + "test:watch": "vitest", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1088.0", + "@aws-sdk/s3-request-presigner": "^3.1088.0", "@fastify/cookie": "^11.0.1", "@fastify/cors": "^10.0.1", "@fastify/helmet": "^12.0.1", @@ -25,10 +29,9 @@ "fastify": "^5.1.0", "fastify-plugin": "^5.0.1", "fastify-type-provider-zod": "^4.0.2", - "pg": "^8.13.1", "pdfkit": "^0.15.0", + "pg": "^8.13.1", "pino": "^9.5.0", - "resend": "^4.0.1", "stripe": "^17.4.0", "tsx": "^4.19.2", "zod": "^3.23.8" @@ -38,6 +41,7 @@ "@types/pdfkit": "^0.13.5", "@types/pg": "^8.11.10", "pino-pretty": "^11.3.0", - "typescript": "^5.6.3" + "typescript": "^5.6.3", + "vitest": "^3.2.7" } } diff --git a/apps/api/src/auth/csrf.ts b/apps/api/src/auth/csrf.ts index dffe921..1c4d5aa 100644 --- a/apps/api/src/auth/csrf.ts +++ b/apps/api/src/auth/csrf.ts @@ -10,12 +10,42 @@ const TOKEN_BYTES = 32; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); -// Routes that legitimately bypass CSRF — they receive their own auth (signature check) -// or have no session yet, so a CSRF attack against them is meaningless. -const CSRF_EXEMPT_PREFIXES = ['/api/auth/', '/api/contact', '/api/webhooks/', '/api/tool-usage']; +// Exact routes that legitimately bypass CSRF: they run pre-session (login/signup/reset) or carry +// their own authentication (Stripe signature), so a CSRF attack against them is meaningless. +// Exact-match only — no prefix matching, so nothing new is silently exempted, and authenticated +// state-changing routes like /api/auth/logout are NOT exempt (the browser client sends the token). +const CSRF_EXEMPT_PATHS = new Set([ + '/api/auth/login', + '/api/auth/signup', + '/api/auth/request-password-reset', + '/api/auth/reset-password', + '/api/contact', + '/api/tool-usage', + '/api/webhooks/stripe', +]); + +// CSRF tokens are HMAC-signed with CSRF_SECRET: `${random}.${sig}`. Signing means a token can't be +// forged by a party that doesn't hold the secret, so an attacker on a sibling/compromised subdomain +// cannot plant a self-consistent cookie+header pair (the classic weakness of naive double-submit). +function signCsrf(value: string): string { + return crypto.createHmac('sha256', env.CSRF_SECRET).update(value).digest('base64url'); +} export function generateCsrfToken(): string { - return crypto.randomBytes(TOKEN_BYTES).toString('base64url'); + const random = crypto.randomBytes(TOKEN_BYTES).toString('base64url'); + return `${random}.${signCsrf(random)}`; +} + +function isValidCsrfToken(token: string): boolean { + const dot = token.lastIndexOf('.'); + if (dot <= 0) return false; + const random = token.slice(0, dot); + const sig = token.slice(dot + 1); + const expected = signCsrf(random); + const a = Buffer.from(sig); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); } function constantTimeEqual(a: string, b: string): boolean { @@ -52,11 +82,13 @@ async function plugin(app: FastifyInstance) { }); }); - // Auto-mint a CSRF token whenever an authenticated session exists but no CSRF cookie is set. - // This makes the protection self-bootstrapping after sessions created before CSRF was enabled. + // Auto-mint a CSRF token whenever an authenticated session exists but no valid CSRF cookie is + // set. Self-bootstrapping for sessions created before CSRF existed, and self-healing: a stale or + // unsigned cookie (fails signature) is replaced with a fresh signed one instead of wedging. app.addHook('onRequest', async (req, reply) => { if (!req.cookies?.[SESSION_COOKIE]) return; - if (req.cookies?.[CSRF_COOKIE]) return; + const existing = req.cookies?.[CSRF_COOKIE]; + if (existing && isValidCsrfToken(existing)) return; const token = generateCsrfToken(); app.setCsrfCookie(reply, token); req.cookies = { ...req.cookies, [CSRF_COOKIE]: token }; @@ -67,11 +99,13 @@ async function plugin(app: FastifyInstance) { if (SAFE_METHODS.has(req.method)) return; if (!req.cookies?.[SESSION_COOKIE]) return; // unauthenticated → nothing to protect const url = req.routeOptions.url || req.url; - if (CSRF_EXEMPT_PREFIXES.some((p) => url.startsWith(p))) return; + if (CSRF_EXEMPT_PATHS.has(url)) return; const cookie = req.cookies?.[CSRF_COOKIE]; const header = (req.headers[CSRF_HEADER] as string | undefined) ?? ''; - if (!cookie || !header || !constantTimeEqual(cookie, header)) { + // Require: cookie and header present, they match (double-submit), and the token carries a + // valid signature (proves it was minted by this server, not planted by another origin). + if (!cookie || !header || !constantTimeEqual(cookie, header) || !isValidCsrfToken(cookie)) { return reply.code(403).send({ error: 'csrf_failed' }); } }); diff --git a/apps/api/src/auth/plugin.ts b/apps/api/src/auth/plugin.ts index 2c7c1c3..23e4a98 100644 --- a/apps/api/src/auth/plugin.ts +++ b/apps/api/src/auth/plugin.ts @@ -37,6 +37,7 @@ async function plugin(app: FastifyInstance) { session.user.id, session.user.email, session.user.isSuperadmin, + session.user.emailVerifiedAt, ); req.user = { diff --git a/apps/api/src/auth/superadmin.ts b/apps/api/src/auth/superadmin.ts index f6ae4c7..08bcaf7 100644 --- a/apps/api/src/auth/superadmin.ts +++ b/apps/api/src/auth/superadmin.ts @@ -6,11 +6,32 @@ export function isSuperadminEmail(email: string): boolean { return env.superadminEmails.includes(email.toLowerCase()); } -// Promote any user whose email is on the SUPERADMIN_EMAILS list. Idempotent. -// Called on signup/login so the assignment happens automatically as soon as the user shows up. -export async function ensureSuperadminFlag(userId: string, email: string, currentFlag: boolean) { - const shouldBe = isSuperadminEmail(email); - if (shouldBe === currentFlag) return shouldBe; - await getDb().update(users).set({ isSuperadmin: shouldBe, updatedAt: new Date() }).where(eq(users.id, userId)); - return shouldBe; +// Reconcile a user's superadmin flag against the SUPERADMIN_EMAILS allowlist. Idempotent. +// Called on signup/login/every request. +// +// Security: promotion (false -> true) requires a VERIFIED email. Public signup never sets +// emailVerifiedAt, so an attacker who registers a listed address before its owner does NOT +// silently become superadmin. Legitimate superadmins are provisioned via scripts/create-admin.ts +// (which sets isSuperadmin + emailVerifiedAt directly) or on an already-verified account. +// Demotion (list removal) still happens immediately, regardless of verification. +export async function ensureSuperadminFlag( + userId: string, + email: string, + currentFlag: boolean, + emailVerifiedAt: Date | null, +) { + const onList = isSuperadminEmail(email); + + if (!onList) { + if (currentFlag) { + await getDb().update(users).set({ isSuperadmin: false, updatedAt: new Date() }).where(eq(users.id, userId)); + } + return false; + } + + if (currentFlag) return true; // already a superadmin — keep it + if (!emailVerifiedAt) return false; // on the list but unverified — do NOT auto-promote + + await getDb().update(users).set({ isSuperadmin: true, updatedAt: new Date() }).where(eq(users.id, userId)); + return true; } diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 108eda5..87ea604 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -19,8 +19,17 @@ const envSchema = z.object({ WEB_DIST_PATH: z.string().optional(), SUPERADMIN_EMAILS: z.string().optional().default(''), SENTRY_DSN_API: z.string().optional().default(''), - STORAGE_PATH: z.string().min(1).default('./storage'), - RESEND_API_KEY: z.string().optional().default(''), + // Object storage — DigitalOcean Spaces (S3-compatible), the platform's sole storage backend. + // Required: the API must not boot into a state where uploads silently have nowhere to go. + SPACES_ENDPOINT: z.string().url(), + SPACES_REGION: z.string().min(1), + SPACES_BUCKET: z.string().min(1), + SPACES_KEY: z.string().min(1), + SPACES_SECRET: z.string().min(1), + SPACES_PUBLIC_BASE: z.string().optional().default(''), + // Legacy local path — read only by the one-time migration script, not the running app. + STORAGE_PATH: z.string().optional().default('./storage'), + SMTP2GO_API_KEY: z.string().optional().default(''), EMAIL_FROM: z.string().optional().default('eLegal Software '), STRIPE_SECRET_KEY: z.string().optional().default(''), STRIPE_WEBHOOK_SECRET: z.string().optional().default(''), diff --git a/apps/api/src/lib/email.ts b/apps/api/src/lib/email.ts index 9a9d638..7c3fd3f 100644 --- a/apps/api/src/lib/email.ts +++ b/apps/api/src/lib/email.ts @@ -1,13 +1,7 @@ -import { Resend } from 'resend'; import { env } from '../env'; -let _resend: Resend | null = null; - -function getResend(): Resend | null { - if (!env.RESEND_API_KEY) return null; - if (!_resend) _resend = new Resend(env.RESEND_API_KEY); - return _resend; -} +// SMTP2GO HTTP API — https://apidoc.smtp2go.com (POST /email/send) +const SMTP2GO_SEND_URL = 'https://api.smtp2go.com/v3/email/send'; export interface EmailOptions { to: string; @@ -25,100 +19,338 @@ export interface SendResult { error?: string; } +const MIME_TYPES: Record = { + pdf: 'application/pdf', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + csv: 'text/csv', + txt: 'text/plain', + html: 'text/html', + zip: 'application/zip', +}; + +function mimeTypeFor(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase() ?? ''; + return MIME_TYPES[ext] ?? 'application/octet-stream'; +} + +interface Smtp2goResponse { + data?: { + succeeded?: number; + email_id?: string; + error?: string; + failures?: string[]; + }; +} + export async function sendEmail(opts: EmailOptions): Promise { - const resend = getResend(); - if (!resend) { - // Logged but not sent — useful in dev when RESEND_API_KEY isn't set. + if (!env.SMTP2GO_API_KEY) { + // Logged but not sent — useful in dev when SMTP2GO_API_KEY isn't set. console.log(`[email skipped] to=${opts.to} subject="${opts.subject}"`); return { ok: true, skipped: true }; } try { - const res = await resend.emails.send({ - from: env.EMAIL_FROM, - to: opts.to, - subject: opts.subject, - html: opts.html, - text: opts.text, - replyTo: opts.replyTo, - attachments: opts.attachments?.map((a) => ({ - filename: a.filename, - content: typeof a.content === 'string' ? a.content : a.content.toString('base64'), - })), + const res = await fetch(SMTP2GO_SEND_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Smtp2go-Api-Key': env.SMTP2GO_API_KEY, + }, + body: JSON.stringify({ + sender: env.EMAIL_FROM, + to: [opts.to], + subject: opts.subject, + html_body: opts.html, + text_body: opts.text, + ...(opts.replyTo ? { custom_headers: [{ header: 'Reply-To', value: opts.replyTo }] } : {}), + ...(opts.attachments?.length + ? { + attachments: opts.attachments.map((a) => ({ + filename: a.filename, + fileblob: + typeof a.content === 'string' + ? Buffer.from(a.content).toString('base64') + : a.content.toString('base64'), + mimetype: mimeTypeFor(a.filename), + })), + } + : {}), + }), }); - if (res.error) return { ok: false, error: res.error.message }; - return { ok: true, id: res.data?.id }; + const json = (await res.json().catch(() => null)) as Smtp2goResponse | null; + if (!res.ok || !json?.data || json.data.error || !json.data.succeeded) { + const error = json?.data?.error ?? json?.data?.failures?.join('; ') ?? `HTTP ${res.status}`; + return { ok: false, error }; + } + return { ok: true, id: json.data.email_id }; } catch (err) { return { ok: false, error: (err as Error).message }; } } // ─────────────────────────── Templates ─────────────────────────── -// Kept simple. Brand-blue header bar + readable body. Plain-text version always provided -// since some clients (and good practice) require it. +// Light theme: soft gray-blue canvas, white card, brand-blue accents, logo above the card. +// Table-based layout so Outlook/Gmail render consistently. Every template returns a +// { subject, html, text } pair — the plain-text alternative is always provided. const BRAND = '#0052FF'; +const INK = '#1f2430'; +const SOFT = '#697281'; +const FAINT = '#8a93a3'; +const CANVAS = '#f4f6fa'; +const BORDER = '#e6eaf2'; +const PANEL = '#f5f7fb'; -function shell(bodyHtml: string): string { +const APP_URL = env.PUBLIC_URL.replace(/\/+$/, ''); +const LOGO_URL = `${APP_URL}/logo-dark.png`; + +/** Escape user-provided strings before interpolating into HTML bodies. */ +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function firstName(name: string | null | undefined): string { + const first = name?.trim().split(/\s+/)[0]; + return first ? esc(first) : 'there'; +} + +// ── Building blocks ── + +function heading(text: string): string { + return `

${text}

`; +} + +function p(html: string): string { + return `

${html}

`; +} + +function muted(html: string): string { + return `

${html}

`; +} + +function btn(url: string, label: string): string { + return ` + +
+ ${label} +
`; +} + +function linkFallback(url: string): string { + return `

If the button doesn't work, paste this link into your browser:
${url}

`; +} + +function panel(html: string): string { + return `
${html}
`; +} + +/** Big invoice/receipt figure with a label above it. */ +function amountBlock(label: string, amount: string, sub?: string): string { + return panel( + `

${label}

+

${amount}

+ ${sub ? `

${sub}

` : ''}`, + ); +} + +function signoff(): string { + return p('— The eLegal Software team'); +} + +function shell(bodyHtml: string, preheader = ''): string { + const pre = preheader + ? `
${preheader} ‌ ‌ ‌ ‌ ‌ ‌
` + : ''; return ` -eLegal Software - -
-
eLegal Software
-
${bodyHtml}
-
© ${new Date().getFullYear()} eLegal Software. You're receiving this because of activity on your account.
-
+ + + + + + + eLegal Software + + + ${pre} + + +
+ + + + +
+ eLegal Software +
+ ${bodyHtml} +
+

© ${new Date().getFullYear()} eLegal Software · Practice management for solo attorneys & small firms
You're receiving this because of activity related to an eLegal Software account.

+
+
`; } +// ── Account lifecycle ── + export function welcomeEmail(toName: string | null, verifyUrl: string | null) { - const name = toName?.split(' ')[0] ?? 'there'; + const name = firstName(toName); const verifyBlock = verifyUrl - ? `

Please confirm your email address so we can send you important updates:

-

Verify my email

-

Or paste this link into your browser: ${verifyUrl}

` + ? `${p('First, please confirm your email address so we can send you important account updates:')} + ${btn(verifyUrl, 'Verify my email')} + ${linkFallback(verifyUrl)}` : ''; return { subject: 'Welcome to eLegal Software', html: shell( - `

Hi ${name},

-

Welcome to eLegal Software. Your account is set up and you're ready to add your first client and case.

+ `${heading(`Welcome aboard, ${name} 👋`)} + ${p("Your account is set up and you're ready to add your first client and case.")} ${verifyBlock} -

If you have questions, just reply to this email — a real person will see it.

-

— The eLegal Software team

`, + ${panel( + `

Get started in three steps

+

1. Add a client  →  2. Open a case  →  3. Track time & send your first invoice

`, + )} + ${p('If you have questions, just reply to this email — a real person will see it.')} + ${signoff()}`, + 'Your account is ready — add your first client and case.', ), - text: `Hi ${name},\n\nWelcome to eLegal Software. Your account is set up and you're ready to add your first client and case.\n\n${verifyUrl ? `Please confirm your email: ${verifyUrl}\n\n` : ''}If you have questions, just reply to this email.\n\n— The eLegal Software team`, + text: `Hi ${name},\n\nWelcome to eLegal Software. Your account is set up and you're ready to add your first client and case.\n\n${verifyUrl ? `Please confirm your email: ${verifyUrl}\n\n` : ''}Get started: 1. Add a client → 2. Open a case → 3. Track time & send your first invoice.\n\nIf you have questions, just reply to this email.\n\n— The eLegal Software team`, + }; +} + +export function verifyEmailEmail(toName: string | null, verifyUrl: string) { + const name = firstName(toName); + return { + subject: 'Confirm your email address', + html: shell( + `${heading('Confirm your email')} + ${p(`Hi ${name},`)} + ${p('Please confirm the email address on your eLegal Software account:')} + ${btn(verifyUrl, 'Verify my email')} + ${linkFallback(verifyUrl)} + ${muted("This link expires in 24 hours. If you didn't create an eLegal Software account, you can safely ignore this email.")}`, + 'One click to confirm the email on your account.', + ), + text: `Hi ${name},\n\nPlease confirm the email address on your eLegal Software account:\n\n${verifyUrl}\n\nThis link expires in 24 hours. If you didn't create an account, ignore this email.`, }; } export function passwordResetEmail(toName: string | null, resetUrl: string) { - const name = toName?.split(' ')[0] ?? 'there'; + const name = firstName(toName); return { subject: 'Reset your eLegal Software password', html: shell( - `

Hi ${name},

-

We got a request to reset the password on your eLegal Software account. Click below to choose a new one:

-

Reset password

-

Or paste this link into your browser: ${resetUrl}

-

This link expires in 1 hour. If you didn't request a reset, you can safely ignore this email.

`, + `${heading('Reset your password')} + ${p(`Hi ${name},`)} + ${p('We got a request to reset the password on your eLegal Software account. Click below to choose a new one:')} + ${btn(resetUrl, 'Reset password')} + ${linkFallback(resetUrl)} + ${muted("This link expires in 1 hour. If you didn't request a reset, you can safely ignore this email — your password won't change.")}`, + 'Choose a new password for your account.', ), text: `Hi ${name},\n\nWe got a request to reset your eLegal Software password.\n\nReset it here: ${resetUrl}\n\nThis link expires in 1 hour. If you didn't request a reset, ignore this email.`, }; } +export function passwordChangedEmail(toName: string | null) { + const name = firstName(toName); + const secureUrl = `${APP_URL}/forgot-password`; + return { + subject: 'Your eLegal Software password was changed', + html: shell( + `${heading('Your password was changed')} + ${p(`Hi ${name},`)} + ${p('The password on your eLegal Software account was just changed, and all other sessions were signed out. If this was you, no further action is needed.')} + ${panel( + `

Didn't do this? Someone else may have access to your account. Reset your password immediately and reply to this email so we can help.

`, + )} + ${btn(secureUrl, 'Secure my account')}`, + 'The password on your account was just changed.', + ), + text: `Hi ${name},\n\nThe password on your eLegal Software account was just changed, and all other sessions were signed out. If this was you, no further action is needed.\n\nIf you DIDN'T do this, reset your password immediately: ${secureUrl}\nThen reply to this email so we can help.`, + }; +} + +export function accountDeletedEmail(toName: string | null) { + const name = firstName(toName); + return { + subject: 'Your eLegal Software account has been deleted', + html: shell( + `${heading('Account deleted')} + ${p(`Hi ${name},`)} + ${p('As requested, your eLegal Software account and all associated firm data — clients, cases, time entries, documents, and invoices — have been permanently deleted. This cannot be undone.')} + ${p("We're sorry to see you go. You're welcome back anytime — creating a new account takes less than a minute.")} + ${muted("If you didn't request this deletion, reply to this email immediately.")} + ${signoff()}`, + 'Your account and all firm data were permanently deleted.', + ), + text: `Hi ${name},\n\nAs requested, your eLegal Software account and all associated firm data — clients, cases, time entries, documents, and invoices — have been permanently deleted. This cannot be undone.\n\nWe're sorry to see you go. You're welcome back anytime.\n\nIf you didn't request this deletion, reply to this email immediately.\n\n— The eLegal Software team`, + }; +} + +// ── Billing ── + export function planUpgradedEmail(toName: string | null, plan: string) { - const name = toName?.split(' ')[0] ?? 'there'; + const name = firstName(toName); return { subject: `You're on eLegal Software ${plan}`, html: shell( - `

Hi ${name},

-

Thanks for upgrading. Your firm is now on the ${plan} plan and the limits and watermarks have been lifted.

-

Open eLegal Software

-

Manage your subscription anytime from Settings → Billing.

`, + `${heading(`Welcome to ${esc(plan)} 🎉`)} + ${p(`Hi ${name},`)} + ${p(`Thanks for upgrading. Your firm is now on the ${esc(plan)} plan — plan limits and invoice watermarks have been lifted.`)} + ${btn(`${APP_URL}/app`, 'Open eLegal Software')} + ${muted('Manage your subscription anytime from Settings → Billing.')}`, + `Your firm is now on the ${plan} plan.`, ), - text: `Hi ${name},\n\nThanks for upgrading. Your firm is now on the ${plan} plan and the limits and watermarks have been lifted.\n\nManage your subscription from Settings → Billing.`, + text: `Hi ${name},\n\nThanks for upgrading. Your firm is now on the ${plan} plan — plan limits and invoice watermarks have been lifted.\n\nManage your subscription from Settings → Billing.`, }; } +export function paymentFailedEmail(toName: string | null, amount?: string | null) { + const name = firstName(toName); + const billingUrl = `${APP_URL}/app/settings`; + return { + subject: 'Payment failed — action needed', + html: shell( + `${heading('We couldn’t process your payment')} + ${p(`Hi ${name},`)} + ${p(`Your latest payment${amount ? ` of ${esc(amount)}` : ''} for eLegal Software Pro didn't go through. This is usually an expired card or a bank decline.`)} + ${p("We'll retry automatically over the next few days — to keep your Pro features active, please update your payment method:")} + ${btn(billingUrl, 'Update payment method')} + ${muted('If payments keep failing, your firm will be moved to the free Starter plan. Your data is never deleted.')}`, + 'Your subscription payment didn’t go through — update your card to keep Pro active.', + ), + text: `Hi ${name},\n\nYour latest payment${amount ? ` of ${amount}` : ''} for eLegal Software Pro didn't go through. This is usually an expired card or a bank decline.\n\nWe'll retry automatically over the next few days. To keep Pro active, update your payment method: ${billingUrl}\n\nIf payments keep failing, your firm will be moved to the free Starter plan. Your data is never deleted.`, + }; +} + +export function subscriptionEndedEmail(toName: string | null) { + const name = firstName(toName); + const billingUrl = `${APP_URL}/app/settings`; + return { + subject: 'Your Pro subscription has ended', + html: shell( + `${heading('Your Pro subscription has ended')} + ${p(`Hi ${name},`)} + ${p('Your eLegal Software Pro subscription has ended and your firm was moved to the free Starter plan.')} + ${panel( + `

What changes on Starter: plan limits apply again and invoices include a watermark. All your data — clients, cases, documents, invoices — is untouched.

`, + )} + ${p('You can reactivate Pro anytime:')} + ${btn(billingUrl, 'Reactivate Pro')} + ${signoff()}`, + 'Your firm was moved to the free Starter plan — your data is untouched.', + ), + text: `Hi ${name},\n\nYour eLegal Software Pro subscription has ended and your firm was moved to the free Starter plan.\n\nWhat changes: plan limits apply again and invoices include a watermark. All your data is untouched.\n\nReactivate Pro anytime: ${billingUrl}\n\n— The eLegal Software team`, + }; +} + +// ── Client-facing invoices (sent on behalf of a firm) ── + export function invoiceEmail(opts: { clientName: string; firmName: string; @@ -127,33 +359,106 @@ export function invoiceEmail(opts: { dueDate?: string | null; notes?: string | null; }) { - const dueLine = opts.dueDate ? `

Due on ${opts.dueDate}.

` : ''; - const notesLine = opts.notes - ? `

${opts.notes}

` + const firm = esc(opts.firmName); + const num = esc(opts.invoiceNumber); + const notesBlock = opts.notes + ? panel(`

${esc(opts.notes)}

`) : ''; return { subject: `Invoice ${opts.invoiceNumber} from ${opts.firmName}`, html: shell( - `

Hi ${opts.clientName.split(' ')[0]},

-

${opts.firmName} sent you a new invoice.

-

${opts.invoiceNumber}${opts.total}

- ${dueLine} - ${notesLine} -

The PDF is attached. Reply to this email if you have any questions.

`, + `${heading(`New invoice from ${firm}`)} + ${p(`Hi ${firstName(opts.clientName)},`)} + ${p(`${firm} sent you a new invoice. The PDF is attached to this email.`)} + ${amountBlock(`Invoice ${num}`, esc(opts.total), opts.dueDate ? `Due ${esc(opts.dueDate)}` : undefined)} + ${notesBlock} + ${p('Reply to this email if you have any questions about this invoice.')}`, + `Invoice ${opts.invoiceNumber} — ${opts.total}${opts.dueDate ? `, due ${opts.dueDate}` : ''}.`, ), - text: `Hi ${opts.clientName},\n\n${opts.firmName} sent you a new invoice: ${opts.invoiceNumber} — ${opts.total}.${opts.dueDate ? ` Due on ${opts.dueDate}.` : ''}\n\nThe PDF is attached.${opts.notes ? `\n\nNotes: ${opts.notes}` : ''}`, + text: `Hi ${opts.clientName},\n\n${opts.firmName} sent you a new invoice: ${opts.invoiceNumber} — ${opts.total}.${opts.dueDate ? ` Due on ${opts.dueDate}.` : ''}\n\nThe PDF is attached.${opts.notes ? `\n\nNotes: ${opts.notes}` : ''}\n\nReply to this email if you have any questions.`, }; } +export function invoicePaidEmail(opts: { + clientName: string; + firmName: string; + invoiceNumber: string; + total: string; +}) { + const firm = esc(opts.firmName); + const num = esc(opts.invoiceNumber); + return { + subject: `Payment received — invoice ${opts.invoiceNumber} from ${opts.firmName}`, + html: shell( + `${heading('Payment received — thank you')} + ${p(`Hi ${firstName(opts.clientName)},`)} + ${p(`${firm} has recorded your payment for invoice ${num}. You're all settled up.`)} + ${amountBlock('Amount paid', esc(opts.total), `Invoice ${num} · Paid in full`)} + ${muted('Keep this email for your records. Reply if anything looks off.')}`, + `Your payment for invoice ${opts.invoiceNumber} was received.`, + ), + text: `Hi ${opts.clientName},\n\n${opts.firmName} has recorded your payment for invoice ${opts.invoiceNumber} — ${opts.total}. You're all settled up.\n\nKeep this email for your records. Reply if anything looks off.`, + }; +} + +export function invoiceOverdueEmail(opts: { + clientName: string; + firmName: string; + invoiceNumber: string; + total: string; + dueDate: string; +}) { + const firm = esc(opts.firmName); + const num = esc(opts.invoiceNumber); + return { + subject: `Reminder: invoice ${opts.invoiceNumber} from ${opts.firmName} is past due`, + html: shell( + `${heading('Friendly payment reminder')} + ${p(`Hi ${firstName(opts.clientName)},`)} + ${p(`This is a friendly reminder that invoice ${num} from ${firm} was due on ${esc(opts.dueDate)} and is still outstanding.`)} + ${amountBlock('Amount due', esc(opts.total), `Invoice ${num} · Due ${esc(opts.dueDate)}`)} + ${p('If you’ve already sent payment, please disregard this notice — and thank you. Otherwise, reply to this email to arrange payment or ask any questions.')}`, + `Invoice ${opts.invoiceNumber} (${opts.total}) was due ${opts.dueDate}.`, + ), + text: `Hi ${opts.clientName},\n\nThis is a friendly reminder that invoice ${opts.invoiceNumber} from ${opts.firmName} was due on ${opts.dueDate} and is still outstanding.\n\nAmount due: ${opts.total}\n\nIf you've already sent payment, please disregard this notice. Otherwise, reply to this email to arrange payment or ask any questions.`, + }; +} + +// ── Contact form ── + export function contactAckEmail(toName: string) { - const name = toName.split(' ')[0]; + const name = firstName(toName); return { subject: "Got your message — we'll be in touch", html: shell( - `

Hi ${name},

-

Thanks for reaching out to eLegal Software. We've received your message and one of us will reply within one business day.

-

— The eLegal Software team

`, + `${heading('We got your message')} + ${p(`Hi ${name},`)} + ${p("Thanks for reaching out to eLegal Software. We've received your message and one of us will reply within one business day.")} + ${signoff()}`, + 'Thanks for reaching out — we reply within one business day.', ), text: `Hi ${name},\n\nThanks for reaching out to eLegal Software. We've received your message and one of us will reply within one business day.\n\n— The eLegal Software team`, }; } + +/** Internal notification to the team when the public contact form is submitted. */ +export function contactNotifyEmail(opts: { + fromName: string; + fromEmail: string; + message: string; + ip?: string | null; +}) { + const messageHtml = esc(opts.message).replace(/\n/g, '
'); + return { + subject: `New contact message from ${opts.fromName}`, + html: shell( + `${heading('New contact form message')} + ${panel(`

${messageHtml}

`)} + ${muted(`From: ${esc(opts.fromName)} <${esc(opts.fromEmail)}>${opts.ip ? ` · IP ${esc(opts.ip)}` : ''}`)} + ${p('Reply directly to this email to answer them.')} + ${muted(`Also visible in the admin panel.`)}`, + `${opts.fromName}: ${opts.message.slice(0, 90)}`, + ), + text: `New contact form message\n\nFrom: ${opts.fromName} <${opts.fromEmail}>${opts.ip ? `\nIP: ${opts.ip}` : ''}\n\n${opts.message}\n\nReply directly to this email to answer them. Also visible at ${APP_URL}/admin/contact`, + }; +} diff --git a/apps/api/src/lib/file-signature.ts b/apps/api/src/lib/file-signature.ts new file mode 100644 index 0000000..f3f60bb --- /dev/null +++ b/apps/api/src/lib/file-signature.ts @@ -0,0 +1,55 @@ +// Magic-byte validation for uploads. The upload route already checks the client-supplied +// Content-Type, but that header is attacker-controlled — a malicious file can claim to be a PDF. +// This verifies the actual leading bytes match the declared type, so a spoofed MIME (e.g. an +// HTML page or executable labelled image/png) is rejected before it's stored and later served +// back with that Content-Type. + +function startsWith(buf: Buffer, sig: number[]): boolean { + if (buf.length < sig.length) return false; + for (let i = 0; i < sig.length; i++) { + if (buf[i] !== sig[i]) return false; + } + return true; +} + +// OOXML (docx/xlsx) and other Office-2007+ files are ZIP containers. +function isZip(buf: Buffer): boolean { + return ( + startsWith(buf, [0x50, 0x4b, 0x03, 0x04]) || // normal + startsWith(buf, [0x50, 0x4b, 0x05, 0x06]) || // empty archive + startsWith(buf, [0x50, 0x4b, 0x07, 0x08]) // spanned + ); +} + +// Legacy Office (.doc/.xls) uses the OLE2 compound-file header. +const OLE2 = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]; + +// text/plain has no signature. Accept only if the content is plausibly text: no NUL bytes in the +// first few KB (NUL is the classic marker of a binary/executable masquerading as text). +function looksLikeText(buf: Buffer): boolean { + const n = Math.min(buf.length, 4096); + for (let i = 0; i < n; i++) { + if (buf[i] === 0) return false; + } + return true; +} + +const CHECKS: Record boolean> = { + 'application/pdf': (b) => startsWith(b, [0x25, 0x50, 0x44, 0x46]), // %PDF + 'image/png': (b) => startsWith(b, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + 'image/jpeg': (b) => startsWith(b, [0xff, 0xd8, 0xff]), + 'image/webp': (b) => + startsWith(b, [0x52, 0x49, 0x46, 0x46]) && b.length >= 12 && b.subarray(8, 12).toString('latin1') === 'WEBP', + 'application/msword': (b) => startsWith(b, OLE2), + 'application/vnd.ms-excel': (b) => startsWith(b, OLE2), + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': isZip, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': isZip, + 'text/plain': looksLikeText, +}; + +// Returns true only if the bytes are consistent with the declared MIME type. +export function verifyFileSignature(buf: Buffer, declaredMime: string): boolean { + const check = CHECKS[declaredMime]; + if (!check) return false; // unknown declared type → reject (upload route already allowlists) + return check(buf); +} diff --git a/apps/api/src/lib/invoice-numbering.ts b/apps/api/src/lib/invoice-numbering.ts index de142ef..333bde9 100644 --- a/apps/api/src/lib/invoice-numbering.ts +++ b/apps/api/src/lib/invoice-numbering.ts @@ -1,20 +1,30 @@ -import { sql } from 'drizzle-orm'; -import { eq, and, like } from 'drizzle-orm'; +import { and, eq, like, sql } from 'drizzle-orm'; import { getDb, invoices } from '@lawdesk/db'; -// Format: INV-YYYY-NNNN, scoped per firm. -// Uses a count-based sequence — the unique-on-(firm_id, number) constraint isn't enforced -// at the DB level yet, so two near-simultaneous creates could collide. For a v1 single-user -// firm this is fine; if it becomes a problem, add a per-firm Postgres sequence. -export async function nextInvoiceNumber(firmId: string): Promise { +// The transaction handle passed by db.transaction(async (tx) => ...). +type Tx = Parameters['transaction']>[0]>[0]; + +// Format: INV-YYYY-NNNN, scoped per firm. Collision-safe: +// 1. pg_advisory_xact_lock serialises number generation per firm for the life of the +// transaction, so two concurrent creates can't read the same value and pick the same number. +// 2. The next value is max(sequence)+1, not count(*) — deleting a draft can never make a later +// invoice reuse a number that still exists in the table. +// +// MUST be called inside the same transaction that inserts the invoice, so the advisory lock is +// held until the new row is committed. +export async function nextInvoiceNumber(tx: Tx, firmId: string): Promise { const year = new Date().getUTCFullYear(); const prefix = `INV-${year}-`; - const [row] = await getDb() - .select({ count: sql`count(*)::int` }) + // Per-firm, transaction-scoped lock; released automatically on commit or rollback. + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${firmId}))`); + + // Highest existing sequence for this firm + year (the trailing NNNN of INV-YYYY-NNNN). + const [row] = await tx + .select({ maxNum: sql`coalesce(max(split_part(${invoices.number}, '-', 3)::int), 0)` }) .from(invoices) .where(and(eq(invoices.firmId, firmId), like(invoices.number, `${prefix}%`))); - const next = (row?.count ?? 0) + 1; + const next = (row?.maxNum ?? 0) + 1; return `${prefix}${String(next).padStart(4, '0')}`; } diff --git a/apps/api/src/lib/storage.ts b/apps/api/src/lib/storage.ts index 05fbe43..a7a73b4 100644 --- a/apps/api/src/lib/storage.ts +++ b/apps/api/src/lib/storage.ts @@ -1,29 +1,165 @@ -import fs from 'node:fs'; -import path from 'node:path'; +// Object storage — DigitalOcean Spaces (S3-compatible), the platform's sole storage backend. +// Uploads go through the API (buffer -> PutObject); downloads stream the object body back +// so tenant/ownership checks stay server-side and the bucket is never exposed directly. +import type { Readable } from 'node:stream'; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, + DeleteObjectsCommand, + ListObjectsV2Command, + type GetObjectCommandOutput, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { env } from '../env'; -function root(): string { - return path.resolve(env.STORAGE_PATH); +let _s3: S3Client | null = null; + +function getS3(): S3Client { + if (_s3) return _s3; + _s3 = new S3Client({ + endpoint: env.SPACES_ENDPOINT, + region: env.SPACES_REGION, + credentials: { accessKeyId: env.SPACES_KEY, secretAccessKey: env.SPACES_SECRET }, + // Virtual-hosted-style (bucket.region.digitaloceanspaces.com) — matches the Spaces URL scheme. + forcePathStyle: false, + }); + return _s3; } -function resolve(key: string): string { - const abs = path.resolve(root(), key); - if (!abs.startsWith(root() + path.sep) && abs !== root()) { +// Storage keys are generated server-side (`${firmId}/${caseId}/${docId}${ext}`), but validate +// defensively: reject absolute paths and any '..' traversal segment before it reaches the bucket. +function assertSafeKey(key: string): void { + if ( + !key || + key.startsWith('/') || + key.includes('\\') || + key.split('/').some((seg) => seg === '..' || seg === '.') + ) { throw new Error('invalid_storage_key'); } - return abs; } -export async function saveFile(key: string, data: Buffer): Promise { - const dest = resolve(key); - await fs.promises.mkdir(path.dirname(dest), { recursive: true }); - await fs.promises.writeFile(dest, data); +export class FileNotFoundError extends Error { + constructor(public key: string) { + super('file_not_found'); + this.name = 'FileNotFoundError'; + } +} + +function isNotFound(err: unknown): boolean { + const e = err as { name?: string; $metadata?: { httpStatusCode?: number } }; + return e?.name === 'NoSuchKey' || e?.name === 'NotFound' || e?.$metadata?.httpStatusCode === 404; +} + +export async function saveFile(key: string, data: Buffer, contentType: string): Promise { + assertSafeKey(key); + await getS3().send( + new PutObjectCommand({ + Bucket: env.SPACES_BUCKET, + Key: key, + Body: data, + ContentType: contentType, + // Private by default — objects are only reachable through authenticated API routes. + ACL: 'private', + }), + ); } export async function deleteFile(key: string): Promise { - await fs.promises.unlink(resolve(key)); + assertSafeKey(key); + await getS3().send(new DeleteObjectCommand({ Bucket: env.SPACES_BUCKET, Key: key })); } -export function createReadStream(key: string): fs.ReadStream { - return fs.createReadStream(resolve(key)); +// Deletes every object under a prefix (e.g. `${firmId}/` on account deletion, or +// `${firmId}/${caseId}/` on case deletion). GDPR erasure depends on this: DB cascades remove +// the document rows, and this removes the actual files. Paginated + batched (S3 caps +// DeleteObjects at 1000 keys). Returns the number of objects deleted. +export async function deletePrefix(prefix: string): Promise { + assertSafeKey(prefix); + if (!prefix.endsWith('/')) throw new Error('prefix_must_end_with_slash'); + + const s3 = getS3(); + let deleted = 0; + let continuationToken: string | undefined; + + do { + const page = await s3.send( + new ListObjectsV2Command({ + Bucket: env.SPACES_BUCKET, + Prefix: prefix, + ContinuationToken: continuationToken, + }), + ); + const keys = (page.Contents ?? []).flatMap((o) => (o.Key ? [{ Key: o.Key }] : [])); + if (keys.length > 0) { + await s3.send( + new DeleteObjectsCommand({ + Bucket: env.SPACES_BUCKET, + Delete: { Objects: keys, Quiet: true }, + }), + ); + deleted += keys.length; + } + continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined; + } while (continuationToken); + + return deleted; +} + +// Lists every object key under a prefix (or the whole bucket with ''). Used by the +// orphan-sweep script to reconcile Spaces contents against the documents table. +export async function listAllKeys(prefix = ''): Promise { + const s3 = getS3(); + const keys: string[] = []; + let continuationToken: string | undefined; + + do { + const page = await s3.send( + new ListObjectsV2Command({ + Bucket: env.SPACES_BUCKET, + Prefix: prefix || undefined, + ContinuationToken: continuationToken, + }), + ); + for (const o of page.Contents ?? []) if (o.Key) keys.push(o.Key); + continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined; + } while (continuationToken); + + return keys; +} + +// Returns a readable stream of the object body. Throws FileNotFoundError when the key is absent +// so callers can map it to a clean 404 instead of a 500. +export async function getObjectStream(key: string): Promise { + assertSafeKey(key); + let out: GetObjectCommandOutput; + try { + out = await getS3().send(new GetObjectCommand({ Bucket: env.SPACES_BUCKET, Key: key })); + } catch (err) { + if (isNotFound(err)) throw new FileNotFoundError(key); + throw err; + } + if (!out.Body) throw new FileNotFoundError(key); + return out.Body as Readable; +} + +// Presigned time-limited GET URL — for future direct-download / CDN use. Not used by the +// current streaming download route, but handy for large files or client-side rendering. +export async function getSignedDownloadUrl( + key: string, + filename: string, + expiresInSeconds = 300, +): Promise { + assertSafeKey(key); + return getSignedUrl( + getS3(), + new GetObjectCommand({ + Bucket: env.SPACES_BUCKET, + Key: key, + ResponseContentDisposition: `attachment; filename="${encodeURIComponent(filename)}"`, + }), + { expiresIn: expiresInSeconds }, + ); } diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index 251a09c..58e617c 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -15,6 +15,8 @@ import { } from '@lawdesk/db'; import { verifyPassword } from '../auth/password'; import { logAudit } from '../lib/audit'; +import { sendEmail, accountDeletedEmail } from '../lib/email'; +import { deletePrefix, getSignedDownloadUrl } from '../lib/storage'; export async function accountRoutes(app: FastifyInstance) { app.addHook('preHandler', app.requireAuth); @@ -59,6 +61,19 @@ export async function accountRoutes(app: FastifyInstance) { : []; 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 (valid 24h; re-export for fresh links). + const docsWithUrls = await Promise.all( + docs.map(async (d) => { + try { + const downloadUrl = await getSignedDownloadUrl(d.storageKey, d.name, 24 * 60 * 60); + return { ...d, downloadUrl, downloadUrlExpiresInHours: 24 }; + } catch { + return { ...d, downloadUrl: null, downloadUrlExpiresInHours: null }; + } + }), + ); + dump.firm = firm; dump.clients = firmClients; dump.cases = firmCases; @@ -67,7 +82,7 @@ export async function accountRoutes(app: FastifyInstance) { ...i, items: items.filter((it) => it.invoiceId === i.id), })); - dump.documents = docs; + dump.documents = docsWithUrls; } await logAudit({ @@ -101,10 +116,11 @@ export async function accountRoutes(app: FastifyInstance) { if (!ok) return reply.code(401).send({ error: 'invalid_password' }); if (firmId) { - const [{ count }] = await db + const countRows = await db .select({ count: sql`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', @@ -130,6 +146,25 @@ export async function accountRoutes(app: FastifyInstance) { 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 }; diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 26e7c02..eb171ea 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -2,12 +2,26 @@ import crypto from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { and, eq, gte, isNull, sql } from 'drizzle-orm'; -import { getDb, users, firms, loginAttempts, passwordResets, sessions as sessionsTable } from '@lawdesk/db'; +import { + getDb, + users, + firms, + loginAttempts, + passwordResets, + emailVerifications, + sessions as sessionsTable, +} from '@lawdesk/db'; import { hashPassword, verifyPassword } from '../auth/password'; import { SESSION_COOKIE, createSession, destroySession } from '../auth/sessions'; import { ensureSuperadminFlag } from '../auth/superadmin'; import { generateCsrfToken } from '../auth/csrf'; -import { sendEmail, passwordResetEmail, welcomeEmail } from '../lib/email'; +import { + sendEmail, + passwordResetEmail, + passwordChangedEmail, + welcomeEmail, + verifyEmailEmail, +} from '../lib/email'; import { env } from '../env'; const signupBody = z.object({ @@ -24,19 +38,33 @@ const loginBody = z.object({ const MAX_FAILS_PER_15_MIN = 5; +// Mints an email-verification token (stored hashed, like password resets) and returns the +// clickable URL. The link hits the API directly — the vite dev proxy and the prod same-origin +// setup both route /api/* to this server. +async function createVerifyUrl(userId: string): Promise { + const rawToken = crypto.randomBytes(32).toString('base64url'); + const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + await getDb().insert(emailVerifications).values({ tokenHash, userId, expiresAt }); + return `${env.PUBLIC_URL}/api/auth/verify-email?token=${rawToken}`; +} + async function recentFailedAttempts(email: string, ip: string | null): Promise { const since = new Date(Date.now() - 15 * 60 * 1000); const db = getDb(); + const conditions = [ + eq(loginAttempts.email, email), + eq(loginAttempts.success, false), + gte(loginAttempts.attemptedAt, since), + ]; + // Key the lockout on (email, ip). A single IP that keeps failing against an account gets + // throttled, but an attacker firing bad passwords from another IP can no longer lock the + // legitimate owner out of their own account (previously this counted by email alone). + if (ip) conditions.push(eq(loginAttempts.ip, ip)); const rows = await db .select({ count: sql`count(*)::int` }) .from(loginAttempts) - .where( - and( - eq(loginAttempts.email, email), - eq(loginAttempts.success, false), - gte(loginAttempts.attemptedAt, since), - ), - ); + .where(and(...conditions)); return rows[0]?.count ?? 0; } @@ -70,7 +98,12 @@ export async function authRoutes(app: FastifyInstance) { .returning(); if (!user) return reply.code(500).send({ error: 'user_create_failed' }); - const isSuperadmin = await ensureSuperadminFlag(user.id, user.email, user.isSuperadmin); + const isSuperadmin = await ensureSuperadminFlag( + user.id, + user.email, + user.isSuperadmin, + user.emailVerifiedAt, + ); const { token, expiresAt } = await createSession({ userId: user.id, @@ -80,9 +113,17 @@ export async function authRoutes(app: FastifyInstance) { app.setSessionCookie(reply, token, expiresAt); app.setCsrfCookie(reply, generateCsrfToken()); - // Fire-and-forget welcome email (no blocking) - const welcome = welcomeEmail(user.fullName, null); - sendEmail({ to: user.email, ...welcome }).catch((err) => app.log.warn({ err }, 'welcome email failed')); + // Fire-and-forget welcome email with a verification link (no blocking) + createVerifyUrl(user.id) + .catch((err) => { + app.log.warn({ err }, 'verify token create failed — sending welcome without link'); + return null; + }) + .then((verifyUrl) => { + const welcome = welcomeEmail(user.fullName, verifyUrl); + return sendEmail({ to: user.email, ...welcome }); + }) + .catch((err) => app.log.warn({ err }, 'welcome email failed')); return reply.code(201).send({ user: { @@ -124,7 +165,12 @@ export async function authRoutes(app: FastifyInstance) { return reply.code(403).send({ error: 'account_suspended' }); } - const isSuperadmin = await ensureSuperadminFlag(user.id, user.email, user.isSuperadmin); + const isSuperadmin = await ensureSuperadminFlag( + user.id, + user.email, + user.isSuperadmin, + user.emailVerifiedAt, + ); await db.update(users).set({ lastSeenAt: new Date() }).where(eq(users.id, user.id)); @@ -238,6 +284,69 @@ export async function authRoutes(app: FastifyInstance) { await tx.delete(sessionsTable).where(eq(sessionsTable.userId, user.id)); }); + // Security notice — lets the real owner react fast if the reset wasn't theirs. + const tpl = passwordChangedEmail(user.fullName); + sendEmail({ to: user.email, ...tpl }).catch((err) => + app.log.warn({ err }, 'password changed email failed'), + ); + + return { ok: true }; + }, + ); + + // ─────────────────────────── Email verification ─────────────────────────── + + // Landing endpoint for the link in welcome/verification emails. Redirects to the web app + // either way; ?verified=1|0 lets the UI show a toast. + app.get('/api/auth/verify-email', async (req, reply) => { + const parsed = z.object({ token: z.string().min(20).max(200) }).safeParse(req.query); + if (!parsed.success) return reply.redirect(`${env.PUBLIC_URL}/login?verified=0`); + + const tokenHash = crypto.createHash('sha256').update(parsed.data.token).digest('hex'); + const db = getDb(); + + const [row] = await db + .select() + .from(emailVerifications) + .where(and(eq(emailVerifications.tokenHash, tokenHash), isNull(emailVerifications.consumedAt))) + .limit(1); + + if (!row || row.expiresAt.getTime() < Date.now()) { + return reply.redirect(`${env.PUBLIC_URL}/login?verified=0`); + } + + await db.transaction(async (tx) => { + await tx + .update(users) + .set({ emailVerifiedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(users.id, row.userId), isNull(users.emailVerifiedAt))); + await tx + .update(emailVerifications) + .set({ consumedAt: new Date() }) + .where(eq(emailVerifications.tokenHash, tokenHash)); + }); + + return reply.redirect(`${env.PUBLIC_URL}/login?verified=1`); + }); + + // Re-send the verification email for the logged-in user. + app.post( + '/api/auth/resend-verification', + { config: { rateLimit: { max: 3, timeWindow: '15 minutes' } } }, + async (req, reply) => { + if (!req.user) return reply.code(401).send({ error: 'unauthorized' }); + + const db = getDb(); + const [user] = await db.select().from(users).where(eq(users.id, req.user.id)).limit(1); + if (!user) return reply.code(404).send({ error: 'user_not_found' }); + if (user.emailVerifiedAt) return { ok: true, alreadyVerified: true }; + + const verifyUrl = await createVerifyUrl(user.id); + const tpl = verifyEmailEmail(user.fullName, verifyUrl); + sendEmail({ to: user.email, ...tpl }).catch((err) => + app.log.warn({ err }, 'verification email failed'), + ); + return { ok: true }; }, ); diff --git a/apps/api/src/routes/billing.ts b/apps/api/src/routes/billing.ts index ac2cf81..ed82948 100644 --- a/apps/api/src/routes/billing.ts +++ b/apps/api/src/routes/billing.ts @@ -38,6 +38,15 @@ export async function billingRoutes(app: FastifyInstance) { const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1); if (!firm) return reply.code(404).send({ error: 'firm_not_found' }); + // Guard against double-billing: a firm already on a paid plan (or with a live subscription) + // must not be able to open a second Checkout session. Send them to the portal instead. + if (firm.plan !== 'starter' || firm.stripeSubscriptionId) { + return reply.code(409).send({ + error: 'already_on_paid_plan', + hint: 'Manage or change your current plan from the billing portal.', + }); + } + const stripe = getStripe(); // Reuse the customer if we've made one before; otherwise let Checkout create one and we'll diff --git a/apps/api/src/routes/cases.ts b/apps/api/src/routes/cases.ts index 7d258e2..29f5e1e 100644 --- a/apps/api/src/routes/cases.ts +++ b/apps/api/src/routes/cases.ts @@ -4,6 +4,7 @@ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'; import { getDb, cases, clients, timeEntries } from '@lawdesk/db'; import { loadFirm } from '../lib/firm'; import { assertCanCreateCase, PlanLimitError } from '../lib/plan-limits'; +import { deletePrefix } from '../lib/storage'; const STATUSES = ['open', 'pending', 'closed', 'archived'] as const; @@ -172,6 +173,12 @@ export async function casesRoutes(app: FastifyInstance) { .where(and(eq(cases.id, id), eq(cases.firmId, firmId))) .returning({ id: cases.id }); if (!row) return reply.code(404).send({ error: 'not_found' }); + + // The cascade removed the document rows; remove the files too (GDPR erasure). + deletePrefix(`${firmId}/${id}/`).catch((err) => + req.log.error({ err, firmId, caseId: id }, 'FAILED to delete case storage'), + ); + return { ok: true }; }); } diff --git a/apps/api/src/routes/clients.ts b/apps/api/src/routes/clients.ts index 710d895..a5bf190 100644 --- a/apps/api/src/routes/clients.ts +++ b/apps/api/src/routes/clients.ts @@ -4,6 +4,7 @@ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'; import { getDb, clients, cases } from '@lawdesk/db'; import { loadFirm } from '../lib/firm'; import { assertCanCreateClient, PlanLimitError } from '../lib/plan-limits'; +import { deletePrefix } from '../lib/storage'; const createBody = z.object({ name: z.string().min(1).max(160).trim(), @@ -111,12 +112,27 @@ export async function clientsRoutes(app: FastifyInstance) { app.delete('/api/clients/:id', async (req, reply) => { const firmId = req.user!.firmId!; const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const db = getDb(); - const [row] = await getDb() + // Capture the client's case ids before the cascade removes them — their storage + // prefixes must be cleaned up after the delete (GDPR erasure). + const clientCases = await db + .select({ id: cases.id }) + .from(cases) + .where(and(eq(cases.clientId, id), eq(cases.firmId, firmId))); + + const [row] = await db .delete(clients) .where(and(eq(clients.id, id), eq(clients.firmId, firmId))) .returning({ id: clients.id }); if (!row) return reply.code(404).send({ error: 'not_found' }); + + for (const c of clientCases) { + deletePrefix(`${firmId}/${c.id}/`).catch((err) => + req.log.error({ err, firmId, caseId: c.id }, 'FAILED to delete case storage'), + ); + } + return { ok: true }; }); } diff --git a/apps/api/src/routes/contact.ts b/apps/api/src/routes/contact.ts index 5e321f4..0bac1a9 100644 --- a/apps/api/src/routes/contact.ts +++ b/apps/api/src/routes/contact.ts @@ -1,7 +1,8 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { getDb, contactMessages } from '@lawdesk/db'; -import { sendEmail, contactAckEmail } from '../lib/email'; +import { sendEmail, contactAckEmail, contactNotifyEmail } from '../lib/email'; +import { env } from '../env'; const contactBody = z.object({ fullName: z.string().min(1).max(120).trim(), @@ -27,6 +28,18 @@ export async function contactRoutes(app: FastifyInstance) { sendEmail({ to: body.email, ...tpl }).catch((err) => app.log.warn({ err }, 'contact ack email failed'), ); + // Notify the team — reply-to points at the submitter so answering is one click. + const notify = contactNotifyEmail({ + fromName: body.fullName, + fromEmail: body.email, + message: body.message, + ip: req.ip ?? null, + }); + for (const admin of env.superadminEmails) { + sendEmail({ to: admin, ...notify, replyTo: body.email }).catch((err) => + app.log.warn({ err }, 'contact notify email failed'), + ); + } return reply.code(201).send({ ok: true }); }, ); diff --git a/apps/api/src/routes/documents.ts b/apps/api/src/routes/documents.ts index 60df49f..2deffaf 100644 --- a/apps/api/src/routes/documents.ts +++ b/apps/api/src/routes/documents.ts @@ -4,7 +4,8 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { and, desc, eq } from 'drizzle-orm'; import { getDb, documents, cases } from '@lawdesk/db'; -import { saveFile, deleteFile, createReadStream } from '../lib/storage'; +import { saveFile, deleteFile, getObjectStream, FileNotFoundError } from '../lib/storage'; +import { verifyFileSignature } from '../lib/file-signature'; const ALLOWED_MIME = new Set([ 'application/pdf', @@ -66,11 +67,18 @@ export async function documentsRoutes(app: FastifyInstance) { } const buf = await data.toBuffer(); + + // Content-type sniffing: the declared mimetype passed the allowlist above, but verify the + // actual bytes match it so a spoofed header can't smuggle in a different (e.g. executable) file. + if (!verifyFileSignature(buf, data.mimetype)) { + return reply.code(400).send({ error: 'file_content_mismatch' }); + } + const docId = randomUUID(); const ext = path.extname(data.filename); const storageKey = `${firmId}/${caseId}/${docId}${ext}`; - await saveFile(storageKey, buf); + await saveFile(storageKey, buf, data.mimetype); const [doc] = await db.insert(documents).values({ id: docId, @@ -83,6 +91,12 @@ export async function documentsRoutes(app: FastifyInstance) { sizeBytes: buf.length, }).returning(); + if (!doc) { + // Row insert failed after the file was written — clean up the orphaned object. + await deleteFile(storageKey).catch(() => {}); + return reply.code(500).send({ error: 'upload_failed' }); + } + return reply.code(201).send({ id: doc.id, name: doc.name, @@ -102,7 +116,13 @@ export async function documentsRoutes(app: FastifyInstance) { .where(and(eq(documents.id, docId), eq(documents.firmId, firmId))).limit(1); if (!doc) return reply.code(404).send({ error: 'not_found' }); - const stream = createReadStream(doc.storageKey); + let stream; + try { + stream = await getObjectStream(doc.storageKey); + } catch (err) { + if (err instanceof FileNotFoundError) return reply.code(404).send({ error: 'file_missing' }); + throw err; + } return reply .header('Content-Type', doc.mimeType) .header('Content-Disposition', `attachment; filename="${encodeURIComponent(doc.name)}"`) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 75fddd4..ae23047 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -14,7 +14,7 @@ import { loadFirm } from '../lib/firm'; import { assertCanCreateInvoice, PlanLimitError } from '../lib/plan-limits'; import { nextInvoiceNumber } from '../lib/invoice-numbering'; import { renderInvoicePdf } from '../lib/invoice-pdf'; -import { sendEmail, invoiceEmail } from '../lib/email'; +import { sendEmail, invoiceEmail, invoicePaidEmail } from '../lib/email'; const STATUSES = ['draft', 'sent', 'paid', 'overdue', 'void'] as const; @@ -262,9 +262,11 @@ export async function invoicesRoutes(app: FastifyInstance) { const taxRate = body.taxRate; const totals = computeTotals(accumulated, taxRate); - const number = await nextInvoiceNumber(firmId); const created = await db.transaction(async (tx) => { + // Generate the number inside the transaction: the advisory lock it takes must be held + // until this insert commits, so concurrent creates serialise and never collide. + const number = await nextInvoiceNumber(tx, firmId); const [inv] = await tx .insert(invoices) .values({ @@ -461,6 +463,32 @@ export async function invoicesRoutes(app: FastifyInstance) { .set({ status: 'paid', paidAt: now, updatedAt: now }) .where(eq(invoices.id, id)) .returning(); + + // Payment receipt to the client (best-effort). + if (row) { + try { + const [client] = await db.select().from(clients).where(eq(clients.id, row.clientId)).limit(1); + const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1); + if (client?.email && firm) { + const totalFmt = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(Number(row.total)); + const tpl = invoicePaidEmail({ + clientName: client.name, + firmName: firm.name, + invoiceNumber: row.number, + total: totalFmt, + }); + sendEmail({ to: client.email, ...tpl }).catch((err) => + app.log.warn({ err, invoiceId: id }, 'invoice paid email failed'), + ); + } + } catch (err) { + app.log.warn({ err, invoiceId: id }, 'failed to send invoice paid email'); + } + } + return row; }); diff --git a/apps/api/src/routes/webhooks-stripe.ts b/apps/api/src/routes/webhooks-stripe.ts index 7b3d9e3..cd760e1 100644 --- a/apps/api/src/routes/webhooks-stripe.ts +++ b/apps/api/src/routes/webhooks-stripe.ts @@ -1,10 +1,15 @@ import type { FastifyInstance } from 'fastify'; import type Stripe from 'stripe'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { getDb, firms, users } from '@lawdesk/db'; import { env } from '../env'; import { getStripe } from '../lib/stripe'; -import { sendEmail, planUpgradedEmail } from '../lib/email'; +import { + sendEmail, + planUpgradedEmail, + paymentFailedEmail, + subscriptionEndedEmail, +} from '../lib/email'; import { logAudit } from '../lib/audit'; // Registered as a sub-app so its own buffer-only content-type parser doesn't affect the rest of @@ -80,13 +85,40 @@ async function handleEvent(event: Stripe.Event, app: FastifyInstance) { const firmId = (sub.metadata?.firmId as string | undefined) ?? null; if (!firmId) return; await applyPlan(firmId, 'starter', { subscriptionId: null }); + for (const u of await firmOwners(firmId)) { + const tpl = subscriptionEndedEmail(u.fullName); + sendEmail({ to: u.email, ...tpl }).catch((err) => + app.log.warn({ err, firmId }, 'subscription ended email failed'), + ); + } break; } case 'invoice.payment_failed': { - // Optional: surface to the user via email later. For now, just log. const invoice = event.data.object as Stripe.Invoice; app.log.warn({ invoice: invoice.id, customer: invoice.customer }, 'stripe invoice payment failed'); + + const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id; + if (!customerId) break; + const [firm] = await getDb() + .select({ id: firms.id }) + .from(firms) + .where(eq(firms.stripeCustomerId, customerId)) + .limit(1); + if (!firm) break; + + const amount = invoice.amount_due + ? new Intl.NumberFormat('en-US', { + style: 'currency', + currency: (invoice.currency ?? 'usd').toUpperCase(), + }).format(invoice.amount_due / 100) + : null; + for (const u of await firmOwners(firm.id)) { + const tpl = paymentFailedEmail(u.fullName, amount); + sendEmail({ to: u.email, ...tpl }).catch((err) => + app.log.warn({ err, firmId: firm.id }, 'payment failed email failed'), + ); + } break; } @@ -117,13 +149,17 @@ async function applyPlan( }); } -async function sendPlanUpgradedNotice(firmId: string, plan: 'pro' | 'lifetime') { - const owners = await getDb() +// Billing emails go to owners only — staff shouldn't get payment notices. +async function firmOwners(firmId: string) { + return getDb() .select({ email: users.email, fullName: users.fullName }) .from(users) - .where(eq(users.firmId, firmId)); + .where(and(eq(users.firmId, firmId), eq(users.role, 'owner'))); +} + +async function sendPlanUpgradedNotice(firmId: string, plan: 'pro' | 'lifetime') { const label = plan === 'pro' ? 'Professional' : 'Lifetime'; - for (const u of owners) { + for (const u of await firmOwners(firmId)) { const tpl = planUpgradedEmail(u.fullName, label); await sendEmail({ to: u.email, ...tpl }); } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 92917a8..548cc54 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -36,14 +36,17 @@ export async function buildServer() { logger: isProd ? { level: 'info' } : { level: 'debug', transport: { target: 'pino-pretty', options: { colorize: true } } }, - trustProxy: true, + // Trust exactly ONE proxy hop (the Plesk/nginx reverse proxy in front of Passenger). + // `true` would trust the entire X-Forwarded-For chain, letting any client spoof req.ip and + // evade the IP-keyed rate limits (including auth brute-force protection). + trustProxy: 1, bodyLimit: 5 * 1024 * 1024, }); // Register the global error handler EARLY so it wins over plugin-default handlers and // catches ZodErrors thrown by .parse() inside route handlers. app.setErrorHandler((err, req, reply) => { - if (err instanceof ZodError || (err as { validation?: unknown }).validation || err.name === 'ZodError') { + if (err instanceof ZodError || (err as { validation?: unknown }).validation || (err as Error).name === 'ZodError') { req.log.info({ err }, 'validation error'); return reply .code(400) @@ -123,15 +126,23 @@ export async function buildServer() { cacheControl: true, maxAge: '1y', immutable: true, - decorateReply: false, + // Must NOT be `false`: the SPA fallback below calls reply.sendFile, which only exists when + // @fastify/static decorates the reply. With it disabled, every deep-link/refresh to a + // non-/api route (e.g. /dashboard, emailed /reset-password links) 500s in production. }); - // SPA fallback: any non-/api path returns index.html + // SPA fallback: any non-/api path returns index.html. index.html itself must not be cached + // for a year (unlike the hashed assets) or clients keep a stale app shell after each deploy. app.setNotFoundHandler((req, reply) => { if (req.raw.url?.startsWith('/api/')) { return reply.code(404).send({ error: 'not_found' }); } - return reply.type('text/html').sendFile('index.html', webDist); + // cacheControl:false stops @fastify/static from stamping its own 1y immutable header + // (which would otherwise override the no-cache below and pin a stale app shell). + return reply + .header('Cache-Control', 'no-cache') + .type('text/html') + .sendFile('index.html', webDist, { cacheControl: false }); }); } else { app.log.warn({ webDist }, 'web/dist not found — SPA assets will not be served'); diff --git a/apps/api/test/file-signature.test.ts b/apps/api/test/file-signature.test.ts new file mode 100644 index 0000000..601e240 --- /dev/null +++ b/apps/api/test/file-signature.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { verifyFileSignature } from '../src/lib/file-signature.js'; + +// Helper: build a buffer from a leading byte signature plus optional trailing filler. +function bytes(sig: number[], pad = 0): Buffer { + return Buffer.concat([Buffer.from(sig), Buffer.alloc(pad, 0x20)]); +} + +const PDF = bytes([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37]); // %PDF-1.7 +const PNG = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 16); +const JPEG = bytes([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46], 16); + +describe('verifyFileSignature', () => { + it('accepts a real PDF declared as application/pdf', () => { + expect(verifyFileSignature(PDF, 'application/pdf')).toBe(true); + }); + + it('accepts a real PNG declared as image/png', () => { + expect(verifyFileSignature(PNG, 'image/png')).toBe(true); + }); + + it('accepts a real JPEG declared as image/jpeg', () => { + expect(verifyFileSignature(JPEG, 'image/jpeg')).toBe(true); + }); + + it('rejects an HTML page spoofed as image/png', () => { + const html = Buffer.from('hi', 'utf8'); + expect(verifyFileSignature(html, 'image/png')).toBe(false); + }); + + it('rejects an HTML page spoofed as image/jpeg', () => { + const html = Buffer.from('', 'utf8'); + expect(verifyFileSignature(html, 'image/jpeg')).toBe(false); + }); + + it('rejects a Windows executable (MZ header) spoofed as text/plain', () => { + // MZ header (0x4D 0x5A) followed by a NUL — NUL bytes disqualify it as text. + const exe = Buffer.from([0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00]); + expect(verifyFileSignature(exe, 'text/plain')).toBe(false); + }); + + it('rejects a PDF whose bytes are actually an executable', () => { + const exe = Buffer.from([0x4d, 0x5a, 0x90, 0x00]); + expect(verifyFileSignature(exe, 'application/pdf')).toBe(false); + }); + + it('rejects an unknown / non-allowlisted declared MIME type', () => { + expect(verifyFileSignature(PDF, 'application/x-shockwave-flash')).toBe(false); + expect(verifyFileSignature(PDF, 'image/svg+xml')).toBe(false); + }); + + it('accepts genuine text declared as text/plain', () => { + const text = Buffer.from('Dear client, please find the attached invoice.\n', 'utf8'); + expect(verifyFileSignature(text, 'text/plain')).toBe(true); + }); + + it('rejects a buffer too short to contain the signature', () => { + expect(verifyFileSignature(Buffer.from([0x25, 0x50]), 'application/pdf')).toBe(false); + }); + + it('accepts a real WEBP image declared as image/webp', () => { + // RIFF....WEBP + const webp = Buffer.concat([ + Buffer.from([0x52, 0x49, 0x46, 0x46]), + Buffer.from([0x00, 0x00, 0x00, 0x00]), + Buffer.from('WEBP', 'latin1'), + ]); + expect(verifyFileSignature(webp, 'image/webp')).toBe(true); + }); +}); diff --git a/apps/api/test/password.test.ts b/apps/api/test/password.test.ts new file mode 100644 index 0000000..f68f75c --- /dev/null +++ b/apps/api/test/password.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { hashPassword, verifyPassword } from '../src/auth/password.js'; + +describe('password hashing (argon2)', () => { + it('verifies a correct password against its hash', async () => { + const hash = await hashPassword('correct horse battery staple'); + expect(await verifyPassword(hash, 'correct horse battery staple')).toBe(true); + }); + + it('rejects a wrong password', async () => { + const hash = await hashPassword('correct horse battery staple'); + expect(await verifyPassword(hash, 'Tr0ub4dor&3')).toBe(false); + }); + + it('produces an argon2id hash, not plaintext', async () => { + const hash = await hashPassword('s3cret'); + expect(hash).toMatch(/^\$argon2id\$/); + expect(hash).not.toContain('s3cret'); + }); + + it('produces a different hash each time (random salt) but both verify', async () => { + const a = await hashPassword('same-password'); + const b = await hashPassword('same-password'); + expect(a).not.toBe(b); + expect(await verifyPassword(a, 'same-password')).toBe(true); + expect(await verifyPassword(b, 'same-password')).toBe(true); + }); + + it('is case-sensitive', async () => { + const hash = await hashPassword('CaseSensitive'); + expect(await verifyPassword(hash, 'casesensitive')).toBe(false); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..c503091 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Unit tests only — no DB, no network, no server bootstrap. + include: ['test/**/*.test.ts'], + environment: 'node', + // Fail fast if a test accidentally reaches for the network/DB by hanging. + testTimeout: 15000, + }, +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8aad71c..ae7cfb5 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -34,6 +34,12 @@ import BlogPostPage from './pages/blog/BlogPostPage'; import PrivacyPage from './pages/legal/PrivacyPage'; import TermsPage from './pages/legal/TermsPage'; import CookiesPage from './pages/legal/CookiesPage'; +import LegalIndexPage from './pages/legal/LegalIndexPage'; +import AcceptableUsePage from './pages/legal/AcceptableUsePage'; +import RefundsPage from './pages/legal/RefundsPage'; +import DisclaimerPage from './pages/legal/DisclaimerPage'; +import DmcaPage from './pages/legal/DmcaPage'; +import DpaPage from './pages/legal/DpaPage'; export default function App() { return ( @@ -57,9 +63,15 @@ export default function App() { } /> } /> + } /> } /> } /> } /> + } /> + } /> + } /> + } /> + } /> }> } /> diff --git a/apps/web/src/components/app/CreateInvoiceDrawer.tsx b/apps/web/src/components/app/CreateInvoiceDrawer.tsx index 220eea3..62d21af 100644 --- a/apps/web/src/components/app/CreateInvoiceDrawer.tsx +++ b/apps/web/src/components/app/CreateInvoiceDrawer.tsx @@ -58,7 +58,11 @@ export function CreateInvoiceDrawer({ open, onClose, initialClientId, initialCas setItems([{ description: '', quantity: '1', rate: '' }]); setSelectedTimeIds(new Set()); create.reset(); - }, [open, initialClientId, initialCaseId, create]); + // Only re-run on the open transition (and when the initial ids change). + // `create` is a fresh object each render; depending on it would re-fire the + // effect every render and wipe user input ("Maximum update depth exceeded"). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, initialClientId, initialCaseId]); // When client changes, clear case selection if the case doesn't belong to that client useEffect(() => { diff --git a/apps/web/src/components/app/ManualEntryDrawer.tsx b/apps/web/src/components/app/ManualEntryDrawer.tsx index 29d85cb..f29450a 100644 --- a/apps/web/src/components/app/ManualEntryDrawer.tsx +++ b/apps/web/src/components/app/ManualEntryDrawer.tsx @@ -59,7 +59,12 @@ export function ManualEntryDrawer({ open, onClose, initialCaseId }: Props) { }); create.reset(); } - }, [open, initialCaseId, reset, create]); + // Only re-run on the open transition (and when the initial case id changes). + // `create` is a fresh object each render; depending on it would re-fire the + // effect every render, clearing the mutation error so failed saves show no + // feedback. `reset` is stable across renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, initialCaseId]); async function onSubmit(values: FormValues) { const minutes = Number(values.minutes); diff --git a/apps/web/src/components/auth/AuthLayout.tsx b/apps/web/src/components/auth/AuthLayout.tsx index cc7981a..1f27374 100644 --- a/apps/web/src/components/auth/AuthLayout.tsx +++ b/apps/web/src/components/auth/AuthLayout.tsx @@ -41,13 +41,13 @@ export function AuthLayout({ title, subtitle, children, footer }: Props) {

    {[ - '60% less time on admin tasks', - '3× faster client invoicing', - '98% billing accuracy rate', - ].map((stat) => ( -
  • + 'Track billable hours against every case', + 'Turn tracked time into invoices in a few clicks', + 'Keep cases, clients, and documents in one place', + ].map((item) => ( +
  • - {stat} + {item}
  • ))}
diff --git a/apps/web/src/components/marketing/Contact.tsx b/apps/web/src/components/marketing/Contact.tsx index 7c6dab6..e211120 100644 --- a/apps/web/src/components/marketing/Contact.tsx +++ b/apps/web/src/components/marketing/Contact.tsx @@ -111,15 +111,11 @@ export function Contact() {
- We respond quickly + We reply by email

- Average response time: 4 hours during weekdays, 12 hours on weekends. + Send us a message and we'll get back to you at the email address you provide.

-
- - Online and ready to help -
diff --git a/apps/web/src/components/marketing/Features.tsx b/apps/web/src/components/marketing/Features.tsx index 49aee16..ebff88b 100644 --- a/apps/web/src/components/marketing/Features.tsx +++ b/apps/web/src/components/marketing/Features.tsx @@ -29,8 +29,8 @@ const FEATURES = [ }, { icon: ShieldCheck, - title: 'Bank-Level Security', - body: 'Enterprise-grade encryption and compliance features to protect sensitive client data.', + title: 'Secure & Private', + body: 'Client data is encrypted in transit and at rest, stored in isolated per-firm storage, with passwords protected by modern hashing.', }, ]; diff --git a/apps/web/src/components/marketing/Footer.tsx b/apps/web/src/components/marketing/Footer.tsx index 286134f..88a7470 100644 --- a/apps/web/src/components/marketing/Footer.tsx +++ b/apps/web/src/components/marketing/Footer.tsx @@ -23,7 +23,19 @@ const COLUMNS = [ links: [ { href: '/resources', label: 'Resource Hub' }, { href: '/blog', label: 'Blog' }, - { href: '/legal', label: 'Legal' }, + ], + }, + { + title: 'Legal', + links: [ + { href: '/legal/terms', label: 'Terms of Service' }, + { href: '/legal/privacy', label: 'Privacy Policy' }, + { href: '/legal/cookies', label: 'Cookie Policy' }, + { href: '/legal/acceptable-use', label: 'Acceptable Use' }, + { href: '/legal/refunds', label: 'Billing & Refunds' }, + { href: '/legal/disclaimer', label: 'Disclaimer' }, + { href: '/legal/dmca', label: 'DMCA' }, + { href: '/legal/dpa', label: 'Data Processing' }, ], }, ]; @@ -31,7 +43,7 @@ const COLUMNS = [ export function Footer() { return ( diff --git a/apps/web/src/components/marketing/Pricing.tsx b/apps/web/src/components/marketing/Pricing.tsx index 871424d..a282711 100644 --- a/apps/web/src/components/marketing/Pricing.tsx +++ b/apps/web/src/components/marketing/Pricing.tsx @@ -18,7 +18,6 @@ const TIERS = [ tag: 'Most Popular', description: 'For growing firms managing multiple cases.', price: '$25', - strike: '$49', cadence: '/month', cta: 'Get Started', href: '/signup?plan=pro', @@ -37,7 +36,6 @@ const TIERS = [ tag: 'Best Value', description: 'For established practices seeking long-term value.', price: '$129', - strike: '$299', cadence: 'one-time', cta: 'Get Lifetime Access', href: '/signup?plan=lifetime', @@ -91,7 +89,6 @@ export function Pricing() {

{t.description}

- {t.strike && {t.strike}} {t.price} {t.cadence}
diff --git a/apps/web/src/components/marketing/Stats.tsx b/apps/web/src/components/marketing/Stats.tsx index 1f4411f..7ebb590 100644 --- a/apps/web/src/components/marketing/Stats.tsx +++ b/apps/web/src/components/marketing/Stats.tsx @@ -1,7 +1,16 @@ -const STATS = [ - { value: '60%', label: 'Less time on admin tasks' }, - { value: '3×', label: 'Faster client invoicing' }, - { value: '98%', label: 'Billing accuracy rate' }, +const BENEFITS = [ + { + title: 'Less time on admin', + label: 'Automate the repetitive parts of case and client management so more of your day goes to billable work.', + }, + { + title: 'Faster invoicing', + label: 'Turn tracked hours into ready-to-send invoices in a few clicks instead of rebuilding them by hand.', + }, + { + title: 'Accurate billing', + label: 'Log time against the right matter as you work, so invoices reflect what you actually did.', + }, ]; export function Stats() { @@ -9,23 +18,23 @@ export function Stats() {
- Real Results + Why eLegal Software

- Measurable impact on your practice + Built to save you time

- Don't rely on guesswork. The data speaks for itself about the efficiency gains our platform delivers. + A workflow designed to cut down on admin, speed up billing, and keep your numbers accurate.

- {STATS.map((s) => ( + {BENEFITS.map((b) => (
-
{s.value}
-

{s.label}

+
{b.title}
+

{b.label}

))}
diff --git a/apps/web/src/components/marketing/Testimonials.tsx b/apps/web/src/components/marketing/Testimonials.tsx index 46fdcca..2cee039 100644 --- a/apps/web/src/components/marketing/Testimonials.tsx +++ b/apps/web/src/components/marketing/Testimonials.tsx @@ -1,97 +1,73 @@ import { motion } from 'framer-motion'; -import { Star } from 'lucide-react'; +import { Scale, Clock, FileText, Users, Shield, CreditCard } from 'lucide-react'; -const TESTIMONIALS = [ +const VALUE_PROPS = [ { - name: 'Sarah Mitchell', - role: 'Partner, Mitchell & Associates', - quote: - 'eLegal Software transformed how our firm manages cases. We cut administrative time by 60% and our billing accuracy improved dramatically.', + icon: Scale, + title: 'Built for legal work', + body: 'Cases, clients, deadlines, and documents organized the way a practice actually runs — not a generic CRM bent to fit.', }, { - name: 'David Chen', - role: 'Solo Attorney, Immigration Law', - quote: - 'Managing 40+ immigration cases used to be overwhelming. Now everything is organized in one place — documents, deadlines, and client communications.', + icon: Clock, + title: 'Capture every billable minute', + body: 'Track time against cases as you work, so nothing slips through the cracks between the work and the invoice.', }, { - name: 'Jennifer Rodriguez', - role: 'Managing Partner, Rodriguez Legal Group', - quote: - 'The billable hours tracking is a game-changer. Our team captures every minute accurately, and invoicing takes seconds instead of hours.', + icon: FileText, + title: 'Documents in one place', + body: 'Keep matter files, templates, and client paperwork together and easy to find when you need them.', }, { - name: 'Michael Thompson', - role: 'Criminal Defense Attorney', - quote: - 'As a solo practitioner, time is everything. eLegal Software helps me stay organized and bill clients accurately. Best investment for my practice.', + icon: CreditCard, + title: 'Invoicing without the busywork', + body: 'Turn tracked hours into clean, professional invoices in a few clicks instead of rebuilding them by hand.', }, { - name: 'Lisa Anderson', - role: 'Partner, Family Law Firm', - quote: - 'We grew from 3 to 15 cases per month without adding staff. The efficiency gains are incredible — we save 20+ hours weekly.', + icon: Users, + title: 'Clear client communication', + body: 'Give clients transparency into their matters and billing, so expectations stay aligned from day one.', }, { - name: 'Robert Kim', - role: 'Corporate Law Partner', - quote: - 'Our clients love the transparency. They can see exactly what we are working on and billing for. Trust has never been higher.', + icon: Shield, + title: 'Secure by design', + body: 'Your matter data stays private and protected, with sensible controls built in from the start.', }, ]; -function initials(name: string) { - return name - .split(' ') - .map((n) => n[0]) - .join('') - .slice(0, 2) - .toUpperCase(); -} - export function Testimonials() { return (
- Success Stories + Built for legal professionals

- Loved by attorneys worldwide + Everything your practice needs, in one place

- Join thousands of legal professionals who transformed their practice with eLegal Software. + eLegal Software brings cases, billable hours, documents, and invoicing together — designed around the way legal work really happens.

- {TESTIMONIALS.map((t, i) => ( - -
- {Array.from({ length: 5 }).map((_, k) => ( - - ))} -
-
- “{t.quote}” -
-
-
- {initials(t.name)} + {VALUE_PROPS.map((v, i) => { + const Icon = v.icon; + return ( + +
+
-
-

{t.name}

-

{t.role}

-
-
-
- ))} +

{v.title}

+

{v.body}

+ + ); + })}
diff --git a/apps/web/src/pages/legal/AcceptableUsePage.tsx b/apps/web/src/pages/legal/AcceptableUsePage.tsx new file mode 100644 index 0000000..094b29e --- /dev/null +++ b/apps/web/src/pages/legal/AcceptableUsePage.tsx @@ -0,0 +1,84 @@ +import { Link } from 'react-router-dom'; +import { LegalLayout, H2, P, UL } from './LegalLayout'; + +export default function AcceptableUsePage() { + return ( + +

+ This Acceptable Use Policy (“AUP”) describes what you may not do on or with + eLegal Software. It is part of the{' '} + Terms of Service. + We wrote it to keep the Service safe and reliable for every firm that depends on it. + Violating this AUP may result in suspension or termination of your account. +

+ +

1. Illegal or harmful use

+

You may not use the Service to:

+
    + +

    2. Security violations

    +

    You may not:

    +
      + +

      3. Abuse of the platform

      +

      You may not:

      +
        + +

        4. Fair use of resources

        +

        + Plans include storage and usage limits. We may apply technical safeguards (throttling, + upload limits) to protect the platform, and will contact you if your usage is far outside + normal patterns before taking action, where practicable. +

        + +

        5. Reporting violations

        +

        + To report a violation of this policy, email{' '} + + abuse@elegalsoftware.com + + . For copyright complaints, use the process in our{' '} + DMCA Policy. For + security vulnerabilities, email{' '} + + security@elegalsoftware.com + {' '} + — we appreciate responsible disclosure and will not pursue good-faith researchers. +

        + +

        6. Enforcement

        +

        + We may investigate suspected violations and may remove content, suspend, or terminate + accounts that violate this AUP. For serious violations we may act without prior notice. + We will preserve and disclose information as required by law or valid legal process. +

        + + ); +} diff --git a/apps/web/src/pages/legal/CookiesPage.tsx b/apps/web/src/pages/legal/CookiesPage.tsx index e4df763..9332308 100644 --- a/apps/web/src/pages/legal/CookiesPage.tsx +++ b/apps/web/src/pages/legal/CookiesPage.tsx @@ -3,7 +3,7 @@ import { LegalLayout, H2, P, UL } from './LegalLayout'; export default function CookiesPage() { return ( - +

        This page explains the cookies eLegal Software sets, what they are for, and how to control them. @@ -40,7 +40,7 @@ export default function CookiesPage() { Essential - elegal:cookie-consent + lawdesk:cookie-consent Stored in localStorage, not as a cookie. Records your cookie banner choice so we don't ask again. diff --git a/apps/web/src/pages/legal/DisclaimerPage.tsx b/apps/web/src/pages/legal/DisclaimerPage.tsx new file mode 100644 index 0000000..b63202c --- /dev/null +++ b/apps/web/src/pages/legal/DisclaimerPage.tsx @@ -0,0 +1,70 @@ +import { Link } from 'react-router-dom'; +import { LegalLayout, H2, P, UL } from './LegalLayout'; + +export default function DisclaimerPage() { + return ( + +

        + This disclaimer applies to everything published or provided by eLegal Software — the + application, this website, our blog, free tools, document templates, and support + communications. It is part of the{' '} + Terms of Service. +

        + +

        1. eLegal Software is not a law firm

        +

        + eLegal Software is a software company. We are not a law firm, we are not licensed to + practice law in any jurisdiction, and we do not provide legal advice, legal opinions, or + legal representation. Nothing in the Service — including features, templates, tools, blog + posts, or support answers — constitutes legal advice, and nothing here is a substitute for + the advice of a licensed attorney familiar with your specific situation. +

        + +

        2. No attorney–client relationship

        +

        + Using the Service does not create an attorney–client relationship between you and + eLegal Software, or between your clients and eLegal Software. Communications with our + support team are not privileged. The attorney–client relationship, if any, exists + solely between attorneys using the platform and their own clients. +

        + +

        3. Templates and free tools

        +
          + +

          4. Professional responsibility remains yours

          +

          + Attorneys and firms using the Service remain solely responsible for their professional + obligations, including competence, confidentiality, conflicts, supervision, + client-communication, record-retention, and trust-accounting duties under the rules of + professional conduct of their jurisdictions. It is your responsibility to satisfy yourself + that using cloud software (including this one) is consistent with those obligations — + many bars publish guidance on reasonable-care standards for cloud services. +

          + +

          5. No guarantee of outcomes

          +

          + We make no representation or warranty about the outcome of any legal matter, the accuracy + of any calculation for your purposes, or the fitness of any template or tool for a + particular use. See the{' '} + Terms of Service{' '} + for warranty disclaimers and liability limits that apply to the Service as a whole. +

          + +

          6. Questions

          +

          + If anything here is unclear, contact{' '} + + legal@elegalsoftware.com + + . For advice about your legal rights or obligations, consult a licensed attorney in your + jurisdiction. +

          + + ); +} diff --git a/apps/web/src/pages/legal/DmcaPage.tsx b/apps/web/src/pages/legal/DmcaPage.tsx new file mode 100644 index 0000000..5e76e27 --- /dev/null +++ b/apps/web/src/pages/legal/DmcaPage.tsx @@ -0,0 +1,89 @@ +import { Link } from 'react-router-dom'; +import { LegalLayout, H2, P, UL } from './LegalLayout'; + +export default function DmcaPage() { + return ( + +

          + eLegal Software respects intellectual-property rights and expects users to do the same. + This policy describes how copyright owners can report infringing material stored on the + Service, and how users can respond, under the U.S. Digital Millennium Copyright Act + (17 U.S.C. § 512). It is part of the{' '} + Terms of Service. +

          + +

          1. Reporting infringement (takedown notice)

          +

          + If you believe material on the Service infringes your copyright, send a written notice to + our designated agent (Section 4) including all of the following: +

          +
            +

            + Under 17 U.S.C. § 512(f), you may be liable for damages (including costs and + attorneys’ fees) if you knowingly materially misrepresent that material is + infringing. +

            + +

            2. Our response

            +
              + +

              3. Counter-notice

              +

              + If you believe material you stored was removed by mistake or misidentification, you may + send our designated agent a written counter-notice including: +

              +
                +

                + If we receive a valid counter-notice, we will forward it to the original complainant and, + unless they notify us within 10–14 business days that they have filed a court action, + we may restore the material. +

                + +

                4. Designated agent

                +

                + DMCA Agent, eLegal Software —{' '} + + dmca@elegalsoftware.com + + . Email is the fastest way to reach us and is sufficient for both notices and + counter-notices. +

                + +

                5. Non-copyright complaints

                +

                + For trademark, defamation, privacy, or other complaints about content on the Service, + email{' '} + + abuse@elegalsoftware.com + {' '} + with enough detail for us to locate the material and evaluate the claim under our{' '} + + Acceptable Use Policy + + . +

                + + ); +} diff --git a/apps/web/src/pages/legal/DpaPage.tsx b/apps/web/src/pages/legal/DpaPage.tsx new file mode 100644 index 0000000..62e2549 --- /dev/null +++ b/apps/web/src/pages/legal/DpaPage.tsx @@ -0,0 +1,118 @@ +import { Link } from 'react-router-dom'; +import { LegalLayout, H2, P, UL } from './LegalLayout'; + +export default function DpaPage() { + return ( + +

                + This Data Processing Addendum (“DPA”) forms part of the{' '} + Terms of Service{' '} + between eLegal Software (“Processor,” “we”) and the firm using the + Service (“Controller,” “you”). It applies whenever we process + personal data contained in your practice data — information about your clients, opposing + parties, witnesses, and other individuals — on your behalf. No separate signature is + required: this DPA is accepted together with the Terms. +

                + +

                1. Roles and scope

                +
                  + +

                  2. Our commitments as processor

                  +
                    + +

                    3. Subprocessors

                    +

                    + You authorize the following subprocessors. We remain responsible for their performance and + will notify account holders by email at least 14 days before adding or replacing a + subprocessor, giving you the opportunity to object. +

                    +
                      + +

                      4. Security measures

                      +
                        + +

                        5. Security incidents

                        +

                        + We will notify you without undue delay, and in any event within 72 hours, after becoming + aware of a personal-data breach affecting your practice data, and will provide information + reasonably required for you to meet your own notification obligations, including to + clients under professional-conduct rules. +

                        + +

                        6. Data subject requests

                        +

                        + If an individual contacts us directly about data controlled by your firm, we will direct + them to you and will not respond substantively except as legally required. The Service + gives you self-serve tools to access, correct, export, and delete practice data. +

                        + +

                        7. Deletion and return

                        +

                        + You can export all practice data (JSON and documents) at any time. On account deletion, + practice data is permanently removed from active systems immediately and from encrypted + backups within 30 days, after which it is unrecoverable. Billing records are retained as + required by tax law. +

                        + +

                        8. International transfers

                        +

                        + Processing takes place in the United States. Where personal data protected by EEA/UK law + is transferred, the parties incorporate the European Commission’s Standard + Contractual Clauses (Module 2: controller-to-processor) and the UK Addendum by reference, + with you as data exporter and us as data importer. +

                        + +

                        9. Audits

                        +

                        + Upon written request no more than once per year, we will provide documentation reasonably + necessary to demonstrate compliance with this DPA (security summaries, subprocessor list, + and available third-party attestations of our infrastructure providers). Where law + requires more, we will cooperate with audits conducted with reasonable notice, during + business hours, without disrupting the Service. +

                        + +

                        10. Liability and order of precedence

                        +

                        + Liability under this DPA is subject to the limitations in the Terms of Service. If this + DPA conflicts with the Terms, this DPA controls with respect to processing of practice + data. Questions:{' '} + + privacy@elegalsoftware.com + + . +

                        + + ); +} diff --git a/apps/web/src/pages/legal/LegalIndexPage.tsx b/apps/web/src/pages/legal/LegalIndexPage.tsx new file mode 100644 index 0000000..f0ebd27 --- /dev/null +++ b/apps/web/src/pages/legal/LegalIndexPage.tsx @@ -0,0 +1,83 @@ +import { Link } from 'react-router-dom'; +import { PublicLayout } from '@/components/public/PublicLayout'; + +const DOCS = [ + { + to: '/legal/terms', + title: 'Terms of Service', + blurb: 'The agreement that governs your use of eLegal Software — accounts, plans, content ownership, disputes.', + }, + { + to: '/legal/privacy', + title: 'Privacy Policy', + blurb: 'What we collect, why, where it lives, who we share it with, and the rights you have over it.', + }, + { + to: '/legal/cookies', + title: 'Cookie Policy', + blurb: 'The (few, essential-only) cookies we set and how to control them.', + }, + { + to: '/legal/acceptable-use', + title: 'Acceptable Use Policy', + blurb: 'What you may not do on the platform — security, abuse, and fair-use rules.', + }, + { + to: '/legal/refunds', + title: 'Billing & Refund Policy', + blurb: 'How subscriptions, renewals, cancellations, refunds, and failed payments work.', + }, + { + to: '/legal/disclaimer', + title: 'Legal Disclaimer', + blurb: 'eLegal Software is software, not a law firm — no legal advice, no attorney–client relationship.', + }, + { + to: '/legal/dmca', + title: 'DMCA & Copyright Policy', + blurb: 'How to report copyright infringement and how takedowns and counter-notices work.', + }, + { + to: '/legal/dpa', + title: 'Data Processing Addendum', + blurb: 'How we process your clients’ data on your behalf — security measures, subprocessors, breach notice.', + }, +]; + +export default function LegalIndexPage() { + return ( + +
                        +
                        +

                        Legal

                        +

                        + Legal center +

                        +

                        + Everything that governs your relationship with eLegal Software, written in plain + English. Questions about any of it:{' '} + + legal@elegalsoftware.com + + . +

                        +
                        + +
                        + {DOCS.map((d) => ( + +

                        + {d.title} +

                        +

                        {d.blurb}

                        + + ))} +
                        +
                        +
                        + ); +} diff --git a/apps/web/src/pages/legal/LegalLayout.tsx b/apps/web/src/pages/legal/LegalLayout.tsx index 4675010..f88c99f 100644 --- a/apps/web/src/pages/legal/LegalLayout.tsx +++ b/apps/web/src/pages/legal/LegalLayout.tsx @@ -1,12 +1,16 @@ import type { ReactNode } from 'react'; import { Link } from 'react-router-dom'; -import { AlertTriangle } from 'lucide-react'; import { PublicLayout } from '@/components/public/PublicLayout'; -const LINKS = [ - { to: '/legal/privacy', label: 'Privacy Policy' }, +export const LEGAL_PAGES = [ { to: '/legal/terms', label: 'Terms of Service' }, + { to: '/legal/privacy', label: 'Privacy Policy' }, { to: '/legal/cookies', label: 'Cookie Policy' }, + { to: '/legal/acceptable-use', label: 'Acceptable Use' }, + { to: '/legal/refunds', label: 'Billing & Refunds' }, + { to: '/legal/disclaimer', label: 'Disclaimer' }, + { to: '/legal/dmca', label: 'DMCA' }, + { to: '/legal/dpa', label: 'Data Processing' }, ]; export function LegalLayout({ @@ -27,18 +31,8 @@ export function LegalLayout({

                        Effective {effectiveDate}

                        -
                        - -

                        - Template notice: these documents are - starting points that the eLegal Software team has drafted in plain English. Before you put - them on a production site, have a licensed attorney in your jurisdiction review and - adapt them to your business and applicable law. -

                        -
                        -