From 223f3aae7b641e62ef8888263114feef8fd1fdb6 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 2 Apr 2026 17:22:21 -0400 Subject: [PATCH 1/6] fix(onboarding): reorder steps so cloud question comes before software Move the infrastructure/cloud hosting question ahead of the software question in the onboarding wizard for a more logical flow. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/app/src/app/(app)/setup/lib/constants.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/app/src/app/(app)/setup/lib/constants.ts b/apps/app/src/app/(app)/setup/lib/constants.ts index 4872027e67..42fd27b7ea 100644 --- a/apps/app/src/app/(app)/setup/lib/constants.ts +++ b/apps/app/src/app/(app)/setup/lib/constants.ts @@ -115,6 +115,12 @@ export const steps: Step[] = [ placeholder: 'e.g., Google Workspace', options: ['Google Workspace', 'Microsoft 365', 'Okta', 'Auth0', 'Email/Password', 'Other'], }, + { + key: 'infrastructure', + question: 'Where do you host your applications and data?', + placeholder: 'e.g., AWS', + options: ['AWS', 'Google Cloud', 'Microsoft Azure', 'Heroku', 'Vercel', 'Other'], + }, { key: 'software', question: 'What software do you use?', @@ -128,12 +134,6 @@ export const steps: Step[] = [ placeholder: 'e.g., Remote', options: ['Fully remote', 'Hybrid (office + remote)', 'Office-based'], }, - { - key: 'infrastructure', - question: 'Where do you host your applications and data?', - placeholder: 'e.g., AWS', - options: ['AWS', 'Google Cloud', 'Microsoft Azure', 'Heroku', 'Vercel', 'Other'], - }, { key: 'dataTypes', question: 'What types of data do you handle?', From 10673ec0c5661eca46a22046bdc1114f0e5ed254 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 2 Apr 2026 17:49:12 -0400 Subject: [PATCH 2/6] feat(portal): add signed policies list page --- .../(app)/(home)/[orgId]/policies/page.tsx | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx new file mode 100644 index 0000000000..a17f8f82f2 --- /dev/null +++ b/apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx @@ -0,0 +1,116 @@ +import { auth } from '@/app/lib/auth'; +import { db } from '@db/server'; +import { + Breadcrumb, + Card, + CardContent, + PageHeader, + PageLayout, + Stack, + Text, +} from '@trycompai/design-system'; +import { Document } from '@trycompai/design-system/icons'; +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +export default async function SignedPoliciesPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = await params; + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user) { + redirect('/auth'); + } + + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: orgId, + deactivated: false, + }, + }); + + if (!member) { + redirect('/'); + } + + const policies = await db.policy.findMany({ + where: { + organizationId: orgId, + status: 'published', + isRequiredToSign: true, + isArchived: false, + signedBy: { has: member.id }, + }, + orderBy: { name: 'asc' }, + select: { + id: true, + name: true, + description: true, + updatedAt: true, + }, + }); + + return ( + + }, + }, + { label: 'Signed Policies', isCurrent: true }, + ]} + /> + + + {policies.length === 0 ? ( + + No signed policies yet. + + ) : ( +
+ {policies.map((policy) => ( + + + +
+
+ +
+
+ + {policy.name} + + {policy.description && ( + + {policy.description} + + )} +
+ + {new Date(policy.updatedAt).toLocaleDateString()} + +
+
+
+ + ))} +
+ )} +
+
+ ); +} From 359381f5be9659c6e656632e66a90d6f468e0c0d Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 2 Apr 2026 17:53:20 -0400 Subject: [PATCH 3/6] feat(portal): add link to signed policies from dashboard Co-Authored-By: Claude Sonnet 4.6 --- .../tasks/PoliciesAccordionItem.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PoliciesAccordionItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PoliciesAccordionItem.tsx index 2575029f3b..0fcb15bfde 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PoliciesAccordionItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PoliciesAccordionItem.tsx @@ -105,16 +105,23 @@ export function PoliciesAccordionItem({ policies, member }: PoliciesAccordionIte ); })} - +
+ + {hasAcceptedPolicies && ( + + + + )} +
) : (

No policies ready to be signed.

From 2875096218e07d898898d0886a53186c2641abf0 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 3 Apr 2026 11:14:53 -0400 Subject: [PATCH 4/6] fix(trust): fix domain verification showing false positive and stale tokens Frontend: - Green checkmark now requires both DB and live Vercel verified status - _vercel TXT value from live Vercel API instead of stale DB value - Show DNS records when Vercel says unverified, even if DB says verified Backend: - Don't delete+re-add domains on Vercel when re-saving (prevents token regen) - Remove old domain from Vercel when switching to a new one - checkDnsRecords fetches live Vercel state instead of stale DB values - Only set domainVerified=true after Vercel confirms verification - Add domain ownership check in checkDnsRecords - CORS only allows verified custom domains Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/auth/auth.server.ts | 1 + .../src/trust-portal/trust-portal.service.ts | 155 ++++++++++++++---- .../components/TrustPortalDomain.tsx | 34 ++-- 3 files changed, 150 insertions(+), 40 deletions(-) diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index 865c84573a..03b5b3b7fd 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -110,6 +110,7 @@ async function getCustomDomains(): Promise> { const trusts = await db.trust.findMany({ where: { domain: { not: null }, + domainVerified: true, status: 'published', }, select: { domain: true }, diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index f2ab6f6fcd..209e714df6 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -752,6 +752,20 @@ export class TrustPortalService { ? currentTrust.domainVerified : false; + // Remove old domain from Vercel if switching to a different one + if (currentTrust?.domain && currentTrust.domain !== domain) { + try { + await this.vercelApi.delete( + `/v9/projects/${projectId}/domains/${currentTrust.domain}`, + { params: { teamId } }, + ); + } catch (error) { + this.logger.warn( + `Failed to remove old domain ${currentTrust.domain} from Vercel: ${error}`, + ); + } + } + // Check if domain already exists on the Vercel project const existingDomainsResp = await this.vercelApi.get( `/v9/projects/${projectId}/domains`, @@ -761,22 +775,54 @@ export class TrustPortalService { const existingDomains: Array<{ name: string }> = existingDomainsResp.data?.domains ?? []; - if (existingDomains.some((d) => d.name === domain)) { - const domainOwner = await db.trust.findUnique({ - where: { organizationId, domain }, + const alreadyOnProject = existingDomains.some((d) => d.name === domain); + + if (alreadyOnProject) { + const domainOwner = await db.trust.findFirst({ + where: { domain, organizationId: { not: organizationId } }, + select: { organizationId: true }, }); - if (!domainOwner || domainOwner.organizationId === organizationId) { - await this.vercelApi.delete( - `/v9/projects/${projectId}/domains/${domain}`, - { params: { teamId } }, - ); - } else { + if (domainOwner) { return { success: false, error: 'Domain is already in use by another organization', }; } + + // Domain already on Vercel for this org — fetch current status + // instead of deleting and re-adding (which regenerates verification tokens) + const statusResp = await this.vercelApi.get( + `/v9/projects/${projectId}/domains/${domain}`, + { params: { teamId } }, + ); + + const statusData = statusResp.data; + const isVercelDomain = statusData.verified === false; + const vercelVerification = + statusData.verification?.[0]?.value || null; + + await db.trust.upsert({ + where: { organizationId }, + update: { + domain, + domainVerified, + isVercelDomain, + vercelVerification, + }, + create: { + organizationId, + domain, + domainVerified: false, + isVercelDomain, + vercelVerification, + }, + }); + + return { + success: true, + needsVerification: !domainVerified, + }; } this.logger.log(`Adding domain to Vercel project: ${domain}`); @@ -902,6 +948,16 @@ export class TrustPortalService { async checkDnsRecords(organizationId: string, domain: string) { this.validateDomain(domain); + // Verify the domain belongs to this organization + const trustRecord = await db.trust.findUnique({ + where: { organizationId }, + }); + if (!trustRecord || trustRecord.domain !== domain) { + throw new BadRequestException( + 'Domain does not match the configured domain for this organization', + ); + } + const rootDomain = domain.split('.').slice(-2).join('.'); const [cnameResp, txtResp, vercelTxtResp] = await Promise.all([ @@ -937,13 +993,45 @@ export class TrustPortalService { const txtRecords = txtResp.data?.records?.TXT; const vercelTxtRecords = vercelTxtResp?.data?.records?.TXT; - const trustRecord = await db.trust.findUnique({ - where: { organizationId, domain }, - select: { isVercelDomain: true, vercelVerification: true }, - }); + // Fetch fresh verification state from Vercel instead of relying on + // potentially stale DB values (tokens change if domain was re-added). + let liveIsVercelDomain = false; + let liveVercelVerification: string | null = null; + + if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) { + try { + const vercelStatusResp = await this.vercelApi.get( + `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}`, + { params: { teamId: process.env.VERCEL_TEAM_ID } }, + ); + const vercelData = vercelStatusResp.data; + liveIsVercelDomain = vercelData.verified === false; + liveVercelVerification = + vercelData.verification?.[0]?.value || null; + + // Sync DB with live Vercel state + await db.trust.update({ + where: { organizationId }, + data: { + isVercelDomain: liveIsVercelDomain, + vercelVerification: liveVercelVerification, + }, + }); + } catch (error) { + this.logger.warn( + `Failed to fetch live Vercel status for ${domain}, falling back to DB: ${error}`, + ); + const trustRecord = await db.trust.findUnique({ + where: { organizationId, domain }, + select: { isVercelDomain: true, vercelVerification: true }, + }); + liveIsVercelDomain = trustRecord?.isVercelDomain === true; + liveVercelVerification = trustRecord?.vercelVerification ?? null; + } + } const expectedTxtValue = `compai-domain-verification=${organizationId}`; - const expectedVercelTxtValue = trustRecord?.vercelVerification; + const expectedVercelTxtValue = liveVercelVerification; // Check CNAME let isCnameVerified = false; @@ -993,7 +1081,7 @@ export class TrustPortalService { }); } - const requiresVercelTxt = trustRecord?.isVercelDomain === true; + const requiresVercelTxt = liveIsVercelDomain; const isVerified = isCnameVerified && isTxtVerified && @@ -1010,34 +1098,43 @@ export class TrustPortalService { }; } - await db.trust.upsert({ - where: { organizationId, domain }, - update: { domainVerified: true, status: 'published' }, - create: { - organizationId, - domain, - status: 'published', - }, - }); - // Trigger Vercel to re-verify the domain so it provisions SSL and starts serving. - // Without this, Vercel doesn't know DNS has been configured and the domain stays inactive - // (previously required CS to manually click "Refresh" in Vercel dashboard). + // Only mark domainVerified=true in our DB if Vercel accepts the verification. + let vercelVerified = false; if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) { try { - await this.vercelApi.post( + const verifyResp = await this.vercelApi.post( `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}/verify`, {}, { params: { teamId: process.env.VERCEL_TEAM_ID } }, ); + vercelVerified = verifyResp.data?.verified === true; } catch (error) { - // Non-fatal — domain is verified on our side, Vercel will eventually pick it up this.logger.warn( `Failed to trigger Vercel domain verification for ${domain}: ${error}`, ); } } + await db.trust.update({ + where: { organizationId }, + data: { + domainVerified: vercelVerified, + ...(vercelVerified ? { status: 'published' as const } : {}), + }, + }); + + if (!vercelVerified) { + return { + success: false, + isCnameVerified, + isTxtVerified, + isVercelTxtVerified, + error: + 'DNS records verified but Vercel has not yet confirmed domain ownership. Please ensure the _vercel TXT record is correctly configured and try again.', + }; + } + return { success: true, isCnameVerified, diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx index a79011bebf..acb3dcec7e 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx @@ -62,6 +62,18 @@ export function TrustPortalDomain({ return null; }, [domainStatus]); + // Domain is truly verified only when both our DB and Vercel agree. + // While Vercel data is loading, fall back to DB value to avoid flicker. + const isVercelVerified = domainStatus?.data?.verified; + const isEffectivelyVerified = + domainVerified && (isVercelVerified === undefined || isVercelVerified); + + // Show _vercel TXT row if DB says so OR live Vercel data has verification requirements + const needsVercelTxt = isVercelDomain || verificationInfo !== null; + + // Prefer live Vercel verification value over stale DB value + const effectiveVercelTxtValue = verificationInfo?.value ?? vercelVerification; + // Get the actual CNAME target from Vercel, with fallback // Normalize to include trailing dot for DNS record display const cnameTarget = useMemo(() => { @@ -182,7 +194,7 @@ export function TrustPortalDomain({ Custom Domain {initialDomain !== '' && - (domainVerified ? ( + (isEffectivelyVerified ? ( @@ -215,7 +227,7 @@ export function TrustPortalDomain({ disabled={!canUpdate} /> - {field.value === initialDomain && initialDomain !== '' && !domainVerified && ( + {field.value === initialDomain && initialDomain !== '' && !isEffectivelyVerified && ( - {isVercelDomain && ( + {needsVercelTxt && ( <>
@@ -490,7 +502,7 @@ export function TrustPortalDomain({ variant="ghost" size="icon" type="button" - onClick={() => handleCopy(vercelVerification || '', 'Name')} + onClick={() => handleCopy(effectiveVercelTxtValue || '', 'Name')} className="h-6 w-6 shrink-0" > @@ -500,12 +512,12 @@ export function TrustPortalDomain({
Value:
- {vercelVerification} + {effectiveVercelTxtValue}