mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user