@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.
Files changed (53) hide show
  1. package/.env.example +4 -0
  2. package/app/components/actions/case-export/download-handlers.ts +60 -4
  3. package/app/components/actions/case-import/confirmation-import.ts +50 -7
  4. package/app/components/actions/case-import/confirmation-package.ts +99 -22
  5. package/app/components/actions/case-import/orchestrator.ts +116 -13
  6. package/app/components/actions/case-import/validation.ts +171 -7
  7. package/app/components/actions/case-import/zip-processing.ts +224 -127
  8. package/app/components/actions/case-manage.ts +110 -10
  9. package/app/components/actions/confirm-export.ts +32 -3
  10. package/app/components/audit/user-audit.module.css +49 -0
  11. package/app/components/audit/viewer/audit-entries-list.tsx +130 -48
  12. package/app/components/navbar/navbar.module.css +0 -10
  13. package/app/components/navbar/navbar.tsx +0 -22
  14. package/app/components/sidebar/case-import/case-import.module.css +7 -131
  15. package/app/components/sidebar/case-import/case-import.tsx +7 -14
  16. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +17 -60
  17. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +23 -39
  18. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +5 -45
  19. package/app/components/sidebar/case-import/components/FileSelector.tsx +5 -6
  20. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +2 -48
  21. package/app/components/sidebar/case-import/utils/file-validation.ts +9 -21
  22. package/app/config-example/config.json +5 -0
  23. package/app/routes/auth/login.tsx +1 -1
  24. package/app/services/audit/audit-console-logger.ts +1 -1
  25. package/app/services/audit/audit-export-csv.ts +1 -1
  26. package/app/services/audit/audit-export-signing.ts +2 -2
  27. package/app/services/audit/audit-export.service.ts +1 -1
  28. package/app/services/audit/audit-worker-client.ts +1 -1
  29. package/app/services/audit/audit.service.ts +5 -75
  30. package/app/services/audit/builders/audit-event-builders-case-file.ts +3 -0
  31. package/app/services/audit/index.ts +2 -2
  32. package/app/types/audit.ts +8 -7
  33. package/app/utils/data/operations/signing-operations.ts +93 -0
  34. package/app/utils/data/operations/types.ts +6 -0
  35. package/app/utils/forensics/export-encryption.ts +316 -0
  36. package/app/utils/forensics/export-verification.ts +1 -409
  37. package/app/utils/forensics/index.ts +1 -0
  38. package/app/utils/ui/case-messages.ts +5 -2
  39. package/package.json +1 -1
  40. package/scripts/deploy-config.sh +97 -3
  41. package/scripts/deploy-worker-secrets.sh +1 -1
  42. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  43. package/workers/data-worker/src/data-worker.example.ts +130 -0
  44. package/workers/data-worker/src/encryption-utils.ts +125 -0
  45. package/workers/data-worker/worker-configuration.d.ts +1 -1
  46. package/workers/data-worker/wrangler.jsonc.example +2 -2
  47. package/workers/image-worker/wrangler.jsonc.example +1 -1
  48. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  49. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  50. package/workers/user-worker/wrangler.jsonc.example +1 -1
  51. package/wrangler.toml.example +1 -1
  52. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +0 -287
  53. 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-20 nodejs_compat
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-23",
6
+ "compatibility_date": "2026-03-24",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "IMAGES_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/image-worker.ts",
5
- "compatibility_date": "2026-03-23",
5
+ "compatibility_date": "2026-03-24",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "KEYS_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/keys.ts",
5
- "compatibility_date": "2026-03-23",
5
+ "compatibility_date": "2026-03-24",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "PDF_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/pdf-worker.ts",
5
- "compatibility_date": "2026-03-23",
5
+ "compatibility_date": "2026-03-24",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "USER_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/user-worker.ts",
5
- "compatibility_date": "2026-03-23",
5
+ "compatibility_date": "2026-03-24",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -1,6 +1,6 @@
1
1
  #:schema node_modules/wrangler/config-schema.json
2
2
  name = "PAGES_PROJECT_NAME"
3
- compatibility_date = "2026-03-23"
3
+ compatibility_date = "2026-03-24"
4
4
  compatibility_flags = ["nodejs_compat"]
5
5
  pages_build_output_dir = "./build/client"
6
6
 
@@ -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
- }