Add e2e auth test suite, retention crons, and email-verification UX
CI / build-and-test (push) Has been cancelled

- E2E suite (23 tests, `npm run test:e2e -w @lawdesk/api`): boots the real
  Fastify app against a disposable Dockerized Postgres (never a real DB) and
  covers signup/login/lockout/rate limits, CSRF (incl. forged-token
  rejection), logout, password reset, email verification, the superadmin
  verified-email promotion gate, and cross-firm tenancy isolation
- packages/db: DATABASE_SSL=disable opt-out for local/test databases that
  don't speak TLS; refused in production
- retention-sweep.ts cron enforcing Privacy Policy windows (sessions,
  tokens, login attempts, tool usage, contact messages, audit log) +
  sweep-orphaned-storage.ts Spaces reconciliation + scripts/README
- Expose emailVerified on the session user; in-app verify-email banner
  with resend, and verified=1|0 toasts on the login page
- Silence Fastify logger under NODE_ENV=test; fix footer resource link;
  document login-attempt/tool-usage retention in the Privacy Policy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-16 14:32:55 -04:00
co-authored by Claude Fable 5
parent 97e1d4c60b
commit d9b807662a
20 changed files with 983 additions and 6 deletions
@@ -2,6 +2,7 @@ import { Navigate, Outlet } from 'react-router-dom';
import { useMe } from '@/hooks/useAuth';
import { Sidebar } from './Sidebar';
import { Topbar } from './Topbar';
import { VerifyEmailBanner } from './VerifyEmailBanner';
export function AppLayout() {
const me = useMe();
@@ -19,6 +20,7 @@ export function AppLayout() {
<Sidebar />
<div className="flex-1 flex flex-col min-w-0">
<Topbar />
<VerifyEmailBanner />
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
@@ -0,0 +1,46 @@
import { useState } from 'react';
import { MailWarning } from 'lucide-react';
import { useMe } from '@/hooks/useAuth';
import { api } from '@/lib/api';
export function VerifyEmailBanner() {
const me = useMe();
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
// Only when the API explicitly says unverified — older cached sessions omit the flag.
if (!me.data || me.data.emailVerified !== false) return null;
async function resend() {
setState('sending');
try {
await api.post('/api/auth/resend-verification');
setState('sent');
} catch {
setState('error');
}
}
return (
<div className="border-b border-amber-200 bg-amber-50 px-4 py-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-amber-900">
<MailWarning className="h-4 w-4 flex-none text-amber-600" />
<span>
Please verify your email we sent a link to <strong>{me.data.email}</strong>.
</span>
{state === 'sent' ? (
<span className="font-medium text-emerald-700">Verification email sent </span>
) : (
<button
type="button"
onClick={resend}
disabled={state === 'sending'}
className="font-semibold text-amber-800 underline underline-offset-2 hover:text-amber-950 disabled:opacity-60"
>
{state === 'sending' ? 'Sending…' : 'Resend email'}
</button>
)}
{state === 'error' && (
<span className="text-rose-700">Couldn&apos;t send try again in a few minutes.</span>
)}
</div>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ const COLUMNS = [
{
title: 'Resources',
links: [
{ href: '/resources', label: 'Resource Hub' },
{ href: '/#resources', label: 'Resource Hub' },
{ href: '/blog', label: 'Blog' },
],
},
+1
View File
@@ -9,6 +9,7 @@ export interface AuthUser {
role: string;
isSuperadmin?: boolean;
isSuspended?: boolean;
emailVerified?: boolean;
}
interface MeResponse {
+15
View File
@@ -25,6 +25,9 @@ export default function LoginPage() {
const me = useMe();
const login = useLogin();
// Landing target of the email-verification link: /login?verified=1|0
const verified = new URLSearchParams(location.search).get('verified');
const {
register,
handleSubmit,
@@ -60,6 +63,18 @@ export default function LoginPage() {
</>
}
>
{verified === '1' && (
<p className="mb-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
Email verified thanks! Sign in to continue.
</p>
)}
{verified === '0' && (
<p className="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">
That verification link is invalid or has expired. Sign in and use &ldquo;Resend
email&rdquo; to get a fresh one.
</p>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Field
label="Email"
+1
View File
@@ -151,6 +151,7 @@ export default function PrivacyPage() {
'Audit log — kept for 24 months.',
'Payment records — kept as required by tax and accounting laws (typically 7 years).',
'Server logs — kept for 30 days.',
'Login-attempt and free-tool usage records (email, IP address) — kept for 90 days, then deleted.',
'Contact-form messages — kept for 24 months, then deleted.',
]}
/>