Storage→Spaces, security hardening, production-blocker fixes, tests + CI
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
310568690b
commit
97e1d4c60b
+17
-5
@@ -22,16 +22,28 @@ DATABASE_URL=postgresql://doadmin:password@db-postgresql-nyc1-xxxxx.b.db.ondigit
|
|||||||
DATABASE_CA_CERT_PATH=./certs/do-ca.crt
|
DATABASE_CA_CERT_PATH=./certs/do-ca.crt
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Local file storage
|
# Object storage — DigitalOcean Spaces (S3-compatible). Sole storage backend (required).
|
||||||
# Absolute path where uploaded documents are stored (outside web root).
|
# Create a Space + access keys in the DO console. Endpoint is the REGION endpoint
|
||||||
# Production example: /var/www/vhosts/elegalsoftware.com/storage
|
# (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
|
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 <noreply@yourdomain.com>"
|
EMAIL_FROM="eLegal Software <noreply@yourdomain.com>"
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,9 +8,13 @@
|
|||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"build": "tsc -p tsconfig.json --noEmit",
|
"build": "tsc -p tsconfig.json --noEmit",
|
||||||
"start": "tsx src/server.ts",
|
"start": "tsx src/server.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1088.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1088.0",
|
||||||
"@fastify/cookie": "^11.0.1",
|
"@fastify/cookie": "^11.0.1",
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
"@fastify/helmet": "^12.0.1",
|
"@fastify/helmet": "^12.0.1",
|
||||||
@@ -25,10 +29,9 @@
|
|||||||
"fastify": "^5.1.0",
|
"fastify": "^5.1.0",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"fastify-type-provider-zod": "^4.0.2",
|
"fastify-type-provider-zod": "^4.0.2",
|
||||||
"pg": "^8.13.1",
|
|
||||||
"pdfkit": "^0.15.0",
|
"pdfkit": "^0.15.0",
|
||||||
|
"pg": "^8.13.1",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.5.0",
|
||||||
"resend": "^4.0.1",
|
|
||||||
"stripe": "^17.4.0",
|
"stripe": "^17.4.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -38,6 +41,7 @@
|
|||||||
"@types/pdfkit": "^0.13.5",
|
"@types/pdfkit": "^0.13.5",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"pino-pretty": "^11.3.0",
|
"pino-pretty": "^11.3.0",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,42 @@ const TOKEN_BYTES = 32;
|
|||||||
|
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
|
||||||
// Routes that legitimately bypass CSRF — they receive their own auth (signature check)
|
// Exact routes that legitimately bypass CSRF: they run pre-session (login/signup/reset) or carry
|
||||||
// or have no session yet, so a CSRF attack against them is meaningless.
|
// their own authentication (Stripe signature), so a CSRF attack against them is meaningless.
|
||||||
const CSRF_EXEMPT_PREFIXES = ['/api/auth/', '/api/contact', '/api/webhooks/', '/api/tool-usage'];
|
// 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 {
|
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 {
|
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.
|
// Auto-mint a CSRF token whenever an authenticated session exists but no valid CSRF cookie is
|
||||||
// This makes the protection self-bootstrapping after sessions created before CSRF was enabled.
|
// 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) => {
|
app.addHook('onRequest', async (req, reply) => {
|
||||||
if (!req.cookies?.[SESSION_COOKIE]) return;
|
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();
|
const token = generateCsrfToken();
|
||||||
app.setCsrfCookie(reply, token);
|
app.setCsrfCookie(reply, token);
|
||||||
req.cookies = { ...req.cookies, [CSRF_COOKIE]: 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 (SAFE_METHODS.has(req.method)) return;
|
||||||
if (!req.cookies?.[SESSION_COOKIE]) return; // unauthenticated → nothing to protect
|
if (!req.cookies?.[SESSION_COOKIE]) return; // unauthenticated → nothing to protect
|
||||||
const url = req.routeOptions.url || req.url;
|
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 cookie = req.cookies?.[CSRF_COOKIE];
|
||||||
const header = (req.headers[CSRF_HEADER] as string | undefined) ?? '';
|
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' });
|
return reply.code(403).send({ error: 'csrf_failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ async function plugin(app: FastifyInstance) {
|
|||||||
session.user.id,
|
session.user.id,
|
||||||
session.user.email,
|
session.user.email,
|
||||||
session.user.isSuperadmin,
|
session.user.isSuperadmin,
|
||||||
|
session.user.emailVerifiedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
req.user = {
|
req.user = {
|
||||||
|
|||||||
@@ -6,11 +6,32 @@ export function isSuperadminEmail(email: string): boolean {
|
|||||||
return env.superadminEmails.includes(email.toLowerCase());
|
return env.superadminEmails.includes(email.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Promote any user whose email is on the SUPERADMIN_EMAILS list. Idempotent.
|
// Reconcile a user's superadmin flag against the SUPERADMIN_EMAILS allowlist. Idempotent.
|
||||||
// Called on signup/login so the assignment happens automatically as soon as the user shows up.
|
// Called on signup/login/every request.
|
||||||
export async function ensureSuperadminFlag(userId: string, email: string, currentFlag: boolean) {
|
//
|
||||||
const shouldBe = isSuperadminEmail(email);
|
// Security: promotion (false -> true) requires a VERIFIED email. Public signup never sets
|
||||||
if (shouldBe === currentFlag) return shouldBe;
|
// emailVerifiedAt, so an attacker who registers a listed address before its owner does NOT
|
||||||
await getDb().update(users).set({ isSuperadmin: shouldBe, updatedAt: new Date() }).where(eq(users.id, userId));
|
// silently become superadmin. Legitimate superadmins are provisioned via scripts/create-admin.ts
|
||||||
return shouldBe;
|
// (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;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -19,8 +19,17 @@ const envSchema = z.object({
|
|||||||
WEB_DIST_PATH: z.string().optional(),
|
WEB_DIST_PATH: z.string().optional(),
|
||||||
SUPERADMIN_EMAILS: z.string().optional().default(''),
|
SUPERADMIN_EMAILS: z.string().optional().default(''),
|
||||||
SENTRY_DSN_API: z.string().optional().default(''),
|
SENTRY_DSN_API: z.string().optional().default(''),
|
||||||
STORAGE_PATH: z.string().min(1).default('./storage'),
|
// Object storage — DigitalOcean Spaces (S3-compatible), the platform's sole storage backend.
|
||||||
RESEND_API_KEY: z.string().optional().default(''),
|
// 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 <noreply@elegalsoftware.com>'),
|
EMAIL_FROM: z.string().optional().default('eLegal Software <noreply@elegalsoftware.com>'),
|
||||||
STRIPE_SECRET_KEY: z.string().optional().default(''),
|
STRIPE_SECRET_KEY: z.string().optional().default(''),
|
||||||
STRIPE_WEBHOOK_SECRET: z.string().optional().default(''),
|
STRIPE_WEBHOOK_SECRET: z.string().optional().default(''),
|
||||||
|
|||||||
+374
-69
@@ -1,13 +1,7 @@
|
|||||||
import { Resend } from 'resend';
|
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
|
|
||||||
let _resend: Resend | null = null;
|
// SMTP2GO HTTP API — https://apidoc.smtp2go.com (POST /email/send)
|
||||||
|
const SMTP2GO_SEND_URL = 'https://api.smtp2go.com/v3/email/send';
|
||||||
function getResend(): Resend | null {
|
|
||||||
if (!env.RESEND_API_KEY) return null;
|
|
||||||
if (!_resend) _resend = new Resend(env.RESEND_API_KEY);
|
|
||||||
return _resend;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmailOptions {
|
export interface EmailOptions {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -25,100 +19,338 @@ export interface SendResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
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<SendResult> {
|
export async function sendEmail(opts: EmailOptions): Promise<SendResult> {
|
||||||
const resend = getResend();
|
if (!env.SMTP2GO_API_KEY) {
|
||||||
if (!resend) {
|
// Logged but not sent — useful in dev when SMTP2GO_API_KEY isn't set.
|
||||||
// Logged but not sent — useful in dev when RESEND_API_KEY isn't set.
|
|
||||||
console.log(`[email skipped] to=${opts.to} subject="${opts.subject}"`);
|
console.log(`[email skipped] to=${opts.to} subject="${opts.subject}"`);
|
||||||
return { ok: true, skipped: true };
|
return { ok: true, skipped: true };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await resend.emails.send({
|
const res = await fetch(SMTP2GO_SEND_URL, {
|
||||||
from: env.EMAIL_FROM,
|
method: 'POST',
|
||||||
to: opts.to,
|
headers: {
|
||||||
subject: opts.subject,
|
'Content-Type': 'application/json',
|
||||||
html: opts.html,
|
'X-Smtp2go-Api-Key': env.SMTP2GO_API_KEY,
|
||||||
text: opts.text,
|
},
|
||||||
replyTo: opts.replyTo,
|
body: JSON.stringify({
|
||||||
attachments: opts.attachments?.map((a) => ({
|
sender: env.EMAIL_FROM,
|
||||||
filename: a.filename,
|
to: [opts.to],
|
||||||
content: typeof a.content === 'string' ? a.content : a.content.toString('base64'),
|
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 };
|
const json = (await res.json().catch(() => null)) as Smtp2goResponse | null;
|
||||||
return { ok: true, id: res.data?.id };
|
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) {
|
} catch (err) {
|
||||||
return { ok: false, error: (err as Error).message };
|
return { ok: false, error: (err as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────── Templates ───────────────────────────
|
// ─────────────────────────── Templates ───────────────────────────
|
||||||
// Kept simple. Brand-blue header bar + readable body. Plain-text version always provided
|
// Light theme: soft gray-blue canvas, white card, brand-blue accents, logo above the card.
|
||||||
// since some clients (and good practice) require it.
|
// 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 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, '>')
|
||||||
|
.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 `<h1 style="margin:0 0 14px;font-size:21px;line-height:1.3;font-weight:700;color:${INK};letter-spacing:-0.01em;">${text}</h1>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function p(html: string): string {
|
||||||
|
return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:${INK};">${html}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function muted(html: string): string {
|
||||||
|
return `<p style="margin:0 0 14px;font-size:13px;line-height:1.55;color:${SOFT};">${html}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function btn(url: string, label: string): string {
|
||||||
|
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:6px 0 22px;"><tr>
|
||||||
|
<td style="border-radius:12px;background:${BRAND};">
|
||||||
|
<a href="${url}" style="display:inline-block;padding:13px 26px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:12px;">${label}</a>
|
||||||
|
</td>
|
||||||
|
</tr></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkFallback(url: string): string {
|
||||||
|
return `<p style="margin:0 0 14px;font-size:12px;line-height:1.5;color:${FAINT};word-break:break-all;">If the button doesn't work, paste this link into your browser:<br><a href="${url}" style="color:${BRAND};">${url}</a></p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function panel(html: string): string {
|
||||||
|
return `<div style="background:${PANEL};border:1px solid ${BORDER};border-radius:12px;padding:16px 18px;margin:0 0 16px;">${html}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Big invoice/receipt figure with a label above it. */
|
||||||
|
function amountBlock(label: string, amount: string, sub?: string): string {
|
||||||
|
return panel(
|
||||||
|
`<p style="margin:0 0 4px;font-size:12px;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;color:${SOFT};">${label}</p>
|
||||||
|
<p style="margin:0;font-size:26px;font-weight:700;color:${INK};letter-spacing:-0.01em;">${amount}</p>
|
||||||
|
${sub ? `<p style="margin:6px 0 0;font-size:13px;color:${SOFT};">${sub}</p>` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function signoff(): string {
|
||||||
|
return p('— The eLegal Software team');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shell(bodyHtml: string, preheader = ''): string {
|
||||||
|
const pre = preheader
|
||||||
|
? `<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${preheader} ‌ ‌ ‌ ‌ ‌ ‌</div>`
|
||||||
|
: '';
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html><head><meta charset="utf-8"><title>eLegal Software</title></head>
|
<html lang="en">
|
||||||
<body style="margin:0;padding:0;background:#f6f7f9;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#23272e;">
|
<head>
|
||||||
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:16px;overflow:hidden;border:1px solid #eceef2;">
|
<meta charset="utf-8">
|
||||||
<div style="background:${BRAND};padding:18px 24px;color:#fff;font-weight:700;letter-spacing:-0.01em;font-size:18px;">eLegal Software</div>
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<div style="padding:28px 24px;line-height:1.55;font-size:15px;">${bodyHtml}</div>
|
<meta name="color-scheme" content="light">
|
||||||
<div style="border-top:1px solid #eceef2;padding:14px 24px;color:#7c8595;font-size:12px;">© ${new Date().getFullYear()} eLegal Software. You're receiving this because of activity on your account.</div>
|
<meta name="supported-color-schemes" content="light">
|
||||||
</div>
|
<title>eLegal Software</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:${CANVAS};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||||
|
${pre}
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${CANVAS};">
|
||||||
|
<tr><td align="center" style="padding:36px 16px;">
|
||||||
|
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="width:560px;max-width:100%;">
|
||||||
|
<tr><td align="left" style="padding:0 8px 18px;">
|
||||||
|
<a href="${APP_URL}" style="text-decoration:none;"><img src="${LOGO_URL}" alt="eLegal Software" width="200" height="20" style="display:block;border:0;height:20px;width:auto;"></a>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="background:#ffffff;border:1px solid ${BORDER};border-radius:16px;padding:32px;">
|
||||||
|
${bodyHtml}
|
||||||
|
</td></tr>
|
||||||
|
<tr><td align="center" style="padding:22px 8px 0;">
|
||||||
|
<p style="margin:0;font-size:12px;line-height:1.7;color:${FAINT};">© ${new Date().getFullYear()} eLegal Software · Practice management for solo attorneys & small firms<br>You're receiving this because of activity related to an eLegal Software account.</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
</body></html>`;
|
</body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Account lifecycle ──
|
||||||
|
|
||||||
export function welcomeEmail(toName: string | null, verifyUrl: string | null) {
|
export function welcomeEmail(toName: string | null, verifyUrl: string | null) {
|
||||||
const name = toName?.split(' ')[0] ?? 'there';
|
const name = firstName(toName);
|
||||||
const verifyBlock = verifyUrl
|
const verifyBlock = verifyUrl
|
||||||
? `<p>Please confirm your email address so we can send you important updates:</p>
|
? `${p('First, please confirm your email address so we can send you important account updates:')}
|
||||||
<p><a href="${verifyUrl}" style="display:inline-block;background:${BRAND};color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600;">Verify my email</a></p>
|
${btn(verifyUrl, 'Verify my email')}
|
||||||
<p style="color:#5b6473;font-size:13px;">Or paste this link into your browser: ${verifyUrl}</p>`
|
${linkFallback(verifyUrl)}`
|
||||||
: '';
|
: '';
|
||||||
return {
|
return {
|
||||||
subject: 'Welcome to eLegal Software',
|
subject: 'Welcome to eLegal Software',
|
||||||
html: shell(
|
html: shell(
|
||||||
`<p>Hi ${name},</p>
|
`${heading(`Welcome aboard, ${name} 👋`)}
|
||||||
<p>Welcome to eLegal Software. Your account is set up and you're ready to add your first client and case.</p>
|
${p("Your account is set up and you're ready to add your first client and case.")}
|
||||||
${verifyBlock}
|
${verifyBlock}
|
||||||
<p>If you have questions, just reply to this email — a real person will see it.</p>
|
${panel(
|
||||||
<p>— The eLegal Software team</p>`,
|
`<p style="margin:0 0 8px;font-size:14px;font-weight:600;color:${INK};">Get started in three steps</p>
|
||||||
|
<p style="margin:0;font-size:14px;line-height:1.8;color:${SOFT};">1. Add a client → 2. Open a case → 3. Track time & send your first invoice</p>`,
|
||||||
|
)}
|
||||||
|
${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) {
|
export function passwordResetEmail(toName: string | null, resetUrl: string) {
|
||||||
const name = toName?.split(' ')[0] ?? 'there';
|
const name = firstName(toName);
|
||||||
return {
|
return {
|
||||||
subject: 'Reset your eLegal Software password',
|
subject: 'Reset your eLegal Software password',
|
||||||
html: shell(
|
html: shell(
|
||||||
`<p>Hi ${name},</p>
|
`${heading('Reset your password')}
|
||||||
<p>We got a request to reset the password on your eLegal Software account. Click below to choose a new one:</p>
|
${p(`Hi ${name},`)}
|
||||||
<p><a href="${resetUrl}" style="display:inline-block;background:${BRAND};color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600;">Reset password</a></p>
|
${p('We got a request to reset the password on your eLegal Software account. Click below to choose a new one:')}
|
||||||
<p style="color:#5b6473;font-size:13px;">Or paste this link into your browser: ${resetUrl}</p>
|
${btn(resetUrl, 'Reset password')}
|
||||||
<p style="color:#5b6473;font-size:13px;">This link expires in 1 hour. If you didn't request a reset, you can safely ignore this email.</p>`,
|
${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.`,
|
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(
|
||||||
|
`<p style="margin:0;font-size:14px;line-height:1.6;color:${INK};"><strong>Didn't do this?</strong> Someone else may have access to your account. Reset your password immediately and reply to this email so we can help.</p>`,
|
||||||
|
)}
|
||||||
|
${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) {
|
export function planUpgradedEmail(toName: string | null, plan: string) {
|
||||||
const name = toName?.split(' ')[0] ?? 'there';
|
const name = firstName(toName);
|
||||||
return {
|
return {
|
||||||
subject: `You're on eLegal Software ${plan}`,
|
subject: `You're on eLegal Software ${plan}`,
|
||||||
html: shell(
|
html: shell(
|
||||||
`<p>Hi ${name},</p>
|
`${heading(`Welcome to ${esc(plan)} 🎉`)}
|
||||||
<p>Thanks for upgrading. Your firm is now on the <strong>${plan}</strong> plan and the limits and watermarks have been lifted.</p>
|
${p(`Hi ${name},`)}
|
||||||
<p><a href="${process.env.PUBLIC_URL ?? 'https://app.elegalsoftware.com'}/app" style="display:inline-block;background:${BRAND};color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600;">Open eLegal Software</a></p>
|
${p(`Thanks for upgrading. Your firm is now on the <strong>${esc(plan)}</strong> plan — plan limits and invoice watermarks have been lifted.`)}
|
||||||
<p>Manage your subscription anytime from Settings → Billing.</p>`,
|
${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 <strong>${esc(amount)}</strong>` : ''} 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 <strong>Starter</strong> plan.')}
|
||||||
|
${panel(
|
||||||
|
`<p style="margin:0;font-size:14px;line-height:1.7;color:${SOFT};">What changes on Starter: plan limits apply again and invoices include a watermark. <strong style="color:${INK};">All your data — clients, cases, documents, invoices — is untouched.</strong></p>`,
|
||||||
|
)}
|
||||||
|
${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: {
|
export function invoiceEmail(opts: {
|
||||||
clientName: string;
|
clientName: string;
|
||||||
firmName: string;
|
firmName: string;
|
||||||
@@ -127,33 +359,106 @@ export function invoiceEmail(opts: {
|
|||||||
dueDate?: string | null;
|
dueDate?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const dueLine = opts.dueDate ? `<p>Due on <strong>${opts.dueDate}</strong>.</p>` : '';
|
const firm = esc(opts.firmName);
|
||||||
const notesLine = opts.notes
|
const num = esc(opts.invoiceNumber);
|
||||||
? `<p style="background:#f6f7f9;border-radius:10px;padding:12px;color:#5b6473;font-size:13px;">${opts.notes}</p>`
|
const notesBlock = opts.notes
|
||||||
|
? panel(`<p style="margin:0;font-size:13px;line-height:1.6;color:${SOFT};">${esc(opts.notes)}</p>`)
|
||||||
: '';
|
: '';
|
||||||
return {
|
return {
|
||||||
subject: `Invoice ${opts.invoiceNumber} from ${opts.firmName}`,
|
subject: `Invoice ${opts.invoiceNumber} from ${opts.firmName}`,
|
||||||
html: shell(
|
html: shell(
|
||||||
`<p>Hi ${opts.clientName.split(' ')[0]},</p>
|
`${heading(`New invoice from ${firm}`)}
|
||||||
<p>${opts.firmName} sent you a new invoice.</p>
|
${p(`Hi ${firstName(opts.clientName)},`)}
|
||||||
<p style="font-size:18px;"><strong>${opts.invoiceNumber}</strong> — <strong>${opts.total}</strong></p>
|
${p(`${firm} sent you a new invoice. The PDF is attached to this email.`)}
|
||||||
${dueLine}
|
${amountBlock(`Invoice ${num}`, esc(opts.total), opts.dueDate ? `Due ${esc(opts.dueDate)}` : undefined)}
|
||||||
${notesLine}
|
${notesBlock}
|
||||||
<p>The PDF is attached. Reply to this email if you have any questions.</p>`,
|
${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 <strong>${num}</strong>. 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 <strong>${num}</strong> from ${firm} was due on <strong>${esc(opts.dueDate)}</strong> 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) {
|
export function contactAckEmail(toName: string) {
|
||||||
const name = toName.split(' ')[0];
|
const name = firstName(toName);
|
||||||
return {
|
return {
|
||||||
subject: "Got your message — we'll be in touch",
|
subject: "Got your message — we'll be in touch",
|
||||||
html: shell(
|
html: shell(
|
||||||
`<p>Hi ${name},</p>
|
`${heading('We got your message')}
|
||||||
<p>Thanks for reaching out to eLegal Software. We've received your message and one of us will reply within one business day.</p>
|
${p(`Hi ${name},`)}
|
||||||
<p>— The eLegal Software team</p>`,
|
${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`,
|
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, '<br>');
|
||||||
|
return {
|
||||||
|
subject: `New contact message from ${opts.fromName}`,
|
||||||
|
html: shell(
|
||||||
|
`${heading('New contact form message')}
|
||||||
|
${panel(`<p style="margin:0;font-size:14px;line-height:1.7;color:${INK};white-space:pre-wrap;">${messageHtml}</p>`)}
|
||||||
|
${muted(`From: <strong>${esc(opts.fromName)}</strong> <${esc(opts.fromEmail)}>${opts.ip ? ` · IP ${esc(opts.ip)}` : ''}`)}
|
||||||
|
${p('Reply directly to this email to answer them.')}
|
||||||
|
${muted(`Also visible in the <a href="${APP_URL}/admin/contact" style="color:${BRAND};">admin panel</a>.`)}`,
|
||||||
|
`${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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<string, (buf: Buffer) => 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);
|
||||||
|
}
|
||||||
@@ -1,20 +1,30 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
import { and, eq, like, sql } from 'drizzle-orm';
|
||||||
import { eq, and, like } from 'drizzle-orm';
|
|
||||||
import { getDb, invoices } from '@lawdesk/db';
|
import { getDb, invoices } from '@lawdesk/db';
|
||||||
|
|
||||||
// Format: INV-YYYY-NNNN, scoped per firm.
|
// The transaction handle passed by db.transaction(async (tx) => ...).
|
||||||
// Uses a count-based sequence — the unique-on-(firm_id, number) constraint isn't enforced
|
type Tx = Parameters<Parameters<ReturnType<typeof getDb>['transaction']>[0]>[0];
|
||||||
// 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.
|
// Format: INV-YYYY-NNNN, scoped per firm. Collision-safe:
|
||||||
export async function nextInvoiceNumber(firmId: string): Promise<string> {
|
// 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<string> {
|
||||||
const year = new Date().getUTCFullYear();
|
const year = new Date().getUTCFullYear();
|
||||||
const prefix = `INV-${year}-`;
|
const prefix = `INV-${year}-`;
|
||||||
|
|
||||||
const [row] = await getDb()
|
// Per-firm, transaction-scoped lock; released automatically on commit or rollback.
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
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<number>`coalesce(max(split_part(${invoices.number}, '-', 3)::int), 0)` })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(and(eq(invoices.firmId, firmId), like(invoices.number, `${prefix}%`)));
|
.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')}`;
|
return `${prefix}${String(next).padStart(4, '0')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-15
@@ -1,29 +1,165 @@
|
|||||||
import fs from 'node:fs';
|
// Object storage — DigitalOcean Spaces (S3-compatible), the platform's sole storage backend.
|
||||||
import path from 'node:path';
|
// 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';
|
import { env } from '../env';
|
||||||
|
|
||||||
function root(): string {
|
let _s3: S3Client | null = null;
|
||||||
return path.resolve(env.STORAGE_PATH);
|
|
||||||
|
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 {
|
// Storage keys are generated server-side (`${firmId}/${caseId}/${docId}${ext}`), but validate
|
||||||
const abs = path.resolve(root(), key);
|
// defensively: reject absolute paths and any '..' traversal segment before it reaches the bucket.
|
||||||
if (!abs.startsWith(root() + path.sep) && abs !== root()) {
|
function assertSafeKey(key: string): void {
|
||||||
|
if (
|
||||||
|
!key ||
|
||||||
|
key.startsWith('/') ||
|
||||||
|
key.includes('\\') ||
|
||||||
|
key.split('/').some((seg) => seg === '..' || seg === '.')
|
||||||
|
) {
|
||||||
throw new Error('invalid_storage_key');
|
throw new Error('invalid_storage_key');
|
||||||
}
|
}
|
||||||
return abs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveFile(key: string, data: Buffer): Promise<void> {
|
export class FileNotFoundError extends Error {
|
||||||
const dest = resolve(key);
|
constructor(public key: string) {
|
||||||
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
super('file_not_found');
|
||||||
await fs.promises.writeFile(dest, data);
|
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<void> {
|
||||||
|
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<void> {
|
export async function deleteFile(key: string): Promise<void> {
|
||||||
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 {
|
// Deletes every object under a prefix (e.g. `${firmId}/` on account deletion, or
|
||||||
return fs.createReadStream(resolve(key));
|
// `${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<number> {
|
||||||
|
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<string[]> {
|
||||||
|
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<Readable> {
|
||||||
|
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<string> {
|
||||||
|
assertSafeKey(key);
|
||||||
|
return getSignedUrl(
|
||||||
|
getS3(),
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: env.SPACES_BUCKET,
|
||||||
|
Key: key,
|
||||||
|
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(filename)}"`,
|
||||||
|
}),
|
||||||
|
{ expiresIn: expiresInSeconds },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
} from '@lawdesk/db';
|
} from '@lawdesk/db';
|
||||||
import { verifyPassword } from '../auth/password';
|
import { verifyPassword } from '../auth/password';
|
||||||
import { logAudit } from '../lib/audit';
|
import { logAudit } from '../lib/audit';
|
||||||
|
import { sendEmail, accountDeletedEmail } from '../lib/email';
|
||||||
|
import { deletePrefix, getSignedDownloadUrl } from '../lib/storage';
|
||||||
|
|
||||||
export async function accountRoutes(app: FastifyInstance) {
|
export async function accountRoutes(app: FastifyInstance) {
|
||||||
app.addHook('preHandler', app.requireAuth);
|
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));
|
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.firm = firm;
|
||||||
dump.clients = firmClients;
|
dump.clients = firmClients;
|
||||||
dump.cases = firmCases;
|
dump.cases = firmCases;
|
||||||
@@ -67,7 +82,7 @@ export async function accountRoutes(app: FastifyInstance) {
|
|||||||
...i,
|
...i,
|
||||||
items: items.filter((it) => it.invoiceId === i.id),
|
items: items.filter((it) => it.invoiceId === i.id),
|
||||||
}));
|
}));
|
||||||
dump.documents = docs;
|
dump.documents = docsWithUrls;
|
||||||
}
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -101,10 +116,11 @@ export async function accountRoutes(app: FastifyInstance) {
|
|||||||
if (!ok) return reply.code(401).send({ error: 'invalid_password' });
|
if (!ok) return reply.code(401).send({ error: 'invalid_password' });
|
||||||
|
|
||||||
if (firmId) {
|
if (firmId) {
|
||||||
const [{ count }] = await db
|
const countRows = await db
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.firmId, firmId));
|
.where(eq(users.firmId, firmId));
|
||||||
|
const count = countRows[0]?.count ?? 0;
|
||||||
if (count > 1) {
|
if (count > 1) {
|
||||||
return reply.code(409).send({
|
return reply.code(409).send({
|
||||||
error: 'firm_has_other_users',
|
error: 'firm_has_other_users',
|
||||||
@@ -130,6 +146,25 @@ export async function accountRoutes(app: FastifyInstance) {
|
|||||||
await tx.delete(users).where(eq(users.id, userId));
|
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.clearSessionCookie(reply);
|
||||||
app.clearCsrfCookie(reply);
|
app.clearCsrfCookie(reply);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
+123
-14
@@ -2,12 +2,26 @@ import crypto from 'node:crypto';
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { and, eq, gte, isNull, sql } from 'drizzle-orm';
|
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 { hashPassword, verifyPassword } from '../auth/password';
|
||||||
import { SESSION_COOKIE, createSession, destroySession } from '../auth/sessions';
|
import { SESSION_COOKIE, createSession, destroySession } from '../auth/sessions';
|
||||||
import { ensureSuperadminFlag } from '../auth/superadmin';
|
import { ensureSuperadminFlag } from '../auth/superadmin';
|
||||||
import { generateCsrfToken } from '../auth/csrf';
|
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';
|
import { env } from '../env';
|
||||||
|
|
||||||
const signupBody = z.object({
|
const signupBody = z.object({
|
||||||
@@ -24,19 +38,33 @@ const loginBody = z.object({
|
|||||||
|
|
||||||
const MAX_FAILS_PER_15_MIN = 5;
|
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<string> {
|
||||||
|
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<number> {
|
async function recentFailedAttempts(email: string, ip: string | null): Promise<number> {
|
||||||
const since = new Date(Date.now() - 15 * 60 * 1000);
|
const since = new Date(Date.now() - 15 * 60 * 1000);
|
||||||
const db = getDb();
|
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
|
const rows = await db
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(loginAttempts)
|
.from(loginAttempts)
|
||||||
.where(
|
.where(and(...conditions));
|
||||||
and(
|
|
||||||
eq(loginAttempts.email, email),
|
|
||||||
eq(loginAttempts.success, false),
|
|
||||||
gte(loginAttempts.attemptedAt, since),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return rows[0]?.count ?? 0;
|
return rows[0]?.count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +98,12 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
.returning();
|
.returning();
|
||||||
if (!user) return reply.code(500).send({ error: 'user_create_failed' });
|
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({
|
const { token, expiresAt } = await createSession({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -80,9 +113,17 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
app.setSessionCookie(reply, token, expiresAt);
|
app.setSessionCookie(reply, token, expiresAt);
|
||||||
app.setCsrfCookie(reply, generateCsrfToken());
|
app.setCsrfCookie(reply, generateCsrfToken());
|
||||||
|
|
||||||
// Fire-and-forget welcome email (no blocking)
|
// Fire-and-forget welcome email with a verification link (no blocking)
|
||||||
const welcome = welcomeEmail(user.fullName, null);
|
createVerifyUrl(user.id)
|
||||||
sendEmail({ to: user.email, ...welcome }).catch((err) => app.log.warn({ err }, 'welcome email failed'));
|
.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({
|
return reply.code(201).send({
|
||||||
user: {
|
user: {
|
||||||
@@ -124,7 +165,12 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
return reply.code(403).send({ error: 'account_suspended' });
|
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));
|
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));
|
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 };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ export async function billingRoutes(app: FastifyInstance) {
|
|||||||
const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1);
|
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' });
|
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();
|
const stripe = getStripe();
|
||||||
|
|
||||||
// Reuse the customer if we've made one before; otherwise let Checkout create one and we'll
|
// Reuse the customer if we've made one before; otherwise let Checkout create one and we'll
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
|
|||||||
import { getDb, cases, clients, timeEntries } from '@lawdesk/db';
|
import { getDb, cases, clients, timeEntries } from '@lawdesk/db';
|
||||||
import { loadFirm } from '../lib/firm';
|
import { loadFirm } from '../lib/firm';
|
||||||
import { assertCanCreateCase, PlanLimitError } from '../lib/plan-limits';
|
import { assertCanCreateCase, PlanLimitError } from '../lib/plan-limits';
|
||||||
|
import { deletePrefix } from '../lib/storage';
|
||||||
|
|
||||||
const STATUSES = ['open', 'pending', 'closed', 'archived'] as const;
|
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)))
|
.where(and(eq(cases.id, id), eq(cases.firmId, firmId)))
|
||||||
.returning({ id: cases.id });
|
.returning({ id: cases.id });
|
||||||
if (!row) return reply.code(404).send({ error: 'not_found' });
|
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 };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
|
|||||||
import { getDb, clients, cases } from '@lawdesk/db';
|
import { getDb, clients, cases } from '@lawdesk/db';
|
||||||
import { loadFirm } from '../lib/firm';
|
import { loadFirm } from '../lib/firm';
|
||||||
import { assertCanCreateClient, PlanLimitError } from '../lib/plan-limits';
|
import { assertCanCreateClient, PlanLimitError } from '../lib/plan-limits';
|
||||||
|
import { deletePrefix } from '../lib/storage';
|
||||||
|
|
||||||
const createBody = z.object({
|
const createBody = z.object({
|
||||||
name: z.string().min(1).max(160).trim(),
|
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) => {
|
app.delete('/api/clients/:id', async (req, reply) => {
|
||||||
const firmId = req.user!.firmId!;
|
const firmId = req.user!.firmId!;
|
||||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
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)
|
.delete(clients)
|
||||||
.where(and(eq(clients.id, id), eq(clients.firmId, firmId)))
|
.where(and(eq(clients.id, id), eq(clients.firmId, firmId)))
|
||||||
.returning({ id: clients.id });
|
.returning({ id: clients.id });
|
||||||
if (!row) return reply.code(404).send({ error: 'not_found' });
|
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 };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb, contactMessages } from '@lawdesk/db';
|
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({
|
const contactBody = z.object({
|
||||||
fullName: z.string().min(1).max(120).trim(),
|
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) =>
|
sendEmail({ to: body.email, ...tpl }).catch((err) =>
|
||||||
app.log.warn({ err }, 'contact ack email failed'),
|
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 });
|
return reply.code(201).send({ ok: true });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { and, desc, eq } from 'drizzle-orm';
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
import { getDb, documents, cases } from '@lawdesk/db';
|
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([
|
const ALLOWED_MIME = new Set([
|
||||||
'application/pdf',
|
'application/pdf',
|
||||||
@@ -66,11 +67,18 @@ export async function documentsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buf = await data.toBuffer();
|
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 docId = randomUUID();
|
||||||
const ext = path.extname(data.filename);
|
const ext = path.extname(data.filename);
|
||||||
const storageKey = `${firmId}/${caseId}/${docId}${ext}`;
|
const storageKey = `${firmId}/${caseId}/${docId}${ext}`;
|
||||||
|
|
||||||
await saveFile(storageKey, buf);
|
await saveFile(storageKey, buf, data.mimetype);
|
||||||
|
|
||||||
const [doc] = await db.insert(documents).values({
|
const [doc] = await db.insert(documents).values({
|
||||||
id: docId,
|
id: docId,
|
||||||
@@ -83,6 +91,12 @@ export async function documentsRoutes(app: FastifyInstance) {
|
|||||||
sizeBytes: buf.length,
|
sizeBytes: buf.length,
|
||||||
}).returning();
|
}).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({
|
return reply.code(201).send({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
name: doc.name,
|
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);
|
.where(and(eq(documents.id, docId), eq(documents.firmId, firmId))).limit(1);
|
||||||
if (!doc) return reply.code(404).send({ error: 'not_found' });
|
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
|
return reply
|
||||||
.header('Content-Type', doc.mimeType)
|
.header('Content-Type', doc.mimeType)
|
||||||
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(doc.name)}"`)
|
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(doc.name)}"`)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { loadFirm } from '../lib/firm';
|
|||||||
import { assertCanCreateInvoice, PlanLimitError } from '../lib/plan-limits';
|
import { assertCanCreateInvoice, PlanLimitError } from '../lib/plan-limits';
|
||||||
import { nextInvoiceNumber } from '../lib/invoice-numbering';
|
import { nextInvoiceNumber } from '../lib/invoice-numbering';
|
||||||
import { renderInvoicePdf } from '../lib/invoice-pdf';
|
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;
|
const STATUSES = ['draft', 'sent', 'paid', 'overdue', 'void'] as const;
|
||||||
|
|
||||||
@@ -262,9 +262,11 @@ export async function invoicesRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const taxRate = body.taxRate;
|
const taxRate = body.taxRate;
|
||||||
const totals = computeTotals(accumulated, taxRate);
|
const totals = computeTotals(accumulated, taxRate);
|
||||||
const number = await nextInvoiceNumber(firmId);
|
|
||||||
|
|
||||||
const created = await db.transaction(async (tx) => {
|
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
|
const [inv] = await tx
|
||||||
.insert(invoices)
|
.insert(invoices)
|
||||||
.values({
|
.values({
|
||||||
@@ -461,6 +463,32 @@ export async function invoicesRoutes(app: FastifyInstance) {
|
|||||||
.set({ status: 'paid', paidAt: now, updatedAt: now })
|
.set({ status: 'paid', paidAt: now, updatedAt: now })
|
||||||
.where(eq(invoices.id, id))
|
.where(eq(invoices.id, id))
|
||||||
.returning();
|
.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;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
import { eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { getDb, firms, users } from '@lawdesk/db';
|
import { getDb, firms, users } from '@lawdesk/db';
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
import { getStripe } from '../lib/stripe';
|
import { getStripe } from '../lib/stripe';
|
||||||
import { sendEmail, planUpgradedEmail } from '../lib/email';
|
import {
|
||||||
|
sendEmail,
|
||||||
|
planUpgradedEmail,
|
||||||
|
paymentFailedEmail,
|
||||||
|
subscriptionEndedEmail,
|
||||||
|
} from '../lib/email';
|
||||||
import { logAudit } from '../lib/audit';
|
import { logAudit } from '../lib/audit';
|
||||||
|
|
||||||
// Registered as a sub-app so its own buffer-only content-type parser doesn't affect the rest of
|
// 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;
|
const firmId = (sub.metadata?.firmId as string | undefined) ?? null;
|
||||||
if (!firmId) return;
|
if (!firmId) return;
|
||||||
await applyPlan(firmId, 'starter', { subscriptionId: null });
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'invoice.payment_failed': {
|
case 'invoice.payment_failed': {
|
||||||
// Optional: surface to the user via email later. For now, just log.
|
|
||||||
const invoice = event.data.object as Stripe.Invoice;
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
app.log.warn({ invoice: invoice.id, customer: invoice.customer }, 'stripe invoice payment failed');
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,13 +149,17 @@ async function applyPlan(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendPlanUpgradedNotice(firmId: string, plan: 'pro' | 'lifetime') {
|
// Billing emails go to owners only — staff shouldn't get payment notices.
|
||||||
const owners = await getDb()
|
async function firmOwners(firmId: string) {
|
||||||
|
return getDb()
|
||||||
.select({ email: users.email, fullName: users.fullName })
|
.select({ email: users.email, fullName: users.fullName })
|
||||||
.from(users)
|
.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';
|
const label = plan === 'pro' ? 'Professional' : 'Lifetime';
|
||||||
for (const u of owners) {
|
for (const u of await firmOwners(firmId)) {
|
||||||
const tpl = planUpgradedEmail(u.fullName, label);
|
const tpl = planUpgradedEmail(u.fullName, label);
|
||||||
await sendEmail({ to: u.email, ...tpl });
|
await sendEmail({ to: u.email, ...tpl });
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-5
@@ -36,14 +36,17 @@ export async function buildServer() {
|
|||||||
logger: isProd
|
logger: isProd
|
||||||
? { level: 'info' }
|
? { level: 'info' }
|
||||||
: { level: 'debug', transport: { target: 'pino-pretty', options: { colorize: true } } },
|
: { 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,
|
bodyLimit: 5 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register the global error handler EARLY so it wins over plugin-default handlers and
|
// Register the global error handler EARLY so it wins over plugin-default handlers and
|
||||||
// catches ZodErrors thrown by .parse() inside route handlers.
|
// catches ZodErrors thrown by .parse() inside route handlers.
|
||||||
app.setErrorHandler((err, req, reply) => {
|
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');
|
req.log.info({ err }, 'validation error');
|
||||||
return reply
|
return reply
|
||||||
.code(400)
|
.code(400)
|
||||||
@@ -123,15 +126,23 @@ export async function buildServer() {
|
|||||||
cacheControl: true,
|
cacheControl: true,
|
||||||
maxAge: '1y',
|
maxAge: '1y',
|
||||||
immutable: true,
|
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) => {
|
app.setNotFoundHandler((req, reply) => {
|
||||||
if (req.raw.url?.startsWith('/api/')) {
|
if (req.raw.url?.startsWith('/api/')) {
|
||||||
return reply.code(404).send({ error: 'not_found' });
|
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 {
|
} else {
|
||||||
app.log.warn({ webDist }, 'web/dist not found — SPA assets will not be served');
|
app.log.warn({ webDist }, 'web/dist not found — SPA assets will not be served');
|
||||||
|
|||||||
@@ -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('<!DOCTYPE html><html><body>hi</body></html>', 'utf8');
|
||||||
|
expect(verifyFileSignature(html, 'image/png')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an HTML page spoofed as image/jpeg', () => {
|
||||||
|
const html = Buffer.from('<html><script>alert(1)</script></html>', '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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -34,6 +34,12 @@ import BlogPostPage from './pages/blog/BlogPostPage';
|
|||||||
import PrivacyPage from './pages/legal/PrivacyPage';
|
import PrivacyPage from './pages/legal/PrivacyPage';
|
||||||
import TermsPage from './pages/legal/TermsPage';
|
import TermsPage from './pages/legal/TermsPage';
|
||||||
import CookiesPage from './pages/legal/CookiesPage';
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -57,9 +63,15 @@ export default function App() {
|
|||||||
<Route path="/blog" element={<BlogIndexPage />} />
|
<Route path="/blog" element={<BlogIndexPage />} />
|
||||||
<Route path="/blog/:slug" element={<BlogPostPage />} />
|
<Route path="/blog/:slug" element={<BlogPostPage />} />
|
||||||
|
|
||||||
|
<Route path="/legal" element={<LegalIndexPage />} />
|
||||||
<Route path="/legal/privacy" element={<PrivacyPage />} />
|
<Route path="/legal/privacy" element={<PrivacyPage />} />
|
||||||
<Route path="/legal/terms" element={<TermsPage />} />
|
<Route path="/legal/terms" element={<TermsPage />} />
|
||||||
<Route path="/legal/cookies" element={<CookiesPage />} />
|
<Route path="/legal/cookies" element={<CookiesPage />} />
|
||||||
|
<Route path="/legal/acceptable-use" element={<AcceptableUsePage />} />
|
||||||
|
<Route path="/legal/refunds" element={<RefundsPage />} />
|
||||||
|
<Route path="/legal/disclaimer" element={<DisclaimerPage />} />
|
||||||
|
<Route path="/legal/dmca" element={<DmcaPage />} />
|
||||||
|
<Route path="/legal/dpa" element={<DpaPage />} />
|
||||||
|
|
||||||
<Route path="/app" element={<AppLayout />}>
|
<Route path="/app" element={<AppLayout />}>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
|
|||||||
@@ -58,7 +58,11 @@ export function CreateInvoiceDrawer({ open, onClose, initialClientId, initialCas
|
|||||||
setItems([{ description: '', quantity: '1', rate: '' }]);
|
setItems([{ description: '', quantity: '1', rate: '' }]);
|
||||||
setSelectedTimeIds(new Set());
|
setSelectedTimeIds(new Set());
|
||||||
create.reset();
|
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
|
// When client changes, clear case selection if the case doesn't belong to that client
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -59,7 +59,12 @@ export function ManualEntryDrawer({ open, onClose, initialCaseId }: Props) {
|
|||||||
});
|
});
|
||||||
create.reset();
|
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) {
|
async function onSubmit(values: FormValues) {
|
||||||
const minutes = Number(values.minutes);
|
const minutes = Number(values.minutes);
|
||||||
|
|||||||
@@ -41,13 +41,13 @@ export function AuthLayout({ title, subtitle, children, footer }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
<ul className="mt-8 space-y-3 text-sm">
|
<ul className="mt-8 space-y-3 text-sm">
|
||||||
{[
|
{[
|
||||||
'60% less time on admin tasks',
|
'Track billable hours against every case',
|
||||||
'3× faster client invoicing',
|
'Turn tracked time into invoices in a few clicks',
|
||||||
'98% billing accuracy rate',
|
'Keep cases, clients, and documents in one place',
|
||||||
].map((stat) => (
|
].map((item) => (
|
||||||
<li key={stat} className="flex items-center gap-3">
|
<li key={item} className="flex items-center gap-3">
|
||||||
<span className="grid h-6 w-6 place-items-center rounded-full bg-white/15 text-xs">✓</span>
|
<span className="grid h-6 w-6 place-items-center rounded-full bg-white/15 text-xs">✓</span>
|
||||||
{stat}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -111,15 +111,11 @@ export function Contact() {
|
|||||||
<div className="rounded-2xl border border-ink-100 bg-white p-6">
|
<div className="rounded-2xl border border-ink-100 bg-white p-6">
|
||||||
<div className="flex items-center gap-2 text-ink-700">
|
<div className="flex items-center gap-2 text-ink-700">
|
||||||
<Clock className="h-4 w-4 text-brand-500" />
|
<Clock className="h-4 w-4 text-brand-500" />
|
||||||
<span className="text-sm font-semibold">We respond quickly</span>
|
<span className="text-sm font-semibold">We reply by email</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-ink-500 leading-relaxed">
|
<p className="mt-2 text-xs text-ink-500 leading-relaxed">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
|
||||||
Online and ready to help
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ const FEATURES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
title: 'Bank-Level Security',
|
title: 'Secure & Private',
|
||||||
body: 'Enterprise-grade encryption and compliance features to protect sensitive client data.',
|
body: 'Client data is encrypted in transit and at rest, stored in isolated per-firm storage, with passwords protected by modern hashing.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,19 @@ const COLUMNS = [
|
|||||||
links: [
|
links: [
|
||||||
{ href: '/resources', label: 'Resource Hub' },
|
{ href: '/resources', label: 'Resource Hub' },
|
||||||
{ href: '/blog', label: 'Blog' },
|
{ 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() {
|
export function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-ink-100 bg-white">
|
<footer className="border-t border-ink-100 bg-white">
|
||||||
<div className="container py-16 grid gap-10 md:grid-cols-4">
|
<div className="container py-16 grid gap-10 sm:grid-cols-2 md:grid-cols-5">
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<a href="/" className="inline-flex" aria-label="Home">
|
<a href="/" className="inline-flex" aria-label="Home">
|
||||||
<img src="/logo-dark.png" alt="eLegal Software" className="h-7 w-auto" width={450} height={45} />
|
<img src="/logo-dark.png" alt="eLegal Software" className="h-7 w-auto" width={450} height={45} />
|
||||||
@@ -60,7 +72,12 @@ export function Footer() {
|
|||||||
<div className="border-t border-ink-100">
|
<div className="border-t border-ink-100">
|
||||||
<div className="container py-6 flex flex-col md:flex-row items-center justify-between gap-3 text-xs text-ink-500">
|
<div className="container py-6 flex flex-col md:flex-row items-center justify-between gap-3 text-xs text-ink-500">
|
||||||
<p>© {new Date().getFullYear()} eLegal Software. All rights reserved.</p>
|
<p>© {new Date().getFullYear()} eLegal Software. All rights reserved.</p>
|
||||||
<p>Built for legal professionals.</p>
|
<p>
|
||||||
|
eLegal Software is not a law firm and does not provide legal advice.{' '}
|
||||||
|
<a href="/legal" className="hover:text-ink-700 underline underline-offset-2">
|
||||||
|
Legal center
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const TIERS = [
|
|||||||
tag: 'Most Popular',
|
tag: 'Most Popular',
|
||||||
description: 'For growing firms managing multiple cases.',
|
description: 'For growing firms managing multiple cases.',
|
||||||
price: '$25',
|
price: '$25',
|
||||||
strike: '$49',
|
|
||||||
cadence: '/month',
|
cadence: '/month',
|
||||||
cta: 'Get Started',
|
cta: 'Get Started',
|
||||||
href: '/signup?plan=pro',
|
href: '/signup?plan=pro',
|
||||||
@@ -37,7 +36,6 @@ const TIERS = [
|
|||||||
tag: 'Best Value',
|
tag: 'Best Value',
|
||||||
description: 'For established practices seeking long-term value.',
|
description: 'For established practices seeking long-term value.',
|
||||||
price: '$129',
|
price: '$129',
|
||||||
strike: '$299',
|
|
||||||
cadence: 'one-time',
|
cadence: 'one-time',
|
||||||
cta: 'Get Lifetime Access',
|
cta: 'Get Lifetime Access',
|
||||||
href: '/signup?plan=lifetime',
|
href: '/signup?plan=lifetime',
|
||||||
@@ -91,7 +89,6 @@ export function Pricing() {
|
|||||||
<p className="mt-1 text-sm text-ink-600">{t.description}</p>
|
<p className="mt-1 text-sm text-ink-600">{t.description}</p>
|
||||||
|
|
||||||
<div className="mt-6 flex items-baseline gap-2">
|
<div className="mt-6 flex items-baseline gap-2">
|
||||||
{t.strike && <span className="text-lg text-ink-400 line-through">{t.strike}</span>}
|
|
||||||
<span className="text-5xl font-bold text-ink-950 font-display">{t.price}</span>
|
<span className="text-5xl font-bold text-ink-950 font-display">{t.price}</span>
|
||||||
<span className="text-sm text-ink-500">{t.cadence}</span>
|
<span className="text-sm text-ink-500">{t.cadence}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
const STATS = [
|
const BENEFITS = [
|
||||||
{ value: '60%', label: 'Less time on admin tasks' },
|
{
|
||||||
{ value: '3×', label: 'Faster client invoicing' },
|
title: 'Less time on admin',
|
||||||
{ value: '98%', label: 'Billing accuracy rate' },
|
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() {
|
export function Stats() {
|
||||||
@@ -9,23 +18,23 @@ export function Stats() {
|
|||||||
<section className="section bg-gradient-to-b from-white to-brand-50/40">
|
<section className="section bg-gradient-to-b from-white to-brand-50/40">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="mx-auto max-w-2xl text-center">
|
<div className="mx-auto max-w-2xl text-center">
|
||||||
<span className="eyebrow">Real Results</span>
|
<span className="eyebrow">Why eLegal Software</span>
|
||||||
<h2 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950">
|
<h2 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950">
|
||||||
Measurable impact on your practice
|
Built to save you time
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 text-lg text-ink-600">
|
<p className="mt-4 text-lg text-ink-600">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-6 md:grid-cols-3">
|
<div className="mt-14 grid gap-6 md:grid-cols-3">
|
||||||
{STATS.map((s) => (
|
{BENEFITS.map((b) => (
|
||||||
<div
|
<div
|
||||||
key={s.label}
|
key={b.title}
|
||||||
className="rounded-2xl border border-ink-100 bg-white p-8 text-center shadow-sm"
|
className="rounded-2xl border border-ink-100 bg-white p-8 text-center shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="text-5xl md:text-6xl font-bold text-brand-500 font-display">{s.value}</div>
|
<div className="text-2xl md:text-3xl font-bold text-brand-500 font-display">{b.title}</div>
|
||||||
<p className="mt-3 text-sm text-ink-600">{s.label}</p>
|
<p className="mt-3 text-sm text-ink-600">{b.label}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,97 +1,73 @@
|
|||||||
import { motion } from 'framer-motion';
|
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',
|
icon: Scale,
|
||||||
role: 'Partner, Mitchell & Associates',
|
title: 'Built for legal work',
|
||||||
quote:
|
body: 'Cases, clients, deadlines, and documents organized the way a practice actually runs — not a generic CRM bent to fit.',
|
||||||
'eLegal Software transformed how our firm manages cases. We cut administrative time by 60% and our billing accuracy improved dramatically.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'David Chen',
|
icon: Clock,
|
||||||
role: 'Solo Attorney, Immigration Law',
|
title: 'Capture every billable minute',
|
||||||
quote:
|
body: 'Track time against cases as you work, so nothing slips through the cracks between the work and the invoice.',
|
||||||
'Managing 40+ immigration cases used to be overwhelming. Now everything is organized in one place — documents, deadlines, and client communications.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Jennifer Rodriguez',
|
icon: FileText,
|
||||||
role: 'Managing Partner, Rodriguez Legal Group',
|
title: 'Documents in one place',
|
||||||
quote:
|
body: 'Keep matter files, templates, and client paperwork together and easy to find when you need them.',
|
||||||
'The billable hours tracking is a game-changer. Our team captures every minute accurately, and invoicing takes seconds instead of hours.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Michael Thompson',
|
icon: CreditCard,
|
||||||
role: 'Criminal Defense Attorney',
|
title: 'Invoicing without the busywork',
|
||||||
quote:
|
body: 'Turn tracked hours into clean, professional invoices in a few clicks instead of rebuilding them by hand.',
|
||||||
'As a solo practitioner, time is everything. eLegal Software helps me stay organized and bill clients accurately. Best investment for my practice.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Lisa Anderson',
|
icon: Users,
|
||||||
role: 'Partner, Family Law Firm',
|
title: 'Clear client communication',
|
||||||
quote:
|
body: 'Give clients transparency into their matters and billing, so expectations stay aligned from day one.',
|
||||||
'We grew from 3 to 15 cases per month without adding staff. The efficiency gains are incredible — we save 20+ hours weekly.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Robert Kim',
|
icon: Shield,
|
||||||
role: 'Corporate Law Partner',
|
title: 'Secure by design',
|
||||||
quote:
|
body: 'Your matter data stays private and protected, with sensible controls built in from the start.',
|
||||||
'Our clients love the transparency. They can see exactly what we are working on and billing for. Trust has never been higher.',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function initials(name: string) {
|
|
||||||
return name
|
|
||||||
.split(' ')
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join('')
|
|
||||||
.slice(0, 2)
|
|
||||||
.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Testimonials() {
|
export function Testimonials() {
|
||||||
return (
|
return (
|
||||||
<section id="testimonials" className="section bg-ink-50/50">
|
<section id="testimonials" className="section bg-ink-50/50">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="mx-auto max-w-2xl text-center">
|
<div className="mx-auto max-w-2xl text-center">
|
||||||
<span className="eyebrow">Success Stories</span>
|
<span className="eyebrow">Built for legal professionals</span>
|
||||||
<h2 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950">
|
<h2 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950">
|
||||||
Loved by attorneys worldwide
|
Everything your practice needs, in one place
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 text-lg text-ink-600">
|
<p className="mt-4 text-lg text-ink-600">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
<div className="mt-14 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{TESTIMONIALS.map((t, i) => (
|
{VALUE_PROPS.map((v, i) => {
|
||||||
<motion.figure
|
const Icon = v.icon;
|
||||||
key={t.name}
|
return (
|
||||||
initial={{ opacity: 0, y: 16 }}
|
<motion.div
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
key={v.title}
|
||||||
viewport={{ once: true, margin: '-50px' }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
transition={{ duration: 0.4, delay: (i % 3) * 0.05 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
className="rounded-2xl border border-ink-100 bg-white p-6 shadow-sm hover:shadow-md transition"
|
viewport={{ once: true, margin: '-50px' }}
|
||||||
>
|
transition={{ duration: 0.4, delay: (i % 3) * 0.05 }}
|
||||||
<div className="flex items-center gap-1 text-amber-400">
|
className="rounded-2xl border border-ink-100 bg-white p-6 shadow-sm hover:shadow-md transition"
|
||||||
{Array.from({ length: 5 }).map((_, k) => (
|
>
|
||||||
<Star key={k} className="h-3.5 w-3.5 fill-current" />
|
<div className="grid h-10 w-10 place-items-center rounded-full bg-brand-100 text-brand-700">
|
||||||
))}
|
<Icon className="h-5 w-5" />
|
||||||
</div>
|
|
||||||
<blockquote className="mt-4 text-sm text-ink-700 leading-relaxed">
|
|
||||||
“{t.quote}”
|
|
||||||
</blockquote>
|
|
||||||
<figcaption className="mt-5 flex items-center gap-3">
|
|
||||||
<div className="grid h-10 w-10 place-items-center rounded-full bg-brand-100 text-brand-700 text-sm font-semibold">
|
|
||||||
{initials(t.name)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<h3 className="mt-5 text-sm font-semibold text-ink-900">{v.title}</h3>
|
||||||
<p className="text-sm font-semibold text-ink-900">{t.name}</p>
|
<p className="mt-2 text-sm text-ink-700 leading-relaxed">{v.body}</p>
|
||||||
<p className="text-xs text-ink-500">{t.role}</p>
|
</motion.div>
|
||||||
</div>
|
);
|
||||||
</figcaption>
|
})}
|
||||||
</motion.figure>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { LegalLayout, H2, P, UL } from './LegalLayout';
|
||||||
|
|
||||||
|
export default function AcceptableUsePage() {
|
||||||
|
return (
|
||||||
|
<LegalLayout title="Acceptable Use Policy" effectiveDate="July 16, 2026">
|
||||||
|
<P>
|
||||||
|
This Acceptable Use Policy (“AUP”) describes what you may not do on or with
|
||||||
|
eLegal Software. It is part of the{' '}
|
||||||
|
<Link to="/legal/terms" className="text-brand-600 hover:underline">Terms of Service</Link>.
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>1. Illegal or harmful use</H2>
|
||||||
|
<P>You may not use the Service to:</P>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Violate any applicable law or regulation, or advocate or facilitate illegal activity.',
|
||||||
|
'Violate the rights of any person, including privacy, publicity, and intellectual-property rights.',
|
||||||
|
'Store or distribute content that is defamatory, fraudulent, or deceptively misleading.',
|
||||||
|
'Store or distribute child sexual abuse material — we report such material to NCMEC and law enforcement.',
|
||||||
|
'Threaten, harass, or incite violence against any person or group.',
|
||||||
|
'Practice law without a license, or assist another person in the unauthorized practice of law.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>2. Security violations</H2>
|
||||||
|
<P>You may not:</P>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Access or attempt to access accounts, data, or systems you are not authorized to access.',
|
||||||
|
'Probe, scan, or test the vulnerability of the Service without our prior written consent.',
|
||||||
|
'Circumvent or attempt to circumvent authentication, rate limits, plan limits, or other security or usage controls.',
|
||||||
|
'Upload malware, or use the Service to distribute malware, phishing pages, or command-and-control infrastructure.',
|
||||||
|
'Interfere with the Service or any user’s access to it, including denial-of-service attacks or resource abuse.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>3. Abuse of the platform</H2>
|
||||||
|
<P>You may not:</P>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Send spam or unsolicited bulk messages through the Service, including through invoice or contact features.',
|
||||||
|
'Misrepresent your identity or affiliation, or impersonate any person or firm.',
|
||||||
|
'Scrape, harvest, or bulk-extract data from the Service other than your own data through the export feature.',
|
||||||
|
'Reverse engineer, decompile, or copy the Service or use it to build a competing product.',
|
||||||
|
'Resell, sublicense, rent, or provide the Service to third parties as a service bureau without our written consent.',
|
||||||
|
'Share one account among multiple people, or create accounts by automated means.',
|
||||||
|
'Use the Service to store or transmit material quantities of data unrelated to legal-practice management (for example, as a general-purpose file host).',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>4. Fair use of resources</H2>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>5. Reporting violations</H2>
|
||||||
|
<P>
|
||||||
|
To report a violation of this policy, email{' '}
|
||||||
|
<a href="mailto:abuse@elegalsoftware.com" className="text-brand-600 hover:underline">
|
||||||
|
abuse@elegalsoftware.com
|
||||||
|
</a>
|
||||||
|
. For copyright complaints, use the process in our{' '}
|
||||||
|
<Link to="/legal/dmca" className="text-brand-600 hover:underline">DMCA Policy</Link>. For
|
||||||
|
security vulnerabilities, email{' '}
|
||||||
|
<a href="mailto:security@elegalsoftware.com" className="text-brand-600 hover:underline">
|
||||||
|
security@elegalsoftware.com
|
||||||
|
</a>{' '}
|
||||||
|
— we appreciate responsible disclosure and will not pursue good-faith researchers.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>6. Enforcement</H2>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
</LegalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { LegalLayout, H2, P, UL } from './LegalLayout';
|
|||||||
|
|
||||||
export default function CookiesPage() {
|
export default function CookiesPage() {
|
||||||
return (
|
return (
|
||||||
<LegalLayout title="Cookie Policy" effectiveDate="April 2026">
|
<LegalLayout title="Cookie Policy" effectiveDate="July 16, 2026">
|
||||||
<P>
|
<P>
|
||||||
This page explains the cookies eLegal Software sets, what they are for, and how to control
|
This page explains the cookies eLegal Software sets, what they are for, and how to control
|
||||||
them.
|
them.
|
||||||
@@ -40,7 +40,7 @@ export default function CookiesPage() {
|
|||||||
<td className="px-4 py-3">Essential</td>
|
<td className="px-4 py-3">Essential</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 font-mono text-xs">elegal:cookie-consent</td>
|
<td className="px-4 py-3 font-mono text-xs">lawdesk:cookie-consent</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
Stored in <span className="font-mono">localStorage</span>, not as a cookie. Records your cookie banner choice
|
Stored in <span className="font-mono">localStorage</span>, not as a cookie. Records your cookie banner choice
|
||||||
so we don't ask again.
|
so we don't ask again.
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { LegalLayout, H2, P, UL } from './LegalLayout';
|
||||||
|
|
||||||
|
export default function DisclaimerPage() {
|
||||||
|
return (
|
||||||
|
<LegalLayout title="Legal Disclaimer" effectiveDate="July 16, 2026">
|
||||||
|
<P>
|
||||||
|
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{' '}
|
||||||
|
<Link to="/legal/terms" className="text-brand-600 hover:underline">Terms of Service</Link>.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>1. eLegal Software is not a law firm</H2>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>2. No attorney–client relationship</H2>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>3. Templates and free tools</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Document templates are general-purpose starting points. They are not tailored to your jurisdiction, facts, or clients, and must be reviewed and adapted by a licensed attorney before use.',
|
||||||
|
'Calculators and free tools (hourly-rate, profitability, hours tracking) produce estimates from the numbers you enter. They are informational only and are not financial, tax, or legal advice.',
|
||||||
|
'Blog content is general commentary, current only as of its publication date.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>4. Professional responsibility remains yours</H2>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>5. No guarantee of outcomes</H2>
|
||||||
|
<P>
|
||||||
|
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{' '}
|
||||||
|
<Link to="/legal/terms" className="text-brand-600 hover:underline">Terms of Service</Link>{' '}
|
||||||
|
for warranty disclaimers and liability limits that apply to the Service as a whole.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>6. Questions</H2>
|
||||||
|
<P>
|
||||||
|
If anything here is unclear, contact{' '}
|
||||||
|
<a href="mailto:legal@elegalsoftware.com" className="text-brand-600 hover:underline">
|
||||||
|
legal@elegalsoftware.com
|
||||||
|
</a>
|
||||||
|
. For advice about your legal rights or obligations, consult a licensed attorney in your
|
||||||
|
jurisdiction.
|
||||||
|
</P>
|
||||||
|
</LegalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { LegalLayout, H2, P, UL } from './LegalLayout';
|
||||||
|
|
||||||
|
export default function DmcaPage() {
|
||||||
|
return (
|
||||||
|
<LegalLayout title="DMCA & Copyright Policy" effectiveDate="July 16, 2026">
|
||||||
|
<P>
|
||||||
|
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{' '}
|
||||||
|
<Link to="/legal/terms" className="text-brand-600 hover:underline">Terms of Service</Link>.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>1. Reporting infringement (takedown notice)</H2>
|
||||||
|
<P>
|
||||||
|
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:
|
||||||
|
</P>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Identification of the copyrighted work you claim is infringed (or a representative list, for multiple works).',
|
||||||
|
'Identification of the material you claim is infringing and information reasonably sufficient for us to locate it (for example, a URL or document identifier).',
|
||||||
|
'Your name, mailing address, telephone number, and email address.',
|
||||||
|
'A statement that you have a good-faith belief that use of the material in the manner complained of is not authorized by the copyright owner, its agent, or the law.',
|
||||||
|
'A statement that the information in the notice is accurate and, under penalty of perjury, that you are the owner of the copyright or authorized to act on the owner’s behalf.',
|
||||||
|
'Your physical or electronic signature.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>2. Our response</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'On receipt of a valid notice, we will remove or disable access to the identified material promptly and notify the user who stored it.',
|
||||||
|
'Most content on the Service is private practice data visible only to the firm that uploaded it; we evaluate notices with that context in mind.',
|
||||||
|
'We will terminate, in appropriate circumstances, users who are repeat infringers.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>3. Counter-notice</H2>
|
||||||
|
<P>
|
||||||
|
If you believe material you stored was removed by mistake or misidentification, you may
|
||||||
|
send our designated agent a written counter-notice including:
|
||||||
|
</P>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Identification of the material removed and its location before removal.',
|
||||||
|
'A statement under penalty of perjury that you have a good-faith belief the material was removed as a result of mistake or misidentification.',
|
||||||
|
'Your name, address, and telephone number, and a statement that you consent to the jurisdiction of the federal district court for your district (or, if outside the United States, the District of Delaware), and that you will accept service of process from the person who filed the original notice.',
|
||||||
|
'Your physical or electronic signature.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>4. Designated agent</H2>
|
||||||
|
<P>
|
||||||
|
DMCA Agent, eLegal Software —{' '}
|
||||||
|
<a href="mailto:dmca@elegalsoftware.com" className="text-brand-600 hover:underline">
|
||||||
|
dmca@elegalsoftware.com
|
||||||
|
</a>
|
||||||
|
. Email is the fastest way to reach us and is sufficient for both notices and
|
||||||
|
counter-notices.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>5. Non-copyright complaints</H2>
|
||||||
|
<P>
|
||||||
|
For trademark, defamation, privacy, or other complaints about content on the Service,
|
||||||
|
email{' '}
|
||||||
|
<a href="mailto:abuse@elegalsoftware.com" className="text-brand-600 hover:underline">
|
||||||
|
abuse@elegalsoftware.com
|
||||||
|
</a>{' '}
|
||||||
|
with enough detail for us to locate the material and evaluate the claim under our{' '}
|
||||||
|
<Link to="/legal/acceptable-use" className="text-brand-600 hover:underline">
|
||||||
|
Acceptable Use Policy
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</P>
|
||||||
|
</LegalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { LegalLayout, H2, P, UL } from './LegalLayout';
|
||||||
|
|
||||||
|
export default function DpaPage() {
|
||||||
|
return (
|
||||||
|
<LegalLayout title="Data Processing Addendum" effectiveDate="July 16, 2026">
|
||||||
|
<P>
|
||||||
|
This Data Processing Addendum (“DPA”) forms part of the{' '}
|
||||||
|
<Link to="/legal/terms" className="text-brand-600 hover:underline">Terms of Service</Link>{' '}
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>1. Roles and scope</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'You are the controller (or "business" under U.S. state privacy laws) of personal data in your practice data; we are your processor / service provider.',
|
||||||
|
'Subject matter: hosting and processing of practice data to provide the Service. Duration: the term of your account plus the deletion window below.',
|
||||||
|
'Nature and purpose: storage, retrieval, display, document generation, invoicing, and transactional email, as directed by you through the Service.',
|
||||||
|
'Categories of data subjects: your firm’s personnel, clients, and individuals appearing in matter records. Categories of data: identification and contact details, matter records, billing records, and documents you upload (which may include sensitive or privileged material).',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>2. Our commitments as processor</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Instructions — we process practice data only on your documented instructions (using the Service is an instruction), unless required by law, in which case we will inform you unless legally prohibited.',
|
||||||
|
'No sale — we do not sell personal data, do not share it for cross-context behavioral advertising, and do not retain, use, or disclose it for any purpose other than providing the Service (including the CCPA "service provider" restrictions).',
|
||||||
|
'No training — we do not use practice data to train machine-learning models.',
|
||||||
|
'Confidentiality — personnel with access are bound by confidentiality obligations and access data only as strictly needed.',
|
||||||
|
'Security — we implement the technical and organizational measures in Section 4.',
|
||||||
|
'Assistance — we provide reasonable assistance with data-subject requests, security incidents, and impact assessments, taking into account the nature of the processing.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>3. Subprocessors</H2>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'DigitalOcean, LLC (USA) — cloud hosting, managed PostgreSQL database, and object storage for documents.',
|
||||||
|
'SMTP2GO — transactional email delivery.',
|
||||||
|
'Stripe, Inc. (USA) — payment processing (your billing data; client practice data is not shared with Stripe).',
|
||||||
|
'Sentry (Functional Software, Inc., USA) — application error monitoring (practice data is not sent to Sentry).',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>4. Security measures</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Encryption of data in transit (TLS 1.2+) and at rest.',
|
||||||
|
'Passwords stored only as argon2id hashes; session and reset tokens stored only as SHA-256 hashes.',
|
||||||
|
'Tenant isolation: every query is scoped to your firm by the access-control layer.',
|
||||||
|
'Role-based access, rate limiting, login-attempt throttling, and CSRF protection.',
|
||||||
|
'Audit logging of privileged and destructive actions, retained 24 months.',
|
||||||
|
'Encrypted backups retained 30 days; infrastructure hosted in SOC 2-audited data centers.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>5. Security incidents</H2>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>6. Data subject requests</H2>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>7. Deletion and return</H2>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>8. International transfers</H2>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>9. Audits</H2>
|
||||||
|
<P>
|
||||||
|
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.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>10. Liability and order of precedence</H2>
|
||||||
|
<P>
|
||||||
|
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:{' '}
|
||||||
|
<a href="mailto:privacy@elegalsoftware.com" className="text-brand-600 hover:underline">
|
||||||
|
privacy@elegalsoftware.com
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</P>
|
||||||
|
</LegalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<PublicLayout>
|
||||||
|
<section className="container py-12 max-w-4xl">
|
||||||
|
<header className="mb-10">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-brand-600 font-semibold">Legal</p>
|
||||||
|
<h1 className="mt-2 text-3xl md:text-4xl font-bold text-ink-950 font-display">
|
||||||
|
Legal center
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-ink-600 max-w-2xl">
|
||||||
|
Everything that governs your relationship with eLegal Software, written in plain
|
||||||
|
English. Questions about any of it:{' '}
|
||||||
|
<a href="mailto:legal@elegalsoftware.com" className="text-brand-600 hover:underline">
|
||||||
|
legal@elegalsoftware.com
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{DOCS.map((d) => (
|
||||||
|
<Link
|
||||||
|
key={d.to}
|
||||||
|
to={d.to}
|
||||||
|
className="rounded-2xl border border-ink-200 p-5 hover:border-brand-200 hover:bg-brand-50/30 transition group"
|
||||||
|
>
|
||||||
|
<h2 className="font-semibold text-ink-950 group-hover:text-brand-700 font-display">
|
||||||
|
{d.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-ink-600 leading-relaxed">{d.blurb}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PublicLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
|
||||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||||
|
|
||||||
const LINKS = [
|
export const LEGAL_PAGES = [
|
||||||
{ to: '/legal/privacy', label: 'Privacy Policy' },
|
|
||||||
{ to: '/legal/terms', label: 'Terms of Service' },
|
{ to: '/legal/terms', label: 'Terms of Service' },
|
||||||
|
{ to: '/legal/privacy', label: 'Privacy Policy' },
|
||||||
{ to: '/legal/cookies', label: 'Cookie 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({
|
export function LegalLayout({
|
||||||
@@ -27,18 +31,8 @@ export function LegalLayout({
|
|||||||
<p className="mt-2 text-sm text-ink-500">Effective {effectiveDate}</p>
|
<p className="mt-2 text-sm text-ink-500">Effective {effectiveDate}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-amber-200 bg-amber-50/40 p-4 mb-8 flex items-start gap-3 text-sm text-amber-900">
|
|
||||||
<AlertTriangle className="h-4 w-4 flex-none mt-0.5 text-amber-600" />
|
|
||||||
<p>
|
|
||||||
<strong className="font-semibold">Template notice:</strong> 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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="mb-10 flex flex-wrap gap-2">
|
<nav className="mb-10 flex flex-wrap gap-2">
|
||||||
{LINKS.map((l) => (
|
{LEGAL_PAGES.map((l) => (
|
||||||
<Link
|
<Link
|
||||||
key={l.to}
|
key={l.to}
|
||||||
to={l.to}
|
to={l.to}
|
||||||
|
|||||||
@@ -3,19 +3,23 @@ import { LegalLayout, H2, H3, P, UL } from './LegalLayout';
|
|||||||
|
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
return (
|
return (
|
||||||
<LegalLayout title="Privacy Policy" effectiveDate="April 2026">
|
<LegalLayout title="Privacy Policy" effectiveDate="July 16, 2026">
|
||||||
<P>
|
<P>
|
||||||
This policy explains what information eLegal Software collects, why we collect it, and the
|
This policy explains what information eLegal Software (“eLegal Software,”
|
||||||
choices you have. We aim for plain language. Where a term has a specific legal meaning,
|
“we,” “us”), a software company operating from the United States,
|
||||||
we say so.
|
collects, why we collect it, and the choices you have. We aim for plain language. Where a
|
||||||
|
term has a specific legal meaning, we say so.
|
||||||
</P>
|
</P>
|
||||||
|
|
||||||
<H2>1. Who we are</H2>
|
<H2>1. Who we are and our roles</H2>
|
||||||
<P>
|
<P>
|
||||||
eLegal Software (“we,” “us”) provides practice-management software for
|
eLegal Software provides practice-management software for law firms. For the information
|
||||||
law firms. When you use the service we are the data controller for the information you
|
you provide about yourself and your firm (your account), we act as the data controller
|
||||||
provide about yourself and your firm. For information your firm uploads about its
|
(or “business” under U.S. state privacy laws). For the information your firm
|
||||||
clients, your firm is the controller and we act as a processor.
|
uploads about its clients and matters, <strong>your firm is the controller and we act
|
||||||
|
strictly as a processor/service provider</strong> on the firm’s documented
|
||||||
|
instructions — see the{' '}
|
||||||
|
<Link to="/legal/dpa" className="text-brand-600 hover:underline">Data Processing Addendum</Link>.
|
||||||
</P>
|
</P>
|
||||||
|
|
||||||
<H2>2. Information we collect</H2>
|
<H2>2. Information we collect</H2>
|
||||||
@@ -25,114 +29,171 @@ export default function PrivacyPage() {
|
|||||||
'Account information — your name, email address, password (stored only as an argon2id hash), and firm name.',
|
'Account information — your name, email address, password (stored only as an argon2id hash), and firm name.',
|
||||||
'Practice data — clients, cases, documents, time entries, invoices, and notes you create or upload.',
|
'Practice data — clients, cases, documents, time entries, invoices, and notes you create or upload.',
|
||||||
'Communications — messages you send through the contact form, support requests, and email replies.',
|
'Communications — messages you send through the contact form, support requests, and email replies.',
|
||||||
'Payment information — handled by our payments processor; we never see or store your full card number.',
|
'Payment information — handled by our payments processor (Stripe); we never see or store your full card number.',
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<H3>2.2 Information we collect automatically</H3>
|
<H3>2.2 Information we collect automatically</H3>
|
||||||
<UL
|
<UL
|
||||||
items={[
|
items={[
|
||||||
'Log data — IP address, user agent, requested URL, response status, and timestamp. Used for security and debugging.',
|
'Log data — IP address, user agent, requested URL, response status, and timestamp. Used for security and debugging.',
|
||||||
'Cookies — see the Cookie Policy.',
|
'Cookies — essential cookies only; see the Cookie Policy.',
|
||||||
'Aggregate usage — anonymous counts of feature use to help us prioritize improvements.',
|
'Aggregate usage — anonymous counts of feature use to help us prioritize improvements.',
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<P>We do not collect biometric data, precise geolocation, or advertising identifiers.</P>
|
||||||
|
|
||||||
<H2>3. How we use information</H2>
|
<H2>3. How we use information</H2>
|
||||||
<UL
|
<UL
|
||||||
items={[
|
items={[
|
||||||
'To operate the service — let you sign in, store your data, generate documents, send invoices.',
|
'To operate the Service — sign-in, storing your data, generating documents, sending invoices and transactional email.',
|
||||||
'To keep accounts secure — rate limiting, anomaly detection, audit logging of privileged actions.',
|
'To keep accounts secure — rate limiting, login-attempt throttling, audit logging of privileged actions.',
|
||||||
'To support you — respond to questions and resolve issues you report.',
|
'To support you — respond to questions and resolve issues you report.',
|
||||||
'To improve — understand what is working and what is not, in aggregate.',
|
'To improve the Service — understand what is working and what is not, in aggregate.',
|
||||||
'To comply with law — respond to legal requests, prevent fraud, and enforce our Terms.',
|
'To comply with law — respond to valid legal requests, prevent fraud, and enforce our Terms.',
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<P>
|
<P>
|
||||||
We do not sell your information. We do not use your firm's practice data to train
|
<strong>We do not sell personal information, we do not share it for cross-context
|
||||||
machine-learning models.
|
behavioral advertising, and we do not use your firm’s practice data to train
|
||||||
|
machine-learning models.</strong>
|
||||||
</P>
|
</P>
|
||||||
|
|
||||||
<H2>4. Where data is stored</H2>
|
<H2>4. Legal bases (EEA/UK users)</H2>
|
||||||
<P>
|
<P>
|
||||||
Your data is stored on infrastructure operated by DigitalOcean in the region you
|
Where the GDPR or UK GDPR applies, we process personal data on these bases: performance of
|
||||||
select. Database backups are encrypted at rest and retained for 30 days. We use TLS for
|
a contract (operating your account), legitimate interests (security, service improvement,
|
||||||
all data in transit.
|
fraud prevention), legal obligation (tax and accounting records), and consent where we ask
|
||||||
|
for it. Where we act as your firm’s processor, the firm determines the legal basis.
|
||||||
</P>
|
</P>
|
||||||
|
|
||||||
<H2>5. Sharing</H2>
|
<H2>5. Where data is stored and security</H2>
|
||||||
<P>We share information only with the parties listed below, and only as needed to operate the service:</P>
|
|
||||||
<UL
|
<UL
|
||||||
items={[
|
items={[
|
||||||
'Hosting and database — DigitalOcean (managed Postgres, app hosting, Spaces object storage).',
|
'Your data is stored on infrastructure operated by DigitalOcean in the United States.',
|
||||||
'Email — our transactional email provider, used to deliver verification, password resets, and invoices.',
|
'All data is encrypted in transit (TLS) and at rest.',
|
||||||
'Payments — our payments processor, for subscription billing.',
|
'Passwords are hashed with argon2id; session tokens are stored only as hashes.',
|
||||||
'Error monitoring — Sentry, used to capture crashes; we do not send personal practice data to Sentry.',
|
'Access to production systems is restricted to authorized personnel with a need to know, and privileged actions are audit-logged.',
|
||||||
|
'Database backups are encrypted and retained for 30 days.',
|
||||||
|
'If we learn of a breach of security affecting your personal information, we will notify you and applicable regulators as required by law, without undue delay.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>6. Sharing</H2>
|
||||||
|
<P>
|
||||||
|
We share information only with the subprocessors listed below, and only as needed to
|
||||||
|
operate the Service:
|
||||||
|
</P>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'DigitalOcean, LLC (USA) — application hosting, managed database, and object storage.',
|
||||||
|
'SMTP2GO — transactional email delivery (verification, password resets, invoices, notifications).',
|
||||||
|
'Stripe, Inc. (USA) — subscription payments.',
|
||||||
|
'Sentry (Functional Software, Inc., USA) — error monitoring; we do not send practice data to Sentry.',
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<P>
|
<P>
|
||||||
We may share information when required by law, valid legal process, or to protect the
|
We may also disclose information when required by law or valid legal process, to protect
|
||||||
rights, safety, or property of eLegal Software, our users, or others.
|
the rights, safety, or property of eLegal Software, our users, or others, or in connection
|
||||||
|
with a merger, acquisition, or sale of assets (in which case this policy continues to
|
||||||
|
apply to the transferred data). We require legal process appropriate to the data sought,
|
||||||
|
and where allowed we will notify you of demands for your data so you may seek protective
|
||||||
|
measures — particularly important where data may be privileged.
|
||||||
</P>
|
</P>
|
||||||
|
|
||||||
<H2>6. Your rights</H2>
|
<H2>7. Your rights and choices</H2>
|
||||||
<P>
|
<P>
|
||||||
Depending on where you live, you may have the right to access, correct, port, or delete
|
You can exercise most rights directly inside the app, regardless of where you live:
|
||||||
your personal information, and to object to or restrict certain processing. You can
|
|
||||||
exercise most of these rights directly inside the app:
|
|
||||||
</P>
|
</P>
|
||||||
<UL
|
<UL
|
||||||
items={[
|
items={[
|
||||||
'Access and portability — go to Settings → Export your data to download a JSON file with everything tied to your account.',
|
'Access and portability — Settings → Export your data downloads a JSON file with everything tied to your account.',
|
||||||
'Deletion — go to Settings → Delete account. We will permanently remove your account, your firm, and the firm’s practice data.',
|
'Deletion — Settings → Delete account permanently removes your account, your firm, and the firm’s practice data.',
|
||||||
'Correction — edit your profile, clients, cases, time entries, invoices, and documents directly.',
|
'Correction — edit your profile, clients, cases, time entries, invoices, and documents directly.',
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<P>
|
<P>
|
||||||
For requests we cannot satisfy in-app, email <a href="mailto:privacy@elegalsoftware.com" className="text-brand-600 hover:underline">privacy@elegalsoftware.com</a>.
|
For anything you cannot do in-app, email{' '}
|
||||||
|
<a href="mailto:privacy@elegalsoftware.com" className="text-brand-600 hover:underline">
|
||||||
|
privacy@elegalsoftware.com
|
||||||
|
</a>
|
||||||
|
. We verify requests using your account email and respond within the time required by
|
||||||
|
applicable law. We will not discriminate against you for exercising any privacy right.
|
||||||
</P>
|
</P>
|
||||||
|
|
||||||
<H2>7. Retention</H2>
|
<H3>7.1 U.S. state privacy rights (California and others)</H3>
|
||||||
|
<P>
|
||||||
|
If you live in California or another U.S. state with a comprehensive privacy law (for
|
||||||
|
example Colorado, Connecticut, Texas, or Virginia), you have the rights to know/access,
|
||||||
|
correct, delete, and obtain a portable copy of your personal information, and the right to
|
||||||
|
opt out of sales, sharing for targeted advertising, and certain profiling.{' '}
|
||||||
|
<strong>We do not sell or share personal information for targeted advertising</strong>, so
|
||||||
|
there is nothing to opt out of. In the preceding 12 months we collected the categories of
|
||||||
|
personal information described in Section 2 (identifiers, commercial information,
|
||||||
|
internet activity, and professional information) for the purposes in Section 3, and
|
||||||
|
disclosed them only to the service providers in Section 6. You may use an authorized agent
|
||||||
|
to submit requests; we will verify the agent’s authority. California users: we honor
|
||||||
|
the Global Privacy Control signal, and because we set no advertising or analytics cookies,
|
||||||
|
no additional browser opt-out is needed.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H3>7.2 EEA/UK rights</H3>
|
||||||
|
<P>
|
||||||
|
Where the GDPR or UK GDPR applies, you additionally have the rights to object to or
|
||||||
|
restrict processing and to lodge a complaint with your supervisory authority. If your
|
||||||
|
personal data is in practice data controlled by a law firm, direct your request to that
|
||||||
|
firm; we will assist the firm in fulfilling it.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>8. Retention</H2>
|
||||||
<UL
|
<UL
|
||||||
items={[
|
items={[
|
||||||
'Account and practice data: kept until you delete it, or up to 30 days after account deletion (in encrypted backups), then purged.',
|
'Account and practice data — kept until you delete it, or up to 30 days after account deletion (in encrypted backups), then purged.',
|
||||||
'Audit log: kept for 24 months.',
|
'Audit log — kept for 24 months.',
|
||||||
'Payment records: kept as required by tax and accounting laws (typically 7 years).',
|
'Payment records — kept as required by tax and accounting laws (typically 7 years).',
|
||||||
'Server logs: kept for 30 days.',
|
'Server logs — kept for 30 days.',
|
||||||
|
'Contact-form messages — kept for 24 months, then deleted.',
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<H2>8. International transfers</H2>
|
<H2>9. International transfers</H2>
|
||||||
<P>
|
<P>
|
||||||
If you access the service from a country other than where the data is hosted, your
|
The Service is hosted in the United States. If you access it from elsewhere, your
|
||||||
information may be transferred to and stored in that hosting region. We use standard
|
information is transferred to and stored in the United States. Where required (for
|
||||||
contractual clauses with our processors where required.
|
example, for EEA/UK personal data), we rely on Standard Contractual Clauses with our
|
||||||
|
subprocessors and equivalent safeguards.
|
||||||
</P>
|
</P>
|
||||||
|
|
||||||
<H2>9. Children</H2>
|
<H2>10. Do Not Track and Global Privacy Control</H2>
|
||||||
<P>
|
<P>
|
||||||
The service is not directed to children under 16, and we do not knowingly collect
|
We set only essential cookies and do no cross-site tracking, so there is no tracking to
|
||||||
personal information from them.
|
disable. We treat enabled Global Privacy Control signals as an opt-out of sale/sharing —
|
||||||
|
which is our default posture for everyone.
|
||||||
</P>
|
</P>
|
||||||
|
|
||||||
<H2>10. Changes to this policy</H2>
|
<H2>11. Children</H2>
|
||||||
|
<P>
|
||||||
|
The Service is not directed to children under 18, and we do not knowingly collect personal
|
||||||
|
information from children. If you believe a child has provided us personal information,
|
||||||
|
contact privacy@elegalsoftware.com and we will delete it.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>12. Changes to this policy</H2>
|
||||||
<P>
|
<P>
|
||||||
We will post material changes here and update the effective date. If a change is
|
We will post material changes here and update the effective date. If a change is
|
||||||
significant we will notify account holders by email at least 14 days before it takes
|
significant, we will notify account holders by email at least 14 days before it takes
|
||||||
effect.
|
effect.
|
||||||
</P>
|
</P>
|
||||||
|
|
||||||
<H2>11. Contact</H2>
|
<H2>13. Contact</H2>
|
||||||
<P>
|
<P>
|
||||||
Questions or requests: <a href="mailto:privacy@elegalsoftware.com" className="text-brand-600 hover:underline">privacy@elegalsoftware.com</a>.
|
Questions or requests:{' '}
|
||||||
For details on the cookies we set, see the{' '}
|
<a href="mailto:privacy@elegalsoftware.com" className="text-brand-600 hover:underline">
|
||||||
<Link to="/legal/cookies" className="text-brand-600 hover:underline">
|
privacy@elegalsoftware.com
|
||||||
Cookie Policy
|
</a>
|
||||||
</Link>
|
. For the cookies we set, see the{' '}
|
||||||
. For the contract that governs your use of the service, see the{' '}
|
<Link to="/legal/cookies" className="text-brand-600 hover:underline">Cookie Policy</Link>.
|
||||||
<Link to="/legal/terms" className="text-brand-600 hover:underline">
|
For the contract that governs your use of the Service, see the{' '}
|
||||||
Terms of Service
|
<Link to="/legal/terms" className="text-brand-600 hover:underline">Terms of Service</Link>.
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</P>
|
</P>
|
||||||
</LegalLayout>
|
</LegalLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { LegalLayout, H2, P, UL } from './LegalLayout';
|
||||||
|
|
||||||
|
export default function RefundsPage() {
|
||||||
|
return (
|
||||||
|
<LegalLayout title="Billing & Refund Policy" effectiveDate="July 16, 2026">
|
||||||
|
<P>
|
||||||
|
This policy explains how billing works on eLegal Software and when refunds are available.
|
||||||
|
It is part of the{' '}
|
||||||
|
<Link to="/legal/terms" className="text-brand-600 hover:underline">Terms of Service</Link>.
|
||||||
|
All amounts are in U.S. dollars unless stated otherwise. Payments are processed by Stripe;
|
||||||
|
we never see or store your full card number.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>1. Plans</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Starter (free) — no charge, with feature and usage limits, including a watermark on generated invoices.',
|
||||||
|
'Professional (subscription) — billed in advance each billing period, renewing automatically until canceled.',
|
||||||
|
'Lifetime (one-time) — a single payment for access to the Professional feature set for the operating life of the product, for the purchasing firm only. "Lifetime" refers to the life of the product, not the purchaser; it is not transferable or resellable.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>2. Renewals and cancellation</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Subscriptions renew automatically at the end of each billing period using your payment method on file.',
|
||||||
|
'You can cancel anytime from Settings → Billing. Cancellation stops future renewals; your plan remains active until the end of the period already paid.',
|
||||||
|
'We do not provide partial-period refunds for cancellation, except where required by law.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>3. Refunds</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'First subscription payment — if eLegal Software is not right for you, email us within 14 days of your first charge and we will refund it in full.',
|
||||||
|
'Renewal payments — non-refundable, except where required by law. Cancel before renewal to avoid a charge; if you forget and contact us within 7 days of an unused renewal (no logins in the new period), we will refund it as a courtesy.',
|
||||||
|
'Lifetime purchases — refundable in full within 14 days of purchase; non-refundable afterwards.',
|
||||||
|
'Duplicate or erroneous charges — refunded in full; contact us with the charge details.',
|
||||||
|
'Refunds are issued to the original payment method and typically appear within 5–10 business days.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>4. Failed payments and downgrades</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'If a renewal payment fails, we notify you by email and Stripe retries the charge automatically over the following days.',
|
||||||
|
'If payment continues to fail, your firm is moved to the free Starter plan. Nothing is deleted — your data remains intact, and upgrading again restores full access.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>5. Price changes</H2>
|
||||||
|
<P>
|
||||||
|
We may change prices with at least 30 days’ notice by email. Changes apply from your
|
||||||
|
next renewal — periods you have already paid for are honored at the price you paid.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>6. Taxes</H2>
|
||||||
|
<P>
|
||||||
|
Prices exclude sales and similar taxes. Where we are required to collect tax, it is added
|
||||||
|
at checkout based on your billing address. You are responsible for any taxes we are not
|
||||||
|
required to collect.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>7. Chargebacks</H2>
|
||||||
|
<P>
|
||||||
|
If you believe a charge is wrong, please contact us first at{' '}
|
||||||
|
<a href="mailto:billing@elegalsoftware.com" className="text-brand-600 hover:underline">
|
||||||
|
billing@elegalsoftware.com
|
||||||
|
</a>{' '}
|
||||||
|
— we resolve billing issues quickly. Initiating a chargeback on a valid charge may result
|
||||||
|
in account suspension while the dispute is resolved.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>8. Contact</H2>
|
||||||
|
<P>
|
||||||
|
Billing questions and refund requests:{' '}
|
||||||
|
<a href="mailto:billing@elegalsoftware.com" className="text-brand-600 hover:underline">
|
||||||
|
billing@elegalsoftware.com
|
||||||
|
</a>
|
||||||
|
. Include the email on the account and the approximate charge date.
|
||||||
|
</P>
|
||||||
|
</LegalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,134 +1,265 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { LegalLayout, H2, P, UL } from './LegalLayout';
|
import { LegalLayout, H2, H3, P, UL } from './LegalLayout';
|
||||||
|
|
||||||
export default function TermsPage() {
|
export default function TermsPage() {
|
||||||
return (
|
return (
|
||||||
<LegalLayout title="Terms of Service" effectiveDate="April 2026">
|
<LegalLayout title="Terms of Service" effectiveDate="July 16, 2026">
|
||||||
<P>
|
<P>
|
||||||
These Terms govern your access to and use of eLegal Software. By creating an account or using
|
These Terms of Service (the “Terms”) are a binding agreement between you and
|
||||||
the service, you agree to them. If you are using eLegal Software on behalf of a firm, you
|
eLegal Software (“eLegal Software,” “we,” “us,” or
|
||||||
represent that you have authority to bind that firm to these Terms.
|
“our”), a software company operating from the United States. They govern your
|
||||||
|
access to and use of the eLegal Software websites, applications, and services (together,
|
||||||
|
the “Service”). By creating an account, clicking to accept, or using the
|
||||||
|
Service, you agree to these Terms and to our{' '}
|
||||||
|
<Link to="/legal/privacy" className="text-brand-600 hover:underline">Privacy Policy</Link>,{' '}
|
||||||
|
<Link to="/legal/acceptable-use" className="text-brand-600 hover:underline">Acceptable Use Policy</Link>, and{' '}
|
||||||
|
<Link to="/legal/refunds" className="text-brand-600 hover:underline">Billing & Refund Policy</Link>,
|
||||||
|
each of which is incorporated by reference. If you do not agree, do not use the Service.
|
||||||
|
</P>
|
||||||
|
<P>
|
||||||
|
<strong>
|
||||||
|
PLEASE READ SECTION 16 CAREFULLY. IT REQUIRES THAT DISPUTES BE RESOLVED THROUGH BINDING
|
||||||
|
INDIVIDUAL ARBITRATION AND INCLUDES A CLASS ACTION WAIVER AND A JURY TRIAL WAIVER.
|
||||||
|
</strong>
|
||||||
</P>
|
</P>
|
||||||
|
|
||||||
<H2>1. The service</H2>
|
<H2>1. Eligibility and authority</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'You must be at least 18 years old and able to form a binding contract to use the Service.',
|
||||||
|
'If you use the Service on behalf of a law firm or other organization, you represent and warrant that you have authority to bind that organization, and "you" includes that organization.',
|
||||||
|
'You may not use the Service if you are barred from doing so under applicable law, or if we have previously suspended or terminated your access.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>2. The Service; not legal advice</H2>
|
||||||
<P>
|
<P>
|
||||||
eLegal Software is software that helps law firms manage cases, track billable hours, store
|
eLegal Software is practice-management software: it helps law firms manage clients, cases,
|
||||||
documents, and produce invoices. We provide the software; you remain responsible for
|
time tracking, documents, and invoices. eLegal Software is <strong>not a law firm</strong>,
|
||||||
the legal work and the relationships with your own clients.
|
does not provide legal advice, legal opinions, or legal representation, and is not a
|
||||||
|
substitute for the advice of a licensed attorney. No attorney–client relationship is
|
||||||
|
created between you (or your clients) and eLegal Software by use of the Service. You remain
|
||||||
|
solely responsible for your professional work product, your compliance with the rules of
|
||||||
|
professional conduct applicable to you, and your relationships with your own clients. See
|
||||||
|
the{' '}
|
||||||
|
<Link to="/legal/disclaimer" className="text-brand-600 hover:underline">Legal Disclaimer</Link>{' '}
|
||||||
|
for details.
|
||||||
</P>
|
</P>
|
||||||
|
|
||||||
<H2>2. Your account</H2>
|
<H2>3. Accounts and security</H2>
|
||||||
<UL
|
<UL
|
||||||
items={[
|
items={[
|
||||||
'You are responsible for safeguarding your password and for any activity under your account.',
|
'You must provide accurate, complete registration information and keep it current.',
|
||||||
'Notify us promptly if you suspect unauthorized access.',
|
'You are responsible for maintaining the confidentiality of your credentials and for all activity that occurs under your account.',
|
||||||
'You must provide accurate registration information and keep it current.',
|
'Notify us immediately at security@elegalsoftware.com if you suspect unauthorized access to your account.',
|
||||||
'One person, one account. Sharing accounts is not permitted.',
|
'One person, one account. Credentials may not be shared, and an account may not be transferred without our written consent.',
|
||||||
|
'We may require email verification, and may suspend accounts that fail verification or present a security risk.',
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<H2>3. Acceptable use</H2>
|
<H2>4. Electronic communications and signatures</H2>
|
||||||
<P>You agree not to:</P>
|
|
||||||
<UL
|
|
||||||
items={[
|
|
||||||
'Use the service to violate any law or the rights of another person.',
|
|
||||||
'Attempt to access accounts, data, or systems you are not authorized to access.',
|
|
||||||
'Reverse engineer, scrape, or interfere with the service or its security features.',
|
|
||||||
'Upload malware or content that is illegal, infringing, or harmful.',
|
|
||||||
'Resell or sublicense the service without our written consent.',
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<H2>4. Plans, fees, and trials</H2>
|
|
||||||
<UL
|
|
||||||
items={[
|
|
||||||
'Paid plans renew automatically until canceled. You can cancel anytime from billing settings.',
|
|
||||||
'Fees are charged in advance for each subscription period and are non-refundable except where required by law.',
|
|
||||||
'We may change pricing with at least 30 days’ notice. Existing paid periods are honored at the prior price.',
|
|
||||||
'Trial accounts and the free Starter plan have feature and usage limits. Limits may change as the product evolves.',
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<H2>5. Your content</H2>
|
|
||||||
<P>
|
<P>
|
||||||
You retain ownership of everything you upload (clients, cases, documents, time
|
You consent to receive communications from us electronically (email and in-app notices),
|
||||||
entries, invoices). You grant us a limited license to host, process, and display that
|
and you agree that all agreements, notices, disclosures, and other communications we
|
||||||
content solely to operate the service for you. We do not use your content to train
|
provide electronically satisfy any legal requirement that such communications be in
|
||||||
machine-learning models, and we will not disclose it except as described in our{' '}
|
writing. You agree that clicking “Sign up,” “I agree,” or similar
|
||||||
<Link to="/legal/privacy" className="text-brand-600 hover:underline">
|
constitutes your electronic signature under the U.S. ESIGN Act and equivalent laws.
|
||||||
Privacy Policy
|
</P>
|
||||||
</Link>
|
|
||||||
|
<H2>5. Plans, fees, and taxes</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Paid subscriptions renew automatically until canceled. You can cancel anytime from Settings → Billing; cancellation takes effect at the end of the current billing period.',
|
||||||
|
'Fees are charged in advance and are non-refundable except as stated in the Billing & Refund Policy or as required by law.',
|
||||||
|
'We may change pricing with at least 30 days’ notice. Price changes apply from your next renewal; paid periods already invoiced are honored at the prior price.',
|
||||||
|
'Fees are exclusive of taxes. You are responsible for applicable sales, use, and similar taxes, other than taxes on our income.',
|
||||||
|
'The free Starter plan and any trials have feature and usage limits that may change as the product evolves.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>6. Your Content</H2>
|
||||||
|
<P>
|
||||||
|
“Your Content” means everything you or your users submit to the Service —
|
||||||
|
clients, cases, documents, time entries, invoices, notes, and other materials. You retain
|
||||||
|
all ownership rights in Your Content. You grant us a limited, non-exclusive, worldwide,
|
||||||
|
royalty-free license to host, store, reproduce, process, transmit, and display Your Content
|
||||||
|
solely (a) to provide and secure the Service for you, (b) to comply with law, and (c) as
|
||||||
|
you otherwise direct (for example, emailing an invoice to your client). This license ends
|
||||||
|
when Your Content is deleted from the Service, subject to limited backup retention
|
||||||
|
described in our Privacy Policy.
|
||||||
|
</P>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'We do not sell Your Content and we do not use it to train machine-learning models.',
|
||||||
|
'You represent and warrant that you have all rights necessary to submit Your Content and that it does not violate law or the rights of any person.',
|
||||||
|
'You are responsible for maintaining independent copies of any content you are professionally obligated to retain. The Service is not an archival or records-retention system of record.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>7. Confidentiality and privileged material</H2>
|
||||||
|
<P>
|
||||||
|
We understand that Your Content may include information that is confidential, privileged,
|
||||||
|
or subject to professional ethics rules. We protect it with encryption in transit and at
|
||||||
|
rest, firm-scoped access controls, and audit logging, and our personnel access it only as
|
||||||
|
strictly necessary to operate the Service or respond to a support request you initiate. Our
|
||||||
|
handling of personal data on your behalf is further governed by the{' '}
|
||||||
|
<Link to="/legal/dpa" className="text-brand-600 hover:underline">Data Processing Addendum</Link>.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>8. Our intellectual property</H2>
|
||||||
|
<P>
|
||||||
|
The Service — including its software, design, text, graphics, logos, and trademarks — is
|
||||||
|
owned by eLegal Software or its licensors and is protected by intellectual-property laws.
|
||||||
|
Except for the limited right to use the Service in accordance with these Terms, no rights
|
||||||
|
are granted to you. If you send us feedback or suggestions, you grant us a perpetual,
|
||||||
|
irrevocable, royalty-free license to use them without restriction or compensation.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>9. Acceptable use</H2>
|
||||||
|
<P>
|
||||||
|
Your use of the Service must comply with our{' '}
|
||||||
|
<Link to="/legal/acceptable-use" className="text-brand-600 hover:underline">Acceptable Use Policy</Link>.
|
||||||
|
Violations may result in suspension or termination. Copyright complaints are handled under
|
||||||
|
our{' '}
|
||||||
|
<Link to="/legal/dmca" className="text-brand-600 hover:underline">DMCA Policy</Link>.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>10. Third-party services</H2>
|
||||||
|
<P>
|
||||||
|
The Service interoperates with third-party services we use to operate it (for example,
|
||||||
|
payment processing and email delivery). Your use of a third-party service is governed by
|
||||||
|
that party’s own terms, and we are not responsible for third-party services we do not
|
||||||
|
control.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>11. Suspension and termination</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'You may stop using the Service and delete your account at any time from Settings → Delete account.',
|
||||||
|
'We may suspend or terminate your access immediately if you materially breach these Terms, create legal exposure for us or other users, fail to pay fees when due, or if we reasonably believe your account presents a security risk. Where practicable, we will notify you and give you an opportunity to cure.',
|
||||||
|
'We may discontinue the Service (or any feature) with at least 60 days’ notice; in that case we will refund any prepaid fees covering the period after discontinuation.',
|
||||||
|
'Upon termination, you may export your data for 30 days; afterwards it is permanently deleted from active systems, and from backups within the following 30 days.',
|
||||||
|
'Sections that by their nature should survive termination (including 6, 8, and 12–17) survive.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>12. Disclaimers of warranties</H2>
|
||||||
|
<P>
|
||||||
|
THE SERVICE IS PROVIDED “AS IS” AND “AS AVAILABLE.” TO THE MAXIMUM
|
||||||
|
EXTENT PERMITTED BY LAW, ELEGAL SOFTWARE AND ITS SUPPLIERS DISCLAIM ALL WARRANTIES, EXPRESS
|
||||||
|
OR IMPLIED, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE,
|
||||||
|
NON-INFRINGEMENT, AND ANY WARRANTIES ARISING FROM COURSE OF DEALING OR USAGE OF TRADE. WE
|
||||||
|
DO NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED, ERROR-FREE, OR SECURE, OR THAT DATA
|
||||||
|
WILL NEVER BE LOST. YOU ARE RESPONSIBLE FOR APPROPRIATE BACKUP OF DATA YOU ARE OBLIGATED TO
|
||||||
|
RETAIN. SOME JURISDICTIONS DO NOT ALLOW CERTAIN WARRANTY DISCLAIMERS, SO SOME OF THE ABOVE
|
||||||
|
MAY NOT APPLY TO YOU.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>13. Limitation of liability</H2>
|
||||||
|
<P>
|
||||||
|
TO THE MAXIMUM EXTENT PERMITTED BY LAW: (A) IN NO EVENT WILL ELEGAL SOFTWARE BE LIABLE FOR
|
||||||
|
ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR
|
||||||
|
LOST PROFITS, REVENUES, GOODWILL, OR DATA, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
|
||||||
|
DAMAGES; AND (B) OUR TOTAL AGGREGATE LIABILITY FOR ALL CLAIMS ARISING OUT OF OR RELATING TO
|
||||||
|
THE SERVICE OR THESE TERMS WILL NOT EXCEED THE GREATER OF (i) THE AMOUNTS YOU PAID US IN
|
||||||
|
THE 12 MONTHS BEFORE THE EVENT GIVING RISE TO THE CLAIM AND (ii) ONE HUNDRED U.S. DOLLARS
|
||||||
|
(US $100). THESE LIMITS APPLY REGARDLESS OF THE THEORY OF LIABILITY AND EVEN IF A REMEDY
|
||||||
|
FAILS OF ITS ESSENTIAL PURPOSE. THEY DO NOT LIMIT LIABILITY THAT CANNOT BE LIMITED BY LAW.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>14. Indemnification</H2>
|
||||||
|
<P>
|
||||||
|
You will defend, indemnify, and hold harmless eLegal Software and its officers, directors,
|
||||||
|
employees, and agents from and against any claims, damages, liabilities, costs, and
|
||||||
|
expenses (including reasonable attorneys’ fees) arising out of or related to (a) Your
|
||||||
|
Content, (b) your use of the Service in violation of these Terms or applicable law, (c)
|
||||||
|
your provision of legal or other professional services to your clients, or (d) your
|
||||||
|
violation of any third party’s rights.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>15. Governing law</H2>
|
||||||
|
<P>
|
||||||
|
These Terms and any dispute arising out of or relating to them or the Service are governed
|
||||||
|
by the Federal Arbitration Act, applicable U.S. federal law, and the laws of the State of
|
||||||
|
Delaware, United States, without regard to conflict-of-laws rules. Subject to Section 16,
|
||||||
|
the state and federal courts located in Delaware will have exclusive jurisdiction, and you
|
||||||
|
consent to personal jurisdiction and venue there.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>16. Dispute resolution: arbitration agreement and class action waiver</H2>
|
||||||
|
<H3>16.1 Informal resolution first</H3>
|
||||||
|
<P>
|
||||||
|
Before filing a claim, you and we agree to try to resolve the dispute informally: send a
|
||||||
|
written notice describing the dispute to legal@elegalsoftware.com (or, if from us, to your
|
||||||
|
account email). If the dispute is not resolved within 60 days of the notice, either party
|
||||||
|
may proceed under this Section.
|
||||||
|
</P>
|
||||||
|
<H3>16.2 Binding arbitration</H3>
|
||||||
|
<P>
|
||||||
|
Except as provided in 16.4, any dispute, claim, or controversy arising out of or relating
|
||||||
|
to these Terms or the Service will be resolved by <strong>binding individual arbitration</strong>{' '}
|
||||||
|
administered by the American Arbitration Association (AAA) under its Consumer Arbitration
|
||||||
|
Rules (or Commercial Arbitration Rules for business accounts). The arbitration will be
|
||||||
|
conducted in English, by a single arbitrator, and may proceed remotely by videoconference
|
||||||
|
or, if an in-person hearing is required, in Wilmington, Delaware, or another mutually
|
||||||
|
agreed location. The arbitrator’s award is final and may be entered in any court of
|
||||||
|
competent jurisdiction. The Federal Arbitration Act governs this Section.
|
||||||
|
</P>
|
||||||
|
<H3>16.3 Class action and jury trial waiver</H3>
|
||||||
|
<P>
|
||||||
|
<strong>
|
||||||
|
YOU AND ELEGAL SOFTWARE EACH WAIVE THE RIGHT TO A JURY TRIAL AND THE RIGHT TO PARTICIPATE
|
||||||
|
IN A CLASS ACTION, CLASS ARBITRATION, OR ANY OTHER REPRESENTATIVE PROCEEDING.
|
||||||
|
</strong>{' '}
|
||||||
|
Disputes will be arbitrated only on an individual basis. If this class waiver is found
|
||||||
|
unenforceable as to a particular claim, that claim (and only that claim) will proceed in
|
||||||
|
court, and the remainder will be arbitrated.
|
||||||
|
</P>
|
||||||
|
<H3>16.4 Exceptions</H3>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Either party may bring an individual claim in small-claims court.',
|
||||||
|
'Either party may seek injunctive or other equitable relief in court to protect intellectual property or confidential information.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<H3>16.5 Opt-out</H3>
|
||||||
|
<P>
|
||||||
|
You may opt out of this arbitration agreement (but not the rest of these Terms) by
|
||||||
|
emailing legal@elegalsoftware.com with the subject “Arbitration Opt-Out” from
|
||||||
|
your account email within 30 days of first accepting these Terms. Opting out has no effect
|
||||||
|
on any other provision.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>17. General terms</H2>
|
||||||
|
<UL
|
||||||
|
items={[
|
||||||
|
'Export and sanctions — you may not use the Service in violation of U.S. export-control or sanctions laws, and you represent that you are not located in an embargoed country or on any U.S. restricted-party list.',
|
||||||
|
'Force majeure — neither party is liable for delay or failure caused by events beyond its reasonable control.',
|
||||||
|
'Assignment — you may not assign these Terms without our written consent; we may assign them in connection with a merger, acquisition, or sale of assets.',
|
||||||
|
'Severability — if any provision is held unenforceable, it will be modified to the minimum extent necessary, and the remainder stays in effect.',
|
||||||
|
'No waiver — a failure to enforce a provision is not a waiver of the right to enforce it later.',
|
||||||
|
'Entire agreement — these Terms, together with the policies they incorporate, are the entire agreement between you and us regarding the Service and supersede prior agreements on that subject.',
|
||||||
|
'Notices — we may provide notices by email to your account address or in-app; legal notices to us go to legal@elegalsoftware.com.',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<H2>18. Changes to these Terms</H2>
|
||||||
|
<P>
|
||||||
|
We may update these Terms from time to time. Material changes take effect no sooner than
|
||||||
|
30 days after we post them and notify account holders by email, except changes required by
|
||||||
|
law or addressing new features, which may take effect immediately. Continued use of the
|
||||||
|
Service after the effective date constitutes acceptance. If you do not agree to a change,
|
||||||
|
stop using the Service and delete your account before the change takes effect.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<H2>19. Contact</H2>
|
||||||
|
<P>
|
||||||
|
Questions about these Terms:{' '}
|
||||||
|
<a href="mailto:legal@elegalsoftware.com" className="text-brand-600 hover:underline">
|
||||||
|
legal@elegalsoftware.com
|
||||||
|
</a>
|
||||||
.
|
.
|
||||||
</P>
|
</P>
|
||||||
|
|
||||||
<H2>6. Confidentiality of your clients’ data</H2>
|
|
||||||
<P>
|
|
||||||
We understand that information about your clients is confidential, often privileged,
|
|
||||||
and subject to professional ethics rules. We treat it accordingly: encrypted in transit
|
|
||||||
and at rest, scoped to your firm by our access-control system, and accessible to our
|
|
||||||
staff only as strictly needed to operate the service or respond to a support request
|
|
||||||
you initiate.
|
|
||||||
</P>
|
|
||||||
|
|
||||||
<H2>7. Suspension and termination</H2>
|
|
||||||
<UL
|
|
||||||
items={[
|
|
||||||
'You may terminate your account anytime from Settings → Delete account.',
|
|
||||||
'We may suspend or terminate accounts that violate these Terms, present a security risk, or are inactive for an extended period (we will notify you first where reasonably possible).',
|
|
||||||
'On termination, you can export your data for up to 30 days; after that, your data is permanently deleted from our active systems and from backups within the following 30 days.',
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<H2>8. Service availability</H2>
|
|
||||||
<P>
|
|
||||||
We aim for high availability but do not guarantee uninterrupted service. We schedule
|
|
||||||
maintenance during low-traffic windows and post notices for significant changes.
|
|
||||||
</P>
|
|
||||||
|
|
||||||
<H2>9. Disclaimer</H2>
|
|
||||||
<P>
|
|
||||||
The service is provided “as is.” To the maximum extent permitted by law, we
|
|
||||||
disclaim all warranties, express or implied, including merchantability, fitness for a
|
|
||||||
particular purpose, and non-infringement. eLegal Software is software, not a substitute for
|
|
||||||
professional legal judgment. Use of the service does not create an attorney–client
|
|
||||||
relationship between you and eLegal Software.
|
|
||||||
</P>
|
|
||||||
|
|
||||||
<H2>10. Limitation of liability</H2>
|
|
||||||
<P>
|
|
||||||
To the maximum extent permitted by law, our total liability for any claim arising out
|
|
||||||
of or related to the service is limited to the amount you paid us in the 12 months
|
|
||||||
preceding the event giving rise to the claim. We are not liable for indirect,
|
|
||||||
incidental, special, consequential, or punitive damages, or for lost profits or
|
|
||||||
revenues.
|
|
||||||
</P>
|
|
||||||
|
|
||||||
<H2>11. Indemnification</H2>
|
|
||||||
<P>
|
|
||||||
You agree to indemnify and hold us harmless from any claims, losses, or expenses
|
|
||||||
arising out of (a) your use of the service in violation of these Terms or applicable
|
|
||||||
law, or (b) your content.
|
|
||||||
</P>
|
|
||||||
|
|
||||||
<H2>12. Changes to the Terms</H2>
|
|
||||||
<P>
|
|
||||||
We may update these Terms from time to time. Material changes take effect 30 days after
|
|
||||||
we post them, or sooner if required by law. Continued use after the effective date
|
|
||||||
means you accept the updated Terms.
|
|
||||||
</P>
|
|
||||||
|
|
||||||
<H2>13. Governing law and disputes</H2>
|
|
||||||
<P>
|
|
||||||
These Terms are governed by the laws of the jurisdiction stated in your account region
|
|
||||||
without regard to conflict-of-laws rules. The parties will attempt to resolve any
|
|
||||||
dispute in good faith. Where that fails, disputes will be resolved by the courts
|
|
||||||
located in that jurisdiction unless applicable law requires otherwise.
|
|
||||||
</P>
|
|
||||||
|
|
||||||
<H2>14. Contact</H2>
|
|
||||||
<P>
|
|
||||||
Questions about these Terms: <a href="mailto:legal@elegalsoftware.com" className="text-brand-600 hover:underline">legal@elegalsoftware.com</a>.
|
|
||||||
</P>
|
|
||||||
</LegalLayout>
|
</LegalLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+878
-211
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -19,7 +19,8 @@
|
|||||||
"start": "node server.cjs",
|
"start": "node server.cjs",
|
||||||
"db:generate": "npm run generate -w packages/db",
|
"db:generate": "npm run generate -w packages/db",
|
||||||
"db:migrate": "npm run migrate -w packages/db",
|
"db:migrate": "npm run migrate -w packages/db",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present"
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
|
"test": "npm run test --workspaces --if-present"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
|
|||||||
@@ -28,15 +28,32 @@ export function getPool(): pg.Pool {
|
|||||||
throw new Error('DATABASE_URL is not set');
|
throw new Error('DATABASE_URL is not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
const caPath = process.env.DATABASE_CA_CERT_PATH;
|
const caPath = process.env.DATABASE_CA_CERT_PATH;
|
||||||
const ca = caPath && fs.existsSync(caPath) ? fs.readFileSync(caPath, 'utf8') : undefined;
|
const ca = caPath && fs.existsSync(caPath) ? fs.readFileSync(caPath, 'utf8') : undefined;
|
||||||
|
|
||||||
// Strip sslmode from the URL so our explicit `ssl` option fully controls TLS behavior.
|
// Strip sslmode from the URL so our explicit `ssl` option fully controls TLS behavior.
|
||||||
// Without this, pg merges URL-derived settings and may force cert verification even when
|
// Without this, pg merges URL-derived settings which can conflict with the options below.
|
||||||
// we want to fall back to TLS-without-verification (no CA cert available).
|
let ssl: pg.PoolConfig['ssl'];
|
||||||
const ssl: pg.PoolConfig['ssl'] = ca
|
if (ca) {
|
||||||
? { ca, rejectUnauthorized: true }
|
// Verified TLS against the managed-DB CA — the correct posture everywhere.
|
||||||
: { rejectUnauthorized: false };
|
ssl = { ca, rejectUnauthorized: true };
|
||||||
|
} else if (isProd) {
|
||||||
|
// Never run production against the database over unverified TLS: fail fast so a missing
|
||||||
|
// CA cert is a loud deploy error instead of a silent man-in-the-middle exposure.
|
||||||
|
throw new Error(
|
||||||
|
'DATABASE_CA_CERT_PATH is required in production: point it at the managed-DB CA cert so TLS certificates are verified (rejectUnauthorized: true).',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Dev only, and only when no CA cert is present. Verification is disabled — connecting to a
|
||||||
|
// production database like this is still MITM-exposed, so warn loudly.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
'[db] WARNING: no DATABASE_CA_CERT_PATH — connecting with TLS certificate verification DISABLED. ' +
|
||||||
|
'Add the CA cert (certs/ca-certificate.crt) to verify the connection.',
|
||||||
|
);
|
||||||
|
ssl = { rejectUnauthorized: false };
|
||||||
|
}
|
||||||
|
|
||||||
_pool = new pg.Pool({
|
_pool = new pg.Pool({
|
||||||
connectionString: stripSslmode(connectionString),
|
connectionString: stripSslmode(connectionString),
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// One-off: creates a superadmin user.
|
||||||
|
// Run from monorepo root: npx tsx scripts/create-admin.ts
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||||
|
|
||||||
|
import argon2 from 'argon2';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getDb, getPool, users } from '@lawdesk/db';
|
||||||
|
|
||||||
|
// Credentials come from the environment or argv — never hardcode them in a tracked file.
|
||||||
|
// ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='...' ADMIN_NAME='You' npx tsx scripts/create-admin.ts
|
||||||
|
// or: npx tsx scripts/create-admin.ts you@example.com 'password' 'Your Name'
|
||||||
|
const EMAIL = process.env.ADMIN_EMAIL ?? process.argv[2];
|
||||||
|
const PASSWORD = process.env.ADMIN_PASSWORD ?? process.argv[3];
|
||||||
|
const NAME = process.env.ADMIN_NAME ?? process.argv[4] ?? 'Admin';
|
||||||
|
|
||||||
|
if (!EMAIL || !PASSWORD) {
|
||||||
|
console.error(
|
||||||
|
'Missing credentials.\n' +
|
||||||
|
"Usage: ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='...' [ADMIN_NAME='...'] npx tsx scripts/create-admin.ts",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (PASSWORD.length < 10) {
|
||||||
|
console.error('ADMIN_PASSWORD must be at least 10 characters.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const db = getDb();
|
||||||
|
const passwordHash = await argon2.hash(PASSWORD, {
|
||||||
|
type: argon2.argon2id,
|
||||||
|
memoryCost: 64 * 1024,
|
||||||
|
timeCost: 3,
|
||||||
|
parallelism: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, EMAIL));
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db.update(users).set({
|
||||||
|
passwordHash,
|
||||||
|
isSuperadmin: true,
|
||||||
|
isSuspended: false,
|
||||||
|
emailVerifiedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).where(eq(users.email, EMAIL));
|
||||||
|
console.log(`Updated existing user → superadmin: ${EMAIL}`);
|
||||||
|
} else {
|
||||||
|
const [u] = await db.insert(users).values({
|
||||||
|
email: EMAIL,
|
||||||
|
passwordHash,
|
||||||
|
fullName: NAME,
|
||||||
|
role: 'owner',
|
||||||
|
isSuperadmin: true,
|
||||||
|
emailVerifiedAt: new Date(),
|
||||||
|
}).returning();
|
||||||
|
console.log(`Created superadmin: ${u.email} (id: ${u.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await getPool().end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// One-time migration: copy locally-stored documents into DigitalOcean Spaces.
|
||||||
|
// Idempotent — re-running skips objects already present in the bucket.
|
||||||
|
//
|
||||||
|
// npx tsx scripts/migrate-storage-to-spaces.ts # migrate
|
||||||
|
// npx tsx scripts/migrate-storage-to-spaces.ts --dry-run # report only, no writes
|
||||||
|
//
|
||||||
|
// After the app is switched to Spaces (lib/storage.ts), this exists only to lift any files
|
||||||
|
// that were written to the old local STORAGE_PATH on the server before the cutover.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||||
|
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { getDb, getPool, documents } from '@lawdesk/db';
|
||||||
|
|
||||||
|
const DRY_RUN = process.argv.includes('--dry-run');
|
||||||
|
|
||||||
|
const BUCKET = process.env.SPACES_BUCKET!;
|
||||||
|
const STORAGE_PATH = process.env.STORAGE_PATH ?? './storage';
|
||||||
|
|
||||||
|
const s3 = new S3Client({
|
||||||
|
endpoint: process.env.SPACES_ENDPOINT,
|
||||||
|
region: process.env.SPACES_REGION,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.SPACES_KEY!,
|
||||||
|
secretAccessKey: process.env.SPACES_SECRET!,
|
||||||
|
},
|
||||||
|
forcePathStyle: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function existsInBucket(key: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Migrating local documents → Spaces bucket "${BUCKET}"${DRY_RUN ? ' (dry run)' : ''}`);
|
||||||
|
console.log(`Local root: ${path.resolve(STORAGE_PATH)}\n`);
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
storageKey: documents.storageKey,
|
||||||
|
name: documents.name,
|
||||||
|
mimeType: documents.mimeType,
|
||||||
|
sizeBytes: documents.sizeBytes,
|
||||||
|
})
|
||||||
|
.from(documents);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log('No document rows in the database — nothing to migrate.');
|
||||||
|
await getPool().end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploaded = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let missing = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const localPath = path.resolve(STORAGE_PATH, row.storageKey);
|
||||||
|
|
||||||
|
if (await existsInBucket(row.storageKey)) {
|
||||||
|
skipped++;
|
||||||
|
console.log(` = already in bucket: ${row.storageKey}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(localPath)) {
|
||||||
|
missing++;
|
||||||
|
console.warn(` ! local file missing (nothing to upload): ${row.storageKey} [${row.name}]`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DRY_RUN) {
|
||||||
|
console.log(` → would upload: ${row.storageKey} (${row.sizeBytes} bytes)`);
|
||||||
|
uploaded++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = fs.readFileSync(localPath);
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: BUCKET,
|
||||||
|
Key: row.storageKey,
|
||||||
|
Body: body,
|
||||||
|
ContentType: row.mimeType,
|
||||||
|
ACL: 'private',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
uploaded++;
|
||||||
|
console.log(` ✓ uploaded: ${row.storageKey} (${body.length} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\nDone. ${uploaded} ${DRY_RUN ? 'to upload' : 'uploaded'}, ${skipped} already present, ${missing} missing locally (of ${rows.length} document rows).`,
|
||||||
|
);
|
||||||
|
if (missing > 0) {
|
||||||
|
console.log(
|
||||||
|
'Missing files have DB rows but no bytes on disk or in the bucket — they were already lost before migration (local storage was never durable).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await getPool().end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Migration failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
// Seeds 10 demo firms, each with one owner user + realistic activity.
|
||||||
|
// Run from monorepo root: npx tsx scripts/seed-demo.ts
|
||||||
|
//
|
||||||
|
// Login: any of the emails below with password `Demo1234!`
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||||
|
|
||||||
|
import argon2 from 'argon2';
|
||||||
|
import {
|
||||||
|
getDb,
|
||||||
|
getPool,
|
||||||
|
firms,
|
||||||
|
users,
|
||||||
|
clients,
|
||||||
|
cases,
|
||||||
|
timeEntries,
|
||||||
|
invoices,
|
||||||
|
invoiceItems,
|
||||||
|
} from '@lawdesk/db';
|
||||||
|
|
||||||
|
// ─── Demo data pools ───────────────────────────────────────────────────────
|
||||||
|
const FIRMS = [
|
||||||
|
{ name: 'Hartwell & Associates', plan: 'pro' as const, area: 'Family Law' },
|
||||||
|
{ name: 'Brennan Law Group', plan: 'lifetime' as const, area: 'Personal Injury' },
|
||||||
|
{ name: 'Cohen Legal Solutions', plan: 'starter' as const, area: 'Criminal Defense' },
|
||||||
|
{ name: 'Davenport & Reed PLLC', plan: 'pro' as const, area: 'Real Estate' },
|
||||||
|
{ name: 'Eastman Law Firm', plan: 'starter' as const, area: 'Employment' },
|
||||||
|
{ name: 'Fairmont Legal Partners', plan: 'pro' as const, area: 'Estate Planning' },
|
||||||
|
{ name: 'Gallo & Whitfield LLP', plan: 'lifetime' as const, area: 'Corporate' },
|
||||||
|
{ name: 'Hayes Immigration Law', plan: 'starter' as const, area: 'Immigration' },
|
||||||
|
{ name: 'Iverson & Marsh', plan: 'pro' as const, area: 'Bankruptcy' },
|
||||||
|
{ name: 'Jensen Tax Law', plan: 'pro' as const, area: 'Tax' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FIRST_NAMES = ['Emma', 'Liam', 'Olivia', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Isabella', 'James', 'Mia', 'Lucas', 'Charlotte', 'Henry', 'Amelia'];
|
||||||
|
const LAST_NAMES = ['Anderson', 'Brown', 'Carter', 'Davis', 'Evans', 'Foster', 'Garcia', 'Hill', 'Jackson', 'King', 'Lee', 'Morris', 'Nelson', 'Owens', 'Parker', 'Quinn', 'Rivera', 'Stone', 'Taylor', 'Walker'];
|
||||||
|
|
||||||
|
const CASE_TITLES: Record<string, string[]> = {
|
||||||
|
'Family Law': ['Divorce — assets division', 'Child custody modification', 'Prenuptial agreement', 'Adoption petition', 'Spousal support'],
|
||||||
|
'Personal Injury': ['Auto accident — rear-end', 'Slip and fall at retail', 'Workplace injury claim', 'Medical malpractice', 'Product liability'],
|
||||||
|
'Criminal Defense': ['DUI defense', 'Assault charge', 'Drug possession', 'White-collar fraud', 'Theft defense'],
|
||||||
|
'Real Estate': ['Commercial lease review', 'Title dispute', 'Zoning variance', 'Purchase agreement', 'Easement dispute'],
|
||||||
|
'Employment': ['Wrongful termination', 'Discrimination claim', 'Wage and hour dispute', 'Non-compete enforcement', 'Severance negotiation'],
|
||||||
|
'Estate Planning': ['Trust formation', 'Will drafting', 'Probate administration', 'Power of attorney', 'Estate dispute'],
|
||||||
|
'Corporate': ['M&A advisory', 'Shareholder agreement', 'Series A financing', 'IP licensing', 'Corporate restructuring'],
|
||||||
|
'Immigration': ['H-1B visa petition', 'Green card application', 'Asylum case', 'Naturalization', 'Family-based visa'],
|
||||||
|
'Bankruptcy': ['Chapter 7 filing', 'Chapter 13 reorganization', 'Creditor negotiation', 'Asset protection', 'Discharge defense'],
|
||||||
|
'Tax': ['IRS audit defense', 'Tax debt settlement', 'Estate tax planning', 'Business tax structuring', 'Tax court appeal'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIME_DESCRIPTIONS = [
|
||||||
|
'Initial client consultation',
|
||||||
|
'Drafted demand letter',
|
||||||
|
'Reviewed discovery documents',
|
||||||
|
'Court appearance — motion hearing',
|
||||||
|
'Phone call with opposing counsel',
|
||||||
|
'Research case law',
|
||||||
|
'Prepared deposition outline',
|
||||||
|
'Client meeting — strategy review',
|
||||||
|
'Email correspondence',
|
||||||
|
'Filed motion to compel',
|
||||||
|
'Mediation session',
|
||||||
|
'Drafted settlement proposal',
|
||||||
|
'Reviewed contract terms',
|
||||||
|
'Prepared trial exhibits',
|
||||||
|
'Settlement conference',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
function pick<T>(arr: T[]): T {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randInt(min: number, max: number): number {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysAgo(days: number): Date {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - days);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slug(s: string): string {
|
||||||
|
return s.toLowerCase().replace(/[^a-z]+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Safety guard ─────────────────────────────────────────────────────────────
|
||||||
|
// This inserts 10 demo firms whose owner logins all use the public password `Demo1234!`
|
||||||
|
// into whatever DATABASE_URL points at — which for this project is the PRODUCTION database.
|
||||||
|
// Require an explicit opt-in so it can never run by accident.
|
||||||
|
if (process.env.ALLOW_SEED !== '1') {
|
||||||
|
const host = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(process.env.DATABASE_URL ?? '').host || 'unknown';
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
console.error(
|
||||||
|
`Refusing to seed. This writes 10 demo firms (owner password "Demo1234!") to: ${host}\n` +
|
||||||
|
'That is the production database for this project. Re-run with ALLOW_SEED=1 only if you are sure:\n' +
|
||||||
|
' ALLOW_SEED=1 npx tsx scripts/seed-demo.ts',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
async function main() {
|
||||||
|
const db = getDb();
|
||||||
|
const passwordHash = await argon2.hash('Demo1234!', {
|
||||||
|
type: argon2.argon2id,
|
||||||
|
memoryCost: 64 * 1024,
|
||||||
|
timeCost: 3,
|
||||||
|
parallelism: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Seeding 10 firms with activity…\n');
|
||||||
|
|
||||||
|
for (let i = 0; i < FIRMS.length; i++) {
|
||||||
|
const def = FIRMS[i]!;
|
||||||
|
const firstName = FIRST_NAMES[i]!;
|
||||||
|
const lastName = LAST_NAMES[i]!;
|
||||||
|
const fullName = `${firstName} ${lastName}`;
|
||||||
|
const email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}@${slug(def.name)}.test`;
|
||||||
|
|
||||||
|
// Firm
|
||||||
|
const [firm] = await db.insert(firms).values({
|
||||||
|
name: def.name,
|
||||||
|
plan: def.plan,
|
||||||
|
watermarkEnabled: def.plan === 'starter',
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// Owner user
|
||||||
|
const [user] = await db.insert(users).values({
|
||||||
|
firmId: firm.id,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
fullName,
|
||||||
|
role: 'owner',
|
||||||
|
emailVerifiedAt: new Date(),
|
||||||
|
lastSeenAt: daysAgo(randInt(0, 5)),
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
// 3–5 clients
|
||||||
|
const numClients = randInt(3, 5);
|
||||||
|
const clientRows = [];
|
||||||
|
for (let c = 0; c < numClients; c++) {
|
||||||
|
const cName = `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`;
|
||||||
|
const [client] = await db.insert(clients).values({
|
||||||
|
firmId: firm.id,
|
||||||
|
name: cName,
|
||||||
|
email: `${cName.toLowerCase().replace(' ', '.')}@example.com`,
|
||||||
|
phone: `(${randInt(200, 999)}) ${randInt(200, 999)}-${randInt(1000, 9999)}`,
|
||||||
|
}).returning();
|
||||||
|
clientRows.push(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5–8 cases
|
||||||
|
const numCases = randInt(5, 8);
|
||||||
|
const caseTitles = CASE_TITLES[def.area]!;
|
||||||
|
const caseRows = [];
|
||||||
|
for (let cc = 0; cc < numCases; cc++) {
|
||||||
|
const status = cc < numCases - 2 ? 'open' : (Math.random() > 0.5 ? 'pending' : 'closed');
|
||||||
|
const hourlyRate = randInt(150, 450);
|
||||||
|
const openedDaysAgo = randInt(7, 120);
|
||||||
|
const [kase] = await db.insert(cases).values({
|
||||||
|
firmId: firm.id,
|
||||||
|
clientId: pick(clientRows).id,
|
||||||
|
title: caseTitles[cc % caseTitles.length]!,
|
||||||
|
caseNumber: `${new Date().getFullYear()}-${String(cc + 1).padStart(4, '0')}`,
|
||||||
|
status,
|
||||||
|
practiceArea: def.area,
|
||||||
|
hourlyRate: String(hourlyRate),
|
||||||
|
openedAt: daysAgo(openedDaysAgo),
|
||||||
|
closedAt: status === 'closed' ? daysAgo(randInt(0, openedDaysAgo - 1)) : null,
|
||||||
|
}).returning();
|
||||||
|
caseRows.push(kase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15–30 time entries spread across cases
|
||||||
|
const numEntries = randInt(15, 30);
|
||||||
|
for (let e = 0; e < numEntries; e++) {
|
||||||
|
const kase = pick(caseRows);
|
||||||
|
const minutes = randInt(15, 240);
|
||||||
|
const startedDaysAgo = randInt(0, 60);
|
||||||
|
const startedAt = daysAgo(startedDaysAgo);
|
||||||
|
const endedAt = new Date(startedAt.getTime() + minutes * 60 * 1000);
|
||||||
|
await db.insert(timeEntries).values({
|
||||||
|
firmId: firm.id,
|
||||||
|
caseId: kase.id,
|
||||||
|
userId: user.id,
|
||||||
|
description: pick(TIME_DESCRIPTIONS),
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
minutes,
|
||||||
|
rate: kase.hourlyRate ?? '250',
|
||||||
|
billable: Math.random() > 0.15,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2–4 invoices
|
||||||
|
const numInvoices = randInt(2, 4);
|
||||||
|
for (let inv = 0; inv < numInvoices; inv++) {
|
||||||
|
const kase = pick(caseRows);
|
||||||
|
const client = clientRows.find((c) => c.id === kase.clientId)!;
|
||||||
|
const numItems = randInt(2, 5);
|
||||||
|
const items = [];
|
||||||
|
let subtotal = 0;
|
||||||
|
for (let it = 0; it < numItems; it++) {
|
||||||
|
const qty = Number((Math.random() * 8 + 0.5).toFixed(2));
|
||||||
|
const rate = Number(kase.hourlyRate ?? 250);
|
||||||
|
const amount = Number((qty * rate).toFixed(2));
|
||||||
|
subtotal += amount;
|
||||||
|
items.push({ description: pick(TIME_DESCRIPTIONS), quantity: qty, rate, amount, sortOrder: it });
|
||||||
|
}
|
||||||
|
const total = Number(subtotal.toFixed(2));
|
||||||
|
|
||||||
|
// Status mix: ~40% sent, ~25% paid, ~15% overdue, ~15% draft, ~5% void
|
||||||
|
const r = Math.random();
|
||||||
|
const status = r < 0.4 ? 'sent' : r < 0.65 ? 'paid' : r < 0.8 ? 'overdue' : r < 0.95 ? 'draft' : 'void';
|
||||||
|
const issuedDaysAgo = randInt(5, 90);
|
||||||
|
const issuedAt = status === 'draft' ? null : daysAgo(issuedDaysAgo);
|
||||||
|
const dueAt = issuedAt ? new Date(issuedAt.getTime() + 30 * 24 * 60 * 60 * 1000) : null;
|
||||||
|
const paidAt = status === 'paid' ? daysAgo(randInt(1, issuedDaysAgo - 1)) : null;
|
||||||
|
|
||||||
|
const [invoice] = await db.insert(invoices).values({
|
||||||
|
firmId: firm.id,
|
||||||
|
clientId: client.id,
|
||||||
|
caseId: kase.id,
|
||||||
|
number: `INV-${new Date().getFullYear()}-${String(inv + 1 + i * 10).padStart(4, '0')}`,
|
||||||
|
status,
|
||||||
|
subtotal: subtotal.toFixed(2),
|
||||||
|
taxRate: '0',
|
||||||
|
total: total.toFixed(2),
|
||||||
|
issuedAt,
|
||||||
|
dueAt,
|
||||||
|
paidAt,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
for (const it of items) {
|
||||||
|
await db.insert(invoiceItems).values({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
description: it.description,
|
||||||
|
quantity: String(it.quantity),
|
||||||
|
rate: String(it.rate),
|
||||||
|
amount: String(it.amount),
|
||||||
|
sortOrder: it.sortOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✓ ${def.name.padEnd(32)} — ${email} (${def.plan})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDone. All 10 users use password: Demo1234!');
|
||||||
|
await getPool().end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Flips past-due 'sent' invoices to 'overdue' and emails the client a payment reminder.
|
||||||
|
// The email goes out only on the sent→overdue transition, so re-running never double-sends.
|
||||||
|
// Run daily from the monorepo root (cron / scheduled task): npx tsx scripts/send-overdue-reminders.ts
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||||
|
|
||||||
|
import { and, eq, lt } from 'drizzle-orm';
|
||||||
|
import { getDb, getPool, invoices, clients, firms } from '@lawdesk/db';
|
||||||
|
import { sendEmail, invoiceOverdueEmail } from '../apps/api/src/lib/email';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const due = await db
|
||||||
|
.select({
|
||||||
|
id: invoices.id,
|
||||||
|
number: invoices.number,
|
||||||
|
total: invoices.total,
|
||||||
|
dueAt: invoices.dueAt,
|
||||||
|
clientName: clients.name,
|
||||||
|
clientEmail: clients.email,
|
||||||
|
firmName: firms.name,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.innerJoin(clients, eq(invoices.clientId, clients.id))
|
||||||
|
.innerJoin(firms, eq(invoices.firmId, firms.id))
|
||||||
|
.where(and(eq(invoices.status, 'sent'), lt(invoices.dueAt, now)));
|
||||||
|
|
||||||
|
console.log(`Found ${due.length} past-due invoice(s) to mark overdue.`);
|
||||||
|
|
||||||
|
let flipped = 0;
|
||||||
|
let emailed = 0;
|
||||||
|
|
||||||
|
for (const inv of due) {
|
||||||
|
// Guard on status='sent' so a concurrent run can't flip (and email) the same invoice twice.
|
||||||
|
const [row] = await db
|
||||||
|
.update(invoices)
|
||||||
|
.set({ status: 'overdue', updatedAt: new Date() })
|
||||||
|
.where(and(eq(invoices.id, inv.id), eq(invoices.status, 'sent')))
|
||||||
|
.returning({ id: invoices.id });
|
||||||
|
if (!row) continue;
|
||||||
|
flipped++;
|
||||||
|
|
||||||
|
if (!inv.clientEmail || !inv.dueAt) {
|
||||||
|
console.log(` ${inv.number}: marked overdue, no reminder (missing client email or due date)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalFmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
|
||||||
|
Number(inv.total),
|
||||||
|
);
|
||||||
|
const dueDate = inv.dueAt.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tpl = invoiceOverdueEmail({
|
||||||
|
clientName: inv.clientName,
|
||||||
|
firmName: inv.firmName,
|
||||||
|
invoiceNumber: inv.number,
|
||||||
|
total: totalFmt,
|
||||||
|
dueDate,
|
||||||
|
});
|
||||||
|
const result = await sendEmail({ to: inv.clientEmail, ...tpl });
|
||||||
|
if (result.ok && !result.skipped) {
|
||||||
|
emailed++;
|
||||||
|
console.log(` ${inv.number}: marked overdue, reminder sent to ${inv.clientEmail}`);
|
||||||
|
} else {
|
||||||
|
console.log(` ${inv.number}: marked overdue, reminder ${result.skipped ? 'skipped (no API key)' : `FAILED: ${result.error}`}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Done. ${flipped} invoice(s) marked overdue, ${emailed} reminder(s) sent.`);
|
||||||
|
await getPool().end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user