@striae-org/striae 3.0.4

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 (223) hide show
  1. package/.env.example +100 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +18 -0
  4. package/README.md +133 -0
  5. package/app/components/actions/case-export/core-export.ts +328 -0
  6. package/app/components/actions/case-export/data-processing.ts +167 -0
  7. package/app/components/actions/case-export/download-handlers.ts +900 -0
  8. package/app/components/actions/case-export/index.ts +41 -0
  9. package/app/components/actions/case-export/metadata-helpers.ts +107 -0
  10. package/app/components/actions/case-export/types-constants.ts +56 -0
  11. package/app/components/actions/case-export/validation-utils.ts +25 -0
  12. package/app/components/actions/case-export.ts +4 -0
  13. package/app/components/actions/case-import/annotation-import.ts +35 -0
  14. package/app/components/actions/case-import/confirmation-import.ts +363 -0
  15. package/app/components/actions/case-import/image-operations.ts +61 -0
  16. package/app/components/actions/case-import/index.ts +39 -0
  17. package/app/components/actions/case-import/orchestrator.ts +420 -0
  18. package/app/components/actions/case-import/storage-operations.ts +270 -0
  19. package/app/components/actions/case-import/validation.ts +189 -0
  20. package/app/components/actions/case-import/zip-processing.ts +413 -0
  21. package/app/components/actions/case-manage.ts +524 -0
  22. package/app/components/actions/case-review.ts +4 -0
  23. package/app/components/actions/confirm-export.ts +351 -0
  24. package/app/components/actions/generate-pdf.ts +210 -0
  25. package/app/components/actions/image-manage.ts +385 -0
  26. package/app/components/actions/notes-manage.ts +33 -0
  27. package/app/components/actions/signout.module.css +15 -0
  28. package/app/components/actions/signout.tsx +50 -0
  29. package/app/components/audit/user-audit-viewer.tsx +975 -0
  30. package/app/components/audit/user-audit.module.css +568 -0
  31. package/app/components/auth/auth-provider.tsx +78 -0
  32. package/app/components/auth/mfa-enrollment.module.css +268 -0
  33. package/app/components/auth/mfa-enrollment.tsx +398 -0
  34. package/app/components/auth/mfa-verification.module.css +251 -0
  35. package/app/components/auth/mfa-verification.tsx +295 -0
  36. package/app/components/button/button.module.css +63 -0
  37. package/app/components/button/button.tsx +46 -0
  38. package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
  39. package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
  40. package/app/components/canvas/canvas.module.css +314 -0
  41. package/app/components/canvas/canvas.tsx +449 -0
  42. package/app/components/canvas/confirmation/confirmation.module.css +187 -0
  43. package/app/components/canvas/confirmation/confirmation.tsx +214 -0
  44. package/app/components/colors/colors.module.css +59 -0
  45. package/app/components/colors/colors.tsx +68 -0
  46. package/app/components/form/base-form.tsx +21 -0
  47. package/app/components/form/form-button.tsx +28 -0
  48. package/app/components/form/form-field.tsx +53 -0
  49. package/app/components/form/form-message.tsx +17 -0
  50. package/app/components/form/form-toggle.tsx +23 -0
  51. package/app/components/form/form.module.css +427 -0
  52. package/app/components/form/index.ts +6 -0
  53. package/app/components/icon/icon.module.css +3 -0
  54. package/app/components/icon/icon.tsx +27 -0
  55. package/app/components/icon/icons.svg +102 -0
  56. package/app/components/icon/manifest.json +110 -0
  57. package/app/components/sidebar/case-export/case-export.module.css +386 -0
  58. package/app/components/sidebar/case-export/case-export.tsx +317 -0
  59. package/app/components/sidebar/case-import/case-import.module.css +626 -0
  60. package/app/components/sidebar/case-import/case-import.tsx +404 -0
  61. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
  62. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
  63. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
  64. package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
  65. package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
  66. package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
  67. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
  68. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
  69. package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
  70. package/app/components/sidebar/case-import/index.ts +18 -0
  71. package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
  72. package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
  73. package/app/components/sidebar/cases/cases-modal.module.css +166 -0
  74. package/app/components/sidebar/cases/cases-modal.tsx +201 -0
  75. package/app/components/sidebar/cases/cases.module.css +713 -0
  76. package/app/components/sidebar/files/files-modal.module.css +209 -0
  77. package/app/components/sidebar/files/files-modal.tsx +239 -0
  78. package/app/components/sidebar/hash/hash-utility.module.css +366 -0
  79. package/app/components/sidebar/hash/hash-utility.tsx +982 -0
  80. package/app/components/sidebar/notes/notes-modal.tsx +51 -0
  81. package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
  82. package/app/components/sidebar/notes/notes.module.css +360 -0
  83. package/app/components/sidebar/sidebar-container.tsx +149 -0
  84. package/app/components/sidebar/sidebar.module.css +321 -0
  85. package/app/components/sidebar/sidebar.tsx +215 -0
  86. package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
  87. package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
  88. package/app/components/theme-provider/theme-provider.tsx +131 -0
  89. package/app/components/theme-provider/theme.ts +155 -0
  90. package/app/components/toast/toast.module.css +137 -0
  91. package/app/components/toast/toast.tsx +56 -0
  92. package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
  93. package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
  94. package/app/components/toolbar/toolbar.module.css +42 -0
  95. package/app/components/toolbar/toolbar.tsx +167 -0
  96. package/app/components/user/delete-account.module.css +274 -0
  97. package/app/components/user/delete-account.tsx +471 -0
  98. package/app/components/user/inactivity-warning.module.css +145 -0
  99. package/app/components/user/inactivity-warning.tsx +84 -0
  100. package/app/components/user/manage-profile.module.css +190 -0
  101. package/app/components/user/manage-profile.tsx +253 -0
  102. package/app/components/user/mfa-phone-update.tsx +739 -0
  103. package/app/config-example/admin-service.json +13 -0
  104. package/app/config-example/config.json +17 -0
  105. package/app/config-example/firebase.ts +21 -0
  106. package/app/config-example/inactivity.ts +13 -0
  107. package/app/config-example/meta-config.json +6 -0
  108. package/app/contexts/auth.context.ts +12 -0
  109. package/app/entry.client.tsx +12 -0
  110. package/app/entry.server.tsx +44 -0
  111. package/app/hooks/useInactivityTimeout.ts +110 -0
  112. package/app/root.tsx +170 -0
  113. package/app/routes/_index.tsx +16 -0
  114. package/app/routes/auth/emailActionHandler.module.css +232 -0
  115. package/app/routes/auth/emailActionHandler.tsx +405 -0
  116. package/app/routes/auth/emailVerification.tsx +120 -0
  117. package/app/routes/auth/login.module.css +523 -0
  118. package/app/routes/auth/login.tsx +654 -0
  119. package/app/routes/auth/passwordReset.module.css +274 -0
  120. package/app/routes/auth/passwordReset.tsx +154 -0
  121. package/app/routes/auth/route.ts +16 -0
  122. package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
  123. package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
  124. package/app/routes/mobile-prevented/route.ts +14 -0
  125. package/app/routes/striae/striae.module.css +30 -0
  126. package/app/routes/striae/striae.tsx +417 -0
  127. package/app/services/audit-export.service.ts +755 -0
  128. package/app/services/audit.service.ts +1454 -0
  129. package/app/services/firebase-errors.ts +106 -0
  130. package/app/services/firebase.ts +15 -0
  131. package/app/styles/legal-pages.module.css +113 -0
  132. package/app/styles/root.module.css +146 -0
  133. package/app/tailwind.css +225 -0
  134. package/app/types/annotations.ts +45 -0
  135. package/app/types/audit.ts +301 -0
  136. package/app/types/case.ts +90 -0
  137. package/app/types/export.ts +8 -0
  138. package/app/types/file.ts +30 -0
  139. package/app/types/import.ts +107 -0
  140. package/app/types/index.ts +24 -0
  141. package/app/types/user.ts +38 -0
  142. package/app/utils/SHA256.ts +461 -0
  143. package/app/utils/annotation-timestamp.ts +25 -0
  144. package/app/utils/audit-export-signature.ts +117 -0
  145. package/app/utils/auth-action-settings.ts +48 -0
  146. package/app/utils/auth.ts +34 -0
  147. package/app/utils/batch-operations.ts +135 -0
  148. package/app/utils/confirmation-signature.ts +193 -0
  149. package/app/utils/data-operations.ts +871 -0
  150. package/app/utils/device-detection.ts +5 -0
  151. package/app/utils/html-sanitizer.ts +80 -0
  152. package/app/utils/id-generator.ts +36 -0
  153. package/app/utils/meta.ts +48 -0
  154. package/app/utils/mfa-phone.ts +97 -0
  155. package/app/utils/mfa.ts +79 -0
  156. package/app/utils/password-policy.ts +28 -0
  157. package/app/utils/permissions.ts +562 -0
  158. package/app/utils/signature-utils.ts +160 -0
  159. package/app/utils/style.ts +83 -0
  160. package/app/utils/version.ts +5 -0
  161. package/firebase.json +11 -0
  162. package/functions/[[path]].ts +10 -0
  163. package/package.json +138 -0
  164. package/postcss.config.js +6 -0
  165. package/public/.well-known/publickey.info@striae.org.asc +17 -0
  166. package/public/.well-known/security.txt +7 -0
  167. package/public/_headers +28 -0
  168. package/public/_routes.json +13 -0
  169. package/public/assets/striae.jpg +0 -0
  170. package/public/clear.jpg +0 -0
  171. package/public/favicon.ico +0 -0
  172. package/public/favicon.svg +9 -0
  173. package/public/icon-256.png +0 -0
  174. package/public/icon-512.png +0 -0
  175. package/public/logo-dark.png +0 -0
  176. package/public/manifest.json +25 -0
  177. package/public/oin-badge.png +0 -0
  178. package/public/shortcut.png +0 -0
  179. package/public/social-image.png +0 -0
  180. package/public/striae-ascii.txt +10 -0
  181. package/scripts/deploy-all.sh +100 -0
  182. package/scripts/deploy-config.sh +940 -0
  183. package/scripts/deploy-pages.sh +34 -0
  184. package/scripts/deploy-worker-secrets.sh +215 -0
  185. package/scripts/dev.cjs +23 -0
  186. package/scripts/install-workers.sh +88 -0
  187. package/scripts/run-eslint.cjs +35 -0
  188. package/scripts/update-compatibility-dates.cjs +124 -0
  189. package/scripts/update-markdown-versions.cjs +43 -0
  190. package/tailwind.config.ts +22 -0
  191. package/tsconfig.json +33 -0
  192. package/vite.config.ts +35 -0
  193. package/worker-configuration.d.ts +7490 -0
  194. package/workers/audit-worker/package.json +17 -0
  195. package/workers/audit-worker/src/audit-worker.example.ts +195 -0
  196. package/workers/audit-worker/worker-configuration.d.ts +7448 -0
  197. package/workers/audit-worker/wrangler.jsonc.example +29 -0
  198. package/workers/data-worker/package.json +17 -0
  199. package/workers/data-worker/src/data-worker.example.ts +267 -0
  200. package/workers/data-worker/src/signature-utils.ts +79 -0
  201. package/workers/data-worker/src/signing-payload-utils.ts +290 -0
  202. package/workers/data-worker/worker-configuration.d.ts +7448 -0
  203. package/workers/data-worker/wrangler.jsonc.example +30 -0
  204. package/workers/image-worker/package.json +17 -0
  205. package/workers/image-worker/src/image-worker.example.ts +180 -0
  206. package/workers/image-worker/worker-configuration.d.ts +7447 -0
  207. package/workers/image-worker/wrangler.jsonc.example +22 -0
  208. package/workers/keys-worker/package.json +17 -0
  209. package/workers/keys-worker/src/keys.example.ts +66 -0
  210. package/workers/keys-worker/src/keys.ts +66 -0
  211. package/workers/keys-worker/worker-configuration.d.ts +7447 -0
  212. package/workers/keys-worker/wrangler.jsonc.example +22 -0
  213. package/workers/pdf-worker/package.json +17 -0
  214. package/workers/pdf-worker/src/format-striae.ts +534 -0
  215. package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
  216. package/workers/pdf-worker/src/report-types.ts +69 -0
  217. package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
  218. package/workers/pdf-worker/wrangler.jsonc.example +26 -0
  219. package/workers/user-worker/package.json +17 -0
  220. package/workers/user-worker/src/user-worker.example.ts +636 -0
  221. package/workers/user-worker/worker-configuration.d.ts +7448 -0
  222. package/workers/user-worker/wrangler.jsonc.example +29 -0
  223. package/wrangler.toml.example +8 -0
@@ -0,0 +1,405 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { Link, useNavigate } from '@remix-run/react';
3
+ import {
4
+ applyActionCode,
5
+ checkActionCode,
6
+ confirmPasswordReset,
7
+ verifyPasswordResetCode,
8
+ } from 'firebase/auth';
9
+ import { auth } from '~/services/firebase';
10
+ import { handleAuthError } from '~/services/firebase-errors';
11
+ import { evaluatePasswordPolicy } from '~/utils/password-policy';
12
+ import { getSafeContinuePath } from '~/utils/auth-action-settings';
13
+ import { auditService } from '~/services/audit.service';
14
+ import { Icon } from '~/components/icon/icon';
15
+ import styles from './emailActionHandler.module.css';
16
+
17
+ interface EmailActionHandlerProps {
18
+ mode: string | null;
19
+ oobCode: string | null;
20
+ continueUrl: string | null;
21
+ lang: string | null;
22
+ }
23
+
24
+ type HandlerState = 'loading' | 'ready-reset' | 'success' | 'error' | 'unsupported';
25
+
26
+ const getUserAgent = (): string | undefined => {
27
+ if (typeof navigator === 'undefined') {
28
+ return undefined;
29
+ }
30
+
31
+ return navigator.userAgent;
32
+ };
33
+
34
+ const getPolicyFeedback = (password: string, confirmPassword: string): string => {
35
+ const policy = evaluatePasswordPolicy(password, confirmPassword);
36
+
37
+ return `Password must contain:
38
+ ${!policy.hasMinLength ? '❌' : '✅'} At least 10 characters
39
+ ${!policy.hasUpperCase ? '❌' : '✅'} Capital letters
40
+ ${!policy.hasNumber ? '❌' : '✅'} Numbers
41
+ ${!policy.hasSpecialChar ? '❌' : '✅'} Special characters
42
+ ${!policy.passwordsMatch ? '❌' : '✅'} Passwords must match`;
43
+ };
44
+
45
+ export const EmailActionHandler = ({ mode, oobCode, continueUrl, lang }: EmailActionHandlerProps) => {
46
+ const navigate = useNavigate();
47
+ const safeContinuePath = useMemo(() => getSafeContinuePath(continueUrl), [continueUrl]);
48
+
49
+ const [state, setState] = useState<HandlerState>('loading');
50
+ const [error, setError] = useState('');
51
+ const [success, setSuccess] = useState('');
52
+ const [resolvedEmail, setResolvedEmail] = useState('');
53
+ const [newPassword, setNewPassword] = useState('');
54
+ const [confirmPassword, setConfirmPassword] = useState('');
55
+ const [showNewPassword, setShowNewPassword] = useState(false);
56
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
57
+ const [passwordFeedback, setPasswordFeedback] = useState('');
58
+ const [isSubmittingReset, setIsSubmittingReset] = useState(false);
59
+
60
+ useEffect(() => {
61
+ let isMounted = true;
62
+
63
+ const initialize = async () => {
64
+ if (!mode || !oobCode) {
65
+ if (!isMounted) {
66
+ return;
67
+ }
68
+
69
+ setError('Invalid email action link.');
70
+ setState('error');
71
+ return;
72
+ }
73
+
74
+ setState('loading');
75
+ setError('');
76
+ setSuccess('');
77
+
78
+ if (mode === 'resetPassword') {
79
+ try {
80
+ const accountEmail = await verifyPasswordResetCode(auth, oobCode);
81
+ if (!isMounted) {
82
+ return;
83
+ }
84
+
85
+ setResolvedEmail(accountEmail);
86
+ setState('ready-reset');
87
+ } catch (err) {
88
+ const { message } = handleAuthError(err);
89
+ if (!isMounted) {
90
+ return;
91
+ }
92
+
93
+ setError(message);
94
+ setState('error');
95
+
96
+ try {
97
+ await auditService.logPasswordReset(
98
+ 'unknown-email',
99
+ 'email',
100
+ 'failure',
101
+ undefined,
102
+ 'email-link',
103
+ 1,
104
+ undefined,
105
+ undefined,
106
+ undefined,
107
+ [`Password reset link validation failed: ${message}`]
108
+ );
109
+ } catch (auditError) {
110
+ console.error('Failed to log password reset link validation failure:', auditError);
111
+ }
112
+ }
113
+
114
+ return;
115
+ }
116
+
117
+ if (mode === 'verifyEmail') {
118
+ let verificationEmail = '';
119
+
120
+ try {
121
+ const codeInfo = await checkActionCode(auth, oobCode);
122
+ verificationEmail = codeInfo.data.email ?? '';
123
+
124
+ await applyActionCode(auth, oobCode);
125
+
126
+ if (auth.currentUser) {
127
+ await auth.currentUser.reload();
128
+ }
129
+
130
+ await auditService.logEmailVerificationByEmail(
131
+ verificationEmail || 'unknown-email',
132
+ 'success',
133
+ 'email-link',
134
+ 1,
135
+ undefined,
136
+ getUserAgent(),
137
+ []
138
+ );
139
+
140
+ if (!isMounted) {
141
+ return;
142
+ }
143
+
144
+ setResolvedEmail(verificationEmail);
145
+ setSuccess('Email verified successfully. You can continue to Striae.');
146
+ setState('success');
147
+ } catch (err) {
148
+ const { message } = handleAuthError(err);
149
+
150
+ try {
151
+ await auditService.logEmailVerificationByEmail(
152
+ verificationEmail || 'unknown-email',
153
+ 'failure',
154
+ 'email-link',
155
+ 1,
156
+ undefined,
157
+ getUserAgent(),
158
+ [message]
159
+ );
160
+ } catch (auditError) {
161
+ console.error('Failed to log unauthenticated email verification failure:', auditError);
162
+ }
163
+
164
+ if (!isMounted) {
165
+ return;
166
+ }
167
+
168
+ setError(message);
169
+ setState('error');
170
+ }
171
+
172
+ return;
173
+ }
174
+
175
+ if (mode === 'recoverEmail') {
176
+ if (!isMounted) {
177
+ return;
178
+ }
179
+
180
+ setState('unsupported');
181
+ setError('Email change recovery is not supported for Striae accounts.');
182
+ return;
183
+ }
184
+
185
+ if (!isMounted) {
186
+ return;
187
+ }
188
+
189
+ setError('Unsupported email action.');
190
+ setState('error');
191
+ };
192
+
193
+ initialize();
194
+
195
+ return () => {
196
+ isMounted = false;
197
+ };
198
+ }, [mode, oobCode]);
199
+
200
+ const handlePasswordResetSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
201
+ event.preventDefault();
202
+
203
+ if (!oobCode) {
204
+ setError('Invalid password reset code.');
205
+ setState('error');
206
+ return;
207
+ }
208
+
209
+ const policy = evaluatePasswordPolicy(newPassword, confirmPassword);
210
+ setPasswordFeedback(getPolicyFeedback(newPassword, confirmPassword));
211
+
212
+ if (!policy.isStrong) {
213
+ setError('Password does not meet requirements.');
214
+ return;
215
+ }
216
+
217
+ setError('');
218
+ setIsSubmittingReset(true);
219
+
220
+ try {
221
+ await confirmPasswordReset(auth, oobCode, newPassword);
222
+
223
+ await auditService.logPasswordReset(
224
+ resolvedEmail || 'unknown-email',
225
+ 'email',
226
+ 'success',
227
+ undefined,
228
+ 'email-link',
229
+ 1,
230
+ true,
231
+ undefined,
232
+ undefined,
233
+ []
234
+ );
235
+
236
+ setSuccess('Password updated successfully. You can now log in with your new password.');
237
+ setState('success');
238
+ } catch (err) {
239
+ const { message } = handleAuthError(err);
240
+ setError(message);
241
+ const errorCode = err && typeof err === 'object' && 'code' in err
242
+ ? String(err.code)
243
+ : '';
244
+
245
+ try {
246
+ await auditService.logPasswordReset(
247
+ resolvedEmail || 'unknown-email',
248
+ 'email',
249
+ 'failure',
250
+ undefined,
251
+ 'email-link',
252
+ 1,
253
+ true,
254
+ undefined,
255
+ undefined,
256
+ [message]
257
+ );
258
+ } catch (auditError) {
259
+ console.error('Failed to log password reset completion failure:', auditError);
260
+ }
261
+
262
+ if (errorCode === 'auth/invalid-action-code' || errorCode === 'auth/expired-action-code') {
263
+ setState('error');
264
+ } else {
265
+ setState('ready-reset');
266
+ }
267
+ } finally {
268
+ setIsSubmittingReset(false);
269
+ }
270
+ };
271
+
272
+ const title = mode === 'resetPassword'
273
+ ? 'Reset Password'
274
+ : mode === 'verifyEmail'
275
+ ? 'Verify Email Address'
276
+ : 'Email Action';
277
+
278
+ const showContinueButton = state === 'success' && safeContinuePath !== '/';
279
+ const showLanguageHint = !!lang && lang.toLowerCase() !== 'en';
280
+
281
+ return (
282
+ <div className={styles.container}>
283
+ <Link
284
+ viewTransition
285
+ prefetch="intent"
286
+ to="https://striae.org"
287
+ className={styles.logoLink}
288
+ >
289
+ <div className={styles.logo} />
290
+ </Link>
291
+
292
+ <div className={styles.formWrapper}>
293
+ <h1 className={styles.title}>{title}</h1>
294
+
295
+ {resolvedEmail && (
296
+ <p className={styles.description}>Account: {resolvedEmail}</p>
297
+ )}
298
+
299
+ {showLanguageHint && (
300
+ <p className={styles.hint}>This page is currently shown in English.</p>
301
+ )}
302
+
303
+ {state === 'loading' && (
304
+ <p className={styles.description}>Validating email action link...</p>
305
+ )}
306
+
307
+ {error && <p className={styles.error}>{error}</p>}
308
+ {success && <p className={styles.success}>{success}</p>}
309
+
310
+ {state === 'ready-reset' && (
311
+ <form className={styles.form} onSubmit={handlePasswordResetSubmit}>
312
+ <div className={styles.passwordField}>
313
+ <input
314
+ type={showNewPassword ? 'text' : 'password'}
315
+ name="newPassword"
316
+ placeholder="New Password"
317
+ autoComplete="new-password"
318
+ className={styles.input}
319
+ required
320
+ value={newPassword}
321
+ onChange={(event) => {
322
+ const nextValue = event.target.value;
323
+ setNewPassword(nextValue);
324
+ setPasswordFeedback(getPolicyFeedback(nextValue, confirmPassword));
325
+ }}
326
+ />
327
+ <button
328
+ type="button"
329
+ className={styles.passwordToggle}
330
+ onClick={() => setShowNewPassword(!showNewPassword)}
331
+ aria-label={showNewPassword ? 'Hide password' : 'Show password'}
332
+ >
333
+ <Icon icon={showNewPassword ? 'eye-off' : 'eye'} />
334
+ </button>
335
+ </div>
336
+ <div className={styles.passwordField}>
337
+ <input
338
+ type={showConfirmPassword ? 'text' : 'password'}
339
+ name="confirmPassword"
340
+ placeholder="Confirm New Password"
341
+ autoComplete="new-password"
342
+ className={styles.input}
343
+ required
344
+ value={confirmPassword}
345
+ onChange={(event) => {
346
+ const nextValue = event.target.value;
347
+ setConfirmPassword(nextValue);
348
+ setPasswordFeedback(getPolicyFeedback(newPassword, nextValue));
349
+ }}
350
+ />
351
+ <button
352
+ type="button"
353
+ className={styles.passwordToggle}
354
+ onClick={() => setShowConfirmPassword(!showConfirmPassword)}
355
+ aria-label={showConfirmPassword ? 'Hide confirm password' : 'Show confirm password'}
356
+ >
357
+ <Icon icon={showConfirmPassword ? 'eye-off' : 'eye'} />
358
+ </button>
359
+ </div>
360
+ {passwordFeedback && (
361
+ <div className={styles.passwordFeedback}>
362
+ <pre>{passwordFeedback}</pre>
363
+ </div>
364
+ )}
365
+ <button
366
+ type="submit"
367
+ className={styles.button}
368
+ disabled={isSubmittingReset}
369
+ >
370
+ {isSubmittingReset ? 'Updating...' : 'Update Password'}
371
+ </button>
372
+ <button
373
+ type="button"
374
+ className={styles.secondaryButton}
375
+ onClick={() => navigate('/')}
376
+ >
377
+ Back to Login
378
+ </button>
379
+ </form>
380
+ )}
381
+
382
+ {(state === 'success' || state === 'error' || state === 'unsupported') && (
383
+ <div className={styles.actions}>
384
+ {showContinueButton && (
385
+ <button
386
+ type="button"
387
+ className={styles.button}
388
+ onClick={() => navigate(safeContinuePath)}
389
+ >
390
+ Continue
391
+ </button>
392
+ )}
393
+ <button
394
+ type="button"
395
+ className={styles.loginToStriaeButton}
396
+ onClick={() => navigate('/')}
397
+ >
398
+ Login to Striae
399
+ </button>
400
+ </div>
401
+ )}
402
+ </div>
403
+ </div>
404
+ );
405
+ };
@@ -0,0 +1,120 @@
1
+ import { useState } from 'react';
2
+ import { Link } from '@remix-run/react';
3
+ import { sendEmailVerification, User } from 'firebase/auth';
4
+ import { auditService } from '~/services/audit.service';
5
+ import { buildActionCodeSettings } from '~/utils/auth-action-settings';
6
+ import styles from './login.module.css';
7
+
8
+ interface EmailVerificationProps {
9
+ user: User;
10
+ onSignOut: () => void; // This will sign out the user and show the login form
11
+ error?: string;
12
+ success?: string;
13
+ onError: (error: string) => void;
14
+ onSuccess: (message: string) => void;
15
+ }
16
+
17
+ export const EmailVerification = ({
18
+ user,
19
+ onSignOut,
20
+ error,
21
+ success,
22
+ onError,
23
+ onSuccess
24
+ }: EmailVerificationProps) => {
25
+ const [isResending, setIsResending] = useState(false);
26
+ const [resendCooldown, setResendCooldown] = useState(0);
27
+
28
+ const handleResendVerification = async () => {
29
+ if (!user || resendCooldown > 0 || isResending) return;
30
+
31
+ setIsResending(true);
32
+ onError('');
33
+ onSuccess('');
34
+
35
+ try {
36
+ await sendEmailVerification(user, buildActionCodeSettings());
37
+
38
+ // Log email verification resend audit event
39
+ try {
40
+ await auditService.logEmailVerification(
41
+ user,
42
+ 'pending',
43
+ 'email-link',
44
+ 1, // Attempt number (could be tracked for multiple resends)
45
+ undefined, // No sessionId during verification
46
+ navigator.userAgent,
47
+ [] // No errors since we successfully sent the email
48
+ );
49
+ } catch (auditError) {
50
+ console.error('Failed to log email verification resend audit:', auditError);
51
+ // Continue even if audit logging fails
52
+ }
53
+
54
+ onSuccess('Verification email sent! Please check your inbox and spam folder.');
55
+
56
+ // Add 60-second cooldown to prevent spam
57
+ setResendCooldown(60);
58
+ const interval = setInterval(() => {
59
+ setResendCooldown((prev) => {
60
+ if (prev <= 1) {
61
+ clearInterval(interval);
62
+ return 0;
63
+ }
64
+ return prev - 1;
65
+ });
66
+ }, 1000);
67
+
68
+ } catch (error) {
69
+ console.error('Failed to resend verification email:', error);
70
+ onError('Failed to send verification email. Please try again later.');
71
+ } finally {
72
+ setIsResending(false);
73
+ }
74
+ };
75
+
76
+ return (
77
+ <div className={styles.container}>
78
+ <Link
79
+ viewTransition
80
+ prefetch="intent"
81
+ to="https://striae.org"
82
+ className={styles.logoLink}>
83
+ <div className={styles.logo} />
84
+ </Link>
85
+ <div className={styles.formWrapper}>
86
+ <h1 className={styles.title}>Email Verification Required</h1>
87
+ <p className={styles.verificationDescription}>Please check your email and verify your account before continuing.</p>
88
+
89
+ {error && <p className={styles.error}>{error}</p>}
90
+ {success && <p className={styles.success}>{success}</p>}
91
+
92
+ <div className={styles.verificationActions}>
93
+ <button
94
+ onClick={handleResendVerification}
95
+ className={styles.button}
96
+ disabled={isResending || resendCooldown > 0}
97
+ title={resendCooldown > 0 ? `Please wait ${resendCooldown} seconds` : undefined}
98
+ >
99
+ {isResending ? 'Sending...' : resendCooldown > 0 ? `Resend in ${resendCooldown}s` : 'Resend Verification Email'}
100
+ </button>
101
+ <button
102
+ onClick={onSignOut}
103
+ className={styles.loginToStriaeButton}
104
+ >
105
+ Login to Striae
106
+ </button>
107
+ </div>
108
+
109
+ <div className={styles.verificationHints}>
110
+ <p className={styles.hint}>Didn't receive the email?</p>
111
+ <ul className={styles.hintList}>
112
+ <li>Check your spam or junk folder</li>
113
+ <li>Make sure {user?.email} is correct</li>
114
+ <li>Add info@striae.org to your contacts</li>
115
+ </ul>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ };