@striae-org/striae 4.3.3 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -0
- package/app/components/actions/case-export/download-handlers.ts +60 -4
- package/app/components/actions/case-import/confirmation-import.ts +50 -7
- package/app/components/actions/case-import/confirmation-package.ts +99 -22
- package/app/components/actions/case-import/orchestrator.ts +116 -13
- package/app/components/actions/case-import/validation.ts +171 -7
- package/app/components/actions/case-import/zip-processing.ts +224 -127
- package/app/components/actions/case-manage.ts +110 -10
- package/app/components/actions/confirm-export.ts +32 -3
- package/app/components/audit/user-audit.module.css +49 -0
- package/app/components/audit/viewer/audit-entries-list.tsx +130 -48
- package/app/components/navbar/navbar.module.css +0 -10
- package/app/components/navbar/navbar.tsx +0 -22
- package/app/components/sidebar/case-import/case-import.module.css +7 -131
- package/app/components/sidebar/case-import/case-import.tsx +7 -14
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +17 -60
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +23 -39
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +5 -45
- package/app/components/sidebar/case-import/components/FileSelector.tsx +5 -6
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +2 -48
- package/app/components/sidebar/case-import/utils/file-validation.ts +9 -21
- package/app/config-example/config.json +5 -0
- package/app/routes/auth/login.tsx +1 -1
- package/app/services/audit/audit-console-logger.ts +1 -1
- package/app/services/audit/audit-export-csv.ts +1 -1
- package/app/services/audit/audit-export-signing.ts +2 -2
- package/app/services/audit/audit-export.service.ts +1 -1
- package/app/services/audit/audit-worker-client.ts +1 -1
- package/app/services/audit/audit.service.ts +5 -75
- package/app/services/audit/builders/audit-event-builders-case-file.ts +3 -0
- package/app/services/audit/index.ts +2 -2
- package/app/types/audit.ts +8 -7
- package/app/utils/data/operations/signing-operations.ts +93 -0
- package/app/utils/data/operations/types.ts +6 -0
- package/app/utils/forensics/export-encryption.ts +316 -0
- package/app/utils/forensics/export-verification.ts +1 -409
- package/app/utils/forensics/index.ts +1 -0
- package/app/utils/ui/case-messages.ts +5 -2
- package/package.json +1 -1
- package/scripts/deploy-config.sh +97 -3
- package/scripts/deploy-worker-secrets.sh +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/data-worker.example.ts +130 -0
- package/workers/data-worker/src/encryption-utils.ts +125 -0
- package/workers/data-worker/worker-configuration.d.ts +1 -1
- package/workers/data-worker/wrangler.jsonc.example +2 -2
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +0 -470
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { signPayload as signWithWorkerKey } from './signature-utils';
|
|
2
|
+
import { decryptExportData, decryptImageBlob } from './encryption-utils';
|
|
2
3
|
import {
|
|
3
4
|
AUDIT_EXPORT_SIGNATURE_VERSION,
|
|
4
5
|
CONFIRMATION_SIGNATURE_VERSION,
|
|
@@ -20,6 +21,8 @@ interface Env {
|
|
|
20
21
|
STRIAE_DATA: R2Bucket;
|
|
21
22
|
MANIFEST_SIGNING_PRIVATE_KEY: string;
|
|
22
23
|
MANIFEST_SIGNING_KEY_ID: string;
|
|
24
|
+
EXPORT_ENCRYPTION_PRIVATE_KEY?: string;
|
|
25
|
+
EXPORT_ENCRYPTION_KEY_ID?: string;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
interface SuccessResponse {
|
|
@@ -50,6 +53,7 @@ const hasValidHeader = (request: Request, env: Env): boolean =>
|
|
|
50
53
|
const SIGN_MANIFEST_PATH = '/api/forensic/sign-manifest';
|
|
51
54
|
const SIGN_CONFIRMATION_PATH = '/api/forensic/sign-confirmation';
|
|
52
55
|
const SIGN_AUDIT_EXPORT_PATH = '/api/forensic/sign-audit-export';
|
|
56
|
+
const DECRYPT_EXPORT_PATH = '/api/forensic/decrypt-export';
|
|
53
57
|
|
|
54
58
|
async function signPayloadWithWorkerKey(payload: string, env: Env): Promise<{
|
|
55
59
|
algorithm: string;
|
|
@@ -196,6 +200,128 @@ async function handleSignAuditExport(request: Request, env: Env): Promise<Respon
|
|
|
196
200
|
}
|
|
197
201
|
}
|
|
198
202
|
|
|
203
|
+
async function handleDecryptExport(request: Request, env: Env): Promise<Response> {
|
|
204
|
+
try {
|
|
205
|
+
// Check if encryption is configured
|
|
206
|
+
if (!env.EXPORT_ENCRYPTION_PRIVATE_KEY || !env.EXPORT_ENCRYPTION_KEY_ID) {
|
|
207
|
+
return createResponse(
|
|
208
|
+
{ error: 'Export decryption is not configured on this server' },
|
|
209
|
+
400
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const requestBody = await request.json() as {
|
|
214
|
+
wrappedKey?: string;
|
|
215
|
+
dataIv?: string;
|
|
216
|
+
encryptedData?: string;
|
|
217
|
+
encryptedImages?: Array<{ filename: string; encryptedData: string; iv?: string }>;
|
|
218
|
+
keyId?: string;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const { wrappedKey, dataIv, encryptedData, encryptedImages, keyId } = requestBody;
|
|
222
|
+
|
|
223
|
+
// Validate required fields
|
|
224
|
+
if (
|
|
225
|
+
!wrappedKey ||
|
|
226
|
+
typeof wrappedKey !== 'string' ||
|
|
227
|
+
!dataIv ||
|
|
228
|
+
typeof dataIv !== 'string' ||
|
|
229
|
+
!encryptedData ||
|
|
230
|
+
typeof encryptedData !== 'string' ||
|
|
231
|
+
!keyId ||
|
|
232
|
+
typeof keyId !== 'string'
|
|
233
|
+
) {
|
|
234
|
+
return createResponse(
|
|
235
|
+
{ error: 'Missing or invalid required fields: wrappedKey, dataIv, encryptedData, keyId' },
|
|
236
|
+
400
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Validate keyId matches configured key
|
|
241
|
+
if (keyId !== env.EXPORT_ENCRYPTION_KEY_ID) {
|
|
242
|
+
return createResponse(
|
|
243
|
+
{ error: `Key ID mismatch: expected ${env.EXPORT_ENCRYPTION_KEY_ID}, got ${keyId}` },
|
|
244
|
+
400
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Decrypt data file
|
|
249
|
+
let plaintextData: string;
|
|
250
|
+
try {
|
|
251
|
+
plaintextData = await decryptExportData(
|
|
252
|
+
encryptedData,
|
|
253
|
+
wrappedKey,
|
|
254
|
+
dataIv,
|
|
255
|
+
env.EXPORT_ENCRYPTION_PRIVATE_KEY
|
|
256
|
+
);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
console.error('Data file decryption failed:', error);
|
|
259
|
+
const errorMessage = error instanceof Error ? error.message : 'Decryption failed';
|
|
260
|
+
return createResponse(
|
|
261
|
+
{ error: `Failed to decrypt data file: ${errorMessage}` },
|
|
262
|
+
500
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Decrypt images if provided
|
|
267
|
+
const decryptedImages: Array<{ filename: string; data: string }> = [];
|
|
268
|
+
if (Array.isArray(encryptedImages) && encryptedImages.length > 0) {
|
|
269
|
+
for (const imageEntry of encryptedImages) {
|
|
270
|
+
try {
|
|
271
|
+
if (!imageEntry.iv || typeof imageEntry.iv !== 'string') {
|
|
272
|
+
return createResponse(
|
|
273
|
+
{ error: `Missing IV for image ${imageEntry.filename}` },
|
|
274
|
+
400
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const imageBlob = await decryptImageBlob(
|
|
279
|
+
imageEntry.encryptedData,
|
|
280
|
+
wrappedKey,
|
|
281
|
+
imageEntry.iv,
|
|
282
|
+
env.EXPORT_ENCRYPTION_PRIVATE_KEY
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Convert blob to base64 for transport
|
|
286
|
+
const arrayBuffer = await imageBlob.arrayBuffer();
|
|
287
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
288
|
+
const chunkSize = 8192;
|
|
289
|
+
let binary = '';
|
|
290
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
291
|
+
const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
|
|
292
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
293
|
+
binary += String.fromCharCode(chunk[j]);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const base64Data = btoa(binary);
|
|
297
|
+
|
|
298
|
+
decryptedImages.push({
|
|
299
|
+
filename: imageEntry.filename,
|
|
300
|
+
data: base64Data
|
|
301
|
+
});
|
|
302
|
+
} catch (error) {
|
|
303
|
+
console.error(`Image decryption failed for ${imageEntry.filename}:`, error);
|
|
304
|
+
const errorMessage = error instanceof Error ? error.message : 'Decryption failed';
|
|
305
|
+
return createResponse(
|
|
306
|
+
{ error: `Failed to decrypt image ${imageEntry.filename}: ${errorMessage}` },
|
|
307
|
+
500
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return createResponse({
|
|
314
|
+
success: true,
|
|
315
|
+
plaintext: plaintextData,
|
|
316
|
+
decryptedImages
|
|
317
|
+
});
|
|
318
|
+
} catch (error) {
|
|
319
|
+
console.error('Export decryption request failed:', error);
|
|
320
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
321
|
+
return createResponse({ error: errorMessage }, 500);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
199
325
|
export default {
|
|
200
326
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
201
327
|
if (request.method === 'OPTIONS') {
|
|
@@ -223,6 +349,10 @@ export default {
|
|
|
223
349
|
return await handleSignAuditExport(request, env);
|
|
224
350
|
}
|
|
225
351
|
|
|
352
|
+
if (request.method === 'POST' && pathname === DECRYPT_EXPORT_PATH) {
|
|
353
|
+
return await handleDecryptExport(request, env);
|
|
354
|
+
}
|
|
355
|
+
|
|
226
356
|
const filename = pathname.slice(1) || 'data.json';
|
|
227
357
|
|
|
228
358
|
if (!filename.endsWith('.json')) {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export function base64UrlDecode(value: string): Uint8Array {
|
|
2
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
3
|
+
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
4
|
+
const decoded = atob(normalized + padding);
|
|
5
|
+
const bytes = new Uint8Array(decoded.length);
|
|
6
|
+
|
|
7
|
+
for (let i = 0; i < decoded.length; i += 1) {
|
|
8
|
+
bytes[i] = decoded.charCodeAt(i);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return bytes;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
|
|
15
|
+
const normalizedKey = privateKey
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/^['"]|['"]$/g, '')
|
|
18
|
+
.replace(/\\n/g, '\n');
|
|
19
|
+
|
|
20
|
+
const pemBody = normalizedKey
|
|
21
|
+
.replace('-----BEGIN PRIVATE KEY-----', '')
|
|
22
|
+
.replace('-----END PRIVATE KEY-----', '')
|
|
23
|
+
.replace(/\s+/g, '');
|
|
24
|
+
|
|
25
|
+
if (!pemBody) {
|
|
26
|
+
throw new Error('Encryption private key is invalid');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const binary = atob(pemBody);
|
|
30
|
+
const bytes = new Uint8Array(binary.length);
|
|
31
|
+
|
|
32
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
33
|
+
bytes[index] = binary.charCodeAt(index);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return bytes.buffer;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Import RSA private key from PKCS8 PEM format
|
|
41
|
+
*/
|
|
42
|
+
async function importRsaOaepPrivateKey(privateKeyPem: string): Promise<CryptoKey> {
|
|
43
|
+
const key = await crypto.subtle.importKey(
|
|
44
|
+
'pkcs8',
|
|
45
|
+
parsePkcs8PrivateKey(privateKeyPem),
|
|
46
|
+
{
|
|
47
|
+
name: 'RSA-OAEP',
|
|
48
|
+
hash: 'SHA-256'
|
|
49
|
+
},
|
|
50
|
+
false,
|
|
51
|
+
['decrypt']
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return key;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Decrypt AES key from RSA-OAEP wrapped form
|
|
59
|
+
*/
|
|
60
|
+
async function unwrapAesKey(
|
|
61
|
+
wrappedKeyBase64: string,
|
|
62
|
+
privateKeyPem: string
|
|
63
|
+
): Promise<CryptoKey> {
|
|
64
|
+
const rsaPrivateKey = await importRsaOaepPrivateKey(privateKeyPem);
|
|
65
|
+
const wrappedKeyBytes = base64UrlDecode(wrappedKeyBase64);
|
|
66
|
+
|
|
67
|
+
const rawAesKey = await crypto.subtle.decrypt(
|
|
68
|
+
{ name: 'RSA-OAEP' },
|
|
69
|
+
rsaPrivateKey,
|
|
70
|
+
wrappedKeyBytes as BufferSource
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return crypto.subtle.importKey(
|
|
74
|
+
'raw',
|
|
75
|
+
rawAesKey,
|
|
76
|
+
{ name: 'AES-GCM' },
|
|
77
|
+
false,
|
|
78
|
+
['decrypt']
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decrypt data file (plaintext JSON/CSV)
|
|
84
|
+
*/
|
|
85
|
+
export async function decryptExportData(
|
|
86
|
+
encryptedDataBase64: string,
|
|
87
|
+
wrappedKeyBase64: string,
|
|
88
|
+
ivBase64: string,
|
|
89
|
+
privateKeyPem: string
|
|
90
|
+
): Promise<string> {
|
|
91
|
+
const aesKey = await unwrapAesKey(wrappedKeyBase64, privateKeyPem);
|
|
92
|
+
const iv = base64UrlDecode(ivBase64);
|
|
93
|
+
const ciphertext = base64UrlDecode(encryptedDataBase64);
|
|
94
|
+
|
|
95
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
96
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
97
|
+
aesKey,
|
|
98
|
+
ciphertext as BufferSource
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return new TextDecoder().decode(plaintext);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Decrypt a single image blob
|
|
106
|
+
*/
|
|
107
|
+
export async function decryptImageBlob(
|
|
108
|
+
encryptedImageBase64: string,
|
|
109
|
+
wrappedKeyBase64: string,
|
|
110
|
+
ivBase64: string,
|
|
111
|
+
privateKeyPem: string
|
|
112
|
+
): Promise<Blob> {
|
|
113
|
+
const aesKey = await unwrapAesKey(wrappedKeyBase64, privateKeyPem);
|
|
114
|
+
const iv = base64UrlDecode(ivBase64);
|
|
115
|
+
const ciphertext = base64UrlDecode(encryptedImageBase64);
|
|
116
|
+
|
|
117
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
118
|
+
{ name: 'AES-GCM', iv: iv as BufferSource },
|
|
119
|
+
aesKey,
|
|
120
|
+
ciphertext as BufferSource
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Return as blob (caller can determine MIME type from context)
|
|
124
|
+
return new Blob([plaintext]);
|
|
125
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable */
|
|
2
2
|
// Generated by Wrangler by running `wrangler types` (hash: 4ccb8b314830f4c7bb743cb9b033a6cb)
|
|
3
|
-
// Runtime types generated with workerd@1.20250823.0 2026-03-
|
|
3
|
+
// Runtime types generated with workerd@1.20250823.0 2026-03-23 nodejs_compat
|
|
4
4
|
declare namespace Cloudflare {
|
|
5
5
|
interface Env {
|
|
6
6
|
STRIAE_DATA: R2Bucket;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
// Required secrets: R2_KEY_SECRET, MANIFEST_SIGNING_PRIVATE_KEY, MANIFEST_SIGNING_KEY_ID
|
|
2
|
+
// Required secrets: R2_KEY_SECRET, MANIFEST_SIGNING_PRIVATE_KEY, MANIFEST_SIGNING_KEY_ID, EXPORT_ENCRYPTION_PRIVATE_KEY, EXPORT_ENCRYPTION_KEY_ID
|
|
3
3
|
"name": "DATA_WORKER_NAME",
|
|
4
4
|
"account_id": "ACCOUNT_ID",
|
|
5
5
|
"main": "src/data-worker.ts",
|
|
6
|
-
"compatibility_date": "2026-03-
|
|
6
|
+
"compatibility_date": "2026-03-24",
|
|
7
7
|
"compatibility_flags": [
|
|
8
8
|
"nodejs_compat"
|
|
9
9
|
],
|
package/wrangler.toml.example
CHANGED
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
.overlay {
|
|
2
|
-
position: fixed;
|
|
3
|
-
inset: 0;
|
|
4
|
-
background-color: color-mix(in lab, var(--background) 60%, transparent);
|
|
5
|
-
display: flex;
|
|
6
|
-
justify-content: center;
|
|
7
|
-
align-items: center;
|
|
8
|
-
z-index: var(--zIndex5);
|
|
9
|
-
padding: var(--spaceL);
|
|
10
|
-
cursor: default;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
.modal {
|
|
14
|
-
position: relative;
|
|
15
|
-
width: 100%;
|
|
16
|
-
max-width: 640px;
|
|
17
|
-
max-height: 90vh;
|
|
18
|
-
background: var(--backgroundLight);
|
|
19
|
-
border-radius: var(--spaceXS);
|
|
20
|
-
display: flex;
|
|
21
|
-
flex-direction: column;
|
|
22
|
-
box-shadow: 0 var(--spaceXS) var(--spaceL)
|
|
23
|
-
color-mix(in lab, var(--black) 18%, transparent);
|
|
24
|
-
overflow: hidden;
|
|
25
|
-
cursor: default;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
.header {
|
|
29
|
-
display: flex;
|
|
30
|
-
justify-content: space-between;
|
|
31
|
-
align-items: center;
|
|
32
|
-
padding: var(--spaceL);
|
|
33
|
-
border-bottom: 1px solid color-mix(in lab, var(--text) 10%, transparent);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
.title {
|
|
37
|
-
margin: 0;
|
|
38
|
-
font-size: var(--fontSizeBodyL);
|
|
39
|
-
font-weight: 600;
|
|
40
|
-
color: var(--textTitle);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
.closeButton {
|
|
44
|
-
background: none;
|
|
45
|
-
border: none;
|
|
46
|
-
font-size: var(--fontSizeH5);
|
|
47
|
-
cursor: pointer;
|
|
48
|
-
padding: var(--spaceS);
|
|
49
|
-
color: var(--textLight);
|
|
50
|
-
transition: color var(--durationS) var(--bezierFastoutSlowin);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
.closeButton:hover {
|
|
54
|
-
color: var(--text);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
.content {
|
|
58
|
-
padding: var(--spaceL);
|
|
59
|
-
flex: 1 1 auto;
|
|
60
|
-
min-height: 0;
|
|
61
|
-
display: flex;
|
|
62
|
-
flex-direction: column;
|
|
63
|
-
gap: var(--spaceM);
|
|
64
|
-
overflow-y: auto;
|
|
65
|
-
overflow-x: hidden;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
.description {
|
|
69
|
-
margin: 0;
|
|
70
|
-
font-size: var(--fontSizeBodyS);
|
|
71
|
-
color: var(--textBody);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
.meta {
|
|
75
|
-
margin: 0;
|
|
76
|
-
font-size: var(--fontSizeBodyS);
|
|
77
|
-
color: var(--textTitle);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
.meta span {
|
|
81
|
-
font-weight: var(--fontWeightMedium);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
.verifierLayout {
|
|
85
|
-
display: flex;
|
|
86
|
-
flex-direction: column;
|
|
87
|
-
gap: var(--spaceL);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
.verificationField {
|
|
91
|
-
display: flex;
|
|
92
|
-
flex-direction: column;
|
|
93
|
-
gap: var(--spaceS);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
.fieldHeader {
|
|
97
|
-
display: flex;
|
|
98
|
-
align-items: center;
|
|
99
|
-
justify-content: space-between;
|
|
100
|
-
gap: var(--spaceS);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
.fieldLabel {
|
|
104
|
-
font-size: var(--fontSizeBodyXS);
|
|
105
|
-
font-weight: var(--fontWeightMedium);
|
|
106
|
-
color: var(--textTitle);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
.hiddenFileInput {
|
|
110
|
-
display: none;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
.clearButton {
|
|
114
|
-
background: none;
|
|
115
|
-
border: none;
|
|
116
|
-
padding: 0;
|
|
117
|
-
color: var(--primary);
|
|
118
|
-
font-size: var(--fontSizeBodyXS);
|
|
119
|
-
font-weight: var(--fontWeightMedium);
|
|
120
|
-
cursor: pointer;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
.dropZone {
|
|
124
|
-
min-height: 144px;
|
|
125
|
-
margin: 0;
|
|
126
|
-
display: flex;
|
|
127
|
-
flex-direction: column;
|
|
128
|
-
justify-content: center;
|
|
129
|
-
gap: var(--spaceXS);
|
|
130
|
-
padding: var(--spaceL);
|
|
131
|
-
border: 1px dashed color-mix(in lab, var(--text) 18%, transparent);
|
|
132
|
-
border-radius: var(--radiusM);
|
|
133
|
-
background: linear-gradient(
|
|
134
|
-
135deg,
|
|
135
|
-
color-mix(in lab, var(--primary) 4%, var(--backgroundLight)),
|
|
136
|
-
color-mix(in lab, var(--background) 94%, transparent)
|
|
137
|
-
);
|
|
138
|
-
cursor: pointer;
|
|
139
|
-
transition:
|
|
140
|
-
border-color var(--durationS) var(--bezierFastoutSlowin),
|
|
141
|
-
background-color var(--durationS) var(--bezierFastoutSlowin),
|
|
142
|
-
box-shadow var(--durationS) var(--bezierFastoutSlowin);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
.dropZone:hover {
|
|
146
|
-
border-color: color-mix(in lab, var(--primary) 35%, transparent);
|
|
147
|
-
background: linear-gradient(
|
|
148
|
-
135deg,
|
|
149
|
-
color-mix(in lab, var(--primary) 7%, var(--backgroundLight)),
|
|
150
|
-
color-mix(in lab, var(--background) 92%, transparent)
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
.dropZone:focus-visible {
|
|
155
|
-
outline: none;
|
|
156
|
-
border-color: color-mix(in lab, var(--primary) 48%, transparent);
|
|
157
|
-
box-shadow: 0 0 0 3px color-mix(in lab, var(--primary) 14%, transparent);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
.dropZoneActive {
|
|
161
|
-
border-color: color-mix(in lab, var(--primary) 50%, transparent);
|
|
162
|
-
background: linear-gradient(
|
|
163
|
-
135deg,
|
|
164
|
-
color-mix(in lab, var(--primary) 10%, var(--backgroundLight)),
|
|
165
|
-
color-mix(in lab, var(--background) 90%, transparent)
|
|
166
|
-
);
|
|
167
|
-
box-shadow: 0 0 0 3px color-mix(in lab, var(--primary) 12%, transparent);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
.dropZoneDisabled {
|
|
171
|
-
opacity: 0.7;
|
|
172
|
-
cursor: not-allowed;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
.dropZonePrimary {
|
|
176
|
-
margin: 0;
|
|
177
|
-
font-size: var(--fontSizeBodyS);
|
|
178
|
-
font-weight: var(--fontWeightMedium);
|
|
179
|
-
color: var(--textTitle);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
.dropZoneSecondary {
|
|
183
|
-
margin: 0;
|
|
184
|
-
font-size: var(--fontSizeBodyXS);
|
|
185
|
-
color: var(--textBody);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
.fieldActions {
|
|
189
|
-
display: flex;
|
|
190
|
-
flex-wrap: wrap;
|
|
191
|
-
gap: var(--spaceS);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
.fieldError {
|
|
195
|
-
margin: 0;
|
|
196
|
-
font-size: var(--fontSizeBodyXS);
|
|
197
|
-
color: var(--error);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
.resultCard {
|
|
201
|
-
display: flex;
|
|
202
|
-
flex-direction: column;
|
|
203
|
-
gap: var(--spaceXS);
|
|
204
|
-
padding: var(--spaceM) var(--spaceL);
|
|
205
|
-
border-radius: var(--radiusM);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
.resultPass {
|
|
209
|
-
border: 1px solid color-mix(in lab, var(--success) 38%, transparent);
|
|
210
|
-
background: color-mix(in lab, var(--success) 12%, var(--backgroundLight));
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
.resultFail {
|
|
214
|
-
border: 1px solid color-mix(in lab, var(--error) 32%, transparent);
|
|
215
|
-
background: color-mix(in lab, var(--errorLight) 40%, var(--backgroundLight));
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
.resultTitle {
|
|
219
|
-
margin: 0;
|
|
220
|
-
font-size: var(--fontSizeBodyM);
|
|
221
|
-
font-weight: var(--fontWeightBold);
|
|
222
|
-
letter-spacing: 0.06em;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
.resultPass .resultTitle {
|
|
226
|
-
color: color-mix(in lab, var(--success) 78%, var(--black));
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
.resultFail .resultTitle {
|
|
230
|
-
color: color-mix(in lab, var(--error) 78%, var(--black));
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
.resultMessage {
|
|
234
|
-
margin: 0;
|
|
235
|
-
font-size: var(--fontSizeBodyS);
|
|
236
|
-
color: var(--textBody);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
.actions {
|
|
240
|
-
display: flex;
|
|
241
|
-
justify-content: flex-end;
|
|
242
|
-
gap: var(--spaceS);
|
|
243
|
-
flex-wrap: wrap;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
.primaryButton,
|
|
247
|
-
.secondaryButton {
|
|
248
|
-
border-radius: var(--spaceXS);
|
|
249
|
-
padding: var(--spaceS) var(--spaceL);
|
|
250
|
-
font-size: var(--fontSizeBodyS);
|
|
251
|
-
font-weight: var(--fontWeightMedium);
|
|
252
|
-
cursor: pointer;
|
|
253
|
-
transition:
|
|
254
|
-
background-color var(--durationS) var(--bezierFastoutSlowin),
|
|
255
|
-
border-color var(--durationS) var(--bezierFastoutSlowin),
|
|
256
|
-
color var(--durationS) var(--bezierFastoutSlowin);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
.primaryButton {
|
|
260
|
-
background: var(--primary);
|
|
261
|
-
color: var(--white);
|
|
262
|
-
border: 1px solid var(--primary);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
.primaryButton:hover:not(:disabled) {
|
|
266
|
-
background: color-mix(in lab, var(--primary) 84%, var(--black));
|
|
267
|
-
border-color: color-mix(in lab, var(--primary) 84%, var(--black));
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
.secondaryButton {
|
|
271
|
-
background: transparent;
|
|
272
|
-
color: var(--textTitle);
|
|
273
|
-
border: 1px solid color-mix(in lab, var(--text) 16%, transparent);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
.secondaryButton:hover:not(:disabled) {
|
|
277
|
-
background: color-mix(in lab, var(--text) 5%, transparent);
|
|
278
|
-
border-color: color-mix(in lab, var(--text) 22%, transparent);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
.primaryButton:disabled,
|
|
282
|
-
.secondaryButton:disabled {
|
|
283
|
-
background: color-mix(in lab, var(--background) 95%, transparent);
|
|
284
|
-
color: var(--textLight);
|
|
285
|
-
border-color: color-mix(in lab, var(--text) 10%, transparent);
|
|
286
|
-
cursor: not-allowed;
|
|
287
|
-
}
|