Dokploy deploy: Dockerfile, DB CA cert, blog images, CSP
CI / build-and-test (push) Has been cancelled
CI / build-and-test (push) Has been cancelled
- Add Dockerfile (multi-stage Node 20), .dockerignore, docker-compose.yml, and DEPLOY-DOKPLOY.md for container deployment on Dokploy. - Commit the DigitalOcean managed-Postgres Project CA cert (certs/ca-certificate.crt) so production TLS verification (fail-closed) works in-container. Public CA, safe to commit. - Blog cover images served from DO Spaces; allow *.digitaloceanspaces.com in the prod CSP img-src. - Includes the AI (case summaries) and Cloudflare Turnstile bot-protection features. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d9b807662a
commit
d1d96e4dd2
@@ -0,0 +1,42 @@
|
|||||||
|
# Keep the build context small and NEVER bake secrets or host-built artifacts into the image.
|
||||||
|
|
||||||
|
# Secrets — must never enter the image (env is injected by Dokploy at runtime).
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Dependencies + build outputs (reinstalled / rebuilt inside the image).
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
apps/web/dist
|
||||||
|
apps/api/dist
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.turbo
|
||||||
|
.cache
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# VCS / CI / editor / OS noise.
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs and legacy Plesk/Passenger runtime scratch.
|
||||||
|
*.log
|
||||||
|
logs
|
||||||
|
tmp
|
||||||
|
|
||||||
|
# Local-only storage dirs (documents live in Spaces).
|
||||||
|
storage
|
||||||
|
uploads
|
||||||
|
|
||||||
|
# Tests aren't needed in the runtime image.
|
||||||
|
apps/api/test
|
||||||
|
**/*.test.ts
|
||||||
|
|
||||||
|
# Note: certs/ is intentionally NOT ignored — the Postgres CA cert (if committed) is baked in
|
||||||
|
# so production TLS verification works. See DEPLOY-DOKPLOY.md.
|
||||||
@@ -46,6 +46,20 @@ STORAGE_PATH=./storage
|
|||||||
SMTP2GO_API_KEY=
|
SMTP2GO_API_KEY=
|
||||||
EMAIL_FROM="eLegal Software <noreply@yourdomain.com>"
|
EMAIL_FROM="eLegal Software <noreply@yourdomain.com>"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# AI (Anthropic) — powers case summaries, document summaries, and text polish.
|
||||||
|
# Leave blank to disable AI features (endpoints return 503 ai_not_configured).
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Cloudflare Turnstile — bot protection on signup, login, password reset, and the
|
||||||
|
# contact form. Leave both blank to disable (dev). The site key is public (bundled
|
||||||
|
# into the web app); the secret key is server-only.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
VITE_TURNSTILE_SITE_KEY=
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Stripe
|
# Stripe
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Deploying eLegal Software on Dokploy
|
||||||
|
|
||||||
|
This app was previously deployed on Plesk + Passenger. It now ships as a single Docker container
|
||||||
|
(built from the repo `Dockerfile`) that serves the built React SPA **and** the Fastify API on one
|
||||||
|
port. Documents live in DigitalOcean Spaces and logs go to stdout, so the container is **stateless**
|
||||||
|
— no volumes needed.
|
||||||
|
|
||||||
|
- **Runtime:** Node 20, one process, listens on `0.0.0.0:$PORT` (default `8080`).
|
||||||
|
- **Health check:** `GET /api/health` → `200 {"ok":true}` (no DB dependency). A deeper
|
||||||
|
`GET /api/health/db` verifies the database.
|
||||||
|
- **Database:** external DigitalOcean Managed Postgres (unchanged).
|
||||||
|
- **Storage:** external DigitalOcean Spaces (unchanged).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Create the application in Dokploy
|
||||||
|
|
||||||
|
1. **Project → Create Application.**
|
||||||
|
2. **Source → Git.** Point it at this repo (`https://tea.serfaty.co/admin/elegalsoftware`), branch
|
||||||
|
`master`. Add a deploy key / token in Dokploy if the repo is private.
|
||||||
|
3. **Build Type → `Dockerfile`.** Path: `./Dockerfile` (repo root). *(Alternatively use the
|
||||||
|
"Compose" type with the bundled `docker-compose.yml`, but Application + Dockerfile is simpler —
|
||||||
|
Dokploy wires Traefik and env for you.)*
|
||||||
|
4. **Port → `8080`.** This is the container port Dokploy/Traefik routes to.
|
||||||
|
|
||||||
|
## 2. Domain + TLS
|
||||||
|
|
||||||
|
- **Domains → Add** your hostname (e.g. `app.elegalsoftware.com`), container port `8080`, and
|
||||||
|
enable **HTTPS / Let's Encrypt**. Traefik terminates TLS and proxies to the container.
|
||||||
|
- Point the hostname's DNS at the Dokploy server first so the ACME challenge can succeed.
|
||||||
|
|
||||||
|
## 3. Runtime environment variables
|
||||||
|
|
||||||
|
Set these in **Environment** (Dokploy injects them at container start). Use `.env.example` as the
|
||||||
|
authoritative list. The essentials:
|
||||||
|
|
||||||
|
| Variable | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `NODE_ENV` | `production` |
|
||||||
|
| `PORT` | `8080` (matches the exposed port) |
|
||||||
|
| `PUBLIC_URL` | Your public HTTPS URL, e.g. `https://app.elegalsoftware.com`. Used in emails and absolute links — **must** be the real domain in prod. |
|
||||||
|
| `COOKIE_DOMAIN` | Your apex/app domain (e.g. `elegalsoftware.com`). Leave blank only in local dev. |
|
||||||
|
| `SESSION_SECRET` / `CSRF_SECRET` | 32+ byte hex each. Generate: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. |
|
||||||
|
| `SUPERADMIN_EMAILS` | Comma-separated. Only **verified** accounts on this list become superadmin. |
|
||||||
|
| `DATABASE_URL` | DO Postgres URL (`...?sslmode=require`). |
|
||||||
|
| `DATABASE_CA_CERT_PATH` | Path to the DO CA cert **inside the container** — required in prod (see §4). |
|
||||||
|
| `SPACES_ENDPOINT` / `SPACES_REGION` / `SPACES_BUCKET` / `SPACES_KEY` / `SPACES_SECRET` | Object storage (required). |
|
||||||
|
| `SMTP2GO_API_KEY` / `EMAIL_FROM` | Transactional email. |
|
||||||
|
| `ANTHROPIC_API_KEY` | AI features (optional; blank disables them). |
|
||||||
|
| `TURNSTILE_SECRET_KEY` | Bot-protection server key (see §5 for the site key). |
|
||||||
|
| `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` / `STRIPE_PRICE_PRO` / `STRIPE_PRICE_LIFETIME` | Billing. |
|
||||||
|
| `SENTRY_DSN_API` | Optional error reporting. |
|
||||||
|
|
||||||
|
> The API **fails fast on boot** if a required variable is missing (secrets, `SPACES_*`, database).
|
||||||
|
> That's intentional — a misconfigured deploy stops loudly instead of running half-broken.
|
||||||
|
|
||||||
|
## 4. Database TLS — the one required extra step
|
||||||
|
|
||||||
|
In production the app **refuses to connect over unverified TLS** (no silent MITM exposure). You must
|
||||||
|
give it the DigitalOcean CA certificate:
|
||||||
|
|
||||||
|
1. In the DO control panel → your Postgres cluster → **Download CA certificate**.
|
||||||
|
2. Make it available in the container, either:
|
||||||
|
- **Commit it** as `certs/ca-certificate.crt` (a CA cert is public, safe to commit). The
|
||||||
|
Dockerfile bakes `certs/` into the image. Set `DATABASE_CA_CERT_PATH=./certs/ca-certificate.crt`.
|
||||||
|
- **Or** mount it via a Dokploy **Volume/Mount** (e.g. at `/app/certs/ca-certificate.crt`) and
|
||||||
|
set `DATABASE_CA_CERT_PATH` to that path.
|
||||||
|
|
||||||
|
Without this, the container boots and `/api/health` still passes, but any request that touches the
|
||||||
|
database will error. (`/api/health/db` will return `503` until the cert is in place.)
|
||||||
|
|
||||||
|
## 5. Build-time variables (Vite) — easy to miss
|
||||||
|
|
||||||
|
`VITE_*` values are **compiled into the browser bundle during `vite build`**, so they must be set as
|
||||||
|
**Build-time variables**, not runtime env:
|
||||||
|
|
||||||
|
- `VITE_TURNSTILE_SITE_KEY` — the public Turnstile site key.
|
||||||
|
- `VITE_SENTRY_DSN` — browser Sentry DSN (optional).
|
||||||
|
|
||||||
|
In Dokploy: **Build → Build-time variables** (passed as Docker build args). If you only set them as
|
||||||
|
runtime env, the browser bundle won't pick them up and Turnstile won't render.
|
||||||
|
|
||||||
|
## 6. Database migrations
|
||||||
|
|
||||||
|
Run migrations as a **deploy step**, not automatically on every container start. In Dokploy, use a
|
||||||
|
**Run Command** (or the app's terminal) after a deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations are tracked (drizzle only applies pending ones) and safe to re-run. Keep the app at **1
|
||||||
|
replica** while migrating; the invoice-numbering advisory locks handle write concurrency, but schema
|
||||||
|
migrations should not run from multiple instances at once.
|
||||||
|
|
||||||
|
## 7. Stripe webhook
|
||||||
|
|
||||||
|
Add the endpoint in the Stripe dashboard:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://<your-domain>/api/webhooks/stripe
|
||||||
|
```
|
||||||
|
|
||||||
|
The route reads the raw request body for signature verification. Unlike Plesk (which needed
|
||||||
|
`proxy_request_buffering off`), Traefik forwards the body fine — no extra proxy config required.
|
||||||
|
Set `STRIPE_WEBHOOK_SECRET` to the signing secret Stripe shows for that endpoint.
|
||||||
|
|
||||||
|
## 8. Deploy
|
||||||
|
|
||||||
|
Trigger a deploy in Dokploy (or enable auto-deploy on push). First deploy checklist:
|
||||||
|
|
||||||
|
- [ ] Runtime env vars set (§3), including `PUBLIC_URL` and `COOKIE_DOMAIN` on the real domain.
|
||||||
|
- [ ] DB CA cert in place and `DATABASE_CA_CERT_PATH` set (§4).
|
||||||
|
- [ ] `VITE_*` set as build-time variables (§5).
|
||||||
|
- [ ] Domain + Let's Encrypt configured (§2).
|
||||||
|
- [ ] Migrations run (§6).
|
||||||
|
- [ ] Stripe webhook pointed at the new URL (§7).
|
||||||
|
|
||||||
|
Verify: `https://<domain>/api/health` → `{"ok":true}`, then load the app, sign up, and open a
|
||||||
|
deep-link/refresh (e.g. `/app`) to confirm SPA routing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Local Docker test:** `docker build -t elegal . && docker run --rm -p 8080:8080 --env-file .env elegal`
|
||||||
|
then hit `http://localhost:8080/api/health`. (Uses your local `.env`; the image itself never
|
||||||
|
contains it — `.env` is in `.dockerignore`.)
|
||||||
|
- **Scaling:** the app is stateless (Spaces storage, stdout logs, Postgres sessions), so it scales
|
||||||
|
horizontally. Just run migrations as a single controlled step, not per-instance.
|
||||||
|
- **Legacy Plesk files** — `app.js`, `server.cjs`, `scripts/plesk-deploy.sh`, and `tmp/restart.txt`
|
||||||
|
are no longer used and can be deleted once the Dokploy cutover is confirmed.
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# Container image for Dokploy (Docker + Traefik). Single Node process that serves the built
|
||||||
|
# React SPA and the Fastify API on one port. Documents live in DigitalOcean Spaces and logs go
|
||||||
|
# to stdout, so the container is stateless — no volumes required.
|
||||||
|
|
||||||
|
# ─── Builder ────────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Toolchain for native modules (argon2) in case no prebuilt binary is available for this platform.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install dependencies first for better layer caching — copy every workspace manifest, then npm ci.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY apps/api/package.json ./apps/api/
|
||||||
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
COPY packages/db/package.json ./packages/db/
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy the rest of the source.
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Public config baked into the Vite bundle. Vite inlines VITE_* AT BUILD TIME, so these must be
|
||||||
|
# provided as build args (Dokploy → Build → Build-time variables), NOT as runtime env vars.
|
||||||
|
# Both are optional: an empty value simply disables that feature in the browser bundle.
|
||||||
|
ARG VITE_TURNSTILE_SITE_KEY=""
|
||||||
|
ARG VITE_SENTRY_DSN=""
|
||||||
|
ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY} \
|
||||||
|
VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
|
||||||
|
|
||||||
|
# Build the SPA → apps/web/dist. The API runs from TypeScript source via the tsx loader, so there
|
||||||
|
# is no separate API build step.
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ─── Runtime ────────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:20-bookworm-slim AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# ca-certificates for outbound TLS (Postgres, Spaces, Stripe, SMTP2GO, Anthropic).
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd -m -u 1001 app
|
||||||
|
|
||||||
|
# Bring over the fully-installed, already-built app (node_modules incl. the compiled argon2 binary
|
||||||
|
# and workspace symlinks, apps/web/dist, TS source run by tsx, and certs/ if the CA cert is present).
|
||||||
|
COPY --from=builder --chown=app:app /app /app
|
||||||
|
|
||||||
|
USER app
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Container liveness. Dokploy/Traefik can additionally health-check the /api/health HTTP path.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
# server.js registers the tsx ESM loader, builds the Fastify app, and listens on 0.0.0.0:$PORT.
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.111.0",
|
||||||
"@aws-sdk/client-s3": "^3.1088.0",
|
"@aws-sdk/client-s3": "^3.1088.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1088.0",
|
"@aws-sdk/s3-request-presigner": "^3.1088.0",
|
||||||
"@fastify/cookie": "^11.0.1",
|
"@fastify/cookie": "^11.0.1",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const envSchema = z.object({
|
|||||||
// Legacy local path — read only by the one-time migration script, not the running app.
|
// Legacy local path — read only by the one-time migration script, not the running app.
|
||||||
STORAGE_PATH: z.string().optional().default('./storage'),
|
STORAGE_PATH: z.string().optional().default('./storage'),
|
||||||
SMTP2GO_API_KEY: z.string().optional().default(''),
|
SMTP2GO_API_KEY: z.string().optional().default(''),
|
||||||
|
ANTHROPIC_API_KEY: z.string().optional().default(''),
|
||||||
|
TURNSTILE_SECRET_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(''),
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// AI layer — Anthropic Claude. All platform AI features go through this module so the
|
||||||
|
// model choice, error mapping, and enablement check live in one place.
|
||||||
|
//
|
||||||
|
// Model: claude-haiku-4-5 — the cheapest current Claude model ($1/M input, $5/M output),
|
||||||
|
// chosen deliberately for cost; swap AI_MODEL to a bigger model if quality needs grow.
|
||||||
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
|
import { env } from '../env';
|
||||||
|
|
||||||
|
export const AI_MODEL = 'claude-haiku-4-5';
|
||||||
|
|
||||||
|
let _client: Anthropic | null = null;
|
||||||
|
|
||||||
|
function getClient(): Anthropic | null {
|
||||||
|
if (!env.ANTHROPIC_API_KEY) return null;
|
||||||
|
if (!_client) _client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAiEnabled(): boolean {
|
||||||
|
return Boolean(env.ANTHROPIC_API_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AiDisabledError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('ai_not_configured');
|
||||||
|
this.name = 'AiDisabledError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AiUnavailableError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public retryable: boolean,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AiUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiUsage {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiResult {
|
||||||
|
text: string;
|
||||||
|
usage: AiUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-turn completion. `content` is either plain text or prebuilt content blocks
|
||||||
|
* (e.g. a document block for PDF summarization).
|
||||||
|
*/
|
||||||
|
export async function aiComplete(opts: {
|
||||||
|
system: string;
|
||||||
|
content: string | Anthropic.ContentBlockParam[];
|
||||||
|
maxTokens?: number;
|
||||||
|
}): Promise<AiResult> {
|
||||||
|
const client = getClient();
|
||||||
|
if (!client) throw new AiDisabledError();
|
||||||
|
|
||||||
|
let response: Anthropic.Message;
|
||||||
|
try {
|
||||||
|
response = await client.messages.create({
|
||||||
|
model: AI_MODEL,
|
||||||
|
max_tokens: opts.maxTokens ?? 1500,
|
||||||
|
system: opts.system,
|
||||||
|
messages: [{ role: 'user', content: opts.content }],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Anthropic.RateLimitError) {
|
||||||
|
throw new AiUnavailableError('ai_rate_limited', true);
|
||||||
|
}
|
||||||
|
if (err instanceof Anthropic.APIConnectionError) {
|
||||||
|
throw new AiUnavailableError('ai_connection_failed', true);
|
||||||
|
}
|
||||||
|
if (err instanceof Anthropic.APIError) {
|
||||||
|
// 4xx: our request is malformed (e.g. unsupported document) — not retryable.
|
||||||
|
// 5xx/overloaded: transient.
|
||||||
|
const retryable = typeof err.status === 'number' ? err.status >= 500 : true;
|
||||||
|
throw new AiUnavailableError(`ai_error_${err.status ?? 'unknown'}`, retryable);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.stop_reason === 'refusal') {
|
||||||
|
throw new AiUnavailableError('ai_refused', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = response.content
|
||||||
|
.filter((b): b is Anthropic.TextBlock => b.type === 'text')
|
||||||
|
.map((b) => b.text)
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
usage: {
|
||||||
|
inputTokens: response.usage.input_tokens,
|
||||||
|
outputTokens: response.usage.output_tokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Cloudflare Turnstile server-side verification. Protects the public, abuse-prone
|
||||||
|
// endpoints (signup, login, password reset, contact form) from bots.
|
||||||
|
// With TURNSTILE_SECRET_KEY unset (local dev), verification is skipped entirely.
|
||||||
|
import { env } from '../env';
|
||||||
|
|
||||||
|
const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||||
|
|
||||||
|
export function isTurnstileEnabled(): boolean {
|
||||||
|
return Boolean(env.TURNSTILE_SECRET_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteverifyResponse {
|
||||||
|
success: boolean;
|
||||||
|
'error-codes'?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a Turnstile token. Returns true when the check passes or Turnstile is
|
||||||
|
* disabled. Tokens are single-use — the widget must be reset after a failed submit.
|
||||||
|
*/
|
||||||
|
export async function verifyTurnstile(
|
||||||
|
token: string | undefined | null,
|
||||||
|
ip?: string | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!isTurnstileEnabled()) return true;
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
secret: env.TURNSTILE_SECRET_KEY,
|
||||||
|
response: token,
|
||||||
|
});
|
||||||
|
if (ip) body.set('remoteip', ip);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(VERIFY_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const json = (await res.json()) as SiteverifyResponse;
|
||||||
|
return json.success === true;
|
||||||
|
} catch {
|
||||||
|
// Cloudflare unreachable — fail closed. The client shows "try again" and the
|
||||||
|
// widget issues a fresh token on retry.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
import { getDb, cases, clients, timeEntries, documents, invoices } from '@lawdesk/db';
|
||||||
|
import { aiComplete, isAiEnabled, AiDisabledError, AiUnavailableError, AI_MODEL } from '../lib/ai';
|
||||||
|
import { getObjectStream, FileNotFoundError } from '../lib/storage';
|
||||||
|
|
||||||
|
// The one non-negotiable framing for a legal-tech product: the model assists with
|
||||||
|
// organization and drafting; it must never present itself as giving legal advice.
|
||||||
|
const BASE_SYSTEM = `You are an assistant inside eLegal Software, a practice-management tool used by attorneys.
|
||||||
|
You help organize and summarize the firm's own records. You do not give legal advice, cite law, or predict case outcomes.
|
||||||
|
Be factual and concise. Only state what is supported by the provided material; if something is unclear or missing, say so plainly.`;
|
||||||
|
|
||||||
|
const AI_DISCLAIMER =
|
||||||
|
'AI-generated from your case records — review for accuracy. Not legal advice.';
|
||||||
|
|
||||||
|
// Document types we can hand to the model directly.
|
||||||
|
const AI_MIME = {
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
text: 'text/plain',
|
||||||
|
images: new Set(['image/jpeg', 'image/png', 'image/webp']),
|
||||||
|
} as const;
|
||||||
|
const MAX_AI_DOC_BYTES = 15 * 1024 * 1024; // base64 expansion must stay under the 32MB request cap
|
||||||
|
|
||||||
|
function sendAiError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||||
|
if (err instanceof AiDisabledError) {
|
||||||
|
return reply.code(503).send({ error: 'ai_not_configured' });
|
||||||
|
}
|
||||||
|
if (err instanceof AiUnavailableError) {
|
||||||
|
return reply.code(err.retryable ? 503 : 422).send({ error: err.message });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream as AsyncIterable<Buffer | string>) {
|
||||||
|
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aiRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook('preHandler', app.requireFirm);
|
||||||
|
|
||||||
|
// Lets the UI decide whether to render AI affordances at all.
|
||||||
|
app.get('/api/ai/status', async () => ({ enabled: isAiEnabled(), model: AI_MODEL }));
|
||||||
|
|
||||||
|
// ── Case summary ─────────────────────────────────────────────────────────
|
||||||
|
// Builds a brief from everything the firm has recorded on a case.
|
||||||
|
app.post(
|
||||||
|
'/api/cases/:id/ai/summary',
|
||||||
|
{ config: { rateLimit: { max: 30, timeWindow: '1 hour' } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const firmId = req.user!.firmId!;
|
||||||
|
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const [c] = await db
|
||||||
|
.select()
|
||||||
|
.from(cases)
|
||||||
|
.where(and(eq(cases.id, id), eq(cases.firmId, firmId)))
|
||||||
|
.limit(1);
|
||||||
|
if (!c) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select({ name: clients.name })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, c.clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const entries = await db
|
||||||
|
.select({
|
||||||
|
description: timeEntries.description,
|
||||||
|
minutes: timeEntries.minutes,
|
||||||
|
billable: timeEntries.billable,
|
||||||
|
startedAt: timeEntries.startedAt,
|
||||||
|
})
|
||||||
|
.from(timeEntries)
|
||||||
|
.where(and(eq(timeEntries.caseId, id), eq(timeEntries.firmId, firmId)))
|
||||||
|
.orderBy(desc(timeEntries.startedAt))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
const docs = await db
|
||||||
|
.select({ name: documents.name, createdAt: documents.createdAt })
|
||||||
|
.from(documents)
|
||||||
|
.where(and(eq(documents.caseId, id), eq(documents.firmId, firmId)))
|
||||||
|
.orderBy(desc(documents.createdAt))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
const caseInvoices = await db
|
||||||
|
.select({ number: invoices.number, status: invoices.status, total: invoices.total })
|
||||||
|
.from(invoices)
|
||||||
|
.where(and(eq(invoices.caseId, id), eq(invoices.firmId, firmId)));
|
||||||
|
|
||||||
|
const fmtDate = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : 'n/a');
|
||||||
|
const context = [
|
||||||
|
`CASE: ${c.title}${c.caseNumber ? ` (No. ${c.caseNumber})` : ''}`,
|
||||||
|
`Client: ${client?.name ?? 'unknown'} · Status: ${c.status} · Practice area: ${c.practiceArea ?? 'n/a'}`,
|
||||||
|
`Opened: ${fmtDate(c.openedAt)}${c.closedAt ? ` · Closed: ${fmtDate(c.closedAt)}` : ''}`,
|
||||||
|
c.description ? `Description: ${c.description}` : '',
|
||||||
|
'',
|
||||||
|
`TIME ENTRIES (most recent ${entries.length}):`,
|
||||||
|
...entries.map(
|
||||||
|
(e) =>
|
||||||
|
`- ${fmtDate(e.startedAt)} · ${e.minutes} min · ${e.billable ? 'billable' : 'non-billable'} · ${e.description}`,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
`DOCUMENTS (${docs.length}):`,
|
||||||
|
...docs.map((d) => `- ${d.name} (uploaded ${fmtDate(d.createdAt)})`),
|
||||||
|
'',
|
||||||
|
`INVOICES (${caseInvoices.length}):`,
|
||||||
|
...caseInvoices.map((i) => `- ${i.number} · ${i.status} · $${i.total}`),
|
||||||
|
]
|
||||||
|
.filter((line) => line !== '')
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await aiComplete({
|
||||||
|
system: `${BASE_SYSTEM}
|
||||||
|
Write a case brief for the attorney working this case, as plain text (no markdown syntax) with these section headings on their own lines:
|
||||||
|
STATUS SNAPSHOT — 2-3 sentences on where the case stands based on the records.
|
||||||
|
RECENT ACTIVITY — the notable recent work, grouped, not a raw list.
|
||||||
|
BILLING PICTURE — hours logged, what's invoiced/outstanding.
|
||||||
|
GAPS & FOLLOW-UPS — anything the records suggest needs attention (stale activity, unbilled time, missing documents).
|
||||||
|
Keep it under 300 words.`,
|
||||||
|
content: context,
|
||||||
|
});
|
||||||
|
return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage };
|
||||||
|
} catch (err) {
|
||||||
|
return sendAiError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Document summary ─────────────────────────────────────────────────────
|
||||||
|
app.post(
|
||||||
|
'/api/documents/:docId/ai/summary',
|
||||||
|
{ config: { rateLimit: { max: 30, timeWindow: '1 hour' } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const firmId = req.user!.firmId!;
|
||||||
|
const { docId } = z.object({ docId: z.string().uuid() }).parse(req.params);
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const [doc] = await db
|
||||||
|
.select()
|
||||||
|
.from(documents)
|
||||||
|
.where(and(eq(documents.id, docId), eq(documents.firmId, firmId)))
|
||||||
|
.limit(1);
|
||||||
|
if (!doc) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
|
const supported =
|
||||||
|
doc.mimeType === AI_MIME.pdf ||
|
||||||
|
doc.mimeType === AI_MIME.text ||
|
||||||
|
AI_MIME.images.has(doc.mimeType);
|
||||||
|
if (!supported) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'unsupported_for_ai',
|
||||||
|
hint: 'AI summaries support PDF, plain text, and image documents.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (doc.sizeBytes > MAX_AI_DOC_BYTES) {
|
||||||
|
return reply.code(400).send({ error: 'document_too_large_for_ai' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let buf: Buffer;
|
||||||
|
try {
|
||||||
|
buf = await streamToBuffer(await getObjectStream(doc.storageKey));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof FileNotFoundError) return reply.code(404).send({ error: 'file_missing' });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instruction = `Summarize the attached document ("${doc.name}") for the attorney's case file, as plain text (no markdown syntax) with these section headings on their own lines:
|
||||||
|
WHAT IT IS — document type and apparent purpose.
|
||||||
|
KEY POINTS — parties, dates, amounts, deadlines, obligations that appear in it.
|
||||||
|
ANYTHING UNUSUAL — inconsistencies, missing signatures/pages, ambiguities worth a closer look.
|
||||||
|
Keep it under 300 words.`;
|
||||||
|
|
||||||
|
const content =
|
||||||
|
doc.mimeType === AI_MIME.text
|
||||||
|
? `${instruction}\n\n--- DOCUMENT CONTENT ---\n${buf.toString('utf8')}`
|
||||||
|
: ([
|
||||||
|
doc.mimeType === AI_MIME.pdf
|
||||||
|
? {
|
||||||
|
type: 'document' as const,
|
||||||
|
source: {
|
||||||
|
type: 'base64' as const,
|
||||||
|
media_type: 'application/pdf' as const,
|
||||||
|
data: buf.toString('base64'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: 'image' as const,
|
||||||
|
source: {
|
||||||
|
type: 'base64' as const,
|
||||||
|
media_type: doc.mimeType as 'image/jpeg' | 'image/png' | 'image/webp',
|
||||||
|
data: buf.toString('base64'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'text' as const, text: instruction },
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await aiComplete({ system: BASE_SYSTEM, content });
|
||||||
|
return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage };
|
||||||
|
} catch (err) {
|
||||||
|
return sendAiError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Text polish ──────────────────────────────────────────────────────────
|
||||||
|
// Generic "make this professional" helper for notes, descriptions, and messages.
|
||||||
|
app.post(
|
||||||
|
'/api/ai/polish',
|
||||||
|
{ config: { rateLimit: { max: 60, timeWindow: '1 hour' } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = z
|
||||||
|
.object({
|
||||||
|
text: z.string().min(1).max(10_000),
|
||||||
|
kind: z.enum(['time_entry', 'invoice_note', 'case_description', 'client_message']),
|
||||||
|
})
|
||||||
|
.parse(req.body);
|
||||||
|
|
||||||
|
const KIND_GUIDANCE: Record<typeof body.kind, string> = {
|
||||||
|
time_entry:
|
||||||
|
'Rewrite as a professional billing narrative: past tense, specific, defensible to a client reviewing the invoice. One or two sentences.',
|
||||||
|
invoice_note:
|
||||||
|
'Rewrite as a courteous, professional note to appear on a client invoice. Brief.',
|
||||||
|
case_description:
|
||||||
|
'Rewrite as a clear internal case description: what the matter is, who is involved, current posture.',
|
||||||
|
client_message:
|
||||||
|
'Rewrite as a professional, warm message from a law firm to its client. Plain language, no legalese.',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await aiComplete({
|
||||||
|
system: `${BASE_SYSTEM}
|
||||||
|
${KIND_GUIDANCE[body.kind]}
|
||||||
|
Return ONLY the rewritten text — no preamble, no quotes, no commentary. Preserve all facts; never invent names, dates, or amounts.`,
|
||||||
|
content: body.text,
|
||||||
|
maxTokens: 800,
|
||||||
|
});
|
||||||
|
return { text: result.text, usage: result.usage };
|
||||||
|
} catch (err) {
|
||||||
|
return sendAiError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,17 +23,20 @@ import {
|
|||||||
verifyEmailEmail,
|
verifyEmailEmail,
|
||||||
} from '../lib/email';
|
} from '../lib/email';
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
|
import { verifyTurnstile } from '../lib/turnstile';
|
||||||
|
|
||||||
const signupBody = z.object({
|
const signupBody = z.object({
|
||||||
email: z.string().email().max(254).toLowerCase().trim(),
|
email: z.string().email().max(254).toLowerCase().trim(),
|
||||||
password: z.string().min(10).max(200),
|
password: z.string().min(10).max(200),
|
||||||
fullName: z.string().min(1).max(120).trim(),
|
fullName: z.string().min(1).max(120).trim(),
|
||||||
firmName: z.string().min(1).max(160).trim(),
|
firmName: z.string().min(1).max(160).trim(),
|
||||||
|
turnstileToken: z.string().max(3000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const loginBody = z.object({
|
const loginBody = z.object({
|
||||||
email: z.string().email().max(254).toLowerCase().trim(),
|
email: z.string().email().max(254).toLowerCase().trim(),
|
||||||
password: z.string().min(1).max(200),
|
password: z.string().min(1).max(200),
|
||||||
|
turnstileToken: z.string().max(3000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const MAX_FAILS_PER_15_MIN = 5;
|
const MAX_FAILS_PER_15_MIN = 5;
|
||||||
@@ -74,6 +77,11 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
{ config: { rateLimit: { max: 5, timeWindow: '1 hour' } } },
|
{ config: { rateLimit: { max: 5, timeWindow: '1 hour' } } },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const body = signupBody.parse(req.body);
|
const body = signupBody.parse(req.body);
|
||||||
|
|
||||||
|
if (!(await verifyTurnstile(body.turnstileToken, req.ip))) {
|
||||||
|
return reply.code(400).send({ error: 'captcha_failed' });
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, body.email)).limit(1);
|
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, body.email)).limit(1);
|
||||||
@@ -144,6 +152,11 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
{ config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } },
|
{ config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const body = loginBody.parse(req.body);
|
const body = loginBody.parse(req.body);
|
||||||
|
|
||||||
|
if (!(await verifyTurnstile(body.turnstileToken, req.ip))) {
|
||||||
|
return reply.code(400).send({ error: 'captcha_failed' });
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const ip = req.ip ?? null;
|
const ip = req.ip ?? null;
|
||||||
|
|
||||||
@@ -216,10 +229,21 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
app.post(
|
app.post(
|
||||||
'/api/auth/request-password-reset',
|
'/api/auth/request-password-reset',
|
||||||
{ config: { rateLimit: { max: 5, timeWindow: '15 minutes' } } },
|
{ config: { rateLimit: { max: 5, timeWindow: '15 minutes' } } },
|
||||||
async (req) => {
|
async (req, reply) => {
|
||||||
const parsed = z.object({ email: z.string().email().max(254).toLowerCase().trim() }).safeParse(req.body);
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email().max(254).toLowerCase().trim(),
|
||||||
|
turnstileToken: z.string().max(3000).optional(),
|
||||||
|
})
|
||||||
|
.safeParse(req.body);
|
||||||
if (!parsed.success) return { ok: true };
|
if (!parsed.success) return { ok: true };
|
||||||
|
|
||||||
|
// Bot check is orthogonal to email enumeration — a captcha failure is reported
|
||||||
|
// honestly; only account existence is concealed by the ok-always contract.
|
||||||
|
if (!(await verifyTurnstile(parsed.data.turnstileToken, req.ip))) {
|
||||||
|
return reply.code(400).send({ error: 'captcha_failed' });
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const [user] = await db.select().from(users).where(eq(users.email, parsed.data.email)).limit(1);
|
const [user] = await db.select().from(users).where(eq(users.email, parsed.data.email)).limit(1);
|
||||||
if (!user || user.isSuspended) return { ok: true };
|
if (!user || user.isSuspended) return { ok: true };
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { z } from 'zod';
|
|||||||
import { getDb, contactMessages } from '@lawdesk/db';
|
import { getDb, contactMessages } from '@lawdesk/db';
|
||||||
import { sendEmail, contactAckEmail, contactNotifyEmail } from '../lib/email';
|
import { sendEmail, contactAckEmail, contactNotifyEmail } from '../lib/email';
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
|
import { verifyTurnstile } from '../lib/turnstile';
|
||||||
|
|
||||||
const contactBody = z.object({
|
const contactBody = z.object({
|
||||||
fullName: z.string().min(1).max(120).trim(),
|
fullName: z.string().min(1).max(120).trim(),
|
||||||
email: z.string().email().max(254).toLowerCase().trim(),
|
email: z.string().email().max(254).toLowerCase().trim(),
|
||||||
message: z.string().min(1).max(5000).trim(),
|
message: z.string().min(1).max(5000).trim(),
|
||||||
|
turnstileToken: z.string().max(3000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function contactRoutes(app: FastifyInstance) {
|
export async function contactRoutes(app: FastifyInstance) {
|
||||||
@@ -18,6 +20,11 @@ export async function contactRoutes(app: FastifyInstance) {
|
|||||||
const parsed = contactBody.safeParse(req.body);
|
const parsed = contactBody.safeParse(req.body);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||||
const body = parsed.data;
|
const body = parsed.data;
|
||||||
|
|
||||||
|
if (!(await verifyTurnstile(body.turnstileToken, req.ip))) {
|
||||||
|
return reply.code(400).send({ error: 'captcha_failed' });
|
||||||
|
}
|
||||||
|
|
||||||
await getDb().insert(contactMessages).values({
|
await getDb().insert(contactMessages).values({
|
||||||
fullName: body.fullName,
|
fullName: body.fullName,
|
||||||
email: body.email,
|
email: body.email,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { accountRoutes } from './routes/account';
|
|||||||
import { toolUsageRoutes } from './routes/tool-usage';
|
import { toolUsageRoutes } from './routes/tool-usage';
|
||||||
import { billingRoutes } from './routes/billing';
|
import { billingRoutes } from './routes/billing';
|
||||||
import { documentsRoutes } from './routes/documents';
|
import { documentsRoutes } from './routes/documents';
|
||||||
|
import { aiRoutes } from './routes/ai';
|
||||||
import { stripeWebhookRoute } from './routes/webhooks-stripe';
|
import { stripeWebhookRoute } from './routes/webhooks-stripe';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -70,11 +71,12 @@ export async function buildServer() {
|
|||||||
? {
|
? {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'"],
|
scriptSrc: ["'self'", 'https://challenges.cloudflare.com'],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||||
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
|
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
|
||||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
imgSrc: ["'self'", 'data:', 'blob:', 'https://*.digitaloceanspaces.com', 'https://*.cdn.digitaloceanspaces.com'],
|
||||||
connectSrc: ["'self'"],
|
connectSrc: ["'self'"],
|
||||||
|
frameSrc: ['https://challenges.cloudflare.com'],
|
||||||
frameAncestors: ["'none'"],
|
frameAncestors: ["'none'"],
|
||||||
formAction: ["'self'"],
|
formAction: ["'self'"],
|
||||||
baseUri: ["'self'"],
|
baseUri: ["'self'"],
|
||||||
@@ -119,6 +121,7 @@ export async function buildServer() {
|
|||||||
await app.register(toolUsageRoutes);
|
await app.register(toolUsageRoutes);
|
||||||
await app.register(billingRoutes);
|
await app.register(billingRoutes);
|
||||||
await app.register(documentsRoutes);
|
await app.register(documentsRoutes);
|
||||||
|
await app.register(aiRoutes);
|
||||||
|
|
||||||
// Serve the built SPA in production. In dev, the Vite dev server runs separately.
|
// Serve the built SPA in production. In dev, the Vite dev server runs separately.
|
||||||
const webDist = env.WEB_DIST_PATH ?? path.resolve(__dirname, '../../web/dist');
|
const webDist = env.WEB_DIST_PATH ?? path.resolve(__dirname, '../../web/dist');
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
// Cloudflare Turnstile widget (explicit render). Renders nothing when
|
||||||
|
// VITE_TURNSTILE_SITE_KEY is unset, so dev without keys just works.
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
turnstile?: {
|
||||||
|
render: (
|
||||||
|
el: HTMLElement,
|
||||||
|
opts: {
|
||||||
|
sitekey: string;
|
||||||
|
callback: (token: string) => void;
|
||||||
|
'expired-callback'?: () => void;
|
||||||
|
'error-callback'?: () => void;
|
||||||
|
theme?: 'light' | 'dark' | 'auto';
|
||||||
|
},
|
||||||
|
) => string;
|
||||||
|
reset: (widgetId: string) => void;
|
||||||
|
remove: (widgetId: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TURNSTILE_SITE_KEY: string = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? '';
|
||||||
|
|
||||||
|
let scriptPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function loadScript(): Promise<void> {
|
||||||
|
if (window.turnstile) return Promise.resolve();
|
||||||
|
if (!scriptPromise) {
|
||||||
|
scriptPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||||
|
s.async = true;
|
||||||
|
s.onload = () => resolve();
|
||||||
|
s.onerror = () => {
|
||||||
|
scriptPromise = null;
|
||||||
|
reject(new Error('turnstile_script_failed'));
|
||||||
|
};
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return scriptPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Turnstile({
|
||||||
|
onToken,
|
||||||
|
resetSignal = 0,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
/** Called with a fresh token, or null when the token expires/errors. */
|
||||||
|
onToken: (token: string | null) => void;
|
||||||
|
/** Bump this number to force a widget reset (tokens are single-use). */
|
||||||
|
resetSignal?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const widgetIdRef = useRef<string | null>(null);
|
||||||
|
const onTokenRef = useRef(onToken);
|
||||||
|
onTokenRef.current = onToken;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!TURNSTILE_SITE_KEY || !containerRef.current) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
loadScript()
|
||||||
|
.then(() => {
|
||||||
|
if (cancelled || !containerRef.current || widgetIdRef.current || !window.turnstile) return;
|
||||||
|
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||||
|
sitekey: TURNSTILE_SITE_KEY,
|
||||||
|
theme: 'light',
|
||||||
|
callback: (token) => onTokenRef.current(token),
|
||||||
|
'expired-callback': () => onTokenRef.current(null),
|
||||||
|
'error-callback': () => onTokenRef.current(null),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => onTokenRef.current(null));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (widgetIdRef.current && window.turnstile) {
|
||||||
|
window.turnstile.remove(widgetIdRef.current);
|
||||||
|
widgetIdRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resetSignal > 0 && widgetIdRef.current && window.turnstile) {
|
||||||
|
window.turnstile.reset(widgetIdRef.current);
|
||||||
|
onTokenRef.current(null);
|
||||||
|
}
|
||||||
|
}, [resetSignal]);
|
||||||
|
|
||||||
|
if (!TURNSTILE_SITE_KEY) return null;
|
||||||
|
return <div ref={containerRef} className={className} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Loader2, Sparkles } from 'lucide-react';
|
||||||
|
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { api, type ApiError } from '@/lib/api';
|
||||||
|
|
||||||
|
interface AiSummaryResponse {
|
||||||
|
summary: string;
|
||||||
|
disclaimer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_COPY: Record<string, string> = {
|
||||||
|
ai_not_configured: 'AI features are not configured on this server.',
|
||||||
|
ai_rate_limited: 'AI is busy right now — try again in a minute.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiCaseSummary({ caseId }: { caseId: string }) {
|
||||||
|
const [result, setResult] = useState<AiSummaryResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await api.post<AiSummaryResponse>(`/api/cases/${caseId}/ai/summary`);
|
||||||
|
setResult(data);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as ApiError).code ?? '';
|
||||||
|
setError(ERROR_COPY[code] ?? 'Could not generate a summary — try again shortly.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="AI case brief"
|
||||||
|
action={
|
||||||
|
<Button variant="secondary" onClick={generate} disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Summarizing…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
{result ? 'Regenerate' : 'Generate summary'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardBody>
|
||||||
|
{error && <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p>}
|
||||||
|
{!error && !result && !loading && (
|
||||||
|
<p className="text-sm text-ink-500">
|
||||||
|
Generate an AI brief of this case from its records — status, recent activity, billing,
|
||||||
|
and suggested follow-ups.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-800">
|
||||||
|
{result.summary}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink-400">{result.disclaimer}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,12 +31,21 @@ export function BlogTeaser() {
|
|||||||
to={`/blog/${p.slug}`}
|
to={`/blog/${p.slug}`}
|
||||||
className="group rounded-2xl border border-ink-100 bg-white overflow-hidden hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col"
|
className="group rounded-2xl border border-ink-100 bg-white overflow-hidden hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="aspect-[16/9] bg-gradient-to-br from-brand-100 via-brand-50 to-white relative">
|
<div className="aspect-[16/9] bg-gradient-to-br from-brand-100 via-brand-50 to-white relative overflow-hidden">
|
||||||
|
{p.coverImage ? (
|
||||||
|
<img
|
||||||
|
src={p.coverImage}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="absolute inset-0 grid place-items-center px-6">
|
<div className="absolute inset-0 grid place-items-center px-6">
|
||||||
<span className="text-2xl font-bold text-brand-300/50 font-display select-none text-center leading-tight">
|
<span className="text-2xl font-bold text-brand-300/50 font-display select-none text-center leading-tight">
|
||||||
{p.title.split(' ').slice(0, 3).join(' ')}
|
{p.title.split(' ').slice(0, 3).join(' ')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 flex flex-col flex-1">
|
<div className="p-6 flex flex-col flex-1">
|
||||||
<p className="text-xs text-ink-500">{formatDate(p.publishedAt)}</p>
|
<p className="text-xs text-ink-500">{formatDate(p.publishedAt)}</p>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Mail, Send, Clock } from 'lucide-react';
|
import { Mail, Send, Clock } from 'lucide-react';
|
||||||
import { api, type ApiError } from '@/lib/api';
|
import { api, type ApiError } from '@/lib/api';
|
||||||
|
import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile';
|
||||||
|
|
||||||
type State = 'idle' | 'submitting' | 'success' | 'error';
|
type State = 'idle' | 'submitting' | 'success' | 'error';
|
||||||
|
|
||||||
export function Contact() {
|
export function Contact() {
|
||||||
const [state, setState] = useState<State>('idle');
|
const [state, setState] = useState<State>('idle');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||||
|
const [captchaReset, setCaptchaReset] = useState(0);
|
||||||
|
|
||||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -18,6 +21,7 @@ export function Contact() {
|
|||||||
fullName: String(fd.get('fullName') ?? '').trim(),
|
fullName: String(fd.get('fullName') ?? '').trim(),
|
||||||
email: String(fd.get('email') ?? '').trim(),
|
email: String(fd.get('email') ?? '').trim(),
|
||||||
message: String(fd.get('message') ?? '').trim(),
|
message: String(fd.get('message') ?? '').trim(),
|
||||||
|
turnstileToken: captchaToken ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -28,6 +32,9 @@ export function Contact() {
|
|||||||
const apiErr = err as ApiError;
|
const apiErr = err as ApiError;
|
||||||
setError(apiErr.code ?? apiErr.message);
|
setError(apiErr.code ?? apiErr.message);
|
||||||
setState('error');
|
setState('error');
|
||||||
|
} finally {
|
||||||
|
// Tokens are single-use — reset the widget whether the send worked or not.
|
||||||
|
setCaptchaReset((n) => n + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +77,13 @@ export function Contact() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={state === 'submitting'} className="btn-primary mt-6 w-full">
|
<Turnstile onToken={setCaptchaToken} resetSignal={captchaReset} className="mt-6" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={state === 'submitting' || (Boolean(TURNSTILE_SITE_KEY) && !captchaToken)}
|
||||||
|
className="btn-primary mt-6 w-full"
|
||||||
|
>
|
||||||
{state === 'submitting' ? 'Sending…' : (
|
{state === 'submitting' ? 'Sending…' : (
|
||||||
<>
|
<>
|
||||||
Send Message
|
Send Message
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface Post {
|
|||||||
publishedAt: string; // ISO date
|
publishedAt: string; // ISO date
|
||||||
readMinutes: number;
|
readMinutes: number;
|
||||||
author: string;
|
author: string;
|
||||||
|
coverImage?: string; // absolute URL (served from DigitalOcean Spaces)
|
||||||
// Body is structured as an array of blocks for simple renderable JSX
|
// Body is structured as an array of blocks for simple renderable JSX
|
||||||
body: Block[];
|
body: Block[];
|
||||||
}
|
}
|
||||||
@@ -27,6 +28,8 @@ export const POSTS: Post[] = [
|
|||||||
publishedAt: '2026-04-08',
|
publishedAt: '2026-04-08',
|
||||||
readMinutes: 8,
|
readMinutes: 8,
|
||||||
author: 'eLegal Software Team',
|
author: 'eLegal Software Team',
|
||||||
|
coverImage:
|
||||||
|
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/maximize-billable-hours-without-burnout.jpg',
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
@@ -89,6 +92,8 @@ export const POSTS: Post[] = [
|
|||||||
publishedAt: '2026-03-21',
|
publishedAt: '2026-03-21',
|
||||||
readMinutes: 12,
|
readMinutes: 12,
|
||||||
author: 'eLegal Software Team',
|
author: 'eLegal Software Team',
|
||||||
|
coverImage:
|
||||||
|
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/client-intake-best-practices-2026.jpg',
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
@@ -157,6 +162,8 @@ export const POSTS: Post[] = [
|
|||||||
publishedAt: '2026-02-14',
|
publishedAt: '2026-02-14',
|
||||||
readMinutes: 10,
|
readMinutes: 10,
|
||||||
author: 'eLegal Software Team',
|
author: 'eLegal Software Team',
|
||||||
|
coverImage:
|
||||||
|
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/legal-billing-software-comparison-2026.jpg',
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function useMe() {
|
|||||||
|
|
||||||
export function useLogin() {
|
export function useLogin() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation<AuthUser, ApiError, { email: string; password: string }>({
|
return useMutation<AuthUser, ApiError, { email: string; password: string; turnstileToken?: string }>({
|
||||||
mutationFn: async (vars) => {
|
mutationFn: async (vars) => {
|
||||||
const data = await api.post<MeResponse>('/api/auth/login', vars);
|
const data = await api.post<MeResponse>('/api/auth/login', vars);
|
||||||
return data.user;
|
return data.user;
|
||||||
@@ -47,7 +47,11 @@ export function useLogin() {
|
|||||||
|
|
||||||
export function useSignup() {
|
export function useSignup() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation<AuthUser, ApiError, { email: string; password: string; fullName: string; firmName: string }>({
|
return useMutation<
|
||||||
|
AuthUser,
|
||||||
|
ApiError,
|
||||||
|
{ email: string; password: string; fullName: string; firmName: string; turnstileToken?: string }
|
||||||
|
>({
|
||||||
mutationFn: async (vars) => {
|
mutationFn: async (vars) => {
|
||||||
const data = await api.post<MeResponse>('/api/auth/signup', vars);
|
const data = await api.post<MeResponse>('/api/auth/signup', vars);
|
||||||
return data.user;
|
return data.user;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMutation } from '@tanstack/react-query';
|
|||||||
import { api, type ApiError } from '@/lib/api';
|
import { api, type ApiError } from '@/lib/api';
|
||||||
|
|
||||||
export function useRequestPasswordReset() {
|
export function useRequestPasswordReset() {
|
||||||
return useMutation<{ ok: boolean }, ApiError, { email: string }>({
|
return useMutation<{ ok: boolean }, ApiError, { email: string; turnstileToken?: string }>({
|
||||||
mutationFn: (body) => api.post('/api/auth/request-password-reset', body),
|
mutationFn: (body) => api.post('/api/auth/request-password-reset', body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ArrowRight, MailCheck } from 'lucide-react';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AuthLayout } from '@/components/auth/AuthLayout';
|
import { AuthLayout } from '@/components/auth/AuthLayout';
|
||||||
import { Field } from '@/components/auth/Field';
|
import { Field } from '@/components/auth/Field';
|
||||||
|
import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile';
|
||||||
import { useRequestPasswordReset } from '@/hooks/useResetPassword';
|
import { useRequestPasswordReset } from '@/hooks/useResetPassword';
|
||||||
|
|
||||||
const schema = z.object({ email: z.string().email('Enter a valid email') });
|
const schema = z.object({ email: z.string().email('Enter a valid email') });
|
||||||
@@ -13,6 +14,8 @@ type FormValues = z.infer<typeof schema>;
|
|||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
const request = useRequestPasswordReset();
|
const request = useRequestPasswordReset();
|
||||||
|
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||||
|
const [captchaReset, setCaptchaReset] = useState(0);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -23,7 +26,13 @@ export default function ForgotPasswordPage() {
|
|||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
const parsed = schema.safeParse(values);
|
const parsed = schema.safeParse(values);
|
||||||
if (!parsed.success) return;
|
if (!parsed.success) return;
|
||||||
await request.mutateAsync(parsed.data);
|
try {
|
||||||
|
await request.mutateAsync({ ...parsed.data, turnstileToken: captchaToken ?? undefined });
|
||||||
|
} catch {
|
||||||
|
// Tokens are single-use — issue a fresh one for the retry.
|
||||||
|
setCaptchaReset((n) => n + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +70,21 @@ export default function ForgotPasswordPage() {
|
|||||||
error={errors.email?.message}
|
error={errors.email?.message}
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
/>
|
/>
|
||||||
|
{request.isError && (
|
||||||
|
<p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||||
|
{request.error?.code === 'captcha_failed'
|
||||||
|
? 'Verification failed — complete the check below and try again.'
|
||||||
|
: 'Something went wrong. Please try again.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Turnstile onToken={setCaptchaToken} resetSignal={captchaReset} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || request.isPending}
|
disabled={
|
||||||
|
isSubmitting || request.isPending || (Boolean(TURNSTILE_SITE_KEY) && !captchaToken)
|
||||||
|
}
|
||||||
className="btn-primary w-full"
|
className="btn-primary w-full"
|
||||||
>
|
>
|
||||||
{request.isPending ? 'Sending…' : (
|
{request.isPending ? 'Sending…' : (
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { AuthLayout } from '@/components/auth/AuthLayout';
|
import { AuthLayout } from '@/components/auth/AuthLayout';
|
||||||
import { Field } from '@/components/auth/Field';
|
import { Field } from '@/components/auth/Field';
|
||||||
|
import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile';
|
||||||
import { useLogin, useMe } from '@/hooks/useAuth';
|
import { useLogin, useMe } from '@/hooks/useAuth';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -17,6 +18,7 @@ type FormValues = z.infer<typeof schema>;
|
|||||||
const ERROR_COPY: Record<string, string> = {
|
const ERROR_COPY: Record<string, string> = {
|
||||||
invalid_credentials: 'Email or password is incorrect.',
|
invalid_credentials: 'Email or password is incorrect.',
|
||||||
too_many_attempts: 'Too many attempts. Try again in a few minutes.',
|
too_many_attempts: 'Too many attempts. Try again in a few minutes.',
|
||||||
|
captcha_failed: 'Verification failed — complete the check below and try again.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@@ -24,6 +26,8 @@ export default function LoginPage() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
|
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||||
|
const [captchaReset, setCaptchaReset] = useState(0);
|
||||||
|
|
||||||
// Landing target of the email-verification link: /login?verified=1|0
|
// Landing target of the email-verification link: /login?verified=1|0
|
||||||
const verified = new URLSearchParams(location.search).get('verified');
|
const verified = new URLSearchParams(location.search).get('verified');
|
||||||
@@ -43,7 +47,13 @@ export default function LoginPage() {
|
|||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
const parsed = schema.safeParse(values);
|
const parsed = schema.safeParse(values);
|
||||||
if (!parsed.success) return;
|
if (!parsed.success) return;
|
||||||
await login.mutateAsync(parsed.data);
|
try {
|
||||||
|
await login.mutateAsync({ ...parsed.data, turnstileToken: captchaToken ?? undefined });
|
||||||
|
} catch {
|
||||||
|
// Tokens are single-use — issue a fresh one for the retry.
|
||||||
|
setCaptchaReset((n) => n + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const next = new URLSearchParams(location.search).get('next') ?? '/app';
|
const next = new URLSearchParams(location.search).get('next') ?? '/app';
|
||||||
navigate(next, { replace: true });
|
navigate(next, { replace: true });
|
||||||
}
|
}
|
||||||
@@ -103,7 +113,13 @@ export default function LoginPage() {
|
|||||||
<p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiError}</p>
|
<p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="submit" disabled={isSubmitting || login.isPending} className="btn-primary w-full">
|
<Turnstile onToken={setCaptchaToken} resetSignal={captchaReset} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || login.isPending || (Boolean(TURNSTILE_SITE_KEY) && !captchaToken)}
|
||||||
|
className="btn-primary w-full"
|
||||||
|
>
|
||||||
{login.isPending ? 'Signing in…' : (
|
{login.isPending ? 'Signing in…' : (
|
||||||
<>
|
<>
|
||||||
Sign in
|
Sign in
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { AuthLayout } from '@/components/auth/AuthLayout';
|
import { AuthLayout } from '@/components/auth/AuthLayout';
|
||||||
import { Field } from '@/components/auth/Field';
|
import { Field } from '@/components/auth/Field';
|
||||||
|
import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile';
|
||||||
import { useMe, useSignup } from '@/hooks/useAuth';
|
import { useMe, useSignup } from '@/hooks/useAuth';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -18,12 +19,15 @@ type FormValues = z.infer<typeof schema>;
|
|||||||
|
|
||||||
const ERROR_COPY: Record<string, string> = {
|
const ERROR_COPY: Record<string, string> = {
|
||||||
email_taken: 'An account with that email already exists.',
|
email_taken: 'An account with that email already exists.',
|
||||||
|
captcha_failed: 'Verification failed — complete the check below and try again.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
const signup = useSignup();
|
const signup = useSignup();
|
||||||
|
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||||
|
const [captchaReset, setCaptchaReset] = useState(0);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -40,7 +44,13 @@ export default function SignupPage() {
|
|||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
const parsed = schema.safeParse(values);
|
const parsed = schema.safeParse(values);
|
||||||
if (!parsed.success) return;
|
if (!parsed.success) return;
|
||||||
await signup.mutateAsync(parsed.data);
|
try {
|
||||||
|
await signup.mutateAsync({ ...parsed.data, turnstileToken: captchaToken ?? undefined });
|
||||||
|
} catch {
|
||||||
|
// Tokens are single-use — issue a fresh one for the retry.
|
||||||
|
setCaptchaReset((n) => n + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigate('/app', { replace: true });
|
navigate('/app', { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +106,13 @@ export default function SignupPage() {
|
|||||||
<p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiError}</p>
|
<p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="submit" disabled={isSubmitting || signup.isPending} className="btn-primary w-full">
|
<Turnstile onToken={setCaptchaToken} resetSignal={captchaReset} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || signup.isPending || (Boolean(TURNSTILE_SITE_KEY) && !captchaToken)}
|
||||||
|
className="btn-primary w-full"
|
||||||
|
>
|
||||||
{signup.isPending ? 'Creating your account…' : (
|
{signup.isPending ? 'Creating your account…' : (
|
||||||
<>
|
<>
|
||||||
Create account
|
Create account
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Input, Select, Textarea } from '@/components/ui/Input';
|
|||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { CaseTimeList } from '@/components/app/CaseTimeList';
|
import { CaseTimeList } from '@/components/app/CaseTimeList';
|
||||||
import { CreateInvoiceDrawer } from '@/components/app/CreateInvoiceDrawer';
|
import { CreateInvoiceDrawer } from '@/components/app/CreateInvoiceDrawer';
|
||||||
|
import { AiCaseSummary } from '@/components/app/AiCaseSummary';
|
||||||
import { useInvoices, type InvoiceStatus } from '@/hooks/useInvoices';
|
import { useInvoices, type InvoiceStatus } from '@/hooks/useInvoices';
|
||||||
import { formatBytes, formatDate, formatMoney } from '@/lib/format';
|
import { formatBytes, formatDate, formatMoney } from '@/lib/format';
|
||||||
import { useDocuments, useUploadDocument, useDeleteDocument } from '@/hooks/useDocuments';
|
import { useDocuments, useUploadDocument, useDeleteDocument } from '@/hooks/useDocuments';
|
||||||
@@ -188,6 +189,10 @@ export default function CaseDetailPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<AiCaseSummary caseId={id!} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Client" />
|
<CardHeader title="Client" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
|
|||||||
@@ -21,12 +21,21 @@ export default function BlogIndexPage() {
|
|||||||
to={`/blog/${p.slug}`}
|
to={`/blog/${p.slug}`}
|
||||||
className="group rounded-2xl border border-ink-100 bg-white overflow-hidden hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col"
|
className="group rounded-2xl border border-ink-100 bg-white overflow-hidden hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="aspect-[16/9] bg-gradient-to-br from-brand-100 via-brand-50 to-white relative">
|
<div className="aspect-[16/9] bg-gradient-to-br from-brand-100 via-brand-50 to-white relative overflow-hidden">
|
||||||
|
{p.coverImage ? (
|
||||||
|
<img
|
||||||
|
src={p.coverImage}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="absolute inset-0 grid place-items-center">
|
<div className="absolute inset-0 grid place-items-center">
|
||||||
<span className="text-4xl font-bold text-brand-300/50 font-display select-none">
|
<span className="text-4xl font-bold text-brand-300/50 font-display select-none">
|
||||||
{p.title.split(' ').slice(0, 2).join(' ')}
|
{p.title.split(' ').slice(0, 2).join(' ')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 flex flex-col flex-1">
|
<div className="p-6 flex flex-col flex-1">
|
||||||
<p className="text-xs text-ink-500">{formatDate(p.publishedAt)}</p>
|
<p className="text-xs text-ink-500">{formatDate(p.publishedAt)}</p>
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export default function BlogPostPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{post.coverImage && (
|
||||||
|
<img
|
||||||
|
src={post.coverImage}
|
||||||
|
alt=""
|
||||||
|
className="mb-10 aspect-[16/9] w-full rounded-2xl border border-ink-100 object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{post.body.map((b, i) => (
|
{post.body.map((b, i) => (
|
||||||
<RenderBlock key={i} block={b} />
|
<RenderBlock key={i} block={b} />
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import path from 'node:path';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
// VITE_* vars live in the monorepo root .env alongside the API's config.
|
||||||
|
envDir: path.resolve(__dirname, '../..'),
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIERDCCAqygAwIBAgIUT+Zwrrq80kuT4VpUr5nmPHR8eZ0wDQYJKoZIhvcNAQEM
|
||||||
|
BQAwOjE4MDYGA1UEAwwvOTFkNTNlYjctMDA5MC00Mzg5LWFjN2EtYjAxN2U2Mjli
|
||||||
|
NjY0IFByb2plY3QgQ0EwHhcNMjYwNDE2MTMwNzMzWhcNMzYwNDEzMTMwNzMzWjA6
|
||||||
|
MTgwNgYDVQQDDC85MWQ1M2ViNy0wMDkwLTQzODktYWM3YS1iMDE3ZTYyOWI2NjQg
|
||||||
|
UHJvamVjdCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAKFiJhw2
|
||||||
|
DC3Xf0HzVb+glBzZajAPgkJP0EwRN4sdxPNR17ajN4mFuDXIjHuil/zhJiwVbByC
|
||||||
|
NA1NX+2wPNJ9MZVSVyYvB6G8xRhBFvlawS+u5KQQ6TqdJ02/398D5cY5L1vbRzNU
|
||||||
|
CZgrDtzuztOlER02dltmE8/mZmKN6rh+p7gyYVRNe+uXMn9VJQj5853fA0yw6OZh
|
||||||
|
1CJdf6xUqVCROf6PaTaeOKq0tu/1YkvKjY/cNioOgAHZe3WcKixdbnjXAwU3P4RU
|
||||||
|
A7CHunxfccIGh32lItz/pSwFGIvEaUcbEcu+343DtO0ADRELNQLXREOUduJTRaNs
|
||||||
|
kApA3YRGImi56CagCklrzL6kAGC6yxRqQDAMIcad11Msk6qreCOy0ozQeg8MRvSy
|
||||||
|
m9qNoOt1OsQXykI0CjsmG/R+lAO3DDL0Fgf34Vaq8BDDAYa4GhwIuWQKOkiotC8q
|
||||||
|
XDLkdc6AYvmGYEr8HfQ+r82Ydbbg4Sp2FSUeTKDoWLt2mgwsP4X+ouT16QIDAQAB
|
||||||
|
o0IwQDAdBgNVHQ4EFgQUnCW66rguV+CMWB4qWelnjPwMoV0wEgYDVR0TAQH/BAgw
|
||||||
|
BgEB/wIBADALBgNVHQ8EBAMCAQYwDQYJKoZIhvcNAQEMBQADggGBAC0JQDGjl+oY
|
||||||
|
GcvpvSMylDo7SY+WvvB1bGaW3lPoh97qPdVdlyAQKKSu4np/hygeJvaX3+I4Ongw
|
||||||
|
GVrdP0OsqIcB51W7c/ktg5BhBhKyXyiXDLHKHovIB0kyK3x4D9J2jHfjBjYzD/eX
|
||||||
|
Fy52AFyZnvVeiRjOh2ZCUpKdCKRjQwtX5c36NQEMn6APCy3toduSPoxjsTsnUan3
|
||||||
|
Kq1bAq2YPnwkNwfpNHE2IYqTnAhp+EjJDPmttcFtxoDQBbQ1V0Ug2oxWxj2N7w2S
|
||||||
|
x6rWF3XTf3jl7ZX5FNk2s6es4BXXw9A0Jylq80t1TZ3BKJdS9thCyWrLlrOcb//W
|
||||||
|
qaqxZtOfqyAG8FgFRO22cHJEEi0oANKU9ZNMG7zq83oEKkFFs5eGE+9eiBCXP/sx
|
||||||
|
oUpw/Le4D6oajrqHMp2R2bQXbX+tId2RCsYTwF+3MkRJk0cCQQa3d6d7S8/c/y8c
|
||||||
|
XgcnmXkdLOsegv6Xt7BsCxNn74YNxwJCP34LdUaFmkPIC2ZcVqY+Gg==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
---
|
||||||
|
Server certificate
|
||||||
|
subject=ST=service, O=91d53eb7-0090-4389-ac7a-b017e629b664, CN=postgress18-cluster-phluit-4
|
||||||
|
issuer=CN=91d53eb7-0090-4389-ac7a-b017e629b664 Project CA
|
||||||
|
---
|
||||||
|
Acceptable client certificate CA names
|
||||||
|
O=None, OU=64c78045-23aa-4275-8aef-87518a39536e, CN=ROOT Aiven CA Certificate
|
||||||
|
CN=91d53eb7-0090-4389-ac7a-b017e629b664 Project CA
|
||||||
|
Requested Signature Algorithms: ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:ed25519:ed448:rsa_pss_pss_sha256:rsa_pss_pss_sha384:rsa_pss_pss_sha512:RSA-PSS+SHA256:RSA-PSS+SHA384:RSA-PSS+SHA512:RSA+SHA256:RSA+SHA384:RSA+SHA512:ECDSA+SHA224:RSA+SHA224
|
||||||
|
Shared Requested Signature Algorithms: ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:ed25519:ed448:rsa_pss_pss_sha256:rsa_pss_pss_sha384:rsa_pss_pss_sha512:RSA-PSS+SHA256:RSA-PSS+SHA384:RSA-PSS+SHA512:RSA+SHA256:RSA+SHA384:RSA+SHA512
|
||||||
|
Peer signing digest: SHA256
|
||||||
|
Peer signature type: rsa_pss_rsae_sha256
|
||||||
|
Peer Temp Key: X25519, 253 bits
|
||||||
|
---
|
||||||
|
SSL handshake has read 3980 bytes and written 1726 bytes
|
||||||
|
Verification error: self-signed certificate in certificate chain
|
||||||
|
---
|
||||||
|
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
|
||||||
|
Protocol: TLSv1.3
|
||||||
|
Server public key is 3072 bit
|
||||||
|
This TLS version forbids renegotiation.
|
||||||
|
Compression: NONE
|
||||||
|
Expansion: NONE
|
||||||
|
No ALPN negotiated
|
||||||
|
Early data was not sent
|
||||||
|
Verify return code: 19 (self-signed certificate in certificate chain)
|
||||||
|
---
|
||||||
|
---
|
||||||
|
Post-Handshake New Session Ticket arrived:
|
||||||
|
SSL-Session:
|
||||||
|
Protocol : TLSv1.3
|
||||||
|
Cipher : TLS_AES_256_GCM_SHA384
|
||||||
|
Session-ID: DBFDB56316C16BACD7CDEF38F538BCF0D7AB03CC806F32D69039D653A5960C5B
|
||||||
|
Session-ID-ctx:
|
||||||
|
Resumption PSK: 1E99F47CD7CB57DA1F1FDE52B6D095F71500815125144362AAB5F13EFC87CC6C545343FFF6FD943E63EBBAEC946F5025
|
||||||
|
PSK identity: None
|
||||||
|
PSK identity hint: None
|
||||||
|
SRP username: None
|
||||||
|
TLS session ticket lifetime hint: 7200 (seconds)
|
||||||
|
TLS session ticket:
|
||||||
|
0000 - cd cd e0 c7 0b b5 c1 7a-e9 54 ba 03 89 7d 1a 01 .......z.T...}..
|
||||||
|
0010 - 38 df 01 03 c0 d9 ea ba-94 4a f6 9e 65 0c 46 dd 8........J..e.F.
|
||||||
|
0020 - 64 e6 c1 9a af e3 25 d2-bb 29 0a 8f a1 89 4e 9b d.....%..)....N.
|
||||||
|
0030 - b4 4c 41 6a 0b 96 a4 ee-a2 7f 3b 9f b8 80 fd b3 .LAj......;.....
|
||||||
|
0040 - 58 55 a4 c9 f6 71 df e9-a8 63 35 55 28 36 19 2e XU...q...c5U(6..
|
||||||
|
0050 - 21 c4 af 42 39 1e e4 b3-6c 11 42 77 31 c3 f0 81 !..B9...l.Bw1...
|
||||||
|
0060 - 5a 3b bb 94 61 61 32 0d-b2 72 dd 2f 8b 4b 68 63 Z;..aa2..r./.Khc
|
||||||
|
0070 - ee 0d fc 01 49 40 0f 2d-dc 65 1c 36 74 dd 89 29 ....I@.-.e.6t..)
|
||||||
|
0080 - 04 dd 5d 81 55 be 5e bf-04 b4 c6 21 a5 5c 03 5c ..].U.^....!.\.\
|
||||||
|
0090 - 7e 81 46 46 e9 7a 76 29-66 73 62 97 c9 22 21 b8 ~.FF.zv)fsb.."!.
|
||||||
|
00a0 - 81 90 fa 6e ac a2 26 55-0f de 2a 7c 6e 8b 94 d8 ...n..&U..*|n...
|
||||||
|
00b0 - 7e 15 9a 4f 31 06 45 d9-9c d7 05 4d 61 47 40 18 ~..O1.E....MaG@.
|
||||||
|
00c0 - 77 c3 5f 7e 66 74 58 c2-85 47 1c 67 4c f2 05 3c w._~ftX..G.gL..<
|
||||||
|
00d0 - 1a dc 8b b8 fd 00 b9 09-f8 bf a1 4e 8b 43 e0 7a ...........N.C.z
|
||||||
|
00e0 - 6b d7 ee 79 16 a8 7d eb-1b 64 ac a8 06 4b c3 ee k..y..}..d...K..
|
||||||
|
|
||||||
|
Start Time: 1784307743
|
||||||
|
Timeout : 7200 (sec)
|
||||||
|
Verify return code: 19 (self-signed certificate in certificate chain)
|
||||||
|
Extended master secret: no
|
||||||
|
Max Early Data: 0
|
||||||
|
---
|
||||||
|
read R BLOCK
|
||||||
|
---
|
||||||
|
Post-Handshake New Session Ticket arrived:
|
||||||
|
SSL-Session:
|
||||||
|
Protocol : TLSv1.3
|
||||||
|
Cipher : TLS_AES_256_GCM_SHA384
|
||||||
|
Session-ID: BAB01FA405C056F9F5291617C230611665884B1ECF63DF775A9851DC9E61DB9A
|
||||||
|
Session-ID-ctx:
|
||||||
|
Resumption PSK: 2EF7A5FCFE32882D91EDCBA48363D8AB406BEBE885E69C686CF91DE52227314C82A9C03D50D940F4B97D28367596683C
|
||||||
|
PSK identity: None
|
||||||
|
PSK identity hint: None
|
||||||
|
SRP username: None
|
||||||
|
TLS session ticket lifetime hint: 7200 (seconds)
|
||||||
|
TLS session ticket:
|
||||||
|
0000 - cd cd e0 c7 0b b5 c1 7a-e9 54 ba 03 89 7d 1a 01 .......z.T...}..
|
||||||
|
0010 - d2 3e 9a 3c 46 d9 35 4b-2b 89 c6 b6 99 41 f3 bb .>.<F.5K+....A..
|
||||||
|
0020 - 3b 65 15 0a d9 82 d5 f0-91 06 81 34 69 4f 6f b4 ;e.........4iOo.
|
||||||
|
0030 - 06 97 93 05 48 d9 cf a2-e4 b7 e3 86 06 33 22 a7 ....H........3".
|
||||||
|
0040 - a9 1a 44 be 08 14 11 bd-f8 f4 00 b9 ec 05 aa 37 ..D............7
|
||||||
|
0050 - e5 74 6c e3 a4 d1 0b 97-ab b9 91 2f b4 7d 25 b1 .tl......../.}%.
|
||||||
|
0060 - 5a 3c 33 fe ab 91 72 6f-19 94 95 08 3e 96 28 f2 Z<3...ro....>.(.
|
||||||
|
0070 - ad 80 eb ca e7 e7 b8 ce-01 ae 45 7b da 6c 02 8d ..........E{.l..
|
||||||
|
0080 - 52 fb 39 ce ee ee 19 27-42 4e 8c c1 fb c5 9e 82 R.9....'BN......
|
||||||
|
0090 - 61 94 0f 85 1b be 52 c3-76 65 b9 04 0d 66 f0 0f a.....R.ve...f..
|
||||||
|
00a0 - 82 5c d8 47 75 f0 da c2-0b 0c 3e ee a1 57 ea b2 .\.Gu.....>..W..
|
||||||
|
00b0 - 96 c6 e5 7d 4c 38 02 95-25 b1 59 e4 e0 c3 5a ac ...}L8..%.Y...Z.
|
||||||
|
00c0 - ce 22 9a 45 62 60 17 7b-fe eb 03 53 93 d1 b3 b1 .".Eb`.{...S....
|
||||||
|
00d0 - e6 3e 8b 0a ce 9d f4 24-67 63 ef bc 7a a8 1f 9d .>.....$gc..z...
|
||||||
|
00e0 - 38 e3 59 68 77 2c 9d e5-b7 a1 cc 33 6f 45 7c 90 8.Yhw,.....3oE|.
|
||||||
|
|
||||||
|
Start Time: 1784307743
|
||||||
|
Timeout : 7200 (sec)
|
||||||
|
Verify return code: 19 (self-signed certificate in certificate chain)
|
||||||
|
Extended master secret: no
|
||||||
|
Max Early Data: 0
|
||||||
|
---
|
||||||
|
read R BLOCK
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Optional Compose file for Dokploy's "Compose" deployment type.
|
||||||
|
#
|
||||||
|
# The RECOMMENDED path is a Dokploy "Application" pointed at this repo with build type = Dockerfile
|
||||||
|
# (Dokploy then manages Traefik routing, the domain, TLS, and env injection for you). This file is
|
||||||
|
# provided for teams who prefer a Compose deployment.
|
||||||
|
#
|
||||||
|
# Runtime env vars (DATABASE_URL, SESSION_SECRET, SPACES_*, etc.) come from Dokploy's Environment
|
||||||
|
# settings — do NOT hardcode them here. VITE_* values are build-time only (see build.args below).
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
# Build-time public config inlined into the browser bundle by Vite.
|
||||||
|
VITE_TURNSTILE_SITE_KEY: ${VITE_TURNSTILE_SITE_KEY:-}
|
||||||
|
VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 8080
|
||||||
|
# All other runtime variables are injected by Dokploy (Environment settings). Keep the full
|
||||||
|
# list from .env.example in sync there: PUBLIC_URL, COOKIE_DOMAIN, SESSION_SECRET, CSRF_SECRET,
|
||||||
|
# SUPERADMIN_EMAILS, DATABASE_URL, DATABASE_CA_CERT_PATH, SPACES_*, SMTP2GO_API_KEY, EMAIL_FROM,
|
||||||
|
# ANTHROPIC_API_KEY, TURNSTILE_SECRET_KEY, STRIPE_*, SENTRY_DSN_API.
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- "fetch('http://127.0.0.1:8080/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 25s
|
||||||
Generated
+63
@@ -22,6 +22,7 @@
|
|||||||
"name": "@lawdesk/api",
|
"name": "@lawdesk/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.111.0",
|
||||||
"@aws-sdk/client-s3": "^3.1088.0",
|
"@aws-sdk/client-s3": "^3.1088.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1088.0",
|
"@aws-sdk/s3-request-presigner": "^3.1088.0",
|
||||||
"@fastify/cookie": "^11.0.1",
|
"@fastify/cookie": "^11.0.1",
|
||||||
@@ -97,6 +98,27 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.111.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.111.0.tgz",
|
||||||
|
"integrity": "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
|
"standardwebhooks": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@aws-sdk/checksums": {
|
"node_modules/@aws-sdk/checksums": {
|
||||||
"version": "3.1000.18",
|
"version": "3.1000.18",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.18.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.18.tgz",
|
||||||
@@ -3125,6 +3147,12 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.3.17",
|
"version": "0.3.17",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz",
|
||||||
@@ -4806,6 +4834,12 @@
|
|||||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
@@ -5647,6 +5681,19 @@
|
|||||||
"dequal": "^2.0.3"
|
"dequal": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
@@ -7240,6 +7287,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -7564,6 +7621,12 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-interface-checker": {
|
"node_modules/ts-interface-checker": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user