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:
55
teressa-copeland-homes/src/app/api/sign/[token]/pdf/route.ts
Normal file
55
teressa-copeland-homes/src/app/api/sign/[token]/pdf/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
108
teressa-copeland-homes/src/app/sign/[token]/page.tsx
Normal file
108
teressa-copeland-homes/src/app/sign/[token]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user