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
3 changes: 0 additions & 3 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"

npx commitlint --edit $1
2 changes: 2 additions & 0 deletions SELF_HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,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.
- **APP_AWS_ORG_ASSETS_BUCKET**: AWS S3 bucket name for organization static assets (e.g., company logos). Required for logo uploads in organization settings. If not set, logo upload will fail.
- **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 @@ -153,6 +154,7 @@ NEXT_PUBLIC_BETTER_AUTH_URL_PORTAL=http://localhost:3002
# APP_AWS_BUCKET_NAME=
# APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET=
# APP_AWS_KNOWLEDGE_BASE_BUCKET=
# APP_AWS_ORG_ASSETS_BUCKET=
# OPENAI_API_KEY=
# UPSTASH_REDIS_REST_URL=
# UPSTASH_REDIS_REST_TOKEN=
Expand Down
112 changes: 112 additions & 0 deletions apps/app/src/actions/organization/update-organization-logo-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use server';

import { authActionClient } from '@/actions/safe-action';
import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { db } from '@db';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

const updateLogoSchema = z.object({
fileName: z.string(),
fileType: z.string(),
fileData: z.string(), // base64 encoded
});

export const updateOrganizationLogoAction = authActionClient
.inputSchema(updateLogoSchema)
.metadata({
name: 'update-organization-logo',
track: {
event: 'update-organization-logo',
channel: 'server',
},
})
.action(async ({ parsedInput, ctx }) => {
const { fileName, fileType, fileData } = parsedInput;
const organizationId = ctx.session.activeOrganizationId;

if (!organizationId) {
throw new Error('No active organization');
}

// Validate file type
if (!fileType.startsWith('image/')) {
throw new Error('Only image files are allowed');
}

// Check S3 client
if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) {
throw new Error('File upload service is not available');
}

// Convert base64 to buffer
const fileBuffer = Buffer.from(fileData, 'base64');

// Validate file size (2MB limit for logos)
const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;
if (fileBuffer.length > MAX_FILE_SIZE_BYTES) {
throw new Error('Logo must be less than 2MB');
}

// Generate S3 key
const timestamp = Date.now();
const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_');
const key = `${organizationId}/logo/${timestamp}-${sanitizedFileName}`;

// Upload to S3
const putCommand = new PutObjectCommand({
Bucket: APP_AWS_ORG_ASSETS_BUCKET,
Key: key,
Body: fileBuffer,
ContentType: fileType,
});
await s3Client.send(putCommand);

// Update organization with new logo key
await db.organization.update({
where: { id: organizationId },
data: { logo: key },
});

// Generate signed URL for immediate display
const getCommand = new GetObjectCommand({
Bucket: APP_AWS_ORG_ASSETS_BUCKET,
Key: key,
});
const signedUrl = await getSignedUrl(s3Client, getCommand, {
expiresIn: 3600,
});

revalidatePath(`/${organizationId}/settings`);

return { success: true, logoUrl: signedUrl };
});

export const removeOrganizationLogoAction = authActionClient
.inputSchema(z.object({}))
.metadata({
name: 'remove-organization-logo',
track: {
event: 'remove-organization-logo',
channel: 'server',
},
})
.action(async ({ ctx }) => {
const organizationId = ctx.session.activeOrganizationId;

if (!organizationId) {
throw new Error('No active organization');
}

// Remove logo from organization
await db.organization.update({
where: { id: organizationId },
data: { logo: null },
});

revalidatePath(`/${organizationId}/settings`);

return { success: true };
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
'use client';

import { Download } from 'lucide-react';
import Image from 'next/image';

interface AuditorViewProps {
initialContent: Record<string, string>;
organizationName: string;
logoUrl: string | null;
employeeCount: string | null;
cSuite: { name: string; title: string }[];
reportSignatory: { fullName: string; jobTitle: string; email: string } | null;
}

export function AuditorView({
initialContent,
organizationName,
logoUrl,
employeeCount,
cSuite,
reportSignatory,
}: AuditorViewProps) {
return (
<div className="flex flex-col gap-10">
{/* Header */}
<div className="flex items-center gap-4">
{logoUrl && (
<a
href={logoUrl}
download={`${organizationName.replace(/[^a-zA-Z0-9]/g, '_')}_logo`}
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border bg-background transition-all hover:shadow-md"
title="Download logo"
>
<Image src={logoUrl} alt={`${organizationName} logo`} fill className="object-contain" />
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<Download className="h-4 w-4 text-white" />
</div>
</a>
)}
<div>
<h1 className="text-foreground text-xl font-semibold tracking-tight">
{organizationName}
</h1>
<p className="text-muted-foreground text-sm">Company Overview</p>
</div>
</div>

{/* Company Information */}
<Section title="Company Information">
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<InfoCell
label="Employees"
value={employeeCount || '—'}
className="lg:border-r lg:border-border lg:pr-6"
/>
<InfoCell
label="Report Signatory"
className="lg:border-r lg:border-border lg:pr-6"
value={
reportSignatory ? (
<div>
<div className="flex items-baseline gap-2">
<span className="font-medium">{reportSignatory.fullName}</span>
<span className="text-muted-foreground text-xs">
{reportSignatory.jobTitle}
</span>
</div>
<div className="text-muted-foreground text-xs mt-0.5">
{reportSignatory.email}
</div>
</div>
) : (
'—'
)
}
/>
<InfoCell
label="Executive Team"
className="sm:col-span-2 lg:col-span-1"
value={
cSuite.length > 0 ? (
<div className="space-y-1">
{cSuite.map((exec, i) => (
<div key={i} className="flex items-baseline gap-2 text-sm">
<span className="font-medium">{exec.name}</span>
<span className="text-muted-foreground text-xs">{exec.title}</span>
</div>
))}
</div>
) : (
'—'
)
}
/>
</div>
</Section>

{/* Business Overview */}
<Section title="Business Overview">
<div className="space-y-6">
<ContentRow
title="Company Background & Overview of Operations"
content={initialContent['Company Background & Overview of Operations']}
/>
<ContentRow
title="Types of Services Provided"
content={initialContent['Types of Services Provided']}
/>
<ContentRow title="Mission & Vision" content={initialContent['Mission & Vision']} />
</div>
</Section>

{/* System Architecture */}
<Section title="System Architecture">
<ContentRow title="System Description" content={initialContent['System Description']} />
</Section>

{/* Third Party Dependencies */}
<Section title="Third Party Dependencies">
<div className="grid gap-6 lg:grid-cols-2">
<ContentRow title="Critical Vendors" content={initialContent['Critical Vendors']} />
<ContentRow
title="Subservice Organizations"
content={initialContent['Subservice Organizations']}
/>
</div>
</Section>
</div>
);
}

function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-4">
<div className="flex items-center gap-3 border-b border-border pb-2">
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{title}
</h2>
</div>
{children}
</div>
);
}

function InfoCell({
label,
value,
className,
}: {
label: string;
value: React.ReactNode;
className?: string;
}) {
return (
<div className={className || ''}>
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-1.5">
{label}
</div>
<div className="text-sm text-foreground">{value}</div>
</div>
);
}

function ContentRow({ title, content }: { title: string; content?: string }) {
const hasContent = content?.trim().length;

return (
<div className="space-y-1.5">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
{hasContent ? (
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">
{content}
</p>
) : (
<p className="text-xs text-muted-foreground/50">Not yet available</p>
)}
</div>
);
}
4 changes: 4 additions & 0 deletions apps/app/src/app/(app)/[orgId]/auditor/(overview)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <div className="m-auto max-w-[1200px] py-8">{children}</div>;
}

Loading
Loading