Shared Sign Cert Validation (#5996)

## PR: Certificate Pre-Validation for Document Signing

### Problem

When a participant uploaded a certificate to sign a document, there was
no validation at submission time. If the certificate had the wrong
password, was expired, or was incompatible with the signing algorithm,
the error only surfaced during **finalization** — potentially days
later, after all other participants had signed. At that point the
session is stuck with no way to recover.

Additionally, `buildKeystore` in the finalization service only
recognised `"P12"` as a cert type, causing a `400 Invalid certificate
type: PKCS12` error when the **owner** signed using the standard
`PKCS12` identifier.

---

### What this PR does

#### Backend — Certificate pre-validation service

Adds `CertificateSubmissionValidator`, which validates a keystore before
it is stored by:
1. Loading the keystore with the provided password (catches wrong
password / corrupt file)
2. Checking the certificate's validity dates (catches expired and
not-yet-valid certs)
3. Test-signing a blank PDF using the same `PdfSigningService` code path
as finalization (catches algorithm incompatibilities)

This runs on both the participant submission endpoint
(`WorkflowParticipantController`) and the owner signing endpoint
(`SigningSessionController`), so both flows are protected.

#### Backend — Bug fix

`SigningFinalizationService.buildKeystore` now accepts `"PKCS12"` and
`"PFX"` as aliases for `"P12"`, consistent with how the validator
already handles them. This fixes a `400` error when the owner signed
using the `PKCS12` cert type.

#### Frontend — Real-time validation feedback

`ParticipantView` gains a debounced validation call (600ms) triggered
whenever the cert file or password changes. The UI shows:
- A spinner while validating
- Green "Certificate valid until [date] · [subject name]" on success
- Red error message on failure (wrong password, expired, not yet valid)
- The submit button is disabled while validation is in flight

#### Tests — Three layers

| Layer | File | Coverage |
|---|---|---|
| Service unit | `CertificateSubmissionValidatorTest` | 11 tests — valid
P12/JKS, wrong password, corrupt bytes, expired, not-yet-valid, signing
failure, cert type aliases |
| Controller unit | `WorkflowParticipantValidateCertificateTest` | 4
tests — valid cert, invalid cert, missing file, invalid token |
| Controller integration | `CertificateValidationIntegrationTest` | 6
tests — real `.p12`/`.jks` files through the full controller → validator
stack |
| Frontend E2E | `CertificateValidationE2E.spec.ts` | 7 Playwright tests
— all feedback states, button behaviour, SERVER type bypass |

#### CI

- **PR**: Playwright runs on chromium when frontend files change (~2-3
min)
- **Nightly / on-demand**: All three browsers (chromium, firefox,
webkit) at 2 AM UTC, also manually triggerable via `workflow_dispatch`
This commit is contained in:
ConnorYoh
2026-03-27 14:01:10 +00:00
committed by GitHub
parent e10c5f6283
commit dd44de349c
29 changed files with 1777 additions and 9 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test';
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './src/tests',
testDir: './src/core/tests',
testMatch: '**/*.spec.ts',
/* Run tests in files in parallel */
fullyParallel: true,
@@ -2317,8 +2317,21 @@ continue = "Continue"
[certSign.collab.signRequest.certModal]
description = "You have placed {{count}} signature(s). Choose your certificate to complete signing."
sign = "Sign Document"
certValidating = "Validating certificate..."
certValidUntil = "Certificate valid until {{date}}"
certInvalid = "Certificate invalid: {{error}}"
certInvalidFallback = "Invalid certificate"
certNetworkError = "Could not validate certificate"
title = "Configure Certificate"
[certSign.collab.participant]
certValidating = "Validating certificate..."
certValid = "✓ Certificate valid"
certValidUntil = " until {{date}}"
certInvalid = "✗ {{error}}"
certInvalidFallback = "Invalid certificate"
certNetworkError = "Could not validate certificate"
[certSign.collab.signRequest.image]
hint = "Upload a PNG or JPG image of your signature"
@@ -1,7 +1,10 @@
import { Modal, Stack, Group, Button, Text, Collapse, TextInput } from '@mantine/core';
import { Modal, Stack, Group, Button, Text, Collapse, TextInput, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import { CertificateSelector, CertificateType, UploadFormat } from '@app/components/tools/certSign/CertificateSelector';
import apiClient from '@app/services/apiClient';
export interface CertificateSubmitData {
certType: CertificateType;
@@ -13,6 +16,12 @@ export interface CertificateSubmitData {
password: string;
}
type CertValidationState =
| { status: 'idle' }
| { status: 'validating' }
| { status: 'valid'; subjectName: string | null; notAfter: string | null }
| { status: 'error'; message: string };
interface CertificateConfigModalProps {
opened: boolean;
onClose: () => void;
@@ -21,6 +30,8 @@ interface CertificateConfigModalProps {
disabled?: boolean;
defaultReason?: string;
defaultLocation?: string;
/** Share token for external participants. When present, the participant validation endpoint is used. */
participantToken?: string;
}
export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
@@ -31,6 +42,7 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
disabled = false,
defaultReason = '',
defaultLocation = '',
participantToken,
}) => {
const { t } = useTranslation();
@@ -42,6 +54,65 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
const [jksFile, setJksFile] = useState<File | null>(null);
const [password, setPassword] = useState('');
const [signing, setSigning] = useState(false);
const [certValidation, setCertValidation] = useState<CertValidationState>({ status: 'idle' });
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Debounced certificate pre-validation: fires 600ms after cert file or password changes
useEffect(() => {
// Only validate uploaded keystores (not SERVER/USER_CERT, not PEM which uses separate files)
const keystoreFile = uploadFormat === 'JKS' ? jksFile : p12File;
if (certType !== 'UPLOAD' || !keystoreFile || uploadFormat === 'PEM') {
setCertValidation({ status: 'idle' });
return;
}
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
setCertValidation({ status: 'validating' });
validationTimerRef.current = setTimeout(async () => {
try {
const formData = new FormData();
formData.append('certType', uploadFormat === 'JKS' ? 'JKS' : 'P12');
formData.append('password', password);
if (uploadFormat === 'JKS') {
formData.append('jksFile', keystoreFile);
} else {
formData.append('p12File', keystoreFile);
}
const endpoint = participantToken
? '/api/v1/workflow/participant/validate-certificate'
: '/api/v1/security/cert-sign/validate-certificate';
if (participantToken) {
formData.append('participantToken', participantToken);
}
const response = await apiClient.post<{
valid: boolean;
subjectName: string | null;
notAfter: string | null;
error: string | null;
}>(endpoint, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
if (response.data.valid) {
setCertValidation({
status: 'valid',
subjectName: response.data.subjectName,
notAfter: response.data.notAfter,
});
} else {
setCertValidation({ status: 'error', message: response.data.error ?? t('certSign.collab.signRequest.certModal.certInvalidFallback', 'Invalid certificate') });
}
} catch {
setCertValidation({ status: 'error', message: t('certSign.collab.signRequest.certModal.certNetworkError', 'Could not validate certificate') });
}
}, 600);
return () => {
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
};
}, [certType, uploadFormat, p12File, jksFile, password, participantToken]);
// Advanced settings
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -118,6 +189,39 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
disabled={disabled || signing}
/>
{/* Certificate validation status */}
{certValidation.status === 'validating' && (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
{t('certSign.collab.signRequest.certModal.certValidating', 'Validating certificate...')}
</Text>
</Group>
)}
{certValidation.status === 'valid' && (
<Group gap="xs">
<CheckCircleIcon fontSize="small" style={{ color: 'var(--mantine-color-green-6)' }} />
<Text size="sm" c="green">
{t('certSign.collab.signRequest.certModal.certValidUntil', 'Certificate valid until {{date}}', {
date: certValidation.notAfter
? new Date(certValidation.notAfter).toLocaleDateString()
: '—',
})}
{certValidation.subjectName ? ` · ${certValidation.subjectName}` : ''}
</Text>
</Group>
)}
{certValidation.status === 'error' && (
<Group gap="xs">
<ErrorIcon fontSize="small" style={{ color: 'var(--mantine-color-red-6)' }} />
<Text size="sm" c="red">
{t('certSign.collab.signRequest.certModal.certInvalid', 'Certificate invalid: {{error}}', {
error: certValidation.message,
})}
</Text>
</Group>
)}
{/* Advanced Settings - Optional */}
<div>
<Button
@@ -156,7 +260,7 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
</Button>
<Button
onClick={handleSign}
disabled={!isValid || disabled || signing}
disabled={!isValid || disabled || signing || certValidation.status === 'validating'}
loading={signing}
>
{t('certSign.collab.signRequest.certModal.sign', 'Sign Document')}
@@ -0,0 +1,280 @@
import { test, expect, type Page } from '@playwright/test';
import path from 'path';
// ---------------------------------------------------------------------------
// Test fixtures — pre-generated keystores in test-fixtures/certs/
// ---------------------------------------------------------------------------
const CERTS_DIR = path.join(__dirname, '../test-fixtures/certs');
const VALID_P12 = path.join(CERTS_DIR, 'valid-test.p12');
const EXPIRED_P12 = path.join(CERTS_DIR, 'expired-test.p12');
const NOT_YET_VALID_P12 = path.join(CERTS_DIR, 'not-yet-valid-test.p12');
const VALID_JKS = path.join(CERTS_DIR, 'valid-test.jks');
// ---------------------------------------------------------------------------
// Stable mock data returned by the mocked backend
// ---------------------------------------------------------------------------
const MOCK_SESSION = {
sessionId: 'test-session-id',
documentName: 'test-document.pdf',
status: 'IN_PROGRESS',
ownerUsername: 'test-owner',
message: null,
dueDate: null,
participants: [],
};
const MOCK_PARTICIPANT = {
id: 1,
email: '[email protected]',
name: 'Test User',
status: 'PENDING',
shareToken: 'test-token',
expiresAt: null,
hasCompleted: false,
isExpired: false,
};
// ---------------------------------------------------------------------------
// Helper: mock the participant session and document endpoints
// (called in beforeEach so each test starts from a clean active session)
// ---------------------------------------------------------------------------
async function mockParticipantApis(page: Page) {
// Mock auth so AppProviders/Landing don't redirect to /login
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
json: {
id: 1,
username: 'testuser',
email: '[email protected]',
roles: ['ROLE_USER'],
},
})
);
await page.route('**/api/v1/workflow/participant/session**', (route) =>
route.fulfill({ json: MOCK_SESSION })
);
await page.route('**/api/v1/workflow/participant/details**', (route) =>
route.fulfill({ json: MOCK_PARTICIPANT })
);
// Minimal stub so the download-document call doesn't throw
await page.route('**/api/v1/workflow/participant/document**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/pdf',
body: Buffer.alloc(128),
})
);
}
// ---------------------------------------------------------------------------
// Helper: set the Mantine <Select> value by opening its dropdown and clicking
// the matching option — Mantine renders a custom combobox, not a native select.
// ---------------------------------------------------------------------------
async function selectCertType(page: Page, label: string) {
await page.getByTestId('cert-type-select').click();
await page.getByRole('option', { name: label }).click();
}
// ---------------------------------------------------------------------------
// Helper: upload a file into the Mantine <FileInput> (hidden native input)
// ---------------------------------------------------------------------------
async function uploadCertFile(page: Page, filePath: string) {
// Mantine FileInput uses a visually hidden <input type="file">.
// We click the visible button to expose it, then set files via the hidden input.
const certFileInput = page.getByTestId('cert-file-input');
await certFileInput.click();
// After click, the file chooser or the hidden input becomes interactive.
// Use the first file input on the page (Mantine places it near the button).
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles(filePath);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe('Certificate Validation — ParticipantView', () => {
test.beforeEach(async ({ page }) => {
await mockParticipantApis(page);
});
// 1. Happy path — valid P12
test('valid P12 cert shows green "Certificate valid until" feedback', async ({ page }) => {
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
route.fulfill({
json: {
valid: true,
subjectName: 'Test Signer',
notAfter: '2027-01-01T00:00:00Z',
notBefore: '2025-01-01T00:00:00Z',
selfSigned: true,
error: null,
},
})
);
await page.goto('/workflow/sign/test-token');
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, VALID_P12);
await page.getByTestId('cert-password-input').fill('testpass');
// Wait for debounce (600 ms) + network round-trip
const feedback = page.getByTestId('cert-validation-feedback');
await expect(feedback).toContainText('Certificate valid until', { timeout: 5000 });
await expect(feedback).toContainText('Test Signer');
});
// 2. Wrong password — red error
test('wrong password shows red error message', async ({ page }) => {
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
route.fulfill({
json: {
valid: false,
subjectName: null,
notAfter: null,
notBefore: null,
selfSigned: false,
error: 'Invalid certificate password or corrupt keystore file',
},
})
);
await page.goto('/workflow/sign/test-token');
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, VALID_P12);
await page.getByTestId('cert-password-input').fill('wrongpass');
const feedback = page.getByTestId('cert-validation-feedback');
await expect(feedback).toContainText('Invalid certificate password', { timeout: 5000 });
});
// 3. Expired certificate
test('expired cert shows "Certificate has expired" error', async ({ page }) => {
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
route.fulfill({
json: {
valid: false,
subjectName: null,
notAfter: null,
notBefore: null,
selfSigned: false,
error: 'Certificate has expired (expired: 2023-01-02 00:00:00 UTC)',
},
})
);
await page.goto('/workflow/sign/test-token');
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, EXPIRED_P12);
await page.getByTestId('cert-password-input').fill('testpass');
const feedback = page.getByTestId('cert-validation-feedback');
await expect(feedback).toContainText('Certificate has expired', { timeout: 5000 });
});
// 4. Not-yet-valid certificate
test('not-yet-valid cert shows "not yet valid" error', async ({ page }) => {
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
route.fulfill({
json: {
valid: false,
subjectName: null,
notAfter: null,
notBefore: null,
selfSigned: false,
error: 'Certificate is not yet valid (valid from: 2027-01-01 00:00:00 UTC)',
},
})
);
await page.goto('/workflow/sign/test-token');
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, NOT_YET_VALID_P12);
await page.getByTestId('cert-password-input').fill('testpass');
const feedback = page.getByTestId('cert-validation-feedback');
await expect(feedback).toContainText('not yet valid', { timeout: 5000 });
});
// 5. Submit button disabled while validating
test('submit button is disabled while validation is in flight', async ({ page }) => {
// Slow response so we can assert the disabled state mid-flight
await page.route('**/api/v1/workflow/participant/validate-certificate', async (route) => {
await new Promise((r) => setTimeout(r, 1500));
await route.fulfill({
json: {
valid: true,
subjectName: 'Test Signer',
notAfter: '2027-01-01T00:00:00Z',
notBefore: '2025-01-01T00:00:00Z',
selfSigned: true,
error: null,
},
});
});
await page.goto('/workflow/sign/test-token');
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, VALID_P12);
await page.getByTestId('cert-password-input').fill('testpass');
// Shortly after typing, validation is in flight — button must be disabled
const submitBtn = page.getByTestId('submit-signature-button');
await expect(submitBtn).toBeDisabled({ timeout: 3000 });
// After validation completes the button should be re-enabled
await expect(submitBtn).toBeEnabled({ timeout: 5000 });
});
// 6. SERVER type — no validation call made, button stays enabled
test('SERVER cert type skips validation and keeps submit enabled', async ({ page }) => {
let validateCalled = false;
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) => {
validateCalled = true;
return route.fulfill({ json: { valid: true } });
});
await page.goto('/workflow/sign/test-token');
await page.waitForSelector('[data-testid="submit-signature-button"]');
await selectCertType(page, 'Server Certificate (if available)');
// Wait longer than debounce to confirm no call is made
await page.waitForTimeout(1000);
expect(validateCalled).toBe(false);
await expect(page.getByTestId('submit-signature-button')).toBeEnabled();
});
// 7. Bonus — valid JKS keystore
test('valid JKS keystore shows green feedback', async ({ page }) => {
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
route.fulfill({
json: {
valid: true,
subjectName: 'JKS Signer',
notAfter: '2027-01-01T00:00:00Z',
notBefore: '2025-01-01T00:00:00Z',
selfSigned: true,
error: null,
},
})
);
await page.goto('/workflow/sign/test-token');
await page.waitForSelector('[data-testid="submit-signature-button"]');
await selectCertType(page, 'JKS Keystore');
await uploadCertFile(page, VALID_JKS);
await page.getByTestId('cert-password-input').fill('jkspass');
const feedback = page.getByTestId('cert-validation-feedback');
await expect(feedback).toContainText('Certificate valid until', { timeout: 5000 });
await expect(feedback).toContainText('JKS Signer');
});
});
+19 -2
View File
@@ -1,5 +1,5 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { Routes, Route, useParams } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
@@ -11,6 +11,7 @@ import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import ShareLinkPage from "@app/routes/ShareLinkPage";
import ParticipantView from "@app/components/workflow/ParticipantView";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
@@ -34,6 +35,13 @@ function MobileScannerProviders({ children }: { children: React.ReactNode }) {
);
}
// Participant signing page — token-gated, no login required
function ParticipantViewPage() {
const { token } = useParams<{ token: string }>();
if (!token) return null;
return <ParticipantView token={token} />;
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
@@ -48,6 +56,16 @@ export default function App() {
}
/>
{/* Participant signing — public, token-gated, no auth required */}
<Route
path="/workflow/sign/:token"
element={
<MobileScannerProviders>
<ParticipantViewPage />
</MobileScannerProviders>
}
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
@@ -55,7 +73,6 @@ export default function App() {
<AppProviders>
<AppLayout>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Card,
@@ -23,6 +24,7 @@ interface ParticipantViewProps {
}
const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
const { t } = useTranslation();
const { session, participant, loading, error, submitSignature, decline, downloadDocument } =
useParticipantSession(token);
@@ -37,6 +39,57 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
type CertValidationState =
| { status: 'idle' }
| { status: 'validating' }
| { status: 'valid'; notAfter: string | null; subjectName: string | null }
| { status: 'error'; message: string };
const [certValidation, setCertValidation] = useState<CertValidationState>({ status: 'idle' });
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (certType === 'SERVER' || !certFile) {
setCertValidation({ status: 'idle' });
return;
}
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
setCertValidation({ status: 'validating' });
validationTimerRef.current = setTimeout(async () => {
try {
const formData = new FormData();
formData.append('participantToken', token);
formData.append('certType', certType);
formData.append('password', password);
if (certType === 'JKS') {
formData.append('jksFile', certFile);
} else {
formData.append('p12File', certFile);
}
const res = await fetch('/api/v1/workflow/participant/validate-certificate', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (data.valid) {
setCertValidation({ status: 'valid', notAfter: data.notAfter, subjectName: data.subjectName });
} else {
setCertValidation({ status: 'error', message: data.error ?? t('certSign.collab.participant.certInvalidFallback', 'Invalid certificate') });
}
} catch {
setCertValidation({ status: 'error', message: t('certSign.collab.participant.certNetworkError', 'Could not validate certificate') });
}
}, 600);
return () => {
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
};
}, [certFile, password, certType, token]);
const handleSubmitSignature = async () => {
if (!certFile && certType !== 'SERVER') {
setNotification({ type: 'error', message: 'Please select a certificate file' });
@@ -190,6 +243,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{ value: 'SERVER', label: 'Server Certificate (if available)' },
]}
size="sm"
data-testid="cert-type-select"
/>
{certType !== 'SERVER' && (
@@ -201,6 +255,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
onChange={setCertFile}
accept=".p12,.pfx,.jks"
size="sm"
data-testid="cert-file-input"
/>
<TextInput
@@ -209,7 +264,31 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
size="sm"
data-testid="cert-password-input"
/>
{/* Certificate validation feedback */}
{certValidation.status === 'validating' && (
<Text size="sm" c="dimmed" data-testid="cert-validation-feedback">
{t('certSign.collab.participant.certValidating', 'Validating certificate...')}
</Text>
)}
{certValidation.status === 'valid' && (
<Text size="sm" c="green" data-testid="cert-validation-feedback">
{t('certSign.collab.participant.certValid', '✓ Certificate valid')}
{certValidation.notAfter
? t('certSign.collab.participant.certValidUntil', ' until {{date}}', {
date: new Date(certValidation.notAfter).toLocaleDateString(),
})
: ''}
{certValidation.subjectName ? ` · ${certValidation.subjectName}` : ''}
</Text>
)}
{certValidation.status === 'error' && (
<Text size="sm" c="red" data-testid="cert-validation-feedback">
{t('certSign.collab.participant.certInvalid', '✗ {{error}}', { error: certValidation.message })}
</Text>
)}
</>
)}
@@ -243,7 +322,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
leftSection={<CheckCircleIcon fontSize="small" />}
onClick={handleSubmitSignature}
loading={isSubmitting}
disabled={isSubmitting || certValidation.status === 'validating'}
color="green"
data-testid="submit-signature-button"
>
Submit Signature
</Button>
@@ -253,6 +334,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
onClick={handleDecline}
color="red"
variant="light"
data-testid="decline-button"
>
Decline
</Button>