@striae-org/striae 5.3.1 → 5.4.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 (100) hide show
  1. package/.env.example +3 -0
  2. package/app/components/actions/generate-pdf.ts +22 -0
  3. package/app/components/auth/auth.module.css +531 -0
  4. package/app/components/auth/mfa-enrollment.tsx +132 -79
  5. package/app/components/auth/mfa-totp-enrollment.tsx +231 -0
  6. package/app/components/auth/mfa-verification.tsx +155 -33
  7. package/app/components/{sidebar/cases/cases-modal.tsx → navbar/case-modals/all-cases-modal.tsx} +4 -4
  8. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -10
  9. package/app/components/navbar/case-modals/case-modal-shared.module.css +88 -0
  10. package/app/components/navbar/case-modals/delete-case-modal.tsx +9 -10
  11. package/app/components/navbar/case-modals/export-case-modal.tsx +9 -10
  12. package/app/components/navbar/case-modals/export-confirmations-modal.tsx +9 -10
  13. package/app/components/navbar/case-modals/open-case-modal.tsx +4 -4
  14. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -10
  15. package/app/components/navbar/navbar.tsx +1 -1
  16. package/app/components/sidebar/files/delete-files-modal.tsx +3 -3
  17. package/app/components/sidebar/files/files-modal.module.css +29 -0
  18. package/app/components/sidebar/notes/{class-details-fields.tsx → class-details/class-details-fields.tsx} +1 -1
  19. package/app/components/sidebar/notes/{class-details-modal.tsx → class-details/class-details-modal.tsx} +1 -1
  20. package/app/components/sidebar/notes/{class-details-sections.tsx → class-details/class-details-sections.tsx} +1 -1
  21. package/app/components/sidebar/notes/notes-editor-form.tsx +2 -2
  22. package/app/components/sidebar/notes/notes-editor-modal.tsx +6 -6
  23. package/app/components/sidebar/notes/notes.module.css +52 -0
  24. package/app/components/toolbar/toolbar-color-selector.tsx +8 -8
  25. package/app/components/toolbar/toolbar.module.css +181 -2
  26. package/app/components/user/delete-account.tsx +7 -7
  27. package/app/components/user/inactivity-warning.tsx +6 -6
  28. package/app/components/user/manage-profile.tsx +18 -1
  29. package/app/components/user/mfa-enrolled-factors.tsx +117 -0
  30. package/app/components/user/mfa-phone-update.tsx +8 -4
  31. package/app/components/user/mfa-totp-section.tsx +446 -0
  32. package/app/components/user/user.module.css +665 -0
  33. package/app/routes/striae/striae.tsx +1 -1
  34. package/app/services/audit/audit.service.ts +1 -1
  35. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  36. package/app/services/firebase/errors.ts +2 -0
  37. package/app/utils/auth/mfa.ts +35 -1
  38. package/functions/api/image/[[path]].ts +19 -3
  39. package/package.json +16 -21
  40. package/scripts/deploy-all.sh +166 -0
  41. package/scripts/deploy-config/modules/env-utils.sh +322 -0
  42. package/scripts/deploy-config/modules/keys.sh +404 -0
  43. package/scripts/deploy-config/modules/prompt.sh +375 -0
  44. package/scripts/deploy-config/modules/scaffolding.sh +310 -0
  45. package/scripts/deploy-config/modules/validation.sh +354 -0
  46. package/scripts/deploy-config.sh +236 -0
  47. package/scripts/deploy-pages-secrets.sh +231 -0
  48. package/scripts/deploy-pages.sh +34 -0
  49. package/scripts/deploy-primershear-emails.sh +167 -0
  50. package/scripts/deploy-worker-secrets.sh +385 -0
  51. package/scripts/dev.cjs +23 -0
  52. package/scripts/enable-totp-mfa.mjs +57 -0
  53. package/scripts/install-workers.sh +87 -0
  54. package/scripts/run-eslint.cjs +43 -0
  55. package/scripts/update-compatibility-dates.cjs +124 -0
  56. package/scripts/update-markdown-versions.cjs +43 -0
  57. package/workers/audit-worker/package.json +1 -1
  58. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  59. package/workers/data-worker/package.json +1 -1
  60. package/workers/data-worker/wrangler.jsonc.example +1 -1
  61. package/workers/image-worker/package.json +1 -1
  62. package/workers/image-worker/src/image-worker.example.ts +36 -2
  63. package/workers/image-worker/wrangler.jsonc.example +1 -1
  64. package/workers/pdf-worker/package.json +1 -1
  65. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  66. package/workers/user-worker/package.json +1 -1
  67. package/workers/user-worker/wrangler.jsonc.example +1 -1
  68. package/wrangler.toml.example +1 -1
  69. package/app/components/auth/mfa-enrollment.module.css +0 -276
  70. package/app/components/auth/mfa-verification.module.css +0 -259
  71. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -34
  72. package/app/components/navbar/case-modals/delete-case-modal.module.css +0 -9
  73. package/app/components/navbar/case-modals/export-case-modal.module.css +0 -27
  74. package/app/components/navbar/case-modals/export-confirmations-modal.module.css +0 -24
  75. package/app/components/navbar/case-modals/open-case-modal.module.css +0 -82
  76. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -9
  77. package/app/components/sidebar/files/delete-files-modal.module.css +0 -26
  78. package/app/components/sidebar/notes/notes-editor-modal.module.css +0 -49
  79. package/app/components/toolbar/toolbar-color-selector.module.css +0 -171
  80. package/app/components/user/delete-account.module.css +0 -277
  81. package/app/components/user/inactivity-warning.module.css +0 -148
  82. package/app/components/user/manage-profile.module.css +0 -192
  83. package/app/routes/auth/login.module.css +0 -523
  84. package/app/routes/auth/login.tsx +0 -705
  85. /package/app/components/{sidebar → navbar}/case-import/case-import.module.css +0 -0
  86. /package/app/components/{sidebar → navbar}/case-import/case-import.tsx +0 -0
  87. /package/app/components/{sidebar → navbar}/case-import/components/CasePreviewSection.tsx +0 -0
  88. /package/app/components/{sidebar → navbar}/case-import/components/ConfirmationDialog.tsx +0 -0
  89. /package/app/components/{sidebar → navbar}/case-import/components/ConfirmationPreviewSection.tsx +0 -0
  90. /package/app/components/{sidebar → navbar}/case-import/components/ExistingCaseSection.tsx +0 -0
  91. /package/app/components/{sidebar → navbar}/case-import/components/FileSelector.tsx +0 -0
  92. /package/app/components/{sidebar → navbar}/case-import/components/ProgressSection.tsx +0 -0
  93. /package/app/components/{sidebar → navbar}/case-import/hooks/useFilePreview.ts +0 -0
  94. /package/app/components/{sidebar → navbar}/case-import/hooks/useImportExecution.ts +0 -0
  95. /package/app/components/{sidebar → navbar}/case-import/hooks/useImportState.ts +0 -0
  96. /package/app/components/{sidebar → navbar}/case-import/index.ts +0 -0
  97. /package/app/components/{sidebar → navbar}/case-import/utils/file-validation.ts +0 -0
  98. /package/app/components/{sidebar/cases/cases-modal.module.css → navbar/case-modals/all-cases-modal.module.css} +0 -0
  99. /package/app/components/sidebar/notes/{class-details-shared.ts → class-details/class-details-shared.ts} +0 -0
  100. /package/app/components/sidebar/notes/{use-class-details-state.ts → class-details/use-class-details-state.ts} +0 -0
@@ -0,0 +1,124 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
5
+
6
+ function getCurrentDate() {
7
+ const now = new Date();
8
+ const year = now.getFullYear();
9
+ const month = String(now.getMonth() + 1).padStart(2, '0');
10
+ const day = String(now.getDate()).padStart(2, '0');
11
+ return `${year}-${month}-${day}`;
12
+ }
13
+
14
+ function replaceTomlCompatibilityDate(content, date) {
15
+ return content.replace(
16
+ /(compatibility_date\s*=\s*")\d{4}-\d{2}-\d{2}(")/,
17
+ `$1${date}$2`
18
+ );
19
+ }
20
+
21
+ function replaceJsoncCompatibilityDate(content, date) {
22
+ return content.replace(
23
+ /("compatibility_date"\s*:\s*")\d{4}-\d{2}-\d{2}(",?)/,
24
+ `$1${date}$2`
25
+ );
26
+ }
27
+
28
+ function updateFile(filePath, date, replacer) {
29
+ if (!fs.existsSync(filePath)) {
30
+ return { filePath, status: 'missing' };
31
+ }
32
+
33
+ const original = fs.readFileSync(filePath, 'utf8');
34
+ const updated = replacer(original, date);
35
+
36
+ if (original === updated) {
37
+ return { filePath, status: 'unchanged' };
38
+ }
39
+
40
+ fs.writeFileSync(filePath, updated, 'utf8');
41
+ return { filePath, status: 'updated' };
42
+ }
43
+
44
+ function updateCompatibilityDates(date = getCurrentDate()) {
45
+ if (!DATE_PATTERN.test(date)) {
46
+ throw new Error(`Invalid date format: ${date}. Use YYYY-MM-DD.`);
47
+ }
48
+
49
+ const rootDir = path.resolve(__dirname, '..');
50
+ const workersDir = path.join(rootDir, 'workers');
51
+
52
+ const results = [];
53
+
54
+ results.push(
55
+ updateFile(
56
+ path.join(rootDir, 'wrangler.toml'),
57
+ date,
58
+ replaceTomlCompatibilityDate
59
+ )
60
+ );
61
+
62
+ results.push(
63
+ updateFile(
64
+ path.join(rootDir, 'wrangler.toml.example'),
65
+ date,
66
+ replaceTomlCompatibilityDate
67
+ )
68
+ );
69
+
70
+ if (fs.existsSync(workersDir)) {
71
+ const workerDirs = fs
72
+ .readdirSync(workersDir, { withFileTypes: true })
73
+ .filter((entry) => entry.isDirectory())
74
+ .map((entry) => entry.name);
75
+
76
+ for (const workerDir of workerDirs) {
77
+ const workerPath = path.join(workersDir, workerDir);
78
+ results.push(
79
+ updateFile(
80
+ path.join(workerPath, 'wrangler.jsonc.example'),
81
+ date,
82
+ replaceJsoncCompatibilityDate
83
+ )
84
+ );
85
+ results.push(
86
+ updateFile(
87
+ path.join(workerPath, 'wrangler.jsonc'),
88
+ date,
89
+ replaceJsoncCompatibilityDate
90
+ )
91
+ );
92
+ }
93
+ }
94
+
95
+ const updatedCount = results.filter((result) => result.status === 'updated').length;
96
+ const unchangedCount = results.filter((result) => result.status === 'unchanged').length;
97
+ const missingCount = results.filter((result) => result.status === 'missing').length;
98
+
99
+ console.log(`Updated compatibility dates to ${date}`);
100
+ console.log(`- Updated: ${updatedCount}`);
101
+ console.log(`- Unchanged: ${unchangedCount}`);
102
+ console.log(`- Missing: ${missingCount}`);
103
+
104
+ for (const result of results) {
105
+ if (result.status !== 'updated') {
106
+ console.log(` ${result.status.toUpperCase()}: ${path.relative(rootDir, result.filePath)}`);
107
+ }
108
+ }
109
+
110
+ return results;
111
+ }
112
+
113
+ if (require.main === module) {
114
+ const dateArg = process.argv[2] || getCurrentDate();
115
+
116
+ try {
117
+ updateCompatibilityDates(dateArg);
118
+ } catch (error) {
119
+ console.error(error.message);
120
+ process.exit(1);
121
+ }
122
+ }
123
+
124
+ module.exports = { updateCompatibilityDates };
@@ -0,0 +1,43 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const packageJson = require('../package.json');
4
+
5
+ const markdownFiles = [
6
+ '.github/SECURITY.md',
7
+ // Add other markdown files that need version updates
8
+ ];
9
+
10
+ function updateMarkdownVersions() {
11
+ console.log(`📝 Updating markdown files with version ${packageJson.version}...`);
12
+
13
+ markdownFiles.forEach(filePath => {
14
+ const fullPath = path.join(__dirname, '..', filePath);
15
+
16
+ if (!fs.existsSync(fullPath)) {
17
+ console.log(`⚠️ Skipping ${filePath} (file not found)`);
18
+ return;
19
+ }
20
+
21
+ try {
22
+ let content = fs.readFileSync(fullPath, 'utf8');
23
+
24
+ // Replace version placeholders
25
+ content = content.replace(/{{VERSION}}/g, packageJson.version);
26
+ content = content.replace(/v\d+\.\d+\.\d+(-\w+)?/g, `v${packageJson.version}`);
27
+
28
+ fs.writeFileSync(fullPath, content);
29
+ console.log(`✅ Updated ${filePath}`);
30
+ } catch (error) {
31
+ console.error(`❌ Error updating ${filePath}:`, error.message);
32
+ }
33
+ });
34
+
35
+ console.log('🎉 Markdown version update complete!');
36
+ }
37
+
38
+ // Run if called directly
39
+ if (require.main === module) {
40
+ updateMarkdownVersions();
41
+ }
42
+
43
+ module.exports = { updateMarkdownVersions };
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.77.0"
12
+ "wrangler": "^4.78.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",
@@ -7,7 +7,7 @@
7
7
  "name": "AUDIT_WORKER_NAME",
8
8
  "account_id": "ACCOUNT_ID",
9
9
  "main": "src/audit-worker.ts",
10
- "compatibility_date": "2026-03-29",
10
+ "compatibility_date": "2026-03-31",
11
11
  "compatibility_flags": [
12
12
  "nodejs_compat"
13
13
  ],
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.77.0"
12
+ "wrangler": "^4.78.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",
@@ -5,7 +5,7 @@
5
5
  "name": "DATA_WORKER_NAME",
6
6
  "account_id": "ACCOUNT_ID",
7
7
  "main": "src/data-worker.ts",
8
- "compatibility_date": "2026-03-29",
8
+ "compatibility_date": "2026-03-31",
9
9
  "compatibility_flags": [
10
10
  "nodejs_compat"
11
11
  ],
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.77.0"
12
+ "wrangler": "^4.78.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",
@@ -14,6 +14,7 @@ interface Env {
14
14
  DATA_AT_REST_ENCRYPTION_ACTIVE_KEY_ID?: string;
15
15
  IMAGE_SIGNED_URL_SECRET?: string;
16
16
  IMAGE_SIGNED_URL_TTL_SECONDS?: string;
17
+ IMAGE_SIGNED_URL_BASE_URL?: string;
17
18
  }
18
19
 
19
20
  interface KeyRegistryPayload {
@@ -383,6 +384,25 @@ function requireSignedUrlConfig(env: Env): void {
383
384
  }
384
385
  }
385
386
 
387
+ function parseSignedUrlBaseUrl(raw: string): string {
388
+ let parsed: URL;
389
+ try {
390
+ parsed = new URL(raw.trim());
391
+ } catch {
392
+ throw new Error(`IMAGE_SIGNED_URL_BASE_URL is not a valid absolute URL: "${raw}"`);
393
+ }
394
+
395
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
396
+ throw new Error(`IMAGE_SIGNED_URL_BASE_URL must use http or https, got: "${parsed.protocol}"`);
397
+ }
398
+
399
+ if (parsed.search || parsed.hash) {
400
+ throw new Error(`IMAGE_SIGNED_URL_BASE_URL must not include a query string or fragment: "${raw}"`);
401
+ }
402
+
403
+ return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
404
+ }
405
+
386
406
  async function getSignedUrlHmacKey(env: Env): Promise<CryptoKey> {
387
407
  const resolvedSecret = (env.IMAGE_SIGNED_URL_SECRET || env.IMAGES_API_TOKEN || '').trim();
388
408
  const keyBytes = new TextEncoder().encode(resolvedSecret);
@@ -592,8 +612,22 @@ async function handleSignedUrlMinting(request: Request, env: Env, fileId: string
592
612
  };
593
613
 
594
614
  const signedToken = await signSignedAccessPayload(payload, env);
595
- const signedPath = `/${encodeURIComponent(fileId)}?st=${encodeURIComponent(signedToken)}`;
596
- const signedUrl = new URL(signedPath, request.url).toString();
615
+
616
+ let baseUrl: string;
617
+ if (env.IMAGE_SIGNED_URL_BASE_URL) {
618
+ try {
619
+ baseUrl = parseSignedUrlBaseUrl(env.IMAGE_SIGNED_URL_BASE_URL);
620
+ } catch (error) {
621
+ console.error('Invalid IMAGE_SIGNED_URL_BASE_URL configuration', {
622
+ reason: error instanceof Error ? error.message : String(error)
623
+ });
624
+ return createJsonResponse({ error: 'Signed URL base URL is misconfigured' }, 500);
625
+ }
626
+ } else {
627
+ baseUrl = new URL(request.url).origin;
628
+ }
629
+
630
+ const signedUrl = `${baseUrl}/${encodeURIComponent(fileId)}?st=${encodeURIComponent(signedToken)}`;
597
631
 
598
632
  return createJsonResponse({
599
633
  success: true,
@@ -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-29",
5
+ "compatibility_date": "2026-03-31",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "devDependencies": {
12
12
  "@cloudflare/puppeteer": "^1.0.6",
13
- "wrangler": "^4.77.0"
13
+ "wrangler": "^4.78.0"
14
14
  },
15
15
  "overrides": {
16
16
  "undici": "7.24.1",
@@ -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-29",
5
+ "compatibility_date": "2026-03-31",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.77.0"
12
+ "wrangler": "^4.78.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",
@@ -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-29",
5
+ "compatibility_date": "2026-03-31",
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-29"
3
+ compatibility_date = "2026-03-31"
4
4
  compatibility_flags = ["nodejs_compat"]
5
5
  pages_build_output_dir = "./build/client"
6
6
 
@@ -1,276 +0,0 @@
1
- .overlay {
2
- position: fixed;
3
- top: 0;
4
- left: 0;
5
- right: 0;
6
- bottom: 0;
7
- background-color: rgba(0, 0, 0, 0.75);
8
- display: flex;
9
- align-items: center;
10
- justify-content: center;
11
- z-index: 1000;
12
- padding: 1rem;
13
- cursor: default;
14
- }
15
-
16
- .modal {
17
- background: white;
18
- border-radius: 12px;
19
- padding: 2rem;
20
- max-width: 500px;
21
- width: 100%;
22
- max-height: 90vh;
23
- overflow-y: auto;
24
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
25
- cursor: default;
26
- }
27
-
28
- .header {
29
- text-align: center;
30
- margin-bottom: 2rem;
31
- }
32
-
33
- .header h2 {
34
- color: #333;
35
- margin: 0 0 1rem 0;
36
- font-size: 1.5rem;
37
- font-weight: 600;
38
- }
39
-
40
- .header p {
41
- color: #666;
42
- margin: 0;
43
- line-height: 1.5;
44
- }
45
-
46
- .content {
47
- margin-bottom: 2rem;
48
- }
49
-
50
- .phoneStep,
51
- .codeStep {
52
- text-align: center;
53
- }
54
-
55
- .phoneStep h3,
56
- .codeStep h3 {
57
- color: #333;
58
- margin: 0 0 1rem 0;
59
- font-size: 1.2rem;
60
- font-weight: 500;
61
- }
62
-
63
- .input {
64
- width: 100%;
65
- padding: 1rem;
66
- border: 2px solid #e1e5e9;
67
- border-radius: 8px;
68
- font-size: 1rem;
69
- margin-bottom: 1rem;
70
- transition: border-color 0.2s ease;
71
- box-sizing: border-box;
72
- }
73
-
74
- .input:focus {
75
- outline: none;
76
- border-color: #4285f4;
77
- box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.1);
78
- }
79
-
80
- .input:disabled {
81
- background-color: #f5f5f5;
82
- cursor: not-allowed;
83
- }
84
-
85
- .note {
86
- color: #666;
87
- font-size: 0.9rem;
88
- margin: 0 0 1.5rem 0;
89
- line-height: 1.4;
90
- }
91
-
92
- .buttonGroup {
93
- display: flex;
94
- flex-direction: column;
95
- gap: 0.75rem;
96
- }
97
-
98
- .primaryButton {
99
- background-color: #4285f4;
100
- color: white;
101
- border: none;
102
- padding: 1rem 2rem;
103
- border-radius: 8px;
104
- font-size: 1rem;
105
- font-weight: 500;
106
- cursor: pointer;
107
- transition: background-color 0.2s ease;
108
- width: 100%;
109
- box-sizing: border-box;
110
- }
111
-
112
- .primaryButton:hover:not(:disabled) {
113
- background-color: #3367d6;
114
- }
115
-
116
- .primaryButton:disabled {
117
- background-color: #ccc;
118
- cursor: not-allowed;
119
- }
120
-
121
- .secondaryButton {
122
- background-color: transparent;
123
- color: #4285f4;
124
- border: 2px solid #4285f4;
125
- padding: 0.75rem 1.5rem;
126
- border-radius: 8px;
127
- font-size: 0.9rem;
128
- font-weight: 500;
129
- cursor: pointer;
130
- transition: all 0.2s ease;
131
- width: 100%;
132
- box-sizing: border-box;
133
- }
134
-
135
- .secondaryButton:hover:not(:disabled) {
136
- background-color: #4285f4;
137
- color: white;
138
- }
139
-
140
- .secondaryButton:disabled {
141
- border-color: #ccc;
142
- color: #ccc;
143
- cursor: not-allowed;
144
- }
145
-
146
- .resendTimer {
147
- color: #999;
148
- font-size: 0.9rem;
149
- margin: 0;
150
- text-align: center;
151
- }
152
-
153
- .errorMessage {
154
- background: linear-gradient(
155
- 135deg,
156
- color-mix(in lab, var(--error) 12%, transparent),
157
- color-mix(in lab, var(--error) 8%, transparent)
158
- );
159
- border: 1px solid color-mix(in lab, var(--error) 30%, transparent);
160
- border-left: 4px solid var(--error);
161
- color: color-mix(in lab, var(--error) 90%, var(--black));
162
- padding: var(--spaceL);
163
- border-radius: var(--spaceS);
164
- font-size: 0.9rem;
165
- margin-bottom: 1rem;
166
- text-align: left;
167
- line-height: 1.4;
168
- position: relative;
169
- overflow: hidden;
170
- box-shadow: 0 4px 16px color-mix(in lab, var(--text) 10%, transparent);
171
- animation: slideInError var(--durationM) var(--bezierFastoutSlowin);
172
- }
173
-
174
- .errorMessage::before {
175
- content: "";
176
- position: absolute;
177
- top: 0;
178
- left: 0;
179
- right: 0;
180
- height: 2px;
181
- background: linear-gradient(
182
- 90deg,
183
- var(--error),
184
- color-mix(in lab, var(--error) 60%, transparent)
185
- );
186
- animation: shimmer 2s ease-in-out infinite;
187
- }
188
-
189
- .errorMessage:empty {
190
- display: none;
191
- }
192
-
193
- .footer {
194
- border-top: 1px solid #e1e5e9;
195
- padding-top: 1.5rem;
196
- text-align: center;
197
- }
198
-
199
- .skipButton {
200
- background-color: transparent;
201
- color: #666;
202
- border: none;
203
- padding: 0.75rem 1.5rem;
204
- border-radius: 8px;
205
- font-size: 0.9rem;
206
- cursor: pointer;
207
- transition: color 0.2s ease;
208
- }
209
-
210
- .skipButton:hover:not(:disabled) {
211
- color: #333;
212
- text-decoration: underline;
213
- }
214
-
215
- .skipButton:disabled {
216
- color: #ccc;
217
- cursor: not-allowed;
218
- }
219
-
220
- .signOutContainer {
221
- margin-top: var(--spaceL);
222
- padding-top: var(--spaceL);
223
- border-top: 1px solid var(--borderLight);
224
- text-align: center;
225
- }
226
-
227
- .signOutText {
228
- color: var(--textLight);
229
- font-size: var(--fontSizeSmall);
230
- margin-bottom: var(--spaceM);
231
- }
232
-
233
- /* Animations */
234
- @keyframes slideInError {
235
- from {
236
- opacity: 0;
237
- transform: translateY(calc(-1 * var(--spaceM))) scaleY(0.8);
238
- max-height: 0;
239
- padding-top: 0;
240
- padding-bottom: 0;
241
- margin-top: 0;
242
- margin-bottom: 0;
243
- }
244
- to {
245
- opacity: 1;
246
- transform: translateY(0) scaleY(1);
247
- max-height: 200px;
248
- padding-top: var(--spaceL);
249
- padding-bottom: var(--spaceL);
250
- margin-top: 0;
251
- margin-bottom: 1rem;
252
- }
253
- }
254
-
255
- @keyframes shimmer {
256
- 0%,
257
- 100% {
258
- opacity: 0.6;
259
- transform: translateX(-100%);
260
- }
261
- 50% {
262
- opacity: 1;
263
- transform: translateX(100%);
264
- }
265
- }
266
-
267
- /* Reduce motion for accessibility */
268
- @media (prefers-reduced-motion: reduce) {
269
- .errorMessage {
270
- animation: none;
271
- }
272
-
273
- .errorMessage::before {
274
- animation: none;
275
- }
276
- }