Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ APP_AWS_SECRET_ACCESS_KEY="" # AWS Secret Access Key
APP_AWS_REGION="" # AWS Region
APP_AWS_BUCKET_NAME="" # AWS Bucket Name
APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET="" # AWS, Required for Security Questionnaire feature

APP_AWS_KNOWLEDGE_BASE_BUCKET="" # AWS Required for the Knowledge Base feature in Security Questionnaire
TRIGGER_SECRET_KEY="" # For background jobs. Self-host or use cloud-version @ https://trigger.dev
# TRIGGER_API_URL="" # Only set if you are self-hosting
TRIGGER_API_KEY="" # API key from Trigger.dev
Expand Down
2 changes: 2 additions & 0 deletions SELF_HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ App (`apps/app`):

- **APP_AWS_REGION**, **APP_AWS_ACCESS_KEY_ID**, **APP_AWS_SECRET_ACCESS_KEY**, **APP_AWS_BUCKET_NAME**: AWS S3 credentials for file storage (attachments, general uploads).
- **APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET**: AWS S3 bucket name specifically for questionnaire file uploads. Required for the Security Questionnaire feature. If not set, users will see an error when trying to parse questionnaires.
- **APP_AWS_KNOWLEDGE_BASE_BUCKET**: AWS S3 bucket name specifically for knowledge base documents. Required for the Knowledge Base feature in Security Questionnaire. If not set, users will see an error when trying to upload knowledge base documents.
- **OPENAI_API_KEY**: Enables AI features that call OpenAI models.
- **UPSTASH_REDIS_REST_URL**, **UPSTASH_REDIS_REST_TOKEN**: Optional Redis (Upstash) used for rate limiting/queues/caching.
- **NEXT_PUBLIC_POSTHOG_KEY**, **NEXT_PUBLIC_POSTHOG_HOST**: Client analytics via PostHog; leave unset to disable.
Expand Down Expand Up @@ -151,6 +152,7 @@ NEXT_PUBLIC_BETTER_AUTH_URL_PORTAL=http://localhost:3002
# APP_AWS_SECRET_ACCESS_KEY=
# APP_AWS_BUCKET_NAME=
# APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET=
# APP_AWS_KNOWLEDGE_BASE_BUCKET=
# OPENAI_API_KEY=
# UPSTASH_REDIS_REST_URL=
# UPSTASH_REDIS_REST_TOKEN=
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/trust-portal/trust-access.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import {
ApiHeader,
ApiOperation,
ApiParam,
ApiResponse,
ApiSecurity,
ApiTags,
Expand Down Expand Up @@ -44,11 +45,16 @@ export class TrustAccessController {
description:
'External users submit request for data access from trust site',
})
@ApiParam({
name: 'friendlyUrl',
description: 'Trust Portal friendly URL or Organization ID',
})
@ApiResponse({
status: HttpStatus.CREATED,
description: 'Access request created and sent for review',
})
async createAccessRequest(
// Note: friendlyUrl can be either the custom friendly URL or the organization ID
@Param('friendlyUrl') friendlyUrl: string,
@Body() dto: CreateAccessRequestDto,
@Req() req: Request,
Expand Down Expand Up @@ -365,11 +371,16 @@ export class TrustAccessController {
description:
'Generate access link for users with existing grants to redownload data',
})
@ApiParam({
name: 'friendlyUrl',
description: 'Trust Portal friendly URL or Organization ID',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Access link sent to email',
})
async reclaimAccess(
// Note: friendlyUrl can be either the custom friendly URL or the organization ID
@Param('friendlyUrl') friendlyUrl: string,
@Body() dto: ReclaimAccessDto,
) {
Expand Down
103 changes: 73 additions & 30 deletions apps/api/src/trust-portal/trust-access.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,28 @@ export class TrustAccessService {
return randomBytes(length).toString('base64url').slice(0, length);
}

private async findPublishedTrustByRouteId(id: string) {
// First, try treating `id` as the existing friendlyUrl.
let trust = await db.trust.findUnique({
where: { friendlyUrl: id },
include: { organization: true },
});

// If none found, fall back to treating `id` as organizationId.
if (!trust) {
trust = await db.trust.findFirst({
where: { organizationId: id },
include: { organization: true },
});
}

if (!trust || trust.status !== 'published') {
throw new NotFoundException('Trust site not found or not published');
}

return trust;
}

constructor(
private readonly ndaPdfService: NdaPdfService,
private readonly emailService: TrustEmailService,
Expand Down Expand Up @@ -60,19 +82,12 @@ export class TrustAccessService {
}

async createAccessRequest(
friendlyUrl: string,
id: string,
dto: CreateAccessRequestDto,
ipAddress: string | undefined,
userAgent: string | undefined,
) {
const trust = await db.trust.findUnique({
where: { friendlyUrl },
include: { organization: true },
});

if (!trust || trust.status !== 'published') {
throw new NotFoundException('Trust site not found or not published');
}
const trust = await this.findPublishedTrustByRouteId(id);

// Check if the email already has an active grant
const existingGrant = await db.trustAccessGrant.findFirst({
Expand Down Expand Up @@ -470,33 +485,67 @@ export class TrustAccessService {
organization: true,
},
},
grant: true,
},
});

if (!nda) {
throw new NotFoundException('NDA agreement not found');
}

const trust = await db.trust.findUnique({
where: { organizationId: nda.organizationId },
select: { friendlyUrl: true },
});

const portalUrl = trust?.friendlyUrl
? `${this.TRUST_APP_URL}/${trust.friendlyUrl}`
: null;

const baseResponse = {
id: nda.id,
organizationName: nda.accessRequest.organization.name,
requesterName: nda.accessRequest.name,
requesterEmail: nda.accessRequest.email,
expiresAt: nda.signTokenExpiresAt,
portalUrl,
};

if (nda.signTokenExpiresAt < new Date()) {
throw new BadRequestException('NDA signing link has expired');
return {
...baseResponse,
status: 'expired',
message: 'NDA signing link has expired',
};
}

if (nda.status === 'void') {
throw new BadRequestException(
'This NDA has been revoked and is no longer valid',
);
return {
...baseResponse,
status: 'void',
message: 'This NDA has been revoked and is no longer valid',
};
}

if (nda.status !== 'pending') {
throw new BadRequestException('NDA has already been signed');
if (nda.status === 'signed') {
let accessUrl = portalUrl;
if (nda.grant?.accessToken && nda.grant.status === 'active') {
if (trust?.friendlyUrl) {
accessUrl = `${this.TRUST_APP_URL}/${trust.friendlyUrl}/access/${nda.grant.accessToken}`;
}
}

return {
...baseResponse,
status: 'signed',
message: 'NDA has already been signed',
portalUrl: accessUrl,
};
}

return {
id: nda.id,
organizationName: nda.accessRequest.organization.name,
requesterName: nda.accessRequest.name,
requesterEmail: nda.accessRequest.email,
expiresAt: nda.signTokenExpiresAt,
...baseResponse,
status: 'pending',
};
}

Expand Down Expand Up @@ -791,15 +840,8 @@ export class TrustAccessService {
};
}

async reclaimAccess(friendlyUrl: string, email: string) {
const trust = await db.trust.findUnique({
where: { friendlyUrl },
include: { organization: true },
});

if (!trust || trust.status !== 'published') {
throw new NotFoundException('Trust site not found or not published');
}
async reclaimAccess(id: string, email: string) {
const trust = await this.findPublishedTrustByRouteId(id);

const grant = await db.trustAccessGrant.findFirst({
where: {
Expand Down Expand Up @@ -849,7 +891,8 @@ export class TrustAccessService {
});
}

const accessLink = `${this.TRUST_APP_URL}/${friendlyUrl}/access/${accessToken}`;
const urlId = trust.friendlyUrl || trust.organizationId;
const accessLink = `${this.TRUST_APP_URL}/${urlId}/access/${accessToken}`;

await this.emailService.sendAccessReclaimEmail({
toEmail: email,
Expand Down
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"geist": "^1.3.1",
"jspdf": "^3.0.2",
"lucide-react": "^0.544.0",
"mammoth": "^1.11.0",
"motion": "^12.9.2",
"next": "^15.4.6",
"next-safe-action": "^8.0.3",
Expand Down
21 changes: 21 additions & 0 deletions apps/app/public/badges/iso9001.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading