From 35f4301fe1dea0b3139a28804bbcb6acbf320fa9 Mon Sep 17 00:00:00 2001 From: poduck Date: Fri, 5 Dec 2025 02:29:35 -0500 Subject: [PATCH] feat(contracts): Add legal export package and ESIGN compliance improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add export_legal endpoint for signed contracts that generates a ZIP with: - Signed contract PDF - Audit certificate PDF with signature details and hash verification - Machine-readable signature_record.json - Integrity verification report - README documentation - Add audit certificate template with: - Contract and signature information - Consent records with exact legal text - Document integrity verification (SHA-256 hash comparison) - ESIGN Act and UETA compliance statement - Update ContractSigning page for ESIGN/UETA compliance: - Consent checkbox text now matches backend-stored legal text - Added proper legal notice with ESIGN Act references - Add signed_at field to ContractListSerializer - Add view/print buttons for signed contracts in Contracts page - Allow viewing signed contracts via public signing URL 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- frontend/src/hooks/useContracts.ts | 66 +++- frontend/src/i18n/locales/en.json | 39 ++- frontend/src/pages/ContractSigning.tsx | 317 +++++++++++------ frontend/src/pages/Contracts.tsx | 161 ++++++--- smoothschedule/contracts/pdf_service.py | 283 ++++++++++++++++ smoothschedule/contracts/serializers.py | 90 +++-- smoothschedule/contracts/views.py | 67 +++- .../contracts/audit_certificate.html | 319 ++++++++++++++++++ 8 files changed, 1156 insertions(+), 186 deletions(-) create mode 100644 smoothschedule/templates/contracts/audit_certificate.html diff --git a/frontend/src/hooks/useContracts.ts b/frontend/src/hooks/useContracts.ts index 99f9254..cb571ad 100644 --- a/frontend/src/hooks/useContracts.ts +++ b/frontend/src/hooks/useContracts.ts @@ -205,7 +205,7 @@ export const useContracts = (filters?: { template_version: c.template_version, scope: c.scope as ContractScope, status: c.status, - content: c.content, + content: c.content_html || c.content, // Backend returns content_html customer: c.customer ? String(c.customer) : undefined, customer_name: c.customer_name || undefined, customer_email: c.customer_email || undefined, @@ -219,7 +219,7 @@ export const useContracts = (filters?: { expires_at: c.expires_at, voided_at: c.voided_at, voided_reason: c.voided_reason, - public_token: c.public_token, + public_token: c.signing_token || c.public_token, // Backend returns signing_token created_at: c.created_at, updated_at: c.updated_at, })); @@ -258,7 +258,7 @@ export const useContract = (id: string) => { expires_at: data.expires_at, voided_at: data.voided_at, voided_reason: data.voided_reason, - public_token: data.public_token, + public_token: data.signing_token || data.public_token, // Backend returns signing_token created_at: data.created_at, updated_at: data.updated_at, }; @@ -270,9 +270,9 @@ export const useContract = (id: string) => { interface ContractInput { template: string; - customer?: string; - appointment?: string; - service?: string; + customer_id?: string; + event_id?: string; + send_email?: boolean; } /** @@ -352,8 +352,8 @@ export const usePublicContract = (token: string) => { return useQuery({ queryKey: ['public-contracts', token], queryFn: async () => { - // Use a plain axios instance without auth - const { data } = await apiClient.get(`/contracts/public/${token}/`); + // Use the public signing endpoint + const { data } = await apiClient.get(`/contracts/sign/${token}/`); return data; }, enabled: !!token, @@ -368,21 +368,57 @@ export const useSignContract = () => { return useMutation({ mutationFn: async ({ token, - signature_data, signer_name, - signer_email, + consent_checkbox_checked, + electronic_consent_given, }: { token: string; - signature_data: string; signer_name: string; - signer_email: string; + consent_checkbox_checked: boolean; + electronic_consent_given: boolean; }) => { - const { data } = await apiClient.post(`/contracts/public/${token}/sign/`, { - signature_data, + const { data } = await apiClient.post(`/contracts/sign/${token}/`, { signer_name, - signer_email, + consent_checkbox_checked, + electronic_consent_given, }); return data; }, }); }; + +/** + * Hook to export a legal compliance package for a signed contract + */ +export const useExportLegalPackage = () => { + return useMutation({ + mutationFn: async (id: string) => { + const response = await apiClient.get(`/contracts/${id}/export_legal/`, { + responseType: 'blob', + }); + + // Create download link + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + + // Extract filename from Content-Disposition header if available + const contentDisposition = response.headers['content-disposition']; + let filename = `legal_export_${id}.zip`; + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename="?([^";\n]+)"?/); + if (filenameMatch && filenameMatch[1]) { + filename = filenameMatch[1]; + } + } + + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + + return { success: true }; + }, + }); +}; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 1a03af1..d6224cd 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -70,7 +70,28 @@ "rememberMe": "Remember me", "twoFactorRequired": "Two-factor authentication required", "enterCode": "Enter verification code", - "verifyCode": "Verify Code" + "verifyCode": "Verify Code", + "login": { + "title": "Sign in to your account", + "subtitle": "Don't have an account?", + "createAccount": "Create one now", + "platformBadge": "Platform Login", + "heroTitle": "Manage Your Business with Confidence", + "heroSubtitle": "Access your dashboard to manage appointments, customers, and grow your business.", + "features": { + "scheduling": "Smart scheduling & resource management", + "automation": "Automated reminders & follow-ups", + "security": "Enterprise-grade security" + }, + "privacy": "Privacy", + "terms": "Terms" + }, + "tenantLogin": { + "welcome": "Welcome to {{business}}", + "subtitle": "Sign in to manage your appointments", + "staffAccess": "Staff Access", + "customerBooking": "Customer Booking" + } }, "nav": { "dashboard": "Dashboard", @@ -679,6 +700,7 @@ "edit": "Edit", "viewDetails": "View Details", "copyLink": "Copy Signing Link", + "copied": "Copied!", "sendEmail": "Send Email", "openSigningPage": "Open Signing Page", "saveChanges": "Save Changes" @@ -723,7 +745,11 @@ "status": "Status", "created": "Created", "contentPreview": "Content Preview", - "signingLink": "Signing Link" + "signingLink": "Signing Link", + "signedTitle": "Contract Successfully Signed", + "viewFullContract": "View Full Contract", + "printContract": "Print Contract", + "exportLegal": "Export Legal Package" }, "preview": { "title": "Preview Contract", @@ -861,7 +887,14 @@ "noCustomersFound": "No customers found matching your search.", "addNewCustomer": "Add New Customer", "createCustomer": "Create Customer", - "errorLoading": "Error loading customers" + "errorLoading": "Error loading customers", + "deleteCustomer": "Delete Customer", + "deleteConfirmation": "Are you sure you want to delete this customer? This action cannot be undone.", + "password": "Password", + "newPassword": "New Password", + "passwordPlaceholder": "Leave blank to keep current password", + "accountInfo": "Account Information", + "contactDetails": "Contact Details" }, "resources": { "title": "Resources", diff --git a/frontend/src/pages/ContractSigning.tsx b/frontend/src/pages/ContractSigning.tsx index dbfb2bd..266d64e 100644 --- a/frontend/src/pages/ContractSigning.tsx +++ b/frontend/src/pages/ContractSigning.tsx @@ -1,36 +1,43 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; -import { FileSignature, CheckCircle, XCircle, Loader } from 'lucide-react'; +import { FileSignature, CheckCircle, XCircle, Loader, Printer, Download } from 'lucide-react'; import { usePublicContract, useSignContract } from '../hooks/useContracts'; +import { useQueryClient } from '@tanstack/react-query'; const ContractSigning: React.FC = () => { const { t } = useTranslation(); const { token } = useParams<{ token: string }>(); + const queryClient = useQueryClient(); const [signerName, setSignerName] = useState(''); - const [signerEmail, setSignerEmail] = useState(''); const [agreedToTerms, setAgreedToTerms] = useState(false); - const [signatureData, setSignatureData] = useState(''); + const [electronicConsent, setElectronicConsent] = useState(false); - const { data: contractData, isLoading, error } = usePublicContract(token || ''); + const { data: contractData, isLoading, error, refetch } = usePublicContract(token || ''); const signMutation = useSignContract(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!token || !signatureData || !agreedToTerms) return; + if (!token || !signerName.trim() || !agreedToTerms || !electronicConsent) return; try { await signMutation.mutateAsync({ token, - signature_data: signatureData, - signer_name: signerName, - signer_email: signerEmail, + signer_name: signerName.trim(), + consent_checkbox_checked: agreedToTerms, + electronic_consent_given: electronicConsent, }); + // Refetch to get updated contract with signature data + await refetch(); } catch (error) { console.error('Failed to sign contract:', error); } }; + const handlePrint = () => { + window.print(); + }; + if (isLoading) { return (
@@ -42,7 +49,8 @@ const ContractSigning: React.FC = () => { ); } - if (error || !contractData) { + if (error || !contractData || !contractData.contract) { + console.error('Contract loading error:', { error, contractData }); return (
@@ -58,27 +66,6 @@ const ContractSigning: React.FC = () => { ); } - if (contractData.contract.status === 'SIGNED') { - return ( -
-
- -

- {t('contracts.signing.alreadySigned')} -

- {contractData.signature && ( -

- {t('contracts.signing.signedBy', { - name: contractData.signature.signer_name, - date: new Date(contractData.signature.signed_at).toLocaleDateString(), - })} -

- )} -
-
- ); - } - if (contractData.is_expired) { return (
@@ -95,22 +82,155 @@ const ContractSigning: React.FC = () => { ); } - if (signMutation.isSuccess) { + // Show signed contract view + if (contractData.contract?.status === 'SIGNED' || signMutation.isSuccess) { return ( -
-
- -

- {t('contracts.signing.success')} -

-

- {t('contracts.signing.thankYou')} -

+
+
+ {/* Print Header - Only visible when printing */} +
+
+
+

{contractData.business.name}

+

{contractData.template.name}

+
+
+

Contract ID: {contractData.contract.id}

+

Status: SIGNED

+
+
+
+ + {/* Success Banner - Hidden when printing */} +
+
+ +
+

+ Contract Successfully Signed +

+

+ This contract has been signed and is now legally binding. +

+
+
+
+ + {/* Action Buttons - Hidden when printing */} +
+ +
+ + {/* Contract Header */} +
+
+ {contractData.business.logo_url ? ( + {contractData.business.name} + ) : ( + + )} +
+

+ {contractData.business.name} +

+

{contractData.template.name}

+
+
+ {contractData.customer && ( +

+ Contract for: {contractData.customer.name} + {contractData.customer.email && ` (${contractData.customer.email})`} +

+ )} +
+ + {/* Contract Content */} +
+
+
+ + {/* Signature Details */} +
+

+ Electronic Signature Record +

+ +
+
+
+

Signer Name

+

+ {contractData.signature?.signer_name || signerName || 'N/A'} +

+
+
+

Signer Email

+

+ {contractData.signature?.signer_email || contractData.customer?.email || 'N/A'} +

+
+
+

Date Signed

+

+ {contractData.signature?.signed_at + ? new Date(contractData.signature.signed_at).toLocaleString() + : new Date().toLocaleString() + } +

+
+
+ +
+
+

Electronic Signature

+
+

+ {contractData.signature?.signer_name || signerName || 'N/A'} +

+
+
+
+

Contract Status

+ + + Signed + +
+
+
+ + {/* Legal Notice */} +
+

+ This document was signed electronically in compliance with the Electronic Signatures in Global and National Commerce Act (ESIGN Act, 15 U.S.C. section 7001 et seq.) and the Uniform Electronic Transactions Act (UETA). The signer expressly agreed to the terms and conditions, consented to conduct business electronically, and acknowledged that their electronic signature is the legal equivalent of a handwritten signature. A complete audit trail including timestamp, IP address, and document hash is maintained. +

+
+
+ + {/* Print Footer - Only visible when printing */} +
+

Printed on: {new Date().toLocaleString()}

+

This is a copy of an electronically signed document.

+
); } + // Show signing form return (
@@ -143,7 +263,7 @@ const ContractSigning: React.FC = () => {
{/* Contract Content */} -
+
{

-
+
+ {/* Electronic Signature - Type Name */}
-
- -
- - setSignerEmail(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - required - /> -
- -
- -
-