diff --git a/teressa-copeland-homes/src/app/api/sign/[token]/pdf/route.ts b/teressa-copeland-homes/src/app/api/sign/[token]/pdf/route.ts new file mode 100644 index 0000000..4fc9495 --- /dev/null +++ b/teressa-copeland-homes/src/app/api/sign/[token]/pdf/route.ts @@ -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 }); + } +} diff --git a/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx b/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx new file mode 100644 index 0000000..1be87e7 --- /dev/null +++ b/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx @@ -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>({}); + // Set of signed field IDs + const [signedFields, setSignedFields] = useState>(new Set()); + const [submitting, setSubmitting] = useState(false); + + // Exposed ref for Plan 04 to populate with captured signatures + const signaturesRef = useRef([]); + + // Group fields by page for efficient lookup + const fieldsByPage = signatureFields.reduce>( + (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 */} + + +
+ {/* Branded header */} +
+
+ Teressa Copeland Homes +
+
+ {documentName} +
+

+ Please review and sign the document below. +

+
+ + {/* PDF viewer */} +
+ setNumPages(n)} + loading={ +
+ Loading document... +
+ } + error={ +
+ Failed to load document. Please try refreshing. +
+ } + > + {Array.from({ length: numPages }, (_, i) => { + const pageNum = i + 1; + const dims = pageDimensions[pageNum]; + const fieldsOnPage = fieldsByPage[pageNum] ?? []; + + return ( +
+ 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 ( +
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); + } + }} + /> + ); + })} +
+ ); + })} + +
+
+ + {/* Sticky progress bar */} + + + ); +} + +// Export signed fields setter so Plan 04 can mark fields as signed after modal capture +export type { SigningPageClientProps }; diff --git a/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClientWrapper.tsx b/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClientWrapper.tsx new file mode 100644 index 0000000..729b1f1 --- /dev/null +++ b/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClientWrapper.tsx @@ -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: () => ( +
+ Loading document... +
+ ), + } +); + +interface SigningPageClientWrapperProps { + token: string; + documentName: string; + signatureFields: SignatureFieldData[]; +} + +export function SigningPageClientWrapper({ + token, + documentName, + signatureFields, +}: SigningPageClientWrapperProps) { + return ( + + ); +} diff --git a/teressa-copeland-homes/src/app/sign/[token]/_components/SigningProgressBar.tsx b/teressa-copeland-homes/src/app/sign/[token]/_components/SigningProgressBar.tsx new file mode 100644 index 0000000..ec0d5c5 --- /dev/null +++ b/teressa-copeland-homes/src/app/sign/[token]/_components/SigningProgressBar.tsx @@ -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 ( +
+ + {signed} of {total} signature{total !== 1 ? 's' : ''} complete + +
+ {!allSigned && ( + + )} + +
+
+ ); +} diff --git a/teressa-copeland-homes/src/app/sign/[token]/page.tsx b/teressa-copeland-homes/src/app/sign/[token]/page.tsx new file mode 100644 index 0000000..436965f --- /dev/null +++ b/teressa-copeland-homes/src/app/sign/[token]/page.tsx @@ -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 ; + } + + if (!payload) { + return ; + } + + // Check one-time use + const tokenRow = await db.query.signingTokens.findFirst({ + where: eq(signingTokens.jti, payload.jti), + }); + + if (!tokenRow) return ; + + if (tokenRow.usedAt !== null) { + return ; + } + + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, payload.documentId), + }); + + if (!doc || !doc.preparedFilePath) return ; + + return ( + + ); +} + +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 ( +
+
+
+ {type === 'used' ? '\u2713' : '\u26a0'} +
+

{title}

+

{body}

+
+
+ ); +}