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 });
}
}

View File

@@ -0,0 +1,277 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { SigningProgressBar } from './SigningProgressBar';
import type { SignatureFieldData } from '@/lib/db/schema';
// Worker setup — reuse same pattern as PdfViewerWrapper (no CDN, works in local/Docker)
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
// PDF rendered at a fixed width for consistent coordinate math
const PDF_RENDER_WIDTH = 800;
interface PageDimensions {
renderedWidth: number;
renderedHeight: number;
originalWidth: number;
originalHeight: number;
}
export interface SignatureCapture {
fieldId: string;
dataURL: string;
}
interface SigningPageClientProps {
token: string;
documentName: string;
signatureFields: SignatureFieldData[];
/** Called when user clicks an unsigned field. Modal added in Plan 04. */
onFieldClick?: (fieldId: string) => void;
}
export function SigningPageClient({
token,
documentName,
signatureFields,
onFieldClick,
}: SigningPageClientProps) {
const [numPages, setNumPages] = useState(0);
// Map from page number (1-indexed) to rendered dimensions
const [pageDimensions, setPageDimensions] = useState<Record<number, PageDimensions>>({});
// Set of signed field IDs
const [signedFields, setSignedFields] = useState<Set<string>>(new Set());
const [submitting, setSubmitting] = useState(false);
// Exposed ref for Plan 04 to populate with captured signatures
const signaturesRef = useRef<SignatureCapture[]>([]);
// Group fields by page for efficient lookup
const fieldsByPage = signatureFields.reduce<Record<number, SignatureFieldData[]>>(
(acc, field) => {
const page = field.page;
if (!acc[page]) acc[page] = [];
acc[page].push(field);
return acc;
},
{}
);
const handlePageLoadSuccess = useCallback(
(pageNum: number, page: { width: number; height: number; view: number[] }) => {
setPageDimensions((prev) => ({
...prev,
[pageNum]: {
renderedWidth: page.width,
renderedHeight: page.height,
originalWidth: Math.max(page.view[0], page.view[2]),
originalHeight: Math.max(page.view[1], page.view[3]),
},
}));
},
[]
);
const handleFieldClick = useCallback(
(fieldId: string) => {
if (signedFields.has(fieldId)) return;
if (onFieldClick) {
onFieldClick(fieldId);
}
},
[signedFields, onFieldClick]
);
const handleJumpToNext = useCallback(() => {
const nextUnsigned = signatureFields.find((f) => !signedFields.has(f.id));
if (nextUnsigned) {
document.getElementById(`field-${nextUnsigned.id}`)?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [signatureFields, signedFields]);
const handleSubmit = useCallback(() => {
if (signedFields.size < signatureFields.length || submitting) return;
setSubmitting(true);
// Submission POST handler added in Plan 04
}, [signedFields.size, signatureFields.length, submitting]);
/**
* Convert PDF user-space coordinates (bottom-left origin) to screen overlay position.
*
* PDF coords: origin at bottom-left, Y increases upward.
* Screen coords: origin at top-left, Y increases downward.
*
* Formula:
* screenTop = renderedHeight - (field.y / originalHeight * renderedHeight) - (field.height / originalHeight * renderedHeight)
* screenLeft = field.x / originalWidth * renderedWidth
*/
const getFieldOverlayStyle = (
field: SignatureFieldData,
dims: PageDimensions
): React.CSSProperties => {
const scaleX = dims.renderedWidth / dims.originalWidth;
const scaleY = dims.renderedHeight / dims.originalHeight;
const top = dims.renderedHeight - field.y * scaleY - field.height * scaleY;
const left = field.x * scaleX;
const width = field.width * scaleX;
const height = field.height * scaleY;
return {
position: 'absolute',
top: `${top}px`,
left: `${left}px`,
width: `${width}px`,
height: `${height}px`,
cursor: 'pointer',
borderRadius: '3px',
animation: 'pulse-border 2s infinite',
};
};
return (
<>
{/* Pulse-border keyframes injected inline */}
<style>{`
@keyframes pulse-border {
0%, 100% {
box-shadow: 0 0 0 2px #3b82f6, 0 0 8px 2px rgba(59,130,246,0.4);
}
50% {
box-shadow: 0 0 0 3px #3b82f6, 0 0 16px 4px rgba(59,130,246,0.6);
}
}
.signing-field-signed {
box-shadow: 0 0 0 2px #22c55e !important;
animation: none !important;
background-color: rgba(34,197,94,0.08);
}
`}</style>
<div
style={{
minHeight: '100vh',
backgroundColor: '#FAF9F7',
fontFamily: 'Georgia, serif',
paddingBottom: '80px', /* space for sticky progress bar */
}}
>
{/* Branded header */}
<header
style={{
backgroundColor: '#1B2B4B',
color: '#fff',
padding: '20px 32px',
textAlign: 'center',
}}
>
<div style={{ fontSize: '20px', fontWeight: 'bold', letterSpacing: '0.02em' }}>
Teressa Copeland Homes
</div>
<div
style={{
fontSize: '16px',
marginTop: '6px',
color: '#C9A84C',
fontStyle: 'italic',
}}
>
{documentName}
</div>
<p style={{ fontSize: '14px', marginTop: '8px', color: '#ccc', fontFamily: 'sans-serif' }}>
Please review and sign the document below.
</p>
</header>
{/* PDF viewer */}
<main style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '32px 16px' }}>
<Document
file={`/api/sign/${token}/pdf`}
onLoadSuccess={({ numPages: n }) => setNumPages(n)}
loading={
<div style={{ padding: '40px', color: '#555', fontFamily: 'sans-serif' }}>
Loading document...
</div>
}
error={
<div style={{ padding: '40px', color: '#c00', fontFamily: 'sans-serif' }}>
Failed to load document. Please try refreshing.
</div>
}
>
{Array.from({ length: numPages }, (_, i) => {
const pageNum = i + 1;
const dims = pageDimensions[pageNum];
const fieldsOnPage = fieldsByPage[pageNum] ?? [];
return (
<div
key={pageNum}
style={{
position: 'relative',
marginBottom: '24px',
boxShadow: '0 2px 12px rgba(0,0,0,0.12)',
}}
>
<Page
pageNumber={pageNum}
width={PDF_RENDER_WIDTH}
onLoadSuccess={(page) => handlePageLoadSuccess(pageNum, page)}
renderAnnotationLayer={false}
renderTextLayer={false}
/>
{/* Signature field overlays — rendered once page dimensions are known */}
{dims &&
fieldsOnPage.map((field) => {
const isSigned = signedFields.has(field.id);
const overlayStyle = getFieldOverlayStyle(field, dims);
return (
<div
key={field.id}
id={`field-${field.id}`}
className={isSigned ? 'signing-field-signed' : ''}
style={overlayStyle}
onClick={() => handleFieldClick(field.id)}
role="button"
tabIndex={0}
aria-label={`Signature field${isSigned ? ' (signed)' : ' — click to sign'}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleFieldClick(field.id);
}
}}
/>
);
})}
</div>
);
})}
</Document>
</main>
</div>
{/* Sticky progress bar */}
<SigningProgressBar
total={signatureFields.length}
signed={signedFields.size}
onJumpToNext={handleJumpToNext}
onSubmit={handleSubmit}
submitting={submitting}
/>
</>
);
}
// Export signed fields setter so Plan 04 can mark fields as signed after modal capture
export type { SigningPageClientProps };

View File

@@ -0,0 +1,47 @@
'use client';
import dynamic from 'next/dynamic';
import type { SignatureFieldData } from '@/lib/db/schema';
// Dynamically import to disable SSR — react-pdf requires browser APIs (canvas, worker)
const SigningPageClient = dynamic(
() => import('./SigningPageClient').then((m) => m.SigningPageClient),
{
ssr: false,
loading: () => (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#FAF9F7',
fontFamily: 'Georgia, serif',
color: '#555',
}}
>
Loading document...
</div>
),
}
);
interface SigningPageClientWrapperProps {
token: string;
documentName: string;
signatureFields: SignatureFieldData[];
}
export function SigningPageClientWrapper({
token,
documentName,
signatureFields,
}: SigningPageClientWrapperProps) {
return (
<SigningPageClient
token={token}
documentName={documentName}
signatureFields={signatureFields}
/>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
interface SigningProgressBarProps {
total: number;
signed: number;
onJumpToNext: () => void;
onSubmit: () => void;
submitting: boolean;
}
export function SigningProgressBar({
total,
signed,
onJumpToNext,
onSubmit,
submitting,
}: SigningProgressBarProps) {
const allSigned = signed >= total;
return (
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 50,
backgroundColor: '#1B2B4B',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 24px',
boxShadow: '0 -2px 8px rgba(0,0,0,0.15)',
}}
>
<span style={{ fontSize: '15px' }}>
{signed} of {total} signature{total !== 1 ? 's' : ''} complete
</span>
<div style={{ display: 'flex', gap: '12px' }}>
{!allSigned && (
<button
onClick={onJumpToNext}
style={{
backgroundColor: 'transparent',
border: '1px solid #C9A84C',
color: '#C9A84C',
padding: '8px 18px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Jump to Next
</button>
)}
<button
onClick={onSubmit}
disabled={!allSigned || submitting}
style={{
backgroundColor: allSigned ? '#C9A84C' : '#555',
color: '#fff',
border: 'none',
padding: '8px 22px',
borderRadius: '4px',
cursor: allSigned ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: 'bold',
opacity: submitting ? 0.7 : 1,
}}
>
{submitting ? 'Submitting...' : 'Submit Signature'}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { verifySigningToken } from '@/lib/signing/token';
import { db } from '@/lib/db';
import { signingTokens, documents } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { SigningPageClientWrapper } from './_components/SigningPageClientWrapper';
interface Props {
params: Promise<{ token: string }>;
}
export default async function SignPage({ params }: Props) {
const { token } = await params;
// CRITICAL: Validate BEFORE rendering any signing UI — no canvas flash on invalid tokens
let payload: { documentId: string; jti: string } | null = null;
let isExpired = false;
try {
payload = await verifySigningToken(token);
} catch {
isExpired = true;
}
if (isExpired) {
return <ErrorPage type="expired" />;
}
if (!payload) {
return <ErrorPage type="invalid" />;
}
// Check one-time use
const tokenRow = await db.query.signingTokens.findFirst({
where: eq(signingTokens.jti, payload.jti),
});
if (!tokenRow) return <ErrorPage type="invalid" />;
if (tokenRow.usedAt !== null) {
return <ErrorPage type="used" signedAt={tokenRow.usedAt} />;
}
const doc = await db.query.documents.findFirst({
where: eq(documents.id, payload.documentId),
});
if (!doc || !doc.preparedFilePath) return <ErrorPage type="invalid" />;
return (
<SigningPageClientWrapper
token={token}
documentName={doc.name}
signatureFields={doc.signatureFields ?? []}
/>
);
}
function ErrorPage({
type,
signedAt,
}: {
type: 'expired' | 'used' | 'invalid';
signedAt?: Date | null;
}) {
const messages = {
expired: {
title: 'Link Expired',
body: 'This signing link has expired. Please contact Teressa Copeland for a new link.',
},
used: {
title: 'Already Signed',
body: `This document has already been signed${
signedAt
? ' on ' +
signedAt.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
: ''
}.`,
},
invalid: {
title: 'Invalid Link',
body: 'This signing link is not valid. Please check your email for the correct link.',
},
};
const { title, body } = messages[type];
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#FAF9F7',
fontFamily: 'Georgia, serif',
}}
>
<div style={{ textAlign: 'center', maxWidth: '420px', padding: '40px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
{type === 'used' ? '\u2713' : '\u26a0'}
</div>
<h1 style={{ color: '#1B2B4B', fontSize: '24px', marginBottom: '12px' }}>{title}</h1>
<p style={{ color: '#555', fontSize: '16px', lineHeight: '1.6' }}>{body}</p>
</div>
</div>
);
}