@striae-org/striae 4.0.3 → 4.2.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 (118) hide show
  1. package/.env.example +8 -0
  2. package/app/components/actions/case-export/core-export.ts +14 -8
  3. package/app/components/actions/case-export/data-processing.ts +1 -0
  4. package/app/components/actions/case-export/download-handlers.ts +7 -0
  5. package/app/components/actions/case-export/metadata-helpers.ts +2 -1
  6. package/app/components/actions/case-import/confirmation-import.ts +12 -2
  7. package/app/components/actions/case-import/orchestrator.ts +78 -32
  8. package/app/components/actions/case-import/storage-operations.ts +97 -8
  9. package/app/components/actions/case-import/zip-processing.ts +159 -86
  10. package/app/components/actions/case-manage.ts +430 -8
  11. package/app/components/actions/confirm-export.ts +13 -4
  12. package/app/components/actions/generate-pdf.ts +10 -2
  13. package/app/components/actions/image-manage.ts +77 -44
  14. package/app/components/audit/user-audit-viewer.tsx +137 -945
  15. package/app/components/audit/user-audit.module.css +41 -0
  16. package/app/components/audit/viewer/audit-activity-summary.tsx +52 -0
  17. package/app/components/audit/viewer/audit-entries-list.tsx +207 -0
  18. package/app/components/audit/viewer/audit-filters-panel.tsx +307 -0
  19. package/app/components/audit/viewer/audit-user-info-card.tsx +44 -0
  20. package/app/components/audit/viewer/audit-viewer-header.tsx +55 -0
  21. package/app/components/audit/viewer/audit-viewer-utils.ts +123 -0
  22. package/app/components/audit/viewer/types.ts +1 -0
  23. package/app/components/audit/viewer/use-audit-viewer-data.ts +186 -0
  24. package/app/components/audit/viewer/use-audit-viewer-export.ts +176 -0
  25. package/app/components/audit/viewer/use-audit-viewer-filters.ts +141 -0
  26. package/app/components/auth/mfa-enrollment.module.css +13 -5
  27. package/app/components/auth/mfa-verification.module.css +13 -5
  28. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  29. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  30. package/app/components/canvas/canvas.module.css +64 -54
  31. package/app/components/canvas/canvas.tsx +17 -16
  32. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  33. package/app/components/canvas/confirmation/confirmation.tsx +17 -47
  34. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  35. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  36. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  37. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  38. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  39. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  40. package/app/components/navbar/navbar.module.css +447 -0
  41. package/app/components/navbar/navbar.tsx +377 -0
  42. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +2 -0
  43. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +21 -51
  44. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  45. package/app/components/sidebar/case-export/case-export.tsx +14 -77
  46. package/app/components/sidebar/case-import/case-import.module.css +25 -0
  47. package/app/components/sidebar/case-import/case-import.tsx +64 -40
  48. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  49. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  50. package/app/components/sidebar/cases/case-sidebar.tsx +25 -519
  51. package/app/components/sidebar/cases/cases-modal.module.css +45 -9
  52. package/app/components/sidebar/cases/cases-modal.tsx +16 -16
  53. package/app/components/sidebar/cases/cases.module.css +62 -21
  54. package/app/components/sidebar/files/files-modal.module.css +46 -10
  55. package/app/components/sidebar/files/files-modal.tsx +22 -23
  56. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  57. package/app/components/sidebar/notes/notes-editor-modal.tsx +66 -0
  58. package/app/components/sidebar/notes/notes-modal.tsx +18 -17
  59. package/app/components/sidebar/notes/notes-sidebar.tsx +199 -113
  60. package/app/components/sidebar/notes/notes.module.css +155 -0
  61. package/app/components/sidebar/sidebar-container.tsx +15 -28
  62. package/app/components/sidebar/sidebar.module.css +7 -71
  63. package/app/components/sidebar/sidebar.tsx +24 -125
  64. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  65. package/app/components/toast/toast.module.css +2 -1
  66. package/app/components/toast/toast.tsx +16 -11
  67. package/app/components/user/delete-account.tsx +10 -31
  68. package/app/components/user/inactivity-warning.module.css +9 -6
  69. package/app/components/user/inactivity-warning.tsx +15 -2
  70. package/app/components/user/manage-profile.module.css +2 -0
  71. package/app/components/user/manage-profile.tsx +108 -40
  72. package/app/hooks/useOverlayDismiss.ts +116 -0
  73. package/app/routes/auth/login.example.tsx +19 -8
  74. package/app/routes/auth/login.tsx +785 -774
  75. package/app/routes/auth/passwordReset.module.css +23 -13
  76. package/app/routes/striae/striae.module.css +10 -3
  77. package/app/routes/striae/striae.tsx +477 -31
  78. package/app/routes.ts +7 -0
  79. package/app/services/audit/audit-export-csv.ts +2 -0
  80. package/app/services/audit/audit.service.ts +202 -32
  81. package/app/services/audit/builders/audit-entry-builder.ts +2 -1
  82. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  83. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  84. package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -0
  85. package/app/services/audit/builders/index.ts +1 -0
  86. package/app/types/audit.ts +5 -2
  87. package/app/types/case.ts +29 -0
  88. package/app/types/import.ts +3 -0
  89. package/app/types/user.ts +1 -0
  90. package/app/utils/data/permissions.ts +17 -1
  91. package/app/utils/forensics/audit-export-signature.ts +5 -1
  92. package/app/utils/forensics/confirmation-signature.ts +3 -0
  93. package/app/utils/forensics/export-verification.ts +497 -22
  94. package/functions/api/pdf/[[path]].ts +32 -1
  95. package/load-context.ts +9 -0
  96. package/package.json +6 -2
  97. package/primershear.emails.example +6 -0
  98. package/scripts/deploy-pages-secrets.sh +6 -0
  99. package/scripts/deploy-primershear-emails.sh +167 -0
  100. package/worker-configuration.d.ts +7493 -7491
  101. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  102. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  103. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  104. package/workers/data-worker/wrangler.jsonc.example +1 -1
  105. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  106. package/workers/image-worker/wrangler.jsonc.example +1 -1
  107. package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
  108. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  109. package/workers/pdf-worker/src/formats/format-striae.ts +8 -7
  110. package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
  111. package/workers/pdf-worker/src/report-types.ts +3 -0
  112. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  113. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  114. package/workers/user-worker/src/user-worker.example.ts +6 -1
  115. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  116. package/workers/user-worker/wrangler.jsonc.example +1 -1
  117. package/wrangler.toml.example +1 -1
  118. package/public/.well-known/keybase.txt +0 -56
@@ -1,775 +1,786 @@
1
- import { useState, useEffect, useRef } from 'react';
2
- import { Link, useSearchParams, type MetaFunction } from 'react-router';
3
- import { auth } from '~/services/firebase';
4
- import {
5
- signInWithEmailAndPassword,
6
- createUserWithEmailAndPassword,
7
- onAuthStateChanged,
8
- sendEmailVerification,
9
- type User,
10
- updateProfile,
11
- getMultiFactorResolver,
12
- type MultiFactorResolver,
13
- type MultiFactorError
14
- } from 'firebase/auth';
15
- import { PasswordReset } from '~/routes/auth/passwordReset';
16
- import { EmailVerification } from '~/routes/auth/emailVerification';
17
- import { EmailActionHandler } from '~/routes/auth/emailActionHandler';
18
- import { handleAuthError } from '~/services/firebase/errors';
19
- import { MFAVerification } from '~/components/auth/mfa-verification';
20
- import { MFAEnrollment } from '~/components/auth/mfa-enrollment';
21
- import { Toast } from '~/components/toast/toast';
22
- import { Icon } from '~/components/icon/icon';
23
- import styles from './login.module.css';
24
- import { Striae } from '~/routes/striae/striae';
25
- import { getUserData, createUser } from '~/utils/data';
26
- import { auditService } from '~/services/audit';
27
- import { generateUniqueId } from '~/utils/common';
28
- import { evaluatePasswordPolicy, buildActionCodeSettings, userHasMFA } from '~/utils/auth';
29
-
30
- const APP_CANONICAL_ORIGIN = 'https://striae.app';
31
- const SOCIAL_IMAGE_PATH = '/social-image.png';
32
- const SOCIAL_IMAGE_ALT = 'Striae forensic annotation and comparison workspace';
33
- const LOGIN_PATH_ALIASES = new Set(['/auth', '/auth/', '/auth/login', '/auth/login/']);
34
-
35
- type AuthMetaContent = {
36
- title: string;
37
- description: string;
38
- robots: string;
39
- };
40
-
41
- const getCanonicalPath = (pathname: string): string => {
42
- if (!pathname || LOGIN_PATH_ALIASES.has(pathname)) {
43
- return '/';
44
- }
45
-
46
- return pathname.startsWith('/') ? pathname : `/${pathname}`;
47
- };
48
-
49
- const getAuthMetaContent = (mode: string | null, hasActionCode: boolean): AuthMetaContent => {
50
- if (!mode && !hasActionCode) {
51
- return {
52
- title: 'Striae: A Firearms Examiner\'s Comparison Companion',
53
- description: 'Sign in to Striae to access your comparison annotation workspace, case files, and review tools.',
54
- robots: 'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1',
55
- };
56
- }
57
-
58
- if (mode === 'resetPassword') {
59
- return {
60
- title: 'Striae | Reset Your Password',
61
- description: 'Use this secure page to reset your Striae account password and restore access to your workspace.',
62
- robots: 'noindex,nofollow,noarchive',
63
- };
64
- }
65
-
66
- if (mode === 'verifyEmail') {
67
- return {
68
- title: 'Striae | Verify Your Email Address',
69
- description: 'Confirm your email address to complete Striae account activation and continue securely.',
70
- robots: 'noindex,nofollow,noarchive',
71
- };
72
- }
73
-
74
- if (mode === 'recoverEmail') {
75
- return {
76
- title: 'Striae | Recover Email Access',
77
- description: 'Complete your Striae account email recovery steps securely.',
78
- robots: 'noindex,nofollow,noarchive',
79
- };
80
- }
81
-
82
- return {
83
- title: 'Striae | Account Action',
84
- description: 'Complete your Striae account action securely.',
85
- robots: 'noindex,nofollow,noarchive',
86
- };
87
- };
88
-
89
- export const meta: MetaFunction = ({ location }) => {
90
- const searchParams = new URLSearchParams(location.search);
91
- const mode = searchParams.get('mode');
92
- const hasActionCode = Boolean(searchParams.get('oobCode'));
93
-
94
- const canonicalPath = getCanonicalPath(location.pathname);
95
- const canonicalHref = `${APP_CANONICAL_ORIGIN}${canonicalPath}`;
96
- const socialImageHref = `${APP_CANONICAL_ORIGIN}${SOCIAL_IMAGE_PATH}`;
97
- const { title, description, robots } = getAuthMetaContent(mode, hasActionCode);
98
-
99
- return [
100
- { title },
101
- { name: 'description', content: description },
102
- { name: 'robots', content: robots },
103
- { property: 'og:site_name', content: 'Striae' },
104
- { property: 'og:type', content: 'website' },
105
- { property: 'og:url', content: canonicalHref },
106
- { property: 'og:title', content: title },
107
- { property: 'og:description', content: description },
108
- { property: 'og:image', content: socialImageHref },
109
- { property: 'og:image:secure_url', content: socialImageHref },
110
- { property: 'og:image:alt', content: SOCIAL_IMAGE_ALT },
111
- { name: 'twitter:card', content: 'summary_large_image' },
112
- { name: 'twitter:title', content: title },
113
- { name: 'twitter:description', content: description },
114
- { name: 'twitter:image', content: socialImageHref },
115
- { name: 'twitter:image:alt', content: SOCIAL_IMAGE_ALT },
116
- { tagName: 'link', rel: 'canonical', href: canonicalHref },
117
- ];
118
- };
119
-
120
- const SUPPORTED_EMAIL_ACTION_MODES = new Set(['resetPassword', 'verifyEmail', 'recoverEmail']);
121
-
122
- const getUserFirstName = (user: User): string => {
123
- const displayName = user.displayName?.trim();
124
- if (displayName) {
125
- const [firstName] = displayName.split(/\s+/);
126
- if (firstName) {
127
- return firstName;
128
- }
129
- }
130
-
131
- const emailPrefix = user.email?.split('@')[0]?.trim();
132
- if (emailPrefix) {
133
- return emailPrefix;
134
- }
135
-
136
- return 'User';
137
- };
138
-
139
- export const Login = () => {
140
- const [searchParams] = useSearchParams();
141
- const shouldShowWelcomeToastRef = useRef(false);
142
-
143
- const [error, setError] = useState('');
144
- const [success, setSuccess] = useState('');
145
- const [welcomeToastMessage, setWelcomeToastMessage] = useState('');
146
- const [isWelcomeToastVisible, setIsWelcomeToastVisible] = useState(false);
147
- const [isLogin, setIsLogin] = useState(true);
148
- const [isLoading, setIsLoading] = useState(false);
149
- const [isCheckingUser, setIsCheckingUser] = useState(false);
150
- const [user, setUser] = useState<User | null>(null);
151
- const [passwordStrength, setPasswordStrength] = useState('');
152
- const [isResetting, setIsResetting] = useState(false);
153
- const [showPassword, setShowPassword] = useState(false);
154
- const [showConfirmPassword, setShowConfirmPassword] = useState(false);
155
- const [isClient, setIsClient] = useState(false);
156
- const [firstName, setFirstName] = useState('');
157
- const [lastName, setLastName] = useState('');
158
- const [company, setCompany] = useState('');
159
- const [confirmPasswordValue, setConfirmPasswordValue] = useState('');
160
-
161
- // MFA state
162
- const [mfaResolver, setMfaResolver] = useState<MultiFactorResolver | null>(null);
163
- const [showMfaVerification, setShowMfaVerification] = useState(false);
164
- const [showMfaEnrollment, setShowMfaEnrollment] = useState(false);
165
-
166
- const actionMode = searchParams.get('mode');
167
- const actionCode = searchParams.get('oobCode');
168
- const continueUrl = searchParams.get('continueUrl');
169
- const actionLang = searchParams.get('lang');
170
-
171
- const shouldHandleEmailAction = Boolean(
172
- actionMode &&
173
- actionCode &&
174
- SUPPORTED_EMAIL_ACTION_MODES.has(actionMode)
175
- );
176
-
177
- // Check if we're on the client side
178
- useEffect(() => {
179
- setIsClient(true);
180
- }, []);
181
-
182
- // Email validation with regex
183
- const validateRegistrationEmail = (email: string): { valid: boolean } => {
184
- const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
185
-
186
- if (!emailRegex.test(email)) {
187
- return { valid: false };
188
- }
189
-
190
- return { valid: true };
191
- };
192
-
193
- const checkPasswordStrength = (password: string, confirmPassword?: string): boolean => {
194
- const normalizedConfirmPassword = confirmPassword ?? '';
195
- if (password.length === 0 && normalizedConfirmPassword.length === 0) {
196
- setPasswordStrength('');
197
- return false;
198
- }
199
-
200
- const policy = evaluatePasswordPolicy(password, confirmPassword);
201
-
202
- setPasswordStrength(
203
- `Password must contain:
204
- ${!policy.hasMinLength ? '❌' : '✅'} At least 10 characters
205
- ${!policy.hasUpperCase ? '❌' : '✅'} Capital letters
206
- ${!policy.hasNumber ? '❌' : '✅'} Numbers
207
- ${!policy.hasSpecialChar ? '❌' : '✅'} Special characters${confirmPassword !== undefined ? `
208
- ${!policy.passwordsMatch ? '❌' : '✅'} Passwords must match` : ''}`
209
- );
210
-
211
- return policy.isStrong;
212
- };
213
-
214
- // Check if user exists in the USER_DB using centralized function
215
- const checkUserExists = async (currentUser: User): Promise<boolean> => {
216
- try {
217
- const userData = await getUserData(currentUser);
218
-
219
- return userData !== null;
220
- } catch (error) {
221
- console.error('Error checking user existence:', error);
222
- // On network/API errors, throw error to prevent login
223
- throw new Error('System error. Please try logging in at a later time.');
224
- }
225
- };
226
-
227
- useEffect(() => {
228
- const unsubscribe = onAuthStateChanged(auth, async (user) => {
229
- if (user) {
230
- let currentUser = user;
231
-
232
- // Refresh auth profile so emailVerified is accurate right after email verification.
233
- try {
234
- await currentUser.reload();
235
- if (auth.currentUser) {
236
- currentUser = auth.currentUser;
237
- }
238
- } catch (reloadError) {
239
- console.error('Failed to refresh user verification status:', reloadError);
240
- }
241
-
242
- setUser(currentUser);
243
-
244
- if (!currentUser.emailVerified) {
245
- // Don't sign out immediately - let them see the verification prompt
246
- setError('');
247
- setSuccess('Please verify your email before continuing. Check your inbox for the verification link.');
248
- setShowMfaEnrollment(false);
249
- setIsCheckingUser(false);
250
- return;
251
- }
252
-
253
- // Check if user exists in the USER_DB
254
- setIsCheckingUser(true);
255
- try {
256
- const userExists = await checkUserExists(currentUser);
257
- setIsCheckingUser(false);
258
-
259
- if (!userExists) {
260
- handleSignOut();
261
- setError('This account does not exist or has been deleted');
262
- return;
263
- }
264
- } catch (error) {
265
- setIsCheckingUser(false);
266
- handleSignOut();
267
- setError(error instanceof Error ? error.message : 'System error. Please try logging in at a later time.');
268
- return;
269
- }
270
-
271
- // Check if user has MFA enrolled
272
- if (!userHasMFA(currentUser)) {
273
- // User has no MFA factors enrolled - require enrollment
274
- setShowMfaEnrollment(true);
275
- return;
276
- }
277
-
278
- console.log("User signed in:", currentUser.email);
279
- setShowMfaEnrollment(false);
280
-
281
- if (shouldShowWelcomeToastRef.current) {
282
- setWelcomeToastMessage(`Welcome to Striae, ${getUserFirstName(currentUser)}!`);
283
- setIsWelcomeToastVisible(true);
284
- shouldShowWelcomeToastRef.current = false;
285
- }
286
-
287
- // Log successful login audit
288
- try {
289
- const sessionId = `session_${currentUser.uid}_${Date.now()}_${generateUniqueId(8)}`;
290
- await auditService.logUserLogin(
291
- currentUser,
292
- sessionId,
293
- 'firebase',
294
- navigator.userAgent
295
- );
296
- } catch (auditError) {
297
- console.error('Failed to log user login audit:', auditError);
298
- // Continue with login even if audit logging fails
299
- }
300
- } else {
301
- setUser(null);
302
- setShowMfaEnrollment(false);
303
- setIsCheckingUser(false);
304
- setIsWelcomeToastVisible(false);
305
- shouldShowWelcomeToastRef.current = false;
306
- }
307
- });
308
-
309
- return () => unsubscribe();
310
- }, []);
311
-
312
- useEffect(() => {
313
- if (shouldHandleEmailAction) {
314
- return;
315
- }
316
-
317
- const currentUser = auth.currentUser;
318
- if (!currentUser) {
319
- return;
320
- }
321
-
322
- let isMounted = true;
323
-
324
- const syncMfaAfterEmailAction = async () => {
325
- try {
326
- await currentUser.reload();
327
- const refreshedUser = auth.currentUser ?? currentUser;
328
-
329
- if (!isMounted) {
330
- return;
331
- }
332
-
333
- setUser(refreshedUser);
334
-
335
- if (!refreshedUser.emailVerified) {
336
- return;
337
- }
338
-
339
- setShowMfaEnrollment(!userHasMFA(refreshedUser));
340
- } catch (refreshError) {
341
- console.error('Failed to sync MFA state after email action:', refreshError);
342
- }
343
- };
344
-
345
- void syncMfaAfterEmailAction();
346
-
347
- return () => {
348
- isMounted = false;
349
- };
350
- }, [shouldHandleEmailAction]);
351
-
352
- const handleSubmit = async (e: React.FormEvent) => {
353
- e.preventDefault();
354
- setIsLoading(true);
355
- setError('');
356
- setSuccess('');
357
-
358
- const formData = new FormData(e.currentTarget as HTMLFormElement);
359
- const email = formData.get('email') as string;
360
- const password = formData.get('password') as string;
361
- const confirmPassword = formData.get('confirmPassword') as string;
362
- // Use state values for these fields instead of FormData
363
- const formFirstName = firstName;
364
- const formLastName = lastName;
365
- const formCompany = company;
366
-
367
- try {
368
- if (!isLogin) {
369
- const emailValidation = validateRegistrationEmail(email);
370
- if (!emailValidation.valid) {
371
- setError('Please enter a valid email address');
372
- setIsLoading(false);
373
- return;
374
- }
375
-
376
- if (password !== confirmPassword) {
377
- setError('Passwords do not match');
378
- setIsLoading(false);
379
- return;
380
- }
381
-
382
- if (!checkPasswordStrength(password)) {
383
- setError('Password does not meet requirements');
384
- setIsLoading(false);
385
- return;
386
- }
387
- }
388
-
389
- if (!isLogin) {
390
- // Registration
391
- const createCredential = await createUserWithEmailAndPassword(auth, email, password);
392
- await updateProfile(createCredential.user, {
393
- displayName: `${formFirstName} ${formLastName}`
394
- });
395
-
396
- const companyName = formCompany.trim();
397
-
398
- // Create user data using centralized function
399
- await createUser(
400
- createCredential.user,
401
- formFirstName,
402
- formLastName,
403
- companyName || '',
404
- true
405
- );
406
-
407
- // Log user registration audit event
408
- try {
409
- await auditService.logUserRegistration(
410
- createCredential.user,
411
- formFirstName,
412
- formLastName,
413
- companyName || '',
414
- 'email-password',
415
- navigator.userAgent
416
- );
417
- } catch (auditError) {
418
- console.error('Failed to log user registration audit:', auditError);
419
- // Continue with registration flow even if audit logging fails
420
- }
421
-
422
- await sendEmailVerification(createCredential.user, buildActionCodeSettings());
423
-
424
- // Log email verification sent audit event
425
- try {
426
- // This logs that we sent the verification email, not that it was verified
427
- // The actual verification happens when user clicks the email link
428
- await auditService.logEmailVerification(
429
- createCredential.user,
430
- 'pending', // Status pending until user clicks verification link
431
- 'email-link',
432
- 1, // First attempt
433
- undefined, // No sessionId during registration
434
- navigator.userAgent,
435
- [] // No errors since we successfully sent the email
436
- );
437
- } catch (auditError) {
438
- console.error('Failed to log email verification audit:', auditError);
439
- // Continue with registration flow even if audit logging fails
440
- }
441
-
442
- setError('');
443
- setSuccess('Account created successfully! Please check your email to verify your account.');
444
- // Don't sign out - let user stay logged in but unverified to see verification screen
445
- } else {
446
- // Login
447
- shouldShowWelcomeToastRef.current = true;
448
- try {
449
- await signInWithEmailAndPassword(auth, email, password);
450
- } catch (loginError: unknown) {
451
- // Check if it's a Firebase Auth error with MFA requirement
452
- if (
453
- loginError &&
454
- typeof loginError === 'object' &&
455
- 'code' in loginError &&
456
- loginError.code === 'auth/multi-factor-auth-required'
457
- ) {
458
- // Handle MFA requirement
459
- const resolver = getMultiFactorResolver(auth, loginError as MultiFactorError);
460
- setMfaResolver(resolver);
461
- setShowMfaVerification(true);
462
- setIsLoading(false);
463
- return;
464
- }
465
- shouldShowWelcomeToastRef.current = false;
466
- throw loginError; // Re-throw non-MFA errors
467
- }
468
- }
469
- } catch (err) {
470
- shouldShowWelcomeToastRef.current = false;
471
- const { message } = handleAuthError(err);
472
- setError(message);
473
-
474
- // Log security violation for failed authentication attempts
475
- try {
476
- // Extract error details for audit
477
- const errorCode = err && typeof err === 'object' && 'code' in err ? err.code : 'unknown';
478
- const isAuthError = typeof errorCode === 'string' && errorCode.startsWith('auth/');
479
-
480
- if (isAuthError) {
481
- // Determine severity based on error type
482
- let severity: 'low' | 'medium' | 'high' | 'critical' = 'medium';
483
- let incidentType: 'unauthorized-access' | 'brute-force' | 'privilege-escalation' = 'unauthorized-access';
484
-
485
- if (errorCode === 'auth/too-many-requests') {
486
- severity = 'high';
487
- incidentType = 'brute-force';
488
- } else if (errorCode === 'auth/user-disabled') {
489
- severity = 'critical';
490
- }
491
-
492
- await auditService.logSecurityViolation(
493
- null, // No user object for failed auth
494
- incidentType,
495
- severity,
496
- `Failed authentication attempt: ${errorCode} - ${message}`,
497
- 'authentication-endpoint',
498
- true // Blocked by system
499
- );
500
- }
501
- } catch (auditError) {
502
- console.error('Failed to log security violation audit:', auditError);
503
- // Continue with error flow even if audit logging fails
504
- }
505
- } finally {
506
- setIsLoading(false);
507
- }
508
- };
509
-
510
- // Add proper sign out handling
511
- const handleSignOut = async () => {
512
- try {
513
- await auth.signOut();
514
- setUser(null);
515
- setIsLoading(false);
516
- setShowMfaEnrollment(false);
517
- setShowMfaVerification(false);
518
- setMfaResolver(null);
519
- setIsWelcomeToastVisible(false);
520
- shouldShowWelcomeToastRef.current = false;
521
- } catch (err) {
522
- console.error('Sign out error:', err);
523
- }
524
- };
525
-
526
- // MFA handlers
527
- const handleMfaSuccess = () => {
528
- setShowMfaVerification(false);
529
- setMfaResolver(null);
530
- // The auth state listener will handle the rest
531
- };
532
-
533
- const handleMfaError = (errorMessage: string) => {
534
- setError(errorMessage);
535
- };
536
-
537
- const handleMfaCancel = () => {
538
- setShowMfaVerification(false);
539
- setMfaResolver(null);
540
- setError('Authentication cancelled');
541
- };
542
-
543
- // MFA enrollment handlers
544
- const handleMfaEnrollmentSuccess = () => {
545
- setShowMfaEnrollment(false);
546
- setError('');
547
- // The auth state listener will re-evaluate the user's MFA status
548
- };
549
-
550
- const handleMfaEnrollmentError = (errorMessage: string) => {
551
- setError(errorMessage);
552
- };
553
-
554
- return (
555
- <>
556
- {shouldHandleEmailAction ? (
557
- <EmailActionHandler
558
- mode={actionMode}
559
- oobCode={actionCode}
560
- continueUrl={continueUrl}
561
- lang={actionLang}
562
- />
563
- ) : user ? (
564
- user.emailVerified ? (
565
- <Striae user={user} />
566
- ) : (
567
- <EmailVerification
568
- user={user}
569
- error={error}
570
- success={success}
571
- onError={setError}
572
- onSuccess={setSuccess}
573
- onSignOut={handleSignOut}
574
- />
575
- )
576
- ) : isResetting ? (
577
- <PasswordReset onBack={() => setIsResetting(false)}/>
578
- ) : (
579
- <div className={styles.container}>
580
- <Link
581
- viewTransition
582
- prefetch="intent"
583
- to="/"
584
- className={styles.logoLink}>
585
- <div className={styles.logo} />
586
- </Link>
587
- <div className={styles.formWrapper}>
588
- <h1 className={styles.title}>{isLogin ? 'Login to Striae' : 'Register a Striae Account'}</h1>
589
-
590
- <form onSubmit={handleSubmit} className={styles.form}>
591
- <input
592
- type="email"
593
- name="email"
594
- placeholder={isLogin ? "Email" : "Email Address"}
595
- autoComplete="email"
596
- className={styles.input}
597
- required
598
- disabled={isLoading}
599
- />
600
- <div className={styles.passwordField}>
601
- <input
602
- type={showPassword ? "text" : "password"}
603
- name="password"
604
- placeholder="Password"
605
- autoComplete={isLogin ? "current-password" : "new-password"}
606
- className={styles.input}
607
- required
608
- disabled={isLoading}
609
- onChange={(e) => !isLogin && checkPasswordStrength(e.target.value, confirmPasswordValue)}
610
- />
611
- <button
612
- type="button"
613
- className={styles.passwordToggle}
614
- onClick={() => setShowPassword(!showPassword)}
615
- aria-label={showPassword ? "Hide password" : "Show password"}
616
- >
617
- <Icon icon={showPassword ? "eye-off" : "eye"} />
618
- </button>
619
- </div>
620
-
621
- {!isLogin && (
622
- <>
623
- <div className={styles.passwordField}>
624
- <input
625
- type={showConfirmPassword ? "text" : "password"}
626
- name="confirmPassword"
627
- placeholder="Confirm Password"
628
- autoComplete="new-password"
629
- className={styles.input}
630
- required
631
- disabled={isLoading}
632
- value={confirmPasswordValue}
633
- onChange={(e) => {
634
- setConfirmPasswordValue(e.target.value);
635
- const passwordInput = (e.target.form?.elements.namedItem('password') as HTMLInputElement);
636
- if (passwordInput) {
637
- checkPasswordStrength(passwordInput.value, e.target.value);
638
- }
639
- }}
640
- />
641
- <button
642
- type="button"
643
- className={styles.passwordToggle}
644
- onClick={() => setShowConfirmPassword(!showConfirmPassword)}
645
- aria-label={showConfirmPassword ? "Hide confirm password" : "Show confirm password"}
646
- >
647
- <Icon icon={showConfirmPassword ? "eye-off" : "eye"} />
648
- </button>
649
- </div>
650
-
651
- <input
652
- type="text"
653
- name="firstName"
654
- required
655
- placeholder="First Name (required)"
656
- autoComplete="given-name"
657
- className={styles.input}
658
- disabled={isLoading}
659
- value={firstName}
660
- onChange={(e) => setFirstName(e.target.value)}
661
- />
662
- <input
663
- type="text"
664
- name="lastName"
665
- required
666
- placeholder="Last Name (required)"
667
- autoComplete="family-name"
668
- className={styles.input}
669
- disabled={isLoading}
670
- value={lastName}
671
- onChange={(e) => setLastName(e.target.value)}
672
- />
673
- <input
674
- type="text"
675
- name="company"
676
- required
677
- placeholder="Company/Lab (required)"
678
- autoComplete="organization"
679
- className={styles.input}
680
- disabled={isLoading}
681
- value={company}
682
- onChange={(e) => setCompany(e.target.value)}
683
- />
684
- {passwordStrength && (
685
- <div className={styles.passwordStrength}>
686
- <pre>{passwordStrength}</pre>
687
- </div>
688
- )}
689
- </>
690
- )}
691
-
692
- {isLogin && (
693
- <button
694
- type="button"
695
- onClick={() => setIsResetting(true)}
696
- className={styles.resetLink}
697
- >
698
- Forgot Password?
699
- </button>
700
- )}
701
-
702
- {error && <p className={styles.error}>{error}</p>}
703
- {success && <p className={styles.success}>{success}</p>}
704
-
705
- <button
706
- type="submit"
707
- className={styles.button}
708
- disabled={isLoading || isCheckingUser}
709
- >
710
- {isCheckingUser
711
- ? 'Verifying account...'
712
- : isLoading
713
- ? 'Loading...'
714
- : isLogin
715
- ? 'Login'
716
- : 'Register'}
717
- </button>
718
- </form>
719
-
720
- <p className={styles.toggle}>
721
- {isLogin ? "Don't have an account? " : "Already have an account? "}
722
- <button
723
- onClick={() => {
724
- setIsLogin(!isLogin);
725
- setShowPassword(false);
726
- setShowConfirmPassword(false);
727
- setPasswordStrength('');
728
- setError('');
729
- setFirstName('');
730
- setLastName('');
731
- setCompany('');
732
- setConfirmPasswordValue('');
733
- }}
734
- className={styles.toggleButton}
735
- disabled={isLoading || isCheckingUser}
736
- >
737
- {isLogin ? 'Register' : 'Login'}
738
- </button>
739
- </p>
740
- </div>
741
- </div>
742
- )}
743
-
744
- {!shouldHandleEmailAction && isClient && showMfaVerification && mfaResolver && (
745
- <MFAVerification
746
- resolver={mfaResolver}
747
- onSuccess={handleMfaSuccess}
748
- onError={handleMfaError}
749
- onCancel={handleMfaCancel}
750
- />
751
- )}
752
-
753
- {!shouldHandleEmailAction && isClient && showMfaEnrollment && user && (
754
- <MFAEnrollment
755
- user={user}
756
- onSuccess={handleMfaEnrollmentSuccess}
757
- onError={handleMfaEnrollmentError}
758
- mandatory={true}
759
- />
760
- )}
761
-
762
- {!shouldHandleEmailAction && (
763
- <Toast
764
- message={welcomeToastMessage}
765
- type="success"
766
- isVisible={isWelcomeToastVisible}
767
- onClose={() => setIsWelcomeToastVisible(false)}
768
- />
769
- )}
770
-
771
- </>
772
- );
773
- };
774
-
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { Link, useSearchParams, type MetaFunction } from 'react-router';
3
+ import { auth } from '~/services/firebase';
4
+ import {
5
+ signInWithEmailAndPassword,
6
+ createUserWithEmailAndPassword,
7
+ onAuthStateChanged,
8
+ sendEmailVerification,
9
+ type User,
10
+ updateProfile,
11
+ getMultiFactorResolver,
12
+ type MultiFactorResolver,
13
+ type MultiFactorError
14
+ } from 'firebase/auth';
15
+ import { PasswordReset } from '~/routes/auth/passwordReset';
16
+ import { EmailVerification } from '~/routes/auth/emailVerification';
17
+ import { EmailActionHandler } from '~/routes/auth/emailActionHandler';
18
+ import { handleAuthError } from '~/services/firebase/errors';
19
+ import { MFAVerification } from '~/components/auth/mfa-verification';
20
+ import { MFAEnrollment } from '~/components/auth/mfa-enrollment';
21
+ import { Toast } from '~/components/toast/toast';
22
+ import { Icon } from '~/components/icon/icon';
23
+ import styles from './login.module.css';
24
+ import { Striae } from '~/routes/striae/striae';
25
+ import { getUserData, createUser } from '~/utils/data';
26
+ import { auditService } from '~/services/audit';
27
+ import { generateUniqueId } from '~/utils/common';
28
+ import { evaluatePasswordPolicy, buildActionCodeSettings, userHasMFA } from '~/utils/auth';
29
+ import type { UserData } from '~/types';
30
+
31
+ const APP_CANONICAL_ORIGIN = 'PAGES_CUSTOM_DOMAIN';
32
+ const SOCIAL_IMAGE_PATH = '/social-image.png';
33
+ const SOCIAL_IMAGE_ALT = 'Striae forensic annotation and comparison workspace';
34
+ const LOGIN_PATH_ALIASES = new Set(['/auth', '/auth/', '/auth/login', '/auth/login/']);
35
+
36
+ type AuthMetaContent = {
37
+ title: string;
38
+ description: string;
39
+ robots: string;
40
+ };
41
+
42
+ const getCanonicalPath = (pathname: string): string => {
43
+ if (!pathname || LOGIN_PATH_ALIASES.has(pathname)) {
44
+ return '/';
45
+ }
46
+
47
+ return pathname.startsWith('/') ? pathname : `/${pathname}`;
48
+ };
49
+
50
+ const getAuthMetaContent = (mode: string | null, hasActionCode: boolean): AuthMetaContent => {
51
+ if (!mode && !hasActionCode) {
52
+ return {
53
+ title: 'Striae: A Firearms Examiner\'s Comparison Companion',
54
+ description: 'Sign in to Striae to access your comparison annotation workspace, case files, and review tools.',
55
+ robots: 'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1',
56
+ };
57
+ }
58
+
59
+ if (mode === 'resetPassword') {
60
+ return {
61
+ title: 'Striae | Reset Your Password',
62
+ description: 'Use this secure page to reset your Striae account password and restore access to your workspace.',
63
+ robots: 'noindex,nofollow,noarchive',
64
+ };
65
+ }
66
+
67
+ if (mode === 'verifyEmail') {
68
+ return {
69
+ title: 'Striae | Verify Your Email Address',
70
+ description: 'Confirm your email address to complete Striae account activation and continue securely.',
71
+ robots: 'noindex,nofollow,noarchive',
72
+ };
73
+ }
74
+
75
+ if (mode === 'recoverEmail') {
76
+ return {
77
+ title: 'Striae | Recover Email Access',
78
+ description: 'Complete your Striae account email recovery steps securely.',
79
+ robots: 'noindex,nofollow,noarchive',
80
+ };
81
+ }
82
+
83
+ return {
84
+ title: 'Striae | Account Action',
85
+ description: 'Complete your Striae account action securely.',
86
+ robots: 'noindex,nofollow,noarchive',
87
+ };
88
+ };
89
+
90
+ export const meta: MetaFunction = ({ location }) => {
91
+ const searchParams = new URLSearchParams(location.search);
92
+ const mode = searchParams.get('mode');
93
+ const hasActionCode = Boolean(searchParams.get('oobCode'));
94
+
95
+ const canonicalPath = getCanonicalPath(location.pathname);
96
+ const canonicalHref = `${APP_CANONICAL_ORIGIN}${canonicalPath}`;
97
+ const socialImageHref = `${APP_CANONICAL_ORIGIN}${SOCIAL_IMAGE_PATH}`;
98
+ const { title, description, robots } = getAuthMetaContent(mode, hasActionCode);
99
+
100
+ return [
101
+ { title },
102
+ { name: 'description', content: description },
103
+ { name: 'robots', content: robots },
104
+ { property: 'og:site_name', content: 'Striae' },
105
+ { property: 'og:type', content: 'website' },
106
+ { property: 'og:url', content: canonicalHref },
107
+ { property: 'og:title', content: title },
108
+ { property: 'og:description', content: description },
109
+ { property: 'og:image', content: socialImageHref },
110
+ { property: 'og:image:secure_url', content: socialImageHref },
111
+ { property: 'og:image:alt', content: SOCIAL_IMAGE_ALT },
112
+ { name: 'twitter:card', content: 'summary_large_image' },
113
+ { name: 'twitter:title', content: title },
114
+ { name: 'twitter:description', content: description },
115
+ { name: 'twitter:image', content: socialImageHref },
116
+ { name: 'twitter:image:alt', content: SOCIAL_IMAGE_ALT },
117
+ { tagName: 'link', rel: 'canonical', href: canonicalHref },
118
+ ];
119
+ };
120
+
121
+ const SUPPORTED_EMAIL_ACTION_MODES = new Set(['resetPassword', 'verifyEmail', 'recoverEmail']);
122
+
123
+ const getUserFirstName = (user: User): string => {
124
+ const displayName = user.displayName?.trim();
125
+ if (displayName) {
126
+ const [firstName] = displayName.split(/\s+/);
127
+ if (firstName) {
128
+ return firstName;
129
+ }
130
+ }
131
+
132
+ const emailPrefix = user.email?.split('@')[0]?.trim();
133
+ if (emailPrefix) {
134
+ return emailPrefix;
135
+ }
136
+
137
+ return 'User';
138
+ };
139
+
140
+ export const Login = () => {
141
+ const [searchParams] = useSearchParams();
142
+ const shouldShowWelcomeToastRef = useRef(false);
143
+
144
+ const [error, setError] = useState('');
145
+ const [success, setSuccess] = useState('');
146
+ const [welcomeToastMessage, setWelcomeToastMessage] = useState('');
147
+ const [welcomeToastType, setWelcomeToastType] = useState<'success' | 'warning'>('success');
148
+ const [isWelcomeToastVisible, setIsWelcomeToastVisible] = useState(false);
149
+ const [isLogin, setIsLogin] = useState(true);
150
+ const [isLoading, setIsLoading] = useState(false);
151
+ const [isCheckingUser, setIsCheckingUser] = useState(false);
152
+ const [user, setUser] = useState<User | null>(null);
153
+ const [passwordStrength, setPasswordStrength] = useState('');
154
+ const [isResetting, setIsResetting] = useState(false);
155
+ const [showPassword, setShowPassword] = useState(false);
156
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
157
+ const [isClient, setIsClient] = useState(false);
158
+ const [firstName, setFirstName] = useState('');
159
+ const [lastName, setLastName] = useState('');
160
+ const [company, setCompany] = useState('');
161
+ const [confirmPasswordValue, setConfirmPasswordValue] = useState('');
162
+
163
+ // MFA state
164
+ const [mfaResolver, setMfaResolver] = useState<MultiFactorResolver | null>(null);
165
+ const [showMfaVerification, setShowMfaVerification] = useState(false);
166
+ const [showMfaEnrollment, setShowMfaEnrollment] = useState(false);
167
+
168
+ const actionMode = searchParams.get('mode');
169
+ const actionCode = searchParams.get('oobCode');
170
+ const continueUrl = searchParams.get('continueUrl');
171
+ const actionLang = searchParams.get('lang');
172
+
173
+ const shouldHandleEmailAction = Boolean(
174
+ actionMode &&
175
+ actionCode &&
176
+ SUPPORTED_EMAIL_ACTION_MODES.has(actionMode)
177
+ );
178
+
179
+ // Check if we're on the client side
180
+ useEffect(() => {
181
+ setIsClient(true);
182
+ }, []);
183
+
184
+ // Email validation with regex
185
+ const validateRegistrationEmail = (email: string): { valid: boolean } => {
186
+ const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
187
+
188
+ if (!emailRegex.test(email)) {
189
+ return { valid: false };
190
+ }
191
+
192
+ return { valid: true };
193
+ };
194
+
195
+ const checkPasswordStrength = (password: string, confirmPassword?: string): boolean => {
196
+ const normalizedConfirmPassword = confirmPassword ?? '';
197
+ if (password.length === 0 && normalizedConfirmPassword.length === 0) {
198
+ setPasswordStrength('');
199
+ return false;
200
+ }
201
+
202
+ const policy = evaluatePasswordPolicy(password, confirmPassword);
203
+
204
+ setPasswordStrength(
205
+ `Password must contain:
206
+ ${!policy.hasMinLength ? '❌' : '✅'} At least 10 characters
207
+ ${!policy.hasUpperCase ? '❌' : '✅'} Capital letters
208
+ ${!policy.hasNumber ? '❌' : '✅'} Numbers
209
+ ${!policy.hasSpecialChar ? '❌' : '✅'} Special characters${confirmPassword !== undefined ? `
210
+ ${!policy.passwordsMatch ? '❌' : '✅'} Passwords must match` : ''}`
211
+ );
212
+
213
+ return policy.isStrong;
214
+ };
215
+
216
+ // Check if user exists in the USER_DB using centralized function
217
+ const checkUserExists = async (currentUser: User): Promise<UserData | null> => {
218
+ try {
219
+ return await getUserData(currentUser);
220
+ } catch (error) {
221
+ console.error('Error checking user existence:', error);
222
+ // On network/API errors, throw error to prevent login
223
+ throw new Error('System error. Please try logging in at a later time.');
224
+ }
225
+ };
226
+
227
+ useEffect(() => {
228
+ const unsubscribe = onAuthStateChanged(auth, async (user) => {
229
+ if (user) {
230
+ let currentUser = user;
231
+
232
+ // Refresh auth profile so emailVerified is accurate right after email verification.
233
+ try {
234
+ await currentUser.reload();
235
+ if (auth.currentUser) {
236
+ currentUser = auth.currentUser;
237
+ }
238
+ } catch (reloadError) {
239
+ console.error('Failed to refresh user verification status:', reloadError);
240
+ }
241
+
242
+ setUser(currentUser);
243
+
244
+ if (!currentUser.emailVerified) {
245
+ // Don't sign out immediately - let them see the verification prompt
246
+ setError('');
247
+ setSuccess('Please verify your email before continuing. Check your inbox for the verification link.');
248
+ setShowMfaEnrollment(false);
249
+ setIsCheckingUser(false);
250
+ return;
251
+ }
252
+
253
+ // Check if user exists in the USER_DB
254
+ let hasBadgeId = true;
255
+ setIsCheckingUser(true);
256
+ try {
257
+ const userData = await checkUserExists(currentUser);
258
+ setIsCheckingUser(false);
259
+
260
+ if (!userData) {
261
+ handleSignOut();
262
+ setError('This account does not exist or has been deleted');
263
+ return;
264
+ }
265
+
266
+ hasBadgeId = Boolean(userData.badgeId?.trim());
267
+ } catch (error) {
268
+ setIsCheckingUser(false);
269
+ handleSignOut();
270
+ setError(error instanceof Error ? error.message : 'System error. Please try logging in at a later time.');
271
+ return;
272
+ }
273
+
274
+ // Check if user has MFA enrolled
275
+ if (!userHasMFA(currentUser)) {
276
+ // User has no MFA factors enrolled - require enrollment
277
+ setShowMfaEnrollment(true);
278
+ return;
279
+ }
280
+
281
+ console.log("User signed in:", currentUser.email);
282
+ setShowMfaEnrollment(false);
283
+
284
+ if (shouldShowWelcomeToastRef.current) {
285
+ if (hasBadgeId) {
286
+ setWelcomeToastType('success');
287
+ setWelcomeToastMessage(`Welcome to Striae, ${getUserFirstName(currentUser)}!`);
288
+ } else {
289
+ setWelcomeToastType('warning');
290
+ setWelcomeToastMessage('Your badge or ID number is not set. You can set one in Manage Profile.');
291
+ }
292
+ setIsWelcomeToastVisible(true);
293
+ shouldShowWelcomeToastRef.current = false;
294
+ }
295
+
296
+ // Log successful login audit
297
+ try {
298
+ const sessionId = `session_${currentUser.uid}_${Date.now()}_${generateUniqueId(8)}`;
299
+ await auditService.logUserLogin(
300
+ currentUser,
301
+ sessionId,
302
+ 'firebase',
303
+ navigator.userAgent
304
+ );
305
+ } catch (auditError) {
306
+ console.error('Failed to log user login audit:', auditError);
307
+ // Continue with login even if audit logging fails
308
+ }
309
+ } else {
310
+ setUser(null);
311
+ setShowMfaEnrollment(false);
312
+ setIsCheckingUser(false);
313
+ setIsWelcomeToastVisible(false);
314
+ setWelcomeToastType('success');
315
+ shouldShowWelcomeToastRef.current = false;
316
+ }
317
+ });
318
+
319
+ return () => unsubscribe();
320
+ }, []);
321
+
322
+ useEffect(() => {
323
+ if (shouldHandleEmailAction) {
324
+ return;
325
+ }
326
+
327
+ const currentUser = auth.currentUser;
328
+ if (!currentUser) {
329
+ return;
330
+ }
331
+
332
+ let isMounted = true;
333
+
334
+ const syncMfaAfterEmailAction = async () => {
335
+ try {
336
+ await currentUser.reload();
337
+ const refreshedUser = auth.currentUser ?? currentUser;
338
+
339
+ if (!isMounted) {
340
+ return;
341
+ }
342
+
343
+ setUser(refreshedUser);
344
+
345
+ if (!refreshedUser.emailVerified) {
346
+ return;
347
+ }
348
+
349
+ setShowMfaEnrollment(!userHasMFA(refreshedUser));
350
+ } catch (refreshError) {
351
+ console.error('Failed to sync MFA state after email action:', refreshError);
352
+ }
353
+ };
354
+
355
+ void syncMfaAfterEmailAction();
356
+
357
+ return () => {
358
+ isMounted = false;
359
+ };
360
+ }, [shouldHandleEmailAction]);
361
+
362
+ const handleSubmit = async (e: React.FormEvent) => {
363
+ e.preventDefault();
364
+ setIsLoading(true);
365
+ setError('');
366
+ setSuccess('');
367
+
368
+ const formData = new FormData(e.currentTarget as HTMLFormElement);
369
+ const email = formData.get('email') as string;
370
+ const password = formData.get('password') as string;
371
+ const confirmPassword = formData.get('confirmPassword') as string;
372
+ // Use state values for these fields instead of FormData
373
+ const formFirstName = firstName;
374
+ const formLastName = lastName;
375
+ const formCompany = company;
376
+
377
+ try {
378
+ if (!isLogin) {
379
+ const emailValidation = validateRegistrationEmail(email);
380
+ if (!emailValidation.valid) {
381
+ setError('Please enter a valid email address');
382
+ setIsLoading(false);
383
+ return;
384
+ }
385
+
386
+ if (password !== confirmPassword) {
387
+ setError('Passwords do not match');
388
+ setIsLoading(false);
389
+ return;
390
+ }
391
+
392
+ if (!checkPasswordStrength(password)) {
393
+ setError('Password does not meet requirements');
394
+ setIsLoading(false);
395
+ return;
396
+ }
397
+ }
398
+
399
+ if (!isLogin) {
400
+ // Registration
401
+ const createCredential = await createUserWithEmailAndPassword(auth, email, password);
402
+ await updateProfile(createCredential.user, {
403
+ displayName: `${formFirstName} ${formLastName}`
404
+ });
405
+
406
+ const companyName = formCompany.trim();
407
+
408
+ // Create user data using centralized function
409
+ await createUser(
410
+ createCredential.user,
411
+ formFirstName,
412
+ formLastName,
413
+ companyName || '',
414
+ true
415
+ );
416
+
417
+ // Log user registration audit event
418
+ try {
419
+ await auditService.logUserRegistration(
420
+ createCredential.user,
421
+ formFirstName,
422
+ formLastName,
423
+ companyName || '',
424
+ 'email-password',
425
+ navigator.userAgent
426
+ );
427
+ } catch (auditError) {
428
+ console.error('Failed to log user registration audit:', auditError);
429
+ // Continue with registration flow even if audit logging fails
430
+ }
431
+
432
+ await sendEmailVerification(createCredential.user, buildActionCodeSettings());
433
+
434
+ // Log email verification sent audit event
435
+ try {
436
+ // This logs that we sent the verification email, not that it was verified
437
+ // The actual verification happens when user clicks the email link
438
+ await auditService.logEmailVerification(
439
+ createCredential.user,
440
+ 'pending', // Status pending until user clicks verification link
441
+ 'email-link',
442
+ 1, // First attempt
443
+ undefined, // No sessionId during registration
444
+ navigator.userAgent,
445
+ [] // No errors since we successfully sent the email
446
+ );
447
+ } catch (auditError) {
448
+ console.error('Failed to log email verification audit:', auditError);
449
+ // Continue with registration flow even if audit logging fails
450
+ }
451
+
452
+ setError('');
453
+ setSuccess('Account created successfully! Please check your email to verify your account.');
454
+ // Don't sign out - let user stay logged in but unverified to see verification screen
455
+ } else {
456
+ // Login
457
+ shouldShowWelcomeToastRef.current = true;
458
+ try {
459
+ await signInWithEmailAndPassword(auth, email, password);
460
+ } catch (loginError: unknown) {
461
+ // Check if it's a Firebase Auth error with MFA requirement
462
+ if (
463
+ loginError &&
464
+ typeof loginError === 'object' &&
465
+ 'code' in loginError &&
466
+ loginError.code === 'auth/multi-factor-auth-required'
467
+ ) {
468
+ // Handle MFA requirement
469
+ const resolver = getMultiFactorResolver(auth, loginError as MultiFactorError);
470
+ setMfaResolver(resolver);
471
+ setShowMfaVerification(true);
472
+ setIsLoading(false);
473
+ return;
474
+ }
475
+ shouldShowWelcomeToastRef.current = false;
476
+ throw loginError; // Re-throw non-MFA errors
477
+ }
478
+ }
479
+ } catch (err) {
480
+ shouldShowWelcomeToastRef.current = false;
481
+ const { message } = handleAuthError(err);
482
+ setError(message);
483
+
484
+ // Log security violation for failed authentication attempts
485
+ try {
486
+ // Extract error details for audit
487
+ const errorCode = err && typeof err === 'object' && 'code' in err ? err.code : 'unknown';
488
+ const isAuthError = typeof errorCode === 'string' && errorCode.startsWith('auth/');
489
+
490
+ if (isAuthError) {
491
+ // Determine severity based on error type
492
+ let severity: 'low' | 'medium' | 'high' | 'critical' = 'medium';
493
+ let incidentType: 'unauthorized-access' | 'brute-force' | 'privilege-escalation' = 'unauthorized-access';
494
+
495
+ if (errorCode === 'auth/too-many-requests') {
496
+ severity = 'high';
497
+ incidentType = 'brute-force';
498
+ } else if (errorCode === 'auth/user-disabled') {
499
+ severity = 'critical';
500
+ }
501
+
502
+ await auditService.logSecurityViolation(
503
+ null, // No user object for failed auth
504
+ incidentType,
505
+ severity,
506
+ `Failed authentication attempt: ${errorCode} - ${message}`,
507
+ 'authentication-endpoint',
508
+ true // Blocked by system
509
+ );
510
+ }
511
+ } catch (auditError) {
512
+ console.error('Failed to log security violation audit:', auditError);
513
+ // Continue with error flow even if audit logging fails
514
+ }
515
+ } finally {
516
+ setIsLoading(false);
517
+ }
518
+ };
519
+
520
+ // Add proper sign out handling
521
+ const handleSignOut = async () => {
522
+ try {
523
+ await auth.signOut();
524
+ setUser(null);
525
+ setIsLoading(false);
526
+ setShowMfaEnrollment(false);
527
+ setShowMfaVerification(false);
528
+ setMfaResolver(null);
529
+ setIsWelcomeToastVisible(false);
530
+ setWelcomeToastType('success');
531
+ shouldShowWelcomeToastRef.current = false;
532
+ } catch (err) {
533
+ console.error('Sign out error:', err);
534
+ }
535
+ };
536
+
537
+ // MFA handlers
538
+ const handleMfaSuccess = () => {
539
+ setShowMfaVerification(false);
540
+ setMfaResolver(null);
541
+ // The auth state listener will handle the rest
542
+ };
543
+
544
+ const handleMfaError = (errorMessage: string) => {
545
+ setError(errorMessage);
546
+ };
547
+
548
+ const handleMfaCancel = () => {
549
+ setShowMfaVerification(false);
550
+ setMfaResolver(null);
551
+ setError('Authentication cancelled');
552
+ };
553
+
554
+ // MFA enrollment handlers
555
+ const handleMfaEnrollmentSuccess = () => {
556
+ setShowMfaEnrollment(false);
557
+ setError('');
558
+ // The auth state listener will re-evaluate the user's MFA status
559
+ };
560
+
561
+ const handleMfaEnrollmentError = (errorMessage: string) => {
562
+ setError(errorMessage);
563
+ };
564
+
565
+ return (
566
+ <>
567
+ {shouldHandleEmailAction ? (
568
+ <EmailActionHandler
569
+ mode={actionMode}
570
+ oobCode={actionCode}
571
+ continueUrl={continueUrl}
572
+ lang={actionLang}
573
+ />
574
+ ) : user ? (
575
+ user.emailVerified ? (
576
+ <Striae user={user} />
577
+ ) : (
578
+ <EmailVerification
579
+ user={user}
580
+ error={error}
581
+ success={success}
582
+ onError={setError}
583
+ onSuccess={setSuccess}
584
+ onSignOut={handleSignOut}
585
+ />
586
+ )
587
+ ) : isResetting ? (
588
+ <PasswordReset onBack={() => setIsResetting(false)}/>
589
+ ) : (
590
+ <div className={styles.container}>
591
+ <Link
592
+ viewTransition
593
+ prefetch="intent"
594
+ to="/"
595
+ className={styles.logoLink}>
596
+ <div className={styles.logo} />
597
+ </Link>
598
+ <div className={styles.formWrapper}>
599
+ <h1 className={styles.title}>{isLogin ? 'Login to Striae' : 'Register a Striae Account'}</h1>
600
+
601
+ <form onSubmit={handleSubmit} className={styles.form}>
602
+ <input
603
+ type="email"
604
+ name="email"
605
+ placeholder={isLogin ? "Email" : "Email Address"}
606
+ autoComplete="email"
607
+ className={styles.input}
608
+ required
609
+ disabled={isLoading}
610
+ />
611
+ <div className={styles.passwordField}>
612
+ <input
613
+ type={showPassword ? "text" : "password"}
614
+ name="password"
615
+ placeholder="Password"
616
+ autoComplete={isLogin ? "current-password" : "new-password"}
617
+ className={styles.input}
618
+ required
619
+ disabled={isLoading}
620
+ onChange={(e) => !isLogin && checkPasswordStrength(e.target.value, confirmPasswordValue)}
621
+ />
622
+ <button
623
+ type="button"
624
+ className={styles.passwordToggle}
625
+ onClick={() => setShowPassword(!showPassword)}
626
+ aria-label={showPassword ? "Hide password" : "Show password"}
627
+ >
628
+ <Icon icon={showPassword ? "eye-off" : "eye"} />
629
+ </button>
630
+ </div>
631
+
632
+ {!isLogin && (
633
+ <>
634
+ <div className={styles.passwordField}>
635
+ <input
636
+ type={showConfirmPassword ? "text" : "password"}
637
+ name="confirmPassword"
638
+ placeholder="Confirm Password"
639
+ autoComplete="new-password"
640
+ className={styles.input}
641
+ required
642
+ disabled={isLoading}
643
+ value={confirmPasswordValue}
644
+ onChange={(e) => {
645
+ setConfirmPasswordValue(e.target.value);
646
+ const passwordInput = (e.target.form?.elements.namedItem('password') as HTMLInputElement);
647
+ if (passwordInput) {
648
+ checkPasswordStrength(passwordInput.value, e.target.value);
649
+ }
650
+ }}
651
+ />
652
+ <button
653
+ type="button"
654
+ className={styles.passwordToggle}
655
+ onClick={() => setShowConfirmPassword(!showConfirmPassword)}
656
+ aria-label={showConfirmPassword ? "Hide confirm password" : "Show confirm password"}
657
+ >
658
+ <Icon icon={showConfirmPassword ? "eye-off" : "eye"} />
659
+ </button>
660
+ </div>
661
+
662
+ <input
663
+ type="text"
664
+ name="firstName"
665
+ required
666
+ placeholder="First Name (required)"
667
+ autoComplete="given-name"
668
+ className={styles.input}
669
+ disabled={isLoading}
670
+ value={firstName}
671
+ onChange={(e) => setFirstName(e.target.value)}
672
+ />
673
+ <input
674
+ type="text"
675
+ name="lastName"
676
+ required
677
+ placeholder="Last Name (required)"
678
+ autoComplete="family-name"
679
+ className={styles.input}
680
+ disabled={isLoading}
681
+ value={lastName}
682
+ onChange={(e) => setLastName(e.target.value)}
683
+ />
684
+ <input
685
+ type="text"
686
+ name="company"
687
+ required
688
+ placeholder="Company/Lab (required)"
689
+ autoComplete="organization"
690
+ className={styles.input}
691
+ disabled={isLoading}
692
+ value={company}
693
+ onChange={(e) => setCompany(e.target.value)}
694
+ />
695
+ {passwordStrength && (
696
+ <div className={styles.passwordStrength}>
697
+ <pre>{passwordStrength}</pre>
698
+ </div>
699
+ )}
700
+ </>
701
+ )}
702
+
703
+ {isLogin && (
704
+ <button
705
+ type="button"
706
+ onClick={() => setIsResetting(true)}
707
+ className={styles.resetLink}
708
+ >
709
+ Forgot Password?
710
+ </button>
711
+ )}
712
+
713
+ {error && <p className={styles.error}>{error}</p>}
714
+ {success && <p className={styles.success}>{success}</p>}
715
+
716
+ <button
717
+ type="submit"
718
+ className={styles.button}
719
+ disabled={isLoading || isCheckingUser}
720
+ >
721
+ {isCheckingUser
722
+ ? 'Verifying account...'
723
+ : isLoading
724
+ ? 'Loading...'
725
+ : isLogin
726
+ ? 'Login'
727
+ : 'Register'}
728
+ </button>
729
+ </form>
730
+
731
+ <p className={styles.toggle}>
732
+ {isLogin ? "Don't have an account? " : "Already have an account? "}
733
+ <button
734
+ onClick={() => {
735
+ setIsLogin(!isLogin);
736
+ setShowPassword(false);
737
+ setShowConfirmPassword(false);
738
+ setPasswordStrength('');
739
+ setError('');
740
+ setFirstName('');
741
+ setLastName('');
742
+ setCompany('');
743
+ setConfirmPasswordValue('');
744
+ }}
745
+ className={styles.toggleButton}
746
+ disabled={isLoading || isCheckingUser}
747
+ >
748
+ {isLogin ? 'Register' : 'Login'}
749
+ </button>
750
+ </p>
751
+ </div>
752
+ </div>
753
+ )}
754
+
755
+ {!shouldHandleEmailAction && isClient && showMfaVerification && mfaResolver && (
756
+ <MFAVerification
757
+ resolver={mfaResolver}
758
+ onSuccess={handleMfaSuccess}
759
+ onError={handleMfaError}
760
+ onCancel={handleMfaCancel}
761
+ />
762
+ )}
763
+
764
+ {!shouldHandleEmailAction && isClient && showMfaEnrollment && user && (
765
+ <MFAEnrollment
766
+ user={user}
767
+ onSuccess={handleMfaEnrollmentSuccess}
768
+ onError={handleMfaEnrollmentError}
769
+ mandatory={true}
770
+ />
771
+ )}
772
+
773
+ {!shouldHandleEmailAction && (
774
+ <Toast
775
+ message={welcomeToastMessage}
776
+ type={welcomeToastType}
777
+ isVisible={isWelcomeToastVisible}
778
+ onClose={() => setIsWelcomeToastVisible(false)}
779
+ />
780
+ )}
781
+
782
+ </>
783
+ );
784
+ };
785
+
775
786
  export default Login;