Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,14 @@ export function CompanySubmissionWizard({

const validateRequiredMatrixCells = () => {
for (const field of matrixFields) {
// Skip matrix validation when a companion file has been uploaded
// (e.g. RBAC matrix allows uploading a spreadsheet instead of filling rows)
const companionFileKey = `${field.key.replace('Rows', '')}File`;
const companionFile = getValues(companionFileKey as never);
if (companionFile && typeof companionFile === 'object' && 'fileKey' in companionFile) {
continue;
}

const rows = normalizeMatrixRows(getValues(field.key as never));
const rowValues = rows.length > 0 ? rows : [createEmptyMatrixRow(field.columns)];

Expand Down Expand Up @@ -672,6 +680,15 @@ export function CompanySubmissionWizard({
if (trimmed === '.svg') return ['image/svg+xml', []];
if (trimmed === '.vsdx')
return ['application/vnd.visio', []];
if (trimmed === '.csv') return ['text/csv', []];
if (trimmed === '.xlsx') {
return [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
[],
];
}
if (trimmed === '.xls')
return ['application/vnd.ms-excel', []];
return null;
})
.filter(
Expand Down Expand Up @@ -830,6 +847,15 @@ export function CompanySubmissionWizard({
if (trimmed === '.txt') return ['text/plain', []];
if (trimmed === '.svg') return ['image/svg+xml', []];
if (trimmed === '.vsdx') return ['application/vnd.visio', []];
if (trimmed === '.csv') return ['text/csv', []];
if (trimmed === '.xlsx') {
return [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
[],
];
}
if (trimmed === '.xls')
return ['application/vnd.ms-excel', []];
return null;
})
.filter((entry): entry is [string, string[]] => entry !== null),
Expand Down
15 changes: 12 additions & 3 deletions packages/company/src/evidence-forms/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export const evidenceFormDefinitions: Record<EvidenceFormType, EvidenceFormDefin
label: 'Supporting file',
type: 'file',
required: false,
accept: '.pdf,.doc,.docx,.txt,.png,.jpg,.jpeg',
accept: '.pdf,.doc,.docx,.txt,.png,.jpg,.jpeg,.csv,.xlsx,.xls',
description: 'Optional file attachment to support your report',
},
],
Expand Down Expand Up @@ -222,7 +222,7 @@ Remediation: Status of fixes (e.g. 3 of 5 critical findings remediated; 2 in pro
key: 'matrixRows',
label: 'RBAC entries',
type: 'matrix',
required: true,
required: false,
description: 'Audit-minimum role access evidence.',
addRowLabel: 'Add RBAC row',
columns: [
Expand Down Expand Up @@ -258,6 +258,15 @@ Remediation: Status of fixes (e.g. 3 of 5 critical findings remediated; 2 in pro
},
],
},
{
key: 'matrixFile',
label: 'Or upload a spreadsheet',
type: 'file',
required: false,
accept: '.csv,.xlsx,.xls,.pdf',
description:
'Optionally upload a CSV or Excel file with your RBAC matrix instead of entering rows manually',
},
],
},
'infrastructure-inventory': {
Expand Down Expand Up @@ -568,7 +577,7 @@ Injects: At 10:30, media contacts the company about the incident.`,
label: 'Supporting evidence',
type: 'file',
required: false,
accept: '.pdf,.doc,.docx,.png,.jpg,.jpeg',
accept: '.pdf,.doc,.docx,.png,.jpg,.jpeg,.csv,.xlsx,.xls',
description:
'Optionally upload additional evidence such as slides, agendas, or sign-in sheets',
},
Expand Down
49 changes: 46 additions & 3 deletions packages/company/src/evidence-forms/submission-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,54 @@ const rbacMatrixRowSchema = z.object({
lastReviewed: requiredTrimmed('Last reviewed'),
});

const rbacMatrixDataSchema = z.object({
submissionDate: required('Submission date'),
matrixRows: z.array(rbacMatrixRowSchema).min(1, 'At least one RBAC entry is required'),
// Lenient row schema — accepts empty strings so the default empty row
// doesn't block Zod parsing before the superRefine can check for a file.
const rbacMatrixRowSchemaLenient = z.object({
system: z.string().default(''),
roleName: z.string().default(''),
permissionsScope: z.string().default(''),
approvedBy: z.string().default(''),
lastReviewed: z.string().default(''),
});

const rbacMatrixDataSchema = z
.object({
submissionDate: required('Submission date'),
matrixRows: z.array(rbacMatrixRowSchemaLenient).optional(),
matrixFile: evidenceFormFileSchema.optional(),
})
.superRefine((data, ctx) => {
if (data.matrixFile) return;

const rows = data.matrixRows ?? [];
const isRowEmpty = (row: Record<string, string>) =>
Object.values(row).every((v) => v.trim().length === 0);

const hasFilledRow = rows.some((row) => !isRowEmpty(row));
if (!hasFilledRow) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Enter at least one RBAC row or upload a spreadsheet',
path: ['matrixRows'],
});
return;
}

for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row || isRowEmpty(row)) continue;
const result = rbacMatrixRowSchema.safeParse(row);
if (!result.success) {
for (const issue of result.error.issues) {
ctx.addIssue({
...issue,
path: ['matrixRows', i, ...issue.path],
});
}
}
}
});

const infrastructureInventoryRowSchema = z.object({
assetId: requiredTrimmed('Asset ID'),
systemType: requiredTrimmed('System type'),
Expand Down
Loading