Skip to content

Commit b34e05b

Browse files
feat(portal): add form visibility toggles and improve form layout
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2e5c1c2 commit b34e05b

12 files changed

Lines changed: 238 additions & 11 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use server';
2+
3+
import { db } from '@db';
4+
import { revalidatePath, revalidateTag } from 'next/cache';
5+
import { headers } from 'next/headers';
6+
import { authActionClient } from '../safe-action';
7+
import { organizationAccessRequestFormSchema } from '../schema';
8+
9+
export const updateOrganizationAccessRequestFormAction = authActionClient
10+
.inputSchema(organizationAccessRequestFormSchema)
11+
.metadata({
12+
name: 'update-organization-access-request-form',
13+
track: {
14+
event: 'update-organization-access-request-form',
15+
channel: 'server',
16+
},
17+
})
18+
.action(async ({ parsedInput, ctx }) => {
19+
const { accessRequestFormEnabled } = parsedInput;
20+
const { activeOrganizationId } = ctx.session;
21+
22+
if (!activeOrganizationId) {
23+
throw new Error('No active organization');
24+
}
25+
26+
try {
27+
await db.organization.update({
28+
where: { id: activeOrganizationId },
29+
data: { accessRequestFormEnabled },
30+
});
31+
32+
const headersList = await headers();
33+
let path = headersList.get('x-pathname') || headersList.get('referer') || '';
34+
path = path.replace(/\/[a-z]{2}\//, '/');
35+
36+
revalidatePath(path);
37+
revalidateTag(`organization_${activeOrganizationId}`, 'max');
38+
39+
return {
40+
success: true,
41+
};
42+
} catch (error) {
43+
console.error(error);
44+
throw new Error('Failed to update access request form setting');
45+
}
46+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use server';
2+
3+
import { db } from '@db';
4+
import { revalidatePath, revalidateTag } from 'next/cache';
5+
import { headers } from 'next/headers';
6+
import { authActionClient } from '../safe-action';
7+
import { organizationWhistleblowerReportSchema } from '../schema';
8+
9+
export const updateOrganizationWhistleblowerReportAction = authActionClient
10+
.inputSchema(organizationWhistleblowerReportSchema)
11+
.metadata({
12+
name: 'update-organization-whistleblower-report',
13+
track: {
14+
event: 'update-organization-whistleblower-report',
15+
channel: 'server',
16+
},
17+
})
18+
.action(async ({ parsedInput, ctx }) => {
19+
const { whistleblowerReportEnabled } = parsedInput;
20+
const { activeOrganizationId } = ctx.session;
21+
22+
if (!activeOrganizationId) {
23+
throw new Error('No active organization');
24+
}
25+
26+
try {
27+
await db.organization.update({
28+
where: { id: activeOrganizationId },
29+
data: { whistleblowerReportEnabled },
30+
});
31+
32+
const headersList = await headers();
33+
let path = headersList.get('x-pathname') || headersList.get('referer') || '';
34+
path = path.replace(/\/[a-z]{2}\//, '/');
35+
36+
revalidatePath(path);
37+
revalidateTag(`organization_${activeOrganizationId}`, 'max');
38+
39+
return {
40+
success: true,
41+
};
42+
} catch (error) {
43+
console.error(error);
44+
throw new Error('Failed to update whistleblower report setting');
45+
}
46+
});

apps/app/src/actions/schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ export const organizationSecurityTrainingStepSchema = z.object({
7777
securityTrainingStepEnabled: z.boolean(),
7878
});
7979

80+
export const organizationWhistleblowerReportSchema = z.object({
81+
whistleblowerReportEnabled: z.boolean(),
82+
});
83+
84+
export const organizationAccessRequestFormSchema = z.object({
85+
accessRequestFormEnabled: z.boolean(),
86+
});
87+
8088
// Risks
8189
export const createRiskSchema = z.object({
8290
title: z

apps/app/src/app/(app)/[orgId]/settings/portal/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ export default async function PortalSettingsPage({
1414
select: {
1515
deviceAgentStepEnabled: true,
1616
securityTrainingStepEnabled: true,
17+
whistleblowerReportEnabled: true,
18+
accessRequestFormEnabled: true,
1719
},
1820
});
1921

2022
return (
2123
<PortalSettings
2224
deviceAgentStepEnabled={organization?.deviceAgentStepEnabled ?? true}
2325
securityTrainingStepEnabled={organization?.securityTrainingStepEnabled ?? true}
26+
whistleblowerReportEnabled={organization?.whistleblowerReportEnabled ?? true}
27+
accessRequestFormEnabled={organization?.accessRequestFormEnabled ?? true}
2428
/>
2529
);
2630
}

apps/app/src/app/(app)/[orgId]/settings/portal/portal-settings.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,24 @@
22

33
import { updateOrganizationDeviceAgentStepAction } from '@/actions/organization/update-organization-device-agent-step-action';
44
import { updateOrganizationSecurityTrainingStepAction } from '@/actions/organization/update-organization-security-training-step-action';
5+
import { updateOrganizationWhistleblowerReportAction } from '@/actions/organization/update-organization-whistleblower-report-action';
6+
import { updateOrganizationAccessRequestFormAction } from '@/actions/organization/update-organization-access-request-form-action';
57
import { SettingGroup, SettingRow, Switch } from '@trycompai/design-system';
68
import { useAction } from 'next-safe-action/hooks';
79
import { toast } from 'sonner';
810

911
interface PortalSettingsProps {
1012
deviceAgentStepEnabled: boolean;
1113
securityTrainingStepEnabled: boolean;
14+
whistleblowerReportEnabled: boolean;
15+
accessRequestFormEnabled: boolean;
1216
}
1317

1418
export function PortalSettings({
1519
deviceAgentStepEnabled,
1620
securityTrainingStepEnabled,
21+
whistleblowerReportEnabled,
22+
accessRequestFormEnabled,
1723
}: PortalSettingsProps) {
1824
const updateDeviceAgentStep = useAction(updateOrganizationDeviceAgentStepAction, {
1925
onSuccess: () => toast.success('Device agent step setting updated'),
@@ -25,6 +31,16 @@ export function PortalSettings({
2531
onError: () => toast.error('Error updating security training step setting'),
2632
});
2733

34+
const updateWhistleblowerReport = useAction(updateOrganizationWhistleblowerReportAction, {
35+
onSuccess: () => toast.success('Whistleblower report visibility updated'),
36+
onError: () => toast.error('Error updating whistleblower report visibility'),
37+
});
38+
39+
const updateAccessRequestForm = useAction(updateOrganizationAccessRequestFormAction, {
40+
onSuccess: () => toast.success('Access request visibility updated'),
41+
onError: () => toast.error('Error updating access request visibility'),
42+
});
43+
2844
return (
2945
<SettingGroup>
3046
<SettingRow
@@ -53,6 +69,32 @@ export function PortalSettings({
5369
disabled={updateSecurityTrainingStep.status === 'executing'}
5470
/>
5571
</SettingRow>
72+
<SettingRow
73+
size="lg"
74+
label="Show Whistleblower Report Form"
75+
description="Employees can submit whistleblower reports from the employee portal."
76+
>
77+
<Switch
78+
checked={whistleblowerReportEnabled}
79+
onCheckedChange={(checked) => {
80+
updateWhistleblowerReport.execute({ whistleblowerReportEnabled: checked });
81+
}}
82+
disabled={updateWhistleblowerReport.status === 'executing'}
83+
/>
84+
</SettingRow>
85+
<SettingRow
86+
size="lg"
87+
label="Show Access Request Form"
88+
description="Employees can submit access requests from the employee portal."
89+
>
90+
<Switch
91+
checked={accessRequestFormEnabled}
92+
onCheckedChange={(checked) => {
93+
updateAccessRequestForm.execute({ accessRequestFormEnabled: checked });
94+
}}
95+
disabled={updateAccessRequestForm.status === 'executing'}
96+
/>
97+
</SettingRow>
5698
</SettingGroup>
5799
);
58100
}

apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ interface EmployeeTasksListProps {
2929
host: Host | null;
3030
deviceAgentStepEnabled: boolean;
3131
securityTrainingStepEnabled: boolean;
32+
whistleblowerReportEnabled: boolean;
33+
accessRequestFormEnabled: boolean;
3234
}
3335

3436
export const EmployeeTasksList = ({
@@ -40,6 +42,8 @@ export const EmployeeTasksList = ({
4042
host,
4143
deviceAgentStepEnabled,
4244
securityTrainingStepEnabled,
45+
whistleblowerReportEnabled,
46+
accessRequestFormEnabled,
4347
}: EmployeeTasksListProps) => {
4448
const {
4549
data: response,
@@ -126,6 +130,11 @@ export const EmployeeTasksList = ({
126130
]
127131
: []),
128132
];
133+
const visiblePortalForms = portalForms.filter((form) => {
134+
if (form.type === 'whistleblower-report') return whistleblowerReportEnabled;
135+
if (form.type === 'access-request') return accessRequestFormEnabled;
136+
return true;
137+
});
129138

130139
const allCompleted = completedCount === accordionItems.length;
131140

@@ -167,12 +176,12 @@ export const EmployeeTasksList = ({
167176
</Accordion>
168177

169178
{/* Company forms */}
170-
{portalForms.length > 0 && (
179+
{visiblePortalForms.length > 0 && (
171180
<div className="space-y-2">
172181
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
173182
Company Forms
174183
</div>
175-
{portalForms.map((form) => (
184+
{visiblePortalForms.map((form) => (
176185
<div
177186
key={form.type}
178187
className="flex items-center justify-between rounded-md border border-border p-3"

apps/portal/src/app/(app)/(home)/[orgId]/components/OrganizationDashboard.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export async function OrganizationDashboard({
8787
host={host}
8888
deviceAgentStepEnabled={org.deviceAgentStepEnabled}
8989
securityTrainingStepEnabled={org.securityTrainingStepEnabled}
90+
whistleblowerReportEnabled={org.whistleblowerReportEnabled}
91+
accessRequestFormEnabled={org.accessRequestFormEnabled}
9092
/>
9193
);
9294
}

apps/portal/src/app/(app)/(home)/[orgId]/documents/[formType]/PortalFormClient.tsx

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '@trycompai/design-system';
2020
import Link from 'next/link';
2121
import { useParams } from 'next/navigation';
22+
import { useState } from 'react';
2223

2324
type FieldDef = {
2425
key: string;
@@ -49,6 +50,7 @@ export function PortalFormClient({
4950
errorMessage,
5051
}: PortalFormClientProps) {
5152
const params = useParams<{ orgId: string }>();
53+
const [selectedFiles, setSelectedFiles] = useState<Record<string, string>>({});
5254

5355
const isCompact = (f: FieldDef) => f.type === 'text' || f.type === 'date' || f.type === 'select';
5456

@@ -149,6 +151,12 @@ export function PortalFormClient({
149151
<Textarea
150152
id={field.key}
151153
name={field.key}
154+
style={{
155+
width: '100%',
156+
maxWidth: 'none',
157+
maxHeight: '350px',
158+
minHeight: '350px',
159+
}}
152160
required={field.required}
153161
placeholder={field.placeholder}
154162
rows={12}
@@ -161,15 +169,36 @@ export function PortalFormClient({
161169

162170
{field.type === 'file' && (
163171
<div className="space-y-2">
164-
<div className="rounded-md border border-border p-4">
165-
<Input
166-
id={field.key}
167-
name={field.key}
168-
type="file"
169-
required={field.required}
170-
accept={field.accept}
171-
/>
172-
</div>
172+
<label htmlFor={field.key} className="block cursor-pointer">
173+
<div className="rounded-md border-2 border-dashed border-border bg-muted/20 p-6 text-center transition hover:bg-muted/40">
174+
<p className="text-sm font-medium text-foreground">
175+
Drop file here or click to upload
176+
</p>
177+
<p className="mt-1 text-xs text-muted-foreground">
178+
Maximum file size: 100 MB
179+
</p>
180+
</div>
181+
</label>
182+
<input
183+
id={field.key}
184+
name={field.key}
185+
type="file"
186+
required={field.required}
187+
accept={field.accept}
188+
className="sr-only"
189+
onChange={(event) => {
190+
const selectedFile = event.target.files?.[0];
191+
setSelectedFiles((current) => ({
192+
...current,
193+
[field.key]: selectedFile?.name ?? '',
194+
}));
195+
}}
196+
/>
197+
{selectedFiles[field.key] && (
198+
<Text size="sm" variant="muted">
199+
Selected: {selectedFiles[field.key]}
200+
</Text>
201+
)}
173202
<Text size="sm" variant="muted">
174203
Accepted: {field.accept ?? 'all file types'}
175204
</Text>

apps/portal/src/app/(app)/(home)/[orgId]/documents/[formType]/page.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { auth } from '@/app/lib/auth';
22
import { env } from '@/env.mjs';
3+
import { db } from '@db';
34
import { Breadcrumb, PageLayout } from '@trycompai/design-system';
45
import { isRedirectError } from 'next/dist/client/components/redirect-error';
56
import { headers as getHeaders } from 'next/headers';
@@ -50,6 +51,23 @@ export default async function PortalCompanyFormPage({
5051
if (!form.portalAccessible) {
5152
notFound();
5253
}
54+
55+
const organization = await db.organization.findUnique({
56+
where: { id: orgId },
57+
select: {
58+
whistleblowerReportEnabled: true,
59+
accessRequestFormEnabled: true,
60+
},
61+
});
62+
if (!organization) {
63+
notFound();
64+
}
65+
if (formTypeValue === 'whistleblower-report' && !organization.whistleblowerReportEnabled) {
66+
notFound();
67+
}
68+
if (formTypeValue === 'access-request' && !organization.accessRequestFormEnabled) {
69+
notFound();
70+
}
5371
const visibleFields = form.fields;
5472
const basePath = `/${orgId}/documents/${formTypeValue}`;
5573
const state = await searchParams;

apps/portal/src/app/(app)/(home)/[orgId]/documents/[formType]/submissions/page.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { auth } from '@/app/lib/auth';
22
import { env } from '@/env.mjs';
3+
import { db } from '@db';
34
import { Breadcrumb, PageLayout } from '@trycompai/design-system';
45
import { headers as getHeaders } from 'next/headers';
56
import Link from 'next/link';
@@ -60,6 +61,23 @@ export default async function PortalSubmissionsPage({
6061
notFound();
6162
}
6263

64+
const organization = await db.organization.findUnique({
65+
where: { id: orgId },
66+
select: {
67+
whistleblowerReportEnabled: true,
68+
accessRequestFormEnabled: true,
69+
},
70+
});
71+
if (!organization) {
72+
notFound();
73+
}
74+
if (formTypeValue === 'whistleblower-report' && !organization.whistleblowerReportEnabled) {
75+
notFound();
76+
}
77+
if (formTypeValue === 'access-request' && !organization.accessRequestFormEnabled) {
78+
notFound();
79+
}
80+
6381
const reqHeaders = await getHeaders();
6482
const session = await auth.api.getSession({ headers: reqHeaders });
6583

0 commit comments

Comments
 (0)