From 96262d145153bca4654accf0ed0321aa145bfdb2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:49:13 -0500 Subject: [PATCH 1/3] feat(trust-access): fix trustportal grand access logic and add endpoint to resend access granted email (#1988) Co-authored-by: Tofik Hasanov --- .../src/email/templates/access-granted.tsx | 20 +- apps/api/src/trust-portal/email.service.ts | 2 +- .../trust-portal/trust-access.controller.ts | 27 ++ .../src/trust-portal/trust-access.service.ts | 235 +++++++++++++++--- .../trust/components/grant-columns.tsx | 35 ++- .../trust/components/grant-data-table.tsx | 10 +- .../[orgId]/trust/components/grants-tab.tsx | 13 +- .../actions/is-friendly-available.ts | 46 ---- .../actions/trust-portal-switch.ts | 91 ++++++- .../components/TrustPortalSwitch.tsx | 209 ++++------------ .../[orgId]/trust/portal-settings/page.tsx | 83 ++++++- apps/app/src/hooks/use-access-requests.ts | 20 ++ packages/docs/openapi.json | 39 +++ 13 files changed, 554 insertions(+), 276 deletions(-) delete mode 100644 apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/is-friendly-available.ts diff --git a/apps/api/src/email/templates/access-granted.tsx b/apps/api/src/email/templates/access-granted.tsx index 3678edfb2..a94bfce64 100644 --- a/apps/api/src/email/templates/access-granted.tsx +++ b/apps/api/src/email/templates/access-granted.tsx @@ -17,7 +17,7 @@ interface Props { toName: string; organizationName: string; expiresAt: Date; - portalUrl?: string | null; + portalUrl: string; } export const AccessGrantedEmail = ({ @@ -76,16 +76,14 @@ export const AccessGrantedEmail = ({ - {portalUrl && ( -
- -
- )} +
+ +
You can download your signed NDA for your records from the diff --git a/apps/api/src/trust-portal/email.service.ts b/apps/api/src/trust-portal/email.service.ts index 59f7bcf41..50531deb3 100644 --- a/apps/api/src/trust-portal/email.service.ts +++ b/apps/api/src/trust-portal/email.service.ts @@ -36,7 +36,7 @@ export class TrustEmailService { toName: string; organizationName: string; expiresAt: Date; - portalUrl?: string | null; + portalUrl: string; }): Promise { const { toEmail, toName, organizationName, expiresAt, portalUrl } = params; diff --git a/apps/api/src/trust-portal/trust-access.controller.ts b/apps/api/src/trust-portal/trust-access.controller.ts index 95717fcc5..8784ae7c8 100644 --- a/apps/api/src/trust-portal/trust-access.controller.ts +++ b/apps/api/src/trust-portal/trust-access.controller.ts @@ -253,6 +253,33 @@ export class TrustAccessController { ); } + @Post('admin/grants/:id/resend-access-email') + @UseGuards(HybridAuthGuard) + @ApiSecurity('apikey') + @ApiHeader({ + name: 'X-Organization-Id', + description: 'Organization ID', + required: true, + }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Resend access granted email', + description: 'Resend the access granted email to user with active grant', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Access email resent', + }) + async resendAccessEmail( + @OrganizationId() organizationId: string, + @Param('id') grantId: string, + ) { + return this.trustAccessService.resendAccessGrantEmail( + organizationId, + grantId, + ); + } + @Get('nda/:token') @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 c9804eae8..1da9d015b 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -20,7 +20,7 @@ import { PolicyPdfRendererService } from './policy-pdf-renderer.service'; 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 { Prisma, TrustFramework } from '@prisma/client'; import archiver from 'archiver'; import { PassThrough, Readable } from 'stream'; @@ -35,6 +35,130 @@ export class TrustAccessService { return randomBytes(length).toString('base64url').slice(0, length); } + /** + * Normalize URL by removing trailing slash + */ + private normalizeUrl(input: string): string { + return input.endsWith('/') ? input.slice(0, -1) : input; + } + + /** + * Normalize domain by removing protocol and path + */ + private normalizeDomain(input: string): string { + const trimmed = input.trim(); + const withoutProtocol = trimmed.replace(/^https?:\/\//i, ''); + const withoutPath = withoutProtocol.split('/')[0] ?? withoutProtocol; + return withoutPath.trim().toLowerCase(); + } + + /** + * Create a URL-friendly slug from organization name + */ + private slugifyOrganizationName(name: string): string { + const cleaned = name + .trim() + .toLowerCase() + .replace(/&/g, 'and') + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + return cleaned.slice(0, 60); + } + + /** + * Ensure organization has a friendlyUrl, create one if missing + */ + private async ensureFriendlyUrl(params: { + organizationId: string; + organizationName: string; + }): Promise { + const { organizationId, organizationName } = params; + + const current = await db.trust.findUnique({ + where: { organizationId }, + select: { friendlyUrl: true }, + }); + + if (current?.friendlyUrl) return current.friendlyUrl; + + const baseCandidate = + this.slugifyOrganizationName(organizationName) || + `org-${organizationId.slice(-8)}`; + + for (let i = 0; i < 25; i += 1) { + const candidate = i === 0 ? baseCandidate : `${baseCandidate}-${i + 1}`; + + const taken = await db.trust.findUnique({ + where: { friendlyUrl: candidate }, + select: { organizationId: true }, + }); + + if (taken && taken.organizationId !== organizationId) continue; + + try { + await db.trust.upsert({ + where: { organizationId }, + update: { friendlyUrl: candidate }, + create: { organizationId, friendlyUrl: candidate }, + }); + return candidate; + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + continue; + } + throw error; + } + } + + return organizationId; + } + + /** + * Build portal base URL, checking custom domain first + */ + private async buildPortalBaseUrl(params: { + organizationId: string; + organizationName: string; + }): Promise { + const { organizationId, organizationName } = params; + + const trust = await db.trust.findUnique({ + where: { organizationId }, + select: { domain: true, domainVerified: true, friendlyUrl: true }, + }); + + if (trust?.domain && trust.domainVerified) { + return `https://${this.normalizeDomain(trust.domain)}`; + } + + const urlId = + trust?.friendlyUrl || + (await this.ensureFriendlyUrl({ organizationId, organizationName })); + + return `${this.normalizeUrl(this.TRUST_APP_URL)}/${urlId}`; + } + + /** + * Build portal access URL with access token + */ + private async buildPortalAccessUrl(params: { + organizationId: string; + organizationName: string; + accessToken: string; + }): Promise { + const { organizationId, organizationName, accessToken } = params; + const base = await this.buildPortalBaseUrl({ + organizationId, + organizationName, + }); + return `${base}/access/${accessToken}`; + } + private async findPublishedTrustByRouteId(id: string) { // First, try treating `id` as the existing friendlyUrl. let trust = await db.trust.findUnique({ @@ -560,6 +684,66 @@ export class TrustAccessService { return updatedGrant; } + async resendAccessGrantEmail(organizationId: string, grantId: string) { + const grant = await db.trustAccessGrant.findFirst({ + where: { + id: grantId, + accessRequest: { + organizationId, + }, + }, + include: { + accessRequest: { + include: { + organization: { + select: { name: true }, + }, + }, + }, + }, + }); + + if (!grant) { + throw new NotFoundException('Grant not found'); + } + + if (grant.status !== 'active') { + throw new BadRequestException( + `Cannot resend access email for ${grant.status} grant`, + ); + } + + // Generate a new access token if expired or missing + let accessToken = grant.accessToken; + const now = new Date(); + + if (!accessToken || (grant.accessTokenExpiresAt && grant.accessTokenExpiresAt < now)) { + accessToken = this.generateToken(32); + const accessTokenExpiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); + + await db.trustAccessGrant.update({ + where: { id: grantId }, + data: { accessToken, accessTokenExpiresAt }, + }); + } + + const portalUrl = await this.buildPortalAccessUrl({ + organizationId, + organizationName: grant.accessRequest.organization.name, + accessToken, + }); + + await this.emailService.sendAccessGrantedEmail({ + toEmail: grant.subjectEmail, + toName: grant.accessRequest.name, + organizationName: grant.accessRequest.organization.name, + expiresAt: grant.expiresAt, + portalUrl, + }); + + return { message: 'Access email resent successfully' }; + } + async getNdaByToken(token: string) { const nda = await db.trustNDAAgreement.findUnique({ where: { signToken: token }, @@ -577,15 +761,11 @@ export class TrustAccessService { throw new NotFoundException('NDA agreement not found'); } - const trust = await db.trust.findUnique({ - where: { organizationId: nda.organizationId }, - select: { friendlyUrl: true }, + const portalUrl = await this.buildPortalBaseUrl({ + organizationId: nda.organizationId, + organizationName: nda.accessRequest.organization.name, }); - const portalUrl = trust?.friendlyUrl - ? `${this.TRUST_APP_URL}/${trust.friendlyUrl}` - : null; - const baseResponse = { id: nda.id, organizationName: nda.accessRequest.organization.name, @@ -612,11 +792,13 @@ export class TrustAccessService { } if (nda.status === 'signed') { - let accessUrl = portalUrl; + let accessUrl: string | null = portalUrl; if (nda.grant?.accessToken && nda.grant.status === 'active') { - if (trust?.friendlyUrl) { - accessUrl = `${this.TRUST_APP_URL}/${trust.friendlyUrl}/access/${nda.grant.accessToken}`; - } + accessUrl = await this.buildPortalAccessUrl({ + organizationId: nda.organizationId, + organizationName: nda.accessRequest.organization.name, + accessToken: nda.grant.accessToken, + }); } return { @@ -683,15 +865,12 @@ export class TrustAccessService { }); } - const trust = await db.trust.findUnique({ - where: { organizationId: nda.organizationId }, - select: { friendlyUrl: true }, + const portalUrl = await this.buildPortalAccessUrl({ + organizationId: nda.organizationId, + organizationName: nda.accessRequest.organization.name, + accessToken, }); - const portalUrl = trust?.friendlyUrl - ? `${this.TRUST_APP_URL}/${trust.friendlyUrl}/access/${accessToken}` - : null; - return { message: 'NDA already signed', grant: nda.grant, @@ -754,15 +933,12 @@ export class TrustAccessService { return { grant, updatedNda }; }); - const trust = await db.trust.findUnique({ - where: { organizationId: nda.organizationId }, - select: { friendlyUrl: true }, + const portalUrl = await this.buildPortalAccessUrl({ + organizationId: nda.organizationId, + organizationName: nda.accessRequest.organization.name, + accessToken, }); - const portalUrl = trust?.friendlyUrl - ? `${this.TRUST_APP_URL}/${trust.friendlyUrl}/access/${accessToken}` - : null; - await this.emailService.sendAccessGrantedEmail({ toEmail: signerEmail, toName: signerName, @@ -975,8 +1151,11 @@ export class TrustAccessService { }); } - const urlId = trust.friendlyUrl || trust.organizationId; - let accessLink = `${this.TRUST_APP_URL}/${urlId}/access/${accessToken}`; + let accessLink = await this.buildPortalAccessUrl({ + organizationId: trust.organizationId, + organizationName: grant.accessRequest.organization.name, + accessToken, + }); // Append query parameter if provided if (query) { diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/grant-columns.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/grant-columns.tsx index 492b40de8..ca8db5187 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/components/grant-columns.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/components/grant-columns.tsx @@ -4,17 +4,18 @@ import type { AccessGrant } from '@/hooks/use-access-requests'; import { Badge } from '@comp/ui/badge'; import { Button } from '@comp/ui/button'; import type { ColumnDef } from '@tanstack/react-table'; -import { Copy } from 'lucide-react'; -import { toast } from 'sonner'; +import { Mail } from 'lucide-react'; export type GrantTableRow = AccessGrant; interface GrantColumnHandlers { onRevoke: (row: AccessGrant) => void; + onResendAccess: (row: AccessGrant) => void; } export function buildGrantColumns({ onRevoke, + onResendAccess, }: GrantColumnHandlers): ColumnDef[] { return [ { @@ -91,20 +92,30 @@ export function buildGrantColumns({ header: 'Actions', cell: ({ row }) => { const grant = row.original; - + if (grant.status === 'active') { return ( - +
+ + +
); } - + return null; }, }, diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/grant-data-table.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/grant-data-table.tsx index 964f1d5d0..0714c3bcd 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/components/grant-data-table.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/components/grant-data-table.tsx @@ -8,10 +8,16 @@ interface GrantDataTableProps { data: AccessGrant[]; isLoading?: boolean; onRevoke: (row: AccessGrant) => void; + onResendAccess: (row: AccessGrant) => void; } -export function GrantDataTable({ data, isLoading, onRevoke }: GrantDataTableProps) { - const columns = buildGrantColumns({ onRevoke }); +export function GrantDataTable({ + data, + isLoading, + onRevoke, + onResendAccess, +}: GrantDataTableProps) { + const columns = buildGrantColumns({ onRevoke, onResendAccess }); return ( (null); const [search, setSearch] = useState(''); const [status, setStatus] = useState('all'); + const handleResendAccess = (grantId: string) => { + toast.promise(resendAccessEmail(grantId), { + loading: 'Resending...', + success: 'Access email resent', + error: 'Failed to resend access email', + }); + }; + const filtered = (data ?? []).filter((grant) => { const matchesSearch = !search || grant.subjectEmail.toLowerCase().includes(search.toLowerCase()); @@ -46,6 +56,7 @@ export function GrantsTab({ orgId }: { orgId: string }) { data={filtered} isLoading={isLoading} onRevoke={(row) => setRevokeId(row.id)} + onResendAccess={(row) => handleResendAccess(row.id)} /> {revokeId && ( diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/is-friendly-available.ts b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/is-friendly-available.ts deleted file mode 100644 index aa4f1c2d5..000000000 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/is-friendly-available.ts +++ /dev/null @@ -1,46 +0,0 @@ -'use server'; - -import { authActionClient } from '@/actions/safe-action'; -import { db } from '@db'; -import { z } from 'zod'; - -const isFriendlyAvailableSchema = z.object({ - friendlyUrl: z.string(), - orgId: z.string(), -}); - -export const isFriendlyAvailable = authActionClient - .inputSchema(isFriendlyAvailableSchema) - .metadata({ - name: 'check-friendly-url', - track: { - event: 'check-friendly-url', - channel: 'server', - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { friendlyUrl, orgId } = parsedInput; - - if (!ctx.session.activeOrganizationId) { - throw new Error('No active organization'); - } - - const url = await db.trust.findUnique({ - where: { - friendlyUrl, - }, - select: { - friendlyUrl: true, - organizationId: true, - }, - }); - - if (url) { - if (url.organizationId === orgId) { - return { isAvailable: true }; - } - return { isAvailable: false }; - } - - return { isAvailable: true }; - }); diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/trust-portal-switch.ts b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/trust-portal-switch.ts index 7f1288049..50db0b336 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/trust-portal-switch.ts +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/trust-portal-switch.ts @@ -1,19 +1,82 @@ -// update-organization-name-action.ts - 'use server'; import { authActionClient } from '@/actions/safe-action'; import { db } from '@db'; +import { Prisma } from '@prisma/client'; import { revalidatePath, revalidateTag } from 'next/cache'; import { z } from 'zod'; const trustPortalSwitchSchema = z.object({ enabled: z.boolean(), contactEmail: z.string().email().optional().or(z.literal('')), - friendlyUrl: z.string().optional(), primaryColor: z.string().optional(), }); +/** + * Create a URL-friendly slug from organization name + */ +const slugifyOrganizationName = (name: string): string => { + const cleaned = name + .trim() + .toLowerCase() + .replace(/&/g, 'and') + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + return cleaned.slice(0, 60); +}; + +/** + * Ensure organization has a friendlyUrl, create one if missing + */ +const ensureFriendlyUrl = async (params: { + organizationId: string; + organizationName: string; +}): Promise => { + const { organizationId, organizationName } = params; + + const current = await db.trust.findUnique({ + where: { organizationId }, + select: { friendlyUrl: true }, + }); + + if (current?.friendlyUrl) return current.friendlyUrl; + + const baseCandidate = + slugifyOrganizationName(organizationName) || `org-${organizationId.slice(-8)}`; + + for (let i = 0; i < 50; i += 1) { + const candidate = i === 0 ? baseCandidate : `${baseCandidate}-${i + 1}`; + + const taken = await db.trust.findUnique({ + where: { friendlyUrl: candidate }, + select: { organizationId: true }, + }); + + if (taken && taken.organizationId !== organizationId) continue; + + try { + await db.trust.upsert({ + where: { organizationId }, + update: { friendlyUrl: candidate }, + create: { organizationId, friendlyUrl: candidate }, + }); + return candidate; + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + continue; + } + throw error; + } + } + + return organizationId; +}; + export const trustPortalSwitchAction = authActionClient .inputSchema(trustPortalSwitchSchema) .metadata({ @@ -24,7 +87,7 @@ export const trustPortalSwitchAction = authActionClient }, }) .action(async ({ parsedInput, ctx }) => { - const { enabled, contactEmail, friendlyUrl, primaryColor } = parsedInput; + const { enabled, contactEmail, primaryColor } = parsedInput; const { activeOrganizationId } = ctx.session; if (!activeOrganizationId) { @@ -32,6 +95,24 @@ export const trustPortalSwitchAction = authActionClient } try { + // Get organization name for friendlyUrl generation + const org = await db.organization.findUnique({ + where: { id: activeOrganizationId }, + select: { name: true }, + }); + + if (!org) { + throw new Error('Organization not found'); + } + + // Ensure friendlyUrl exists when enabling the portal + if (enabled) { + await ensureFriendlyUrl({ + organizationId: activeOrganizationId, + organizationName: org.name, + }); + } + // Update Trust table await db.trust.upsert({ where: { @@ -40,13 +121,11 @@ export const trustPortalSwitchAction = authActionClient update: { status: enabled ? 'published' : 'draft', contactEmail: contactEmail === '' ? null : contactEmail, - friendlyUrl: friendlyUrl === '' ? null : friendlyUrl, }, create: { organizationId: activeOrganizationId, status: enabled ? 'published' : 'draft', contactEmail: contactEmail === '' ? null : contactEmail, - friendlyUrl: friendlyUrl === '' ? null : friendlyUrl, }, }); 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 469d74184..a35b9ecdf 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 @@ -17,7 +17,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; -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'; @@ -25,6 +24,7 @@ import { TrustPortalAdditionalDocumentsSection, type TrustPortalDocument, } from './TrustPortalAdditionalDocumentsSection'; +import { TrustPortalDomain } from './TrustPortalDomain'; import { GDPR, HIPAA, @@ -42,7 +42,6 @@ const trustPortalSwitchSchema = z.object({ enabled: z.boolean(), contactEmail: z.string().email().or(z.literal('')).optional(), primaryColor: z.string().optional(), - friendlyUrl: z.string().optional(), soc2type1: z.boolean(), soc2type2: z.boolean(), iso27001: z.boolean(), @@ -68,7 +67,6 @@ type TrustPortalSwitchActionInput = { enabled: boolean; contactEmail?: string | ''; primaryColor?: string; - friendlyUrl?: string; }; const FRAMEWORK_KEY_TO_API_SLUG: Record = { @@ -122,8 +120,9 @@ export function TrustPortalSwitch({ nen7510Status, iso9001, iso9001Status, - friendlyUrl, faqs, + isVercelDomain, + vercelVerification, // File props - will be passed from page.tsx later iso27001FileName, iso42001FileName, @@ -161,8 +160,9 @@ export function TrustPortalSwitch({ nen7510Status: 'started' | 'in_progress' | 'compliant'; iso9001: boolean; iso9001Status: 'started' | 'in_progress' | 'compliant'; - friendlyUrl: string | null; faqs: any[] | null; + isVercelDomain?: boolean; + vercelVerification?: string | null; iso27001FileName?: string | null; iso42001FileName?: string | null; gdprFileName?: string | null; @@ -297,8 +297,6 @@ export function TrustPortalSwitch({ const trustPortalSwitchRef = useRef(trustPortalSwitch); trustPortalSwitchRef.current = trustPortalSwitch; - const checkFriendlyUrl = useAction(isFriendlyAvailable); - const form = useForm>({ resolver: zodResolver(trustPortalSwitchSchema), defaultValues: { @@ -323,7 +321,6 @@ export function TrustPortalSwitch({ pcidssStatus: pcidssStatus ?? 'started', nen7510Status: nen7510Status ?? 'started', iso9001Status: iso9001Status ?? 'started', - friendlyUrl: friendlyUrl ?? undefined, }, }); @@ -338,20 +335,18 @@ export function TrustPortalSwitch({ const lastSaved = useRef<{ [key: string]: string | boolean | null }>({ contactEmail: contactEmail ?? '', - friendlyUrl: friendlyUrl ?? '', enabled: enabled, primaryColor: primaryColor ?? null, }); const savingRef = useRef<{ [key: string]: boolean }>({ contactEmail: false, - friendlyUrl: false, enabled: false, primaryColor: false, }); const autoSave = useCallback( - async (field: string, value: any) => { + async (field: string, value: unknown) => { // Prevent concurrent saves for the same field if (savingRef.current[field]) { return; @@ -362,15 +357,14 @@ export function TrustPortalSwitch({ savingRef.current[field] = true; try { // Only send fields that trustPortalSwitchAction accepts - // Server schema accepts: enabled, contactEmail, friendlyUrl, primaryColor + // Server schema accepts: enabled, contactEmail, primaryColor const data: TrustPortalSwitchActionInput = { - enabled: field === 'enabled' ? value : current.enabled, - contactEmail: field === 'contactEmail' ? value : current.contactEmail ?? '', - friendlyUrl: field === 'friendlyUrl' ? value : current.friendlyUrl ?? undefined, - primaryColor: field === 'primaryColor' ? value : current.primaryColor ?? undefined, + enabled: field === 'enabled' ? (value as boolean) : current.enabled, + contactEmail: field === 'contactEmail' ? (value as string) : (current.contactEmail ?? ''), + primaryColor: field === 'primaryColor' ? (value as string) : (current.primaryColor ?? undefined), }; await onSubmit(data); - lastSaved.current[field] = value; + lastSaved.current[field] = value as string | boolean | null; } finally { savingRef.current[field] = false; } @@ -427,77 +421,6 @@ export function TrustPortalSwitch({ [form, autoSave], ); - const [friendlyUrlValue, setFriendlyUrlValue] = useState(form.getValues('friendlyUrl') || ''); - const debouncedFriendlyUrl = useDebounce(friendlyUrlValue, 700); - const [friendlyUrlStatus, setFriendlyUrlStatus] = useState< - 'idle' | 'checking' | 'available' | 'unavailable' - >('idle'); - const lastCheckedUrlRef = useRef(''); - const processingResultRef = useRef(''); - - useEffect(() => { - if (!debouncedFriendlyUrl || debouncedFriendlyUrl === (friendlyUrl ?? '')) { - setFriendlyUrlStatus('idle'); - lastCheckedUrlRef.current = ''; - processingResultRef.current = ''; - return; - } - - // Only check if we haven't already checked this exact value - if (lastCheckedUrlRef.current === debouncedFriendlyUrl) { - return; - } - - lastCheckedUrlRef.current = debouncedFriendlyUrl; - processingResultRef.current = ''; - setFriendlyUrlStatus('checking'); - checkFriendlyUrl.execute({ friendlyUrl: debouncedFriendlyUrl, orgId }); - }, [debouncedFriendlyUrl, orgId, friendlyUrl]); - - useEffect(() => { - if (checkFriendlyUrl.status === 'executing') return; - - const result = checkFriendlyUrl.result?.data; - const checkedUrl = lastCheckedUrlRef.current; - - // Only process if this result matches the currently checked URL - if (checkedUrl !== debouncedFriendlyUrl || !checkedUrl) { - return; - } - - // Prevent processing the same result multiple times - if (processingResultRef.current === checkedUrl) { - return; - } - - if (result?.isAvailable === true) { - setFriendlyUrlStatus('available'); - processingResultRef.current = checkedUrl; - - if ( - debouncedFriendlyUrl !== lastSaved.current.friendlyUrl && - !savingRef.current.friendlyUrl - ) { - form.setValue('friendlyUrl', debouncedFriendlyUrl); - void autoSave('friendlyUrl', debouncedFriendlyUrl); - } - } else if (result?.isAvailable === false) { - setFriendlyUrlStatus('unavailable'); - processingResultRef.current = checkedUrl; - } - }, [checkFriendlyUrl.status, checkFriendlyUrl.result, debouncedFriendlyUrl, form, autoSave]); - - const handleFriendlyUrlBlur = useCallback( - (e: React.FocusEvent) => { - const value = e.target.value; - if (friendlyUrlStatus === 'available' && value !== lastSaved.current.friendlyUrl) { - form.setValue('friendlyUrl', value); - autoSave('friendlyUrl', value); - } - }, - [form, autoSave, friendlyUrlStatus], - ); - const handleEnabledChange = useCallback( (val: boolean) => { form.setValue('enabled', val); @@ -547,76 +470,6 @@ export function TrustPortalSwitch({

Trust Portal Settings

- ( - - Custom URL - -
-
- { - field.onChange(e); - setFriendlyUrlValue(e.target.value); - }} - onBlur={handleFriendlyUrlBlur} - placeholder="my-org" - autoComplete="off" - autoCapitalize="none" - autoCorrect="off" - spellCheck="false" - prefix="trust.inc/" - /> -
- {friendlyUrlValue && ( -
- {friendlyUrlStatus === 'checking' && 'Checking availability...'} - {friendlyUrlStatus === 'available' && ( - {'This URL is available!'} - )} - {friendlyUrlStatus === 'unavailable' && ( - - {'This URL is already taken.'} - - )} -
- )} -
-
-
- )} - /> - ( - - Contact Email - - { - field.onChange(e); - setContactEmailValue(e.target.value); - }} - onBlur={handleContactEmailBlur} - placeholder="contact@example.com" - className="w-auto" - autoComplete="off" - autoCapitalize="none" - autoCorrect="off" - spellCheck="false" - /> - - - )} - /> -
@@ -672,6 +525,31 @@ export function TrustPortalSwitch({ )} /> + ( + + Contact Email + + { + field.onChange(e); + setContactEmailValue(e.target.value); + }} + onBlur={handleContactEmailBlur} + placeholder="contact@example.com" + autoComplete="off" + autoCapitalize="none" + autoCorrect="off" + spellCheck="false" + /> + + + )} + />

@@ -1017,6 +895,17 @@ 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 1d857bd1a..1168f998c 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 @@ -3,12 +3,16 @@ import type { Metadata } from 'next'; import { auth } from '@/utils/auth'; import { env } from '@/env.mjs'; import { db } from '@db'; +import { Prisma } from '@prisma/client'; import { headers } from 'next/headers'; import { TrustPortalSwitch } from './components/TrustPortalSwitch'; -import { TrustPortalDomain } from './components/TrustPortalDomain'; export default async function PortalSettingsPage({ params }: { params: Promise<{ orgId: string }> }) { const { orgId } = await params; + + // Ensure friendlyUrl exists for enabled portals + await ensureFriendlyUrlIfEnabled(orgId); + const trustPortal = await getTrustPortal(orgId); const certificateFiles = await fetchComplianceCertificates(orgId); const primaryColor = await fetchOrganizationPrimaryColor(orgId); // can be null @@ -53,8 +57,9 @@ export default async function PortalSettingsPage({ params }: { params: Promise<{ pcidssStatus={trustPortal?.pcidssStatus ?? 'started'} nen7510Status={trustPortal?.nen7510Status ?? 'started'} iso9001Status={trustPortal?.iso9001Status ?? 'started'} - friendlyUrl={trustPortal?.friendlyUrl ?? null} faqs={faqs} + isVercelDomain={trustPortal?.isVercelDomain ?? false} + vercelVerification={trustPortal?.vercelVerification ?? null} iso27001FileName={certificateFiles.iso27001FileName} iso42001FileName={certificateFiles.iso42001FileName} gdprFileName={certificateFiles.gdprFileName} @@ -72,13 +77,6 @@ export default async function PortalSettingsPage({ params }: { params: Promise<{ updatedAt: doc.updatedAt.toISOString(), }))} /> - @@ -132,6 +130,73 @@ const getTrustPortal = async (orgId: string) => { }; }; +/** + * Create a URL-friendly slug from organization name + */ +const slugifyOrganizationName = (name: string): string => { + const cleaned = name + .trim() + .toLowerCase() + .replace(/&/g, 'and') + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + return cleaned.slice(0, 60); +}; + +/** + * Ensure friendlyUrl exists for enabled trust portals + * This auto-heals cases where portal was enabled before friendlyUrl was required + */ +const ensureFriendlyUrlIfEnabled = async (organizationId: string): Promise => { + const trust = await db.trust.findUnique({ + where: { organizationId }, + select: { status: true, friendlyUrl: true }, + }); + + // Only sync if portal is enabled and friendlyUrl is missing + if (!trust || trust.status !== 'published' || trust.friendlyUrl) { + return; + } + + const org = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }); + + if (!org) return; + + const baseCandidate = slugifyOrganizationName(org.name) || `org-${organizationId.slice(-8)}`; + + for (let i = 0; i < 25; i += 1) { + const candidate = i === 0 ? baseCandidate : `${baseCandidate}-${i + 1}`; + + const taken = await db.trust.findUnique({ + where: { friendlyUrl: candidate }, + select: { organizationId: true }, + }); + + if (taken && taken.organizationId !== organizationId) continue; + + try { + await db.trust.update({ + where: { organizationId }, + data: { friendlyUrl: candidate }, + }); + return; + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + continue; + } + throw error; + } + } +}; + type CertificateFiles = { iso27001FileName: string | null; iso42001FileName: string | null; diff --git a/apps/app/src/hooks/use-access-requests.ts b/apps/app/src/hooks/use-access-requests.ts index 039ede456..c4dbd68d8 100644 --- a/apps/app/src/hooks/use-access-requests.ts +++ b/apps/app/src/hooks/use-access-requests.ts @@ -175,6 +175,26 @@ export function useRevokeAccessGrant(orgId: string) { }); } +export function useResendAccessEmail(orgId: string) { + const api = useApi(); + + return useMutation({ + mutationFn: async (grantId: string) => { + const response = await api.post( + `/v1/trust-access/admin/grants/${grantId}/resend-access-email`, + {}, + orgId, + ); + + if (response.error) { + throw new Error(response.error); + } + + return response.data; + }, + }); +} + export function useResendNda(orgId: string) { const api = useApi(); diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 628b959cb..818fd5b30 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -8106,6 +8106,45 @@ ] } }, + "/v1/trust-access/admin/grants/{id}/resend-access-email": { + "post": { + "description": "Resend the access granted email to user with active grant", + "operationId": "TrustAccessController_resendAccessEmail_v1", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Access email resent" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Resend access granted email", + "tags": [ + "Trust Access" + ] + } + }, "/v1/trust-access/nda/{token}": { "get": { "description": "Fetch NDA agreement details for signing", From 5425fb39f0625e92618b54f50f910510c25dfb0c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:37:48 -0500 Subject: [PATCH 2/3] feat(trust-access): add expiration check for access grants and disable resend button (#1991) Co-authored-by: Tofik Hasanov --- apps/api/src/trust-portal/trust-access.service.ts | 10 ++++++++-- .../(app)/[orgId]/trust/components/grant-columns.tsx | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 1da9d015b..4d47cf7f0 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -713,9 +713,15 @@ export class TrustAccessService { ); } + const now = new Date(); + + // Check if grant has expired + if (grant.expiresAt < now) { + throw new BadRequestException('Cannot resend access email for expired grant'); + } + // Generate a new access token if expired or missing let accessToken = grant.accessToken; - const now = new Date(); if (!accessToken || (grant.accessTokenExpiresAt && grant.accessTokenExpiresAt < now)) { accessToken = this.generateToken(32); @@ -1774,4 +1780,4 @@ export class TrustAccessService { return { name: 'All Policies', downloadUrl }; } -} +} \ No newline at end of file diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/grant-columns.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/grant-columns.tsx index ca8db5187..99fafe008 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/components/grant-columns.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/components/grant-columns.tsx @@ -92,6 +92,7 @@ export function buildGrantColumns({ header: 'Actions', cell: ({ row }) => { const grant = row.original; + const isExpired = new Date(grant.expiresAt) < new Date(); if (grant.status === 'active') { return ( @@ -101,6 +102,8 @@ export function buildGrantColumns({ variant="outline" onClick={() => onResendAccess(grant)} className="h-8 px-2" + disabled={isExpired} + title={isExpired ? 'Grant has expired' : undefined} > Resend Access @@ -120,4 +123,4 @@ export function buildGrantColumns({ }, }, ]; -} +} \ No newline at end of file From 0654f5dc509cd04b0e2789088af697100553bdf5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:02:52 -0500 Subject: [PATCH 3/3] feat(trust-access): update expired grants to inactive status in listGrants (#1992) Co-authored-by: Tofik Hasanov --- .../src/trust-portal/trust-access.service.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 4d47cf7f0..d4209fd33 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -577,6 +577,24 @@ export class TrustAccessService { } async listGrants(organizationId: string) { + const now = new Date(); + + // Update expired grants that are still marked as active + await db.trustAccessGrant.updateMany({ + where: { + accessRequest: { + organizationId, + }, + status: 'active', + expiresAt: { + lt: now, + }, + }, + data: { + status: 'expired', + }, + }); + const grants = await db.trustAccessGrant.findMany({ where: { accessRequest: {