From c28939612252d683227c3f9ea103e4252b5b3a11 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 09:09:08 -0500 Subject: [PATCH 1/2] [dev] [tofikwest] tofik/trust-portal-setting-additional-docs-section (#1967) * feat(api): add DTOs and UI component for managing trust portal documents * refactor(api): track file names case-insensitively to avoid collisions * fix: ui and api bugs * fix(api): zip stream --------- Co-authored-by: Tofik Hasanov --- .../trust-portal/dto/trust-document.dto.ts | 93 ++++ .../trust-portal/trust-access.controller.ts | 64 +++ .../src/trust-portal/trust-access.service.ts | 210 ++++++++- .../trust-portal/trust-portal.controller.ts | 93 ++++ .../src/trust-portal/trust-portal.service.ts | 188 ++++++++ .../TrustPortalAdditionalDocumentsSection.tsx | 352 +++++++++++++++ .../components/TrustPortalSwitch.tsx | 15 + .../[orgId]/trust/portal-settings/page.tsx | 12 + packages/docs/openapi.json | 410 ++++++++++++++++++ 9 files changed, 1436 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/trust-portal/dto/trust-document.dto.ts create mode 100644 apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalAdditionalDocumentsSection.tsx diff --git a/apps/api/src/trust-portal/dto/trust-document.dto.ts b/apps/api/src/trust-portal/dto/trust-document.dto.ts new file mode 100644 index 000000000..a040f11ee --- /dev/null +++ b/apps/api/src/trust-portal/dto/trust-document.dto.ts @@ -0,0 +1,93 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class UploadTrustDocumentDto { + @ApiProperty({ + description: 'Organization ID that owns the document', + example: 'org_6914cd0e16e4c7dccbb54426', + }) + @IsString() + organizationId!: string; + + @ApiProperty({ + description: 'Original file name', + example: 'security-overview.pdf', + }) + @IsString() + fileName!: string; + + @ApiPropertyOptional({ + description: 'MIME type (optional)', + example: 'application/pdf', + }) + @IsOptional() + @IsString() + fileType?: string; + + @ApiProperty({ + description: 'Base64-encoded file contents (no data URL prefix)', + }) + @IsString() + fileData!: string; + + @ApiPropertyOptional({ + description: 'Optional description shown in the trust portal', + example: 'Overview of our security program', + }) + @IsOptional() + @IsString() + description?: string; +} + +export class TrustDocumentResponseDto { + @ApiProperty({ example: 'tdoc_abc123' }) + @IsString() + id!: string; + + @ApiProperty({ example: 'security-overview.pdf' }) + @IsString() + name!: string; + + @ApiPropertyOptional({ example: 'Overview of our security program' }) + @IsOptional() + @IsString() + description?: string | null; + + @ApiProperty({ example: '2026-01-02T10:15:00.000Z' }) + @IsString() + createdAt!: string; + + @ApiProperty({ example: '2026-01-02T10:15:00.000Z' }) + @IsString() + updatedAt!: string; +} + +export class TrustDocumentSignedUrlDto { + @ApiProperty({ + description: 'Organization ID that owns the document', + example: 'org_6914cd0e16e4c7dccbb54426', + }) + @IsString() + organizationId!: string; +} + +export class TrustDocumentUrlResponseDto { + @ApiProperty() + @IsString() + signedUrl!: string; + + @ApiProperty() + @IsString() + fileName!: string; +} + +export class DeleteTrustDocumentDto { + @ApiProperty({ + description: 'Organization ID that owns the document', + example: 'org_6914cd0e16e4c7dccbb54426', + }) + @IsString() + organizationId!: string; +} + + diff --git a/apps/api/src/trust-portal/trust-access.controller.ts b/apps/api/src/trust-portal/trust-access.controller.ts index 1f380dbe4..95717fcc5 100644 --- a/apps/api/src/trust-portal/trust-access.controller.ts +++ b/apps/api/src/trust-portal/trust-access.controller.ts @@ -455,6 +455,70 @@ export class TrustAccessController { return this.trustAccessService.getComplianceResourcesByAccessToken(token); } + @Get('access/:token/documents') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'List additional documents by access token', + description: + 'Get list of trust portal additional documents available for download', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Documents list returned', + }) + async getTrustDocumentsByAccessToken(@Param('token') token: string) { + return this.trustAccessService.getTrustDocumentsByAccessToken(token); + } + + @Get('access/:token/documents/download-all') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Download all additional documents as a ZIP by access token', + description: + 'Creates a ZIP archive of all active trust portal additional documents and returns a signed download URL', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Signed URL for ZIP archive returned', + }) + async downloadAllTrustDocuments(@Param('token') token: string) { + return this.trustAccessService.downloadAllTrustDocumentsByAccessToken(token); + } + + @Get('access/:token/documents/:documentId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Download additional document by access token', + description: + 'Get signed URL to download a specific trust portal additional document', + }) + @ApiParam({ + name: 'documentId', + description: 'Trust document ID', + example: 'tdoc_abc123', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Signed URL for document returned', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Document not found', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid access token', + }) + async getTrustDocumentUrlByAccessToken( + @Param('token') token: string, + @Param('documentId') documentId: string, + ) { + return this.trustAccessService.getTrustDocumentUrlByAccessToken( + token, + documentId, + ); + } + @Get('access/:token/compliance-resources/:framework') @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 524c8c8ba..c9804eae8 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -17,10 +17,12 @@ import { TrustEmailService } from './email.service'; import { NdaPdfService } from './nda-pdf.service'; import { AttachmentsService } from '../attachments/attachments.service'; import { PolicyPdfRendererService } from './policy-pdf-renderer.service'; -import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '../app/s3'; import { TrustFramework } from '@prisma/client'; +import archiver from 'archiver'; +import { PassThrough, Readable } from 'stream'; @Injectable() export class TrustAccessService { @@ -1187,6 +1189,212 @@ export class TrustAccessService { })); } + async getTrustDocumentsByAccessToken(token: string) { + const grant = await this.validateAccessToken(token); + + const documents = await db.trustDocument.findMany({ + where: { + organizationId: grant.accessRequest.organizationId, + isActive: true, + }, + select: { + id: true, + name: true, + description: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + return documents.map((d) => ({ + id: d.id, + name: d.name, + description: d.description, + createdAt: d.createdAt.toISOString(), + updatedAt: d.updatedAt.toISOString(), + })); + } + + async getTrustDocumentUrlByAccessToken(token: string, documentId: string) { + const grant = await this.validateAccessToken(token); + + if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { + throw new InternalServerErrorException( + 'Organization assets bucket is not configured', + ); + } + + const document = await db.trustDocument.findFirst({ + where: { + id: documentId, + organizationId: grant.accessRequest.organizationId, + isActive: true, + }, + select: { + name: true, + s3Key: true, + }, + }); + + if (!document) { + throw new NotFoundException('Document not found'); + } + + const getCommand = new GetObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: document.s3Key, + ResponseContentDisposition: `attachment; filename="${document.name.replaceAll('"', '')}"`, + }); + + const signedUrl = await getSignedUrl(s3Client, getCommand, { + expiresIn: 900, + }); + + return { + signedUrl, + fileName: document.name, + }; + } + + async downloadAllTrustDocumentsByAccessToken(token: string) { + const grant = await this.validateAccessToken(token); + + if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { + throw new InternalServerErrorException( + 'Organization assets bucket is not configured', + ); + } + + const organizationId = grant.accessRequest.organizationId; + const documents = await db.trustDocument.findMany({ + where: { + organizationId, + isActive: true, + }, + select: { + id: true, + name: true, + s3Key: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (documents.length === 0) { + throw new NotFoundException('No additional documents available'); + } + + const timestamp = Date.now(); + const zipKey = `${organizationId}/trust-documents/bundles/${grant.id}-${timestamp}.zip`; + + const archive = archiver('zip', { zlib: { level: 9 } }); + const zipStream = new PassThrough(); + let putPromise: + | Promise + | undefined; + + try { + putPromise = s3Client.send( + new PutObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: zipKey, + Body: zipStream, + ContentType: 'application/zip', + Metadata: { + organizationId, + grantId: grant.id, + kind: 'trust_documents_bundle', + }, + }), + ); + + archive.on('error', (err) => { + zipStream.destroy(err); + }); + + archive.pipe(zipStream); + + // Track names case-insensitively to avoid collisions on case-insensitive filesystems + // (e.g. Windows/macOS): "Report.pdf" vs "report.pdf" + const usedNamesLower = new Set(); + const toSafeName = (name: string): string => { + const sanitized = + name.replace(/[^\w.\-() ]/g, '_').trim() || 'document'; + const dot = sanitized.lastIndexOf('.'); + const base = dot > 0 ? sanitized.slice(0, dot) : sanitized; + const ext = dot > 0 ? sanitized.slice(dot) : ''; + + let candidate = `${base}${ext}`; + let i = 1; + while (usedNamesLower.has(candidate.toLowerCase())) { + candidate = `${base} (${i})${ext}`; + i += 1; + } + usedNamesLower.add(candidate.toLowerCase()); + return candidate; + }; + + for (const doc of documents) { + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: doc.s3Key, + }), + ); + + if (!response.Body) { + throw new InternalServerErrorException( + `No file data received from S3 for document ${doc.id}`, + ); + } + + const bodyStream = + response.Body instanceof Readable + ? response.Body + : Readable.from(response.Body as any); + + archive.append(bodyStream, { name: toSafeName(doc.name) }); + } + + await archive.finalize(); + await putPromise; + } catch (error) { + // Ensure the upload stream is closed, otherwise the S3 PutObject may hang/reject later. + try { + archive.abort(); + } catch { + // ignore + } + + if (!zipStream.destroyed) { + zipStream.destroy( + error instanceof Error ? error : new Error('ZIP generation failed'), + ); + } + + // Avoid unhandled rejections from an in-flight S3 put. + await putPromise?.catch(() => undefined); + + throw error; + } + + const signedUrl = await getSignedUrl( + s3Client, + new GetObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: zipKey, + ResponseContentDisposition: `attachment; filename="additional-documents-${timestamp}.zip"`, + }), + { expiresIn: 900 }, + ); + + return { + name: 'Additional Documents', + fileCount: documents.length, + downloadUrl: signedUrl, + }; + } + /** * Get FAQ markdown for a published trust portal. * diff --git a/apps/api/src/trust-portal/trust-portal.controller.ts b/apps/api/src/trust-portal/trust-portal.controller.ts index e1cbb5869..9a1379c2e 100644 --- a/apps/api/src/trust-portal/trust-portal.controller.ts +++ b/apps/api/src/trust-portal/trust-portal.controller.ts @@ -5,6 +5,7 @@ import { Get, HttpCode, HttpStatus, + Param, Post, Query, UseGuards, @@ -33,6 +34,13 @@ import { ComplianceResourceUrlResponseDto, UploadComplianceResourceDto, } from './dto/compliance-resource.dto'; +import { + DeleteTrustDocumentDto, + TrustDocumentResponseDto, + TrustDocumentSignedUrlDto, + TrustDocumentUrlResponseDto, + UploadTrustDocumentDto, +} from './dto/trust-document.dto'; import { TrustPortalService } from './trust-portal.service'; class ListComplianceResourcesDto { @@ -153,6 +161,91 @@ export class TrustPortalController { return this.trustPortalService.listComplianceResources(dto.organizationId); } + @Post('documents/upload') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Upload an additional trust portal document', + description: + 'Stores a document in the organization assets bucket and registers it for the trust portal.', + }) + @ApiBody({ type: UploadTrustDocumentDto }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Document uploaded successfully', + type: TrustDocumentResponseDto, + }) + async uploadTrustDocument( + @Body() dto: UploadTrustDocumentDto, + @AuthContext() authContext: AuthContextType, + ): Promise { + this.assertOrganizationAccess(dto.organizationId, authContext); + return this.trustPortalService.uploadTrustDocument(dto); + } + + @Post('documents/list') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'List additional trust portal documents for the organization', + }) + @ApiBody({ type: ListComplianceResourcesDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Documents retrieved successfully', + type: [TrustDocumentResponseDto], + }) + async listTrustDocuments( + @Body() dto: ListComplianceResourcesDto, + @AuthContext() authContext: AuthContextType, + ): Promise { + this.assertOrganizationAccess(dto.organizationId, authContext); + return this.trustPortalService.listTrustDocuments(dto.organizationId); + } + + @Post('documents/:documentId/download') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Generate a temporary signed URL for a trust portal document', + }) + @ApiBody({ type: TrustDocumentSignedUrlDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Signed URL generated successfully', + type: TrustDocumentUrlResponseDto, + }) + async getTrustDocumentUrl( + @Body() dto: TrustDocumentSignedUrlDto, + @Param('documentId') documentId: string, + @AuthContext() authContext: AuthContextType, + ): Promise { + this.assertOrganizationAccess(dto.organizationId, authContext); + return this.trustPortalService.getTrustDocumentUrl(documentId, dto); + } + + @Post('documents/:documentId/delete') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Delete (deactivate) a trust portal document', + }) + @ApiBody({ type: DeleteTrustDocumentDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Document deleted successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + }, + }, + }) + async deleteTrustDocument( + @Body() dto: DeleteTrustDocumentDto, + @Param('documentId') documentId: string, + @AuthContext() authContext: AuthContextType, + ): Promise<{ success: boolean }> { + this.assertOrganizationAccess(dto.organizationId, authContext); + return this.trustPortalService.deleteTrustDocument(documentId, dto); + } + private assertOrganizationAccess( organizationId: string, authContext: AuthContextType, diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index bae05af05..10d99f142 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -26,6 +26,13 @@ import { UploadComplianceResourceDto, } from './dto/compliance-resource.dto'; import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '../app/s3'; +import { + DeleteTrustDocumentDto, + TrustDocumentResponseDto, + TrustDocumentSignedUrlDto, + TrustDocumentUrlResponseDto, + UploadTrustDocumentDto, +} from './dto/trust-document.dto'; interface VercelDomainVerification { type: string; @@ -330,6 +337,157 @@ export class TrustPortalService { }; } + async listTrustDocuments( + organizationId: string, + ): Promise { + const records = await db.trustDocument.findMany({ + where: { + organizationId, + isActive: true, + }, + orderBy: { + createdAt: 'desc', + }, + select: { + id: true, + name: true, + description: true, + createdAt: true, + updatedAt: true, + }, + }); + + return records.map((record) => ({ + id: record.id, + name: record.name, + description: record.description, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + })); + } + + async uploadTrustDocument( + dto: UploadTrustDocumentDto, + ): Promise { + this.ensureS3Availability(); + + const { fileBuffer, sanitizedFileName } = this.prepareGenericFilePayload({ + fileData: dto.fileData, + fileName: dto.fileName, + }); + + const timestamp = Date.now(); + const s3Prefix = `${dto.organizationId}/trust-documents`; + const s3Key = `${s3Prefix}/${timestamp}-${sanitizedFileName}`; + + const putCommand = new PutObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: s3Key, + Body: fileBuffer, + ContentType: dto.fileType || 'application/octet-stream', + Metadata: { + organizationId: dto.organizationId, + originalFileName: dto.fileName, + }, + }); + + await s3Client!.send(putCommand); + + const record = await db.trustDocument.create({ + data: { + organizationId: dto.organizationId, + name: dto.fileName, + description: dto.description || null, + s3Key, + isActive: true, + }, + select: { + id: true, + name: true, + description: true, + createdAt: true, + updatedAt: true, + }, + }); + + return { + id: record.id, + name: record.name, + description: record.description, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + }; + } + + async getTrustDocumentUrl( + documentId: string, + dto: TrustDocumentSignedUrlDto, + ): Promise { + this.ensureS3Availability(); + + const record = await db.trustDocument.findUnique({ + where: { + id: documentId, + organizationId: dto.organizationId, + }, + select: { + s3Key: true, + name: true, + isActive: true, + }, + }); + + if (!record || !record.isActive) { + throw new NotFoundException('Document not found'); + } + + const getCommand = new GetObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: record.s3Key, + ResponseContentDisposition: `attachment; filename="${record.name.replaceAll('"', '')}"`, + }); + + const signedUrl = await getSignedUrl(s3Client!, getCommand, { + expiresIn: this.SIGNED_URL_EXPIRY_SECONDS, + }); + + return { + signedUrl, + fileName: record.name, + }; + } + + async deleteTrustDocument( + documentId: string, + dto: DeleteTrustDocumentDto, + ): Promise<{ success: boolean }> { + const record = await db.trustDocument.findUnique({ + where: { + id: documentId, + organizationId: dto.organizationId, + }, + select: { + id: true, + s3Key: true, + isActive: true, + }, + }); + + if (!record || !record.isActive) { + throw new NotFoundException('Document not found'); + } + + await db.trustDocument.update({ + where: { id: record.id }, + data: { isActive: false }, + }); + + // Best-effort cleanup: if S3 deletion fails, the document is already hidden from users + await this.safeDeleteObject(record.s3Key); + + return { success: true }; + } + private async assertFrameworkIsCompliant( organizationId: string, framework: TrustFramework, @@ -397,6 +555,36 @@ export class TrustPortalService { return { fileBuffer, sanitizedFileName }; } + private prepareGenericFilePayload({ + fileData, + fileName, + }: { + fileData: string; + fileName: string; + }) { + let fileBuffer: Buffer; + try { + fileBuffer = Buffer.from(fileData, 'base64'); + } catch { + throw new BadRequestException( + 'Invalid file data. Expected base64 string.', + ); + } + + if (!fileBuffer.length) { + throw new BadRequestException('File cannot be empty'); + } + + if (fileBuffer.length > this.MAX_FILE_SIZE_BYTES) { + throw new BadRequestException( + `File exceeds the ${this.MAX_FILE_SIZE_BYTES / (1024 * 1024)}MB limit`, + ); + } + + const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + return { fileBuffer, sanitizedFileName }; + } + private ensureS3Availability(): void { if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { throw new InternalServerErrorException( diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalAdditionalDocumentsSection.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalAdditionalDocumentsSection.tsx new file mode 100644 index 000000000..84c86e714 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalAdditionalDocumentsSection.tsx @@ -0,0 +1,352 @@ +'use client'; + +import { FileUploader } from '@/components/file-uploader'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@comp/ui/alert-dialog'; +import { Button } from '@comp/ui/button'; +import { Card } from '@comp/ui/card'; +import { Download, FileText, Trash2, Upload } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useCallback, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { api } from '@/lib/api-client'; + +export type TrustPortalDocument = { + id: string; + name: string; + description: string | null; + createdAt: string; + updatedAt: string; +}; + +interface TrustPortalAdditionalDocumentsSectionProps { + organizationId: string; + enabled: boolean; + documents: TrustPortalDocument[]; +} + +type UploadTrustPortalDocumentResponse = { + id: string; + name: string; + description?: string | null; + createdAt: string; + updatedAt: string; +}; + +type TrustPortalDocumentDownloadResponse = { + signedUrl: string; + fileName: string; +}; + +export function TrustPortalAdditionalDocumentsSection({ + organizationId, + enabled, + documents, +}: TrustPortalAdditionalDocumentsSectionProps) { + const router = useRouter(); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState>({}); + const [downloadingIds, setDownloadingIds] = useState>(new Set()); + const [deletingId, setDeletingId] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [documentToDelete, setDocumentToDelete] = useState<{ id: string; name: string } | null>( + null, + ); + + const sortedDocuments = useMemo(() => { + return [...documents].sort((a, b) => { + const aDate = new Date(a.createdAt).getTime(); + const bDate = new Date(b.createdAt).getTime(); + return bDate - aDate; + }); + }, [documents]); + + const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(',')[1]; + resolve(base64); + }; + reader.onerror = (error) => reject(error); + }); + }; + + const handleFileUpload = useCallback( + async (files: File[]) => { + if (!enabled) return; + + setIsUploading(true); + const newProgress: Record = {}; + + try { + files.forEach((file) => { + newProgress[file.name] = 0; + }); + setUploadProgress(newProgress); + + for (const file of files) { + try { + const fileData = await fileToBase64(file); + newProgress[file.name] = 50; + setUploadProgress({ ...newProgress }); + + const response = await api.post( + '/v1/trust-portal/documents/upload', + { + organizationId, + fileName: file.name, + fileType: file.type || 'application/octet-stream', + fileData, + }, + organizationId, + ); + + if (response.error) { + throw new Error(response.error || 'Failed to upload file'); + } + + if (response.data?.id) { + newProgress[file.name] = 100; + setUploadProgress({ ...newProgress }); + toast.success(`Uploaded ${file.name}`); + } else { + throw new Error('Failed to upload file: invalid response'); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Failed to upload ${file.name}: ${message}`); + delete newProgress[file.name]; + setUploadProgress({ ...newProgress }); + } + } + + router.refresh(); + } finally { + setIsUploading(false); + setUploadProgress({}); + } + }, + [enabled, organizationId, router], + ); + + const handleDownload = useCallback( + async (documentId: string, fileName: string) => { + if (downloadingIds.has(documentId)) return; + + setDownloadingIds((prev) => new Set(prev).add(documentId)); + try { + const response = await api.post( + `/v1/trust-portal/documents/${documentId}/download`, + { organizationId }, + organizationId, + ); + + if (response.error) { + toast.error(response.error || 'Failed to download file'); + return; + } + + if (!response.data?.signedUrl) { + toast.error('Failed to download file: invalid response'); + return; + } + + const link = document.createElement('a'); + link.href = response.data.signedUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + toast.success(`Downloading ${fileName}...`); + } catch (error) { + console.error('Error downloading trust portal document:', error); + toast.error('An error occurred while downloading the file'); + } finally { + setDownloadingIds((prev) => { + const next = new Set(prev); + next.delete(documentId); + return next; + }); + } + }, + [downloadingIds, organizationId], + ); + + const handleDeleteClick = (documentId: string, fileName: string) => { + setDocumentToDelete({ id: documentId, name: fileName }); + setIsDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = useCallback(async () => { + if (!documentToDelete) return; + + setDeletingId(documentToDelete.id); + setIsDeleteDialogOpen(false); + + try { + const response = await api.post<{ success: boolean }>( + `/v1/trust-portal/documents/${documentToDelete.id}/delete`, + { organizationId }, + organizationId, + ); + + if (response.error) { + toast.error(response.error || 'Failed to delete document'); + return; + } + + if (response.data?.success) { + toast.success(`Deleted ${documentToDelete.name}`); + router.refresh(); + } else { + toast.error('Failed to delete document: invalid response'); + } + } catch (error) { + console.error('Error deleting trust portal document:', error); + toast.error('An error occurred while deleting the document'); + } finally { + setDeletingId(null); + setDocumentToDelete(null); + } + }, [documentToDelete, organizationId, router]); + + return ( + +
+
+

Additional Documents

+

+ Upload any documents you want to make available on your trust portal. +

+
+
+ + {sortedDocuments.length} +
+
+ + {!enabled && ( +
+ Enable the trust portal to upload documents. +
+ )} + + {sortedDocuments.length > 0 && ( +
+ {sortedDocuments.map((doc) => { + const isDownloading = downloadingIds.has(doc.id); + const isDeleting = deletingId === doc.id; + + return ( +
+
!isDownloading && !isDeleting && handleDownload(doc.id, doc.name)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter') { + if (isDownloading || isDeleting) return; + void handleDownload(doc.id, doc.name); + } + }} + aria-label={`Download ${doc.name}`} + > +
+ +
+
+
+

{doc.name}

+ +
+
+ {new Date(doc.createdAt).toLocaleDateString()} +
+
+
+ + +
+ ); + })} +
+ )} + +
+ +
+ + + + + Delete Document + + Are you sure you want to delete "{documentToDelete?.name}"? This action cannot + be undone. + + + + Cancel + + {deletingId ? 'Deleting...' : 'Delete'} + + + + +
+ ); +} + + diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx index c97a58c2b..469d74184 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx @@ -21,6 +21,10 @@ import { isFriendlyAvailable } from '../actions/is-friendly-available'; import { trustPortalSwitchAction } from '../actions/trust-portal-switch'; import { updateTrustPortalFrameworks } from '../actions/update-trust-portal-frameworks'; import { TrustPortalFaqBuilder } from './TrustPortalFaqBuilder'; +import { + TrustPortalAdditionalDocumentsSection, + type TrustPortalDocument, +} from './TrustPortalAdditionalDocumentsSection'; import { GDPR, HIPAA, @@ -130,6 +134,7 @@ export function TrustPortalSwitch({ pcidssFileName, nen7510FileName, iso9001FileName, + additionalDocuments, }: { enabled: boolean; slug: string; @@ -167,6 +172,7 @@ export function TrustPortalSwitch({ pcidssFileName?: string | null; nen7510FileName?: string | null; iso9001FileName?: string | null; + additionalDocuments: TrustPortalDocument[]; }) { const [certificateFiles, setCertificateFiles] = useState>({ iso27001: iso27001FileName ?? null, @@ -999,6 +1005,15 @@ export function TrustPortalSwitch({ )} + {form.watch('enabled') && ( +
+ +
+ )} diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/page.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/page.tsx index 7f57c9bf8..1d857bd1a 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/page.tsx @@ -13,6 +13,11 @@ export default async function PortalSettingsPage({ params }: { params: Promise<{ const certificateFiles = await fetchComplianceCertificates(orgId); const primaryColor = await fetchOrganizationPrimaryColor(orgId); // can be null const faqs = await fetchOrganizationFaqs(orgId); // can be null + const additionalDocuments = await db.trustDocument.findMany({ + where: { organizationId: orgId, isActive: true }, + select: { id: true, name: true, description: true, createdAt: true, updatedAt: true }, + orderBy: { createdAt: 'desc' }, + }); return ( @@ -59,6 +64,13 @@ export default async function PortalSettingsPage({ params }: { params: Promise<{ pcidssFileName={certificateFiles.pcidssFileName} nen7510FileName={certificateFiles.nen7510FileName} iso9001FileName={certificateFiles.iso9001FileName} + additionalDocuments={additionalDocuments.map((doc) => ({ + id: doc.id, + name: doc.name, + description: doc.description, + createdAt: doc.createdAt.toISOString(), + updatedAt: doc.updatedAt.toISOString(), + }))} /> Date: Mon, 5 Jan 2026 09:51:23 -0500 Subject: [PATCH 2/2] fix(integrations): enhance combobox to support custom values (#1969) --- .../integrations/ConnectIntegrationDialog.tsx | 5 +- .../integrations/ManageIntegrationDialog.tsx | 51 ++++++++++++------- .../src/manifests/aws/credentials.ts | 28 ++++++++++ 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/apps/app/src/components/integrations/ConnectIntegrationDialog.tsx b/apps/app/src/components/integrations/ConnectIntegrationDialog.tsx index a4eb1da15..7009e18b7 100644 --- a/apps/app/src/components/integrations/ConnectIntegrationDialog.tsx +++ b/apps/app/src/components/integrations/ConnectIntegrationDialog.tsx @@ -96,7 +96,10 @@ function CredentialInput({ label: opt.label, })) || []; - const selectedItem = items.find((item) => item.id === value); + // Find existing item or create synthetic one for custom values + const selectedItem = value + ? items.find((item) => item.id === value) ?? { id: value, label: value } + : undefined; return ( ) : field.type === 'combobox' && field.options ? ( - ({ + (() => { + const items = field.options.map((opt) => ({ id: opt.value, label: opt.label, - }))} - selectedItem={field.options - .map((opt) => ({ id: opt.value, label: opt.label })) - .find((item) => item.id === credentialValues[field.id])} - onSelect={(item) => setCredentialValues((prev) => ({ ...prev, [field.id]: item.id }))} - onCreate={(customValue) => - setCredentialValues((prev) => ({ ...prev, [field.id]: customValue })) - } - placeholder={field.placeholder || `Select ${field.label.toLowerCase()}...`} - searchPlaceholder="Search or type custom value..." - renderOnCreate={(customValue) => ( -
- Use custom value: - {customValue} -
- )} - /> + })); + const currentValue = credentialValues[field.id]; + // Find existing item or create synthetic one for custom values + const selectedItem = currentValue + ? items.find((item) => item.id === currentValue) ?? { + id: currentValue, + label: currentValue, + } + : undefined; + return ( + + setCredentialValues((prev) => ({ ...prev, [field.id]: item.id })) + } + onCreate={(customValue) => + setCredentialValues((prev) => ({ ...prev, [field.id]: customValue })) + } + placeholder={field.placeholder || `Select ${field.label.toLowerCase()}...`} + searchPlaceholder="Search or type custom value..." + renderOnCreate={(customValue) => ( +
+ Use custom value: + {customValue} +
+ )} + /> + ); + })() ) : field.type === 'select' && field.options ? (