Dokploy deploy: Dockerfile, DB CA cert, blog images, CSP
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:
Leon Serfaty
2026-07-17 13:04:18 -04:00
co-authored by Claude Fable 5
parent d9b807662a
commit d1d96e4dd2
29 changed files with 1224 additions and 28 deletions
+98
View File
@@ -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}`}
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="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">
{p.title.split(' ').slice(0, 3).join(' ')}
</span>
</div>
<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">
<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(' ')}
</span>
</div>
)}
</div>
<div className="p-6 flex flex-col flex-1">
<p className="text-xs text-ink-500">{formatDate(p.publishedAt)}</p>
+14 -1
View File
@@ -1,12 +1,15 @@
import { useState } from 'react';
import { Mail, Send, Clock } from 'lucide-react';
import { api, type ApiError } from '@/lib/api';
import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile';
type State = 'idle' | 'submitting' | 'success' | 'error';
export function Contact() {
const [state, setState] = useState<State>('idle');
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>) {
e.preventDefault();
@@ -18,6 +21,7 @@ export function Contact() {
fullName: String(fd.get('fullName') ?? '').trim(),
email: String(fd.get('email') ?? '').trim(),
message: String(fd.get('message') ?? '').trim(),
turnstileToken: captchaToken ?? undefined,
};
try {
@@ -28,6 +32,9 @@ export function Contact() {
const apiErr = err as ApiError;
setError(apiErr.code ?? apiErr.message);
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>
<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' : (
<>
Send Message
+7
View File
@@ -5,6 +5,7 @@ export interface Post {
publishedAt: string; // ISO date
readMinutes: number;
author: string;
coverImage?: string; // absolute URL (served from DigitalOcean Spaces)
// Body is structured as an array of blocks for simple renderable JSX
body: Block[];
}
@@ -27,6 +28,8 @@ export const POSTS: Post[] = [
publishedAt: '2026-04-08',
readMinutes: 8,
author: 'eLegal Software Team',
coverImage:
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/maximize-billable-hours-without-burnout.jpg',
body: [
{
type: 'p',
@@ -89,6 +92,8 @@ export const POSTS: Post[] = [
publishedAt: '2026-03-21',
readMinutes: 12,
author: 'eLegal Software Team',
coverImage:
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/client-intake-best-practices-2026.jpg',
body: [
{
type: 'p',
@@ -157,6 +162,8 @@ export const POSTS: Post[] = [
publishedAt: '2026-02-14',
readMinutes: 10,
author: 'eLegal Software Team',
coverImage:
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/legal-billing-software-comparison-2026.jpg',
body: [
{
type: 'p',
+6 -2
View File
@@ -36,7 +36,7 @@ export function useMe() {
export function useLogin() {
const qc = useQueryClient();
return useMutation<AuthUser, ApiError, { email: string; password: string }>({
return useMutation<AuthUser, ApiError, { email: string; password: string; turnstileToken?: string }>({
mutationFn: async (vars) => {
const data = await api.post<MeResponse>('/api/auth/login', vars);
return data.user;
@@ -47,7 +47,11 @@ export function useLogin() {
export function useSignup() {
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) => {
const data = await api.post<MeResponse>('/api/auth/signup', vars);
return data.user;
+1 -1
View File
@@ -2,7 +2,7 @@ import { useMutation } from '@tanstack/react-query';
import { api, type ApiError } from '@/lib/api';
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),
});
}
+23 -2
View File
@@ -5,6 +5,7 @@ import { ArrowRight, MailCheck } from 'lucide-react';
import { z } from 'zod';
import { AuthLayout } from '@/components/auth/AuthLayout';
import { Field } from '@/components/auth/Field';
import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile';
import { useRequestPasswordReset } from '@/hooks/useResetPassword';
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() {
const [submitted, setSubmitted] = useState(false);
const request = useRequestPasswordReset();
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [captchaReset, setCaptchaReset] = useState(0);
const {
register,
@@ -23,7 +26,13 @@ export default function ForgotPasswordPage() {
async function onSubmit(values: FormValues) {
const parsed = schema.safeParse(values);
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);
}
@@ -61,9 +70,21 @@ export default function ForgotPasswordPage() {
error={errors.email?.message}
{...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
type="submit"
disabled={isSubmitting || request.isPending}
disabled={
isSubmitting || request.isPending || (Boolean(TURNSTILE_SITE_KEY) && !captchaToken)
}
className="btn-primary w-full"
>
{request.isPending ? 'Sending' : (
+19 -3
View File
@@ -1,10 +1,11 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { ArrowRight } from 'lucide-react';
import { AuthLayout } from '@/components/auth/AuthLayout';
import { Field } from '@/components/auth/Field';
import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile';
import { useLogin, useMe } from '@/hooks/useAuth';
const schema = z.object({
@@ -17,6 +18,7 @@ type FormValues = z.infer<typeof schema>;
const ERROR_COPY: Record<string, string> = {
invalid_credentials: 'Email or password is incorrect.',
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() {
@@ -24,6 +26,8 @@ export default function LoginPage() {
const location = useLocation();
const me = useMe();
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
const verified = new URLSearchParams(location.search).get('verified');
@@ -43,7 +47,13 @@ export default function LoginPage() {
async function onSubmit(values: FormValues) {
const parsed = schema.safeParse(values);
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';
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>
)}
<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…' : (
<>
Sign in
+19 -3
View File
@@ -1,10 +1,11 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { ArrowRight } from 'lucide-react';
import { AuthLayout } from '@/components/auth/AuthLayout';
import { Field } from '@/components/auth/Field';
import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile';
import { useMe, useSignup } from '@/hooks/useAuth';
const schema = z.object({
@@ -18,12 +19,15 @@ type FormValues = z.infer<typeof schema>;
const ERROR_COPY: Record<string, string> = {
email_taken: 'An account with that email already exists.',
captcha_failed: 'Verification failed — complete the check below and try again.',
};
export default function SignupPage() {
const navigate = useNavigate();
const me = useMe();
const signup = useSignup();
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [captchaReset, setCaptchaReset] = useState(0);
const {
register,
@@ -40,7 +44,13 @@ export default function SignupPage() {
async function onSubmit(values: FormValues) {
const parsed = schema.safeParse(values);
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 });
}
@@ -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>
)}
<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…' : (
<>
Create account
@@ -9,6 +9,7 @@ import { Input, Select, Textarea } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { CaseTimeList } from '@/components/app/CaseTimeList';
import { CreateInvoiceDrawer } from '@/components/app/CreateInvoiceDrawer';
import { AiCaseSummary } from '@/components/app/AiCaseSummary';
import { useInvoices, type InvoiceStatus } from '@/hooks/useInvoices';
import { formatBytes, formatDate, formatMoney } from '@/lib/format';
import { useDocuments, useUploadDocument, useDeleteDocument } from '@/hooks/useDocuments';
@@ -188,6 +189,10 @@ export default function CaseDetailPage() {
</CardBody>
</Card>
<div className="lg:col-span-2">
<AiCaseSummary caseId={id!} />
</div>
<Card>
<CardHeader title="Client" />
<CardBody>
+15 -6
View File
@@ -21,12 +21,21 @@ export default function BlogIndexPage() {
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"
>
<div className="aspect-[16/9] bg-gradient-to-br from-brand-100 via-brand-50 to-white relative">
<div className="absolute inset-0 grid place-items-center">
<span className="text-4xl font-bold text-brand-300/50 font-display select-none">
{p.title.split(' ').slice(0, 2).join(' ')}
</span>
</div>
<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">
<span className="text-4xl font-bold text-brand-300/50 font-display select-none">
{p.title.split(' ').slice(0, 2).join(' ')}
</span>
</div>
)}
</div>
<div className="p-6 flex flex-col flex-1">
<p className="text-xs text-ink-500">{formatDate(p.publishedAt)}</p>
+8
View File
@@ -48,6 +48,14 @@ export default function BlogPostPage() {
</div>
</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">
{post.body.map((b, i) => (
<RenderBlock key={i} block={b} />
+2
View File
@@ -4,6 +4,8 @@ import path from 'node:path';
export default defineConfig({
plugins: [react()],
// VITE_* vars live in the monorepo root .env alongside the API's config.
envDir: path.resolve(__dirname, '../..'),
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),