feat(06-03): signing page — server component, PDF viewer, field overlays, progress bar

- page.tsx: server component validates JWT + one-time-use before rendering any UI
- Three error states (expired/used/invalid) show static pages with no canvas
- SigningPageClientWrapper: dynamic import (ssr:false) for react-pdf browser requirement
- SigningPageClient: full-scroll PDF viewer with pulsing blue field overlays
- Field overlay coordinates convert PDF user-space (bottom-left) to screen (top-left)
- SigningProgressBar: sticky bottom bar with X/Y count + jump-to-next + submit button
- api/sign/[token]/pdf: token-authenticated PDF streaming route (no agent auth)
This commit is contained in:
Chandler Copeland
2026-03-20 11:30:38 -06:00
parent 877ad66ead
commit dcf503dfea
5 changed files with 563 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFile } from 'fs/promises';
import { join } from 'path';
import { verifySigningToken } from '@/lib/signing/token';
import { db } from '@/lib/db';
import { documents } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
// Public route — authenticated by signing token, no agent auth required
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
// Validate JWT (no usedAt check — client may still be viewing while session is active)
let payload: { documentId: string; jti: string; exp: number };
try {
payload = await verifySigningToken(token);
} catch {
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
}
// Fetch preparedFilePath
const doc = await db.query.documents.findFirst({
where: eq(documents.id, payload.documentId),
columns: { preparedFilePath: true },
});
if (!doc?.preparedFilePath) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 });
}
// Path traversal guard — preparedFilePath is stored as relative path (e.g. clients/{id}/{uuid}.pdf)
const normalizedPath = doc.preparedFilePath.replace(/^\/+/, '');
if (normalizedPath.includes('..')) {
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 });
}
const absolutePath = join(process.cwd(), 'uploads', normalizedPath);
try {
const fileBuffer = await readFile(absolutePath);
return new NextResponse(fileBuffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'inline',
'Cache-Control': 'private, no-store',
},
});
} catch {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
}