@striae-org/striae 3.2.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.env.example +1 -1
  2. package/app/components/actions/case-export/core-export.ts +5 -2
  3. package/app/components/actions/case-export/download-handlers.ts +51 -3
  4. package/app/components/actions/case-import/confirmation-import.ts +65 -40
  5. package/app/components/actions/case-import/confirmation-package.ts +86 -0
  6. package/app/components/actions/case-import/image-operations.ts +20 -49
  7. package/app/components/actions/case-import/index.ts +1 -0
  8. package/app/components/actions/case-import/orchestrator.ts +13 -3
  9. package/app/components/actions/case-import/storage-operations.ts +54 -89
  10. package/app/components/actions/case-import/validation.ts +7 -111
  11. package/app/components/actions/case-import/zip-processing.ts +44 -2
  12. package/app/components/actions/case-manage.ts +15 -27
  13. package/app/components/actions/confirm-export.ts +44 -13
  14. package/app/components/actions/generate-pdf.ts +3 -7
  15. package/app/components/actions/image-manage.ts +63 -129
  16. package/app/components/button/button.module.css +12 -8
  17. package/app/components/form/form-button.tsx +1 -1
  18. package/app/components/form/form.module.css +9 -0
  19. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
  20. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
  21. package/app/components/sidebar/case-export/case-export.tsx +13 -60
  22. package/app/components/sidebar/case-import/case-import.tsx +18 -6
  23. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
  24. package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
  25. package/app/components/sidebar/cases/case-sidebar.tsx +122 -52
  26. package/app/components/sidebar/cases/cases.module.css +101 -18
  27. package/app/components/sidebar/notes/notes.module.css +33 -13
  28. package/app/components/sidebar/sidebar.module.css +0 -2
  29. package/app/components/user/delete-account.tsx +7 -7
  30. package/app/components/user/manage-profile.tsx +1 -1
  31. package/app/components/user/mfa-phone-update.tsx +15 -12
  32. package/app/config-example/config.json +2 -8
  33. package/app/hooks/useInactivityTimeout.ts +2 -5
  34. package/app/root.tsx +96 -65
  35. package/app/routes/auth/login.tsx +132 -11
  36. package/app/routes/auth/route.ts +4 -3
  37. package/app/routes/striae/striae.tsx +4 -8
  38. package/app/services/audit/audit-api-client.ts +40 -0
  39. package/app/services/audit/audit-worker-client.ts +14 -17
  40. package/app/styles/root.module.css +13 -101
  41. package/app/tailwind.css +9 -2
  42. package/app/utils/SHA256.ts +5 -1
  43. package/app/utils/auth.ts +5 -32
  44. package/app/utils/confirmation-signature.ts +5 -1
  45. package/app/utils/data-api-client.ts +43 -0
  46. package/app/utils/data-operations.ts +59 -75
  47. package/app/utils/export-verification.ts +353 -0
  48. package/app/utils/image-api-client.ts +130 -0
  49. package/app/utils/pdf-api-client.ts +43 -0
  50. package/app/utils/permissions.ts +10 -23
  51. package/app/utils/signature-utils.ts +74 -4
  52. package/app/utils/user-api-client.ts +90 -0
  53. package/functions/api/_shared/firebase-auth.ts +255 -0
  54. package/functions/api/audit/[[path]].ts +150 -0
  55. package/functions/api/data/[[path]].ts +141 -0
  56. package/functions/api/image/[[path]].ts +127 -0
  57. package/functions/api/pdf/[[path]].ts +110 -0
  58. package/functions/api/user/[[path]].ts +196 -0
  59. package/package.json +8 -4
  60. package/public/favicon.ico +0 -0
  61. package/public/icon-256.png +0 -0
  62. package/public/icon-512.png +0 -0
  63. package/public/manifest.json +39 -0
  64. package/public/shortcut.png +0 -0
  65. package/public/social-image.png +0 -0
  66. package/react-router.config.ts +5 -0
  67. package/scripts/deploy-all.sh +22 -8
  68. package/scripts/deploy-config.sh +143 -148
  69. package/scripts/deploy-pages-secrets.sh +231 -0
  70. package/scripts/deploy-worker-secrets.sh +1 -1
  71. package/workers/audit-worker/wrangler.jsonc.example +1 -8
  72. package/workers/data-worker/wrangler.jsonc.example +1 -8
  73. package/workers/image-worker/wrangler.jsonc.example +1 -8
  74. package/workers/keys-worker/wrangler.jsonc.example +2 -9
  75. package/workers/pdf-worker/scripts/generate-assets.js +94 -0
  76. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  77. package/workers/pdf-worker/wrangler.jsonc.example +1 -8
  78. package/workers/user-worker/src/user-worker.example.ts +121 -41
  79. package/workers/user-worker/wrangler.jsonc.example +1 -8
  80. package/wrangler.toml.example +1 -1
  81. package/app/styles/legal-pages.module.css +0 -113
  82. package/public/favicon.svg +0 -9
@@ -1,5 +1,5 @@
1
- import { useState, useEffect } from 'react';
2
- import { Link, useSearchParams } from 'react-router';
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { Link, useSearchParams, type MetaFunction } from 'react-router';
3
3
  import { auth } from '~/services/firebase';
4
4
  import {
5
5
  signInWithEmailAndPassword,
@@ -18,6 +18,7 @@ import { EmailActionHandler } from '~/routes/auth/emailActionHandler';
18
18
  import { handleAuthError } from '~/services/firebase/errors';
19
19
  import { MFAVerification } from '~/components/auth/mfa-verification';
20
20
  import { MFAEnrollment } from '~/components/auth/mfa-enrollment';
21
+ import { Toast } from '~/components/toast/toast';
21
22
  import { Icon } from '~/components/icon/icon';
22
23
  import styles from './login.module.css';
23
24
  import { Striae } from '~/routes/striae/striae';
@@ -28,23 +29,123 @@ import { evaluatePasswordPolicy } from '~/utils/password-policy';
28
29
  import { buildActionCodeSettings } from '~/utils/auth-action-settings';
29
30
  import { userHasMFA } from '~/utils/mfa';
30
31
 
31
- export const meta = () => {
32
- const titleText = 'Striae | Welcome to Striae';
33
- const description = 'Login to your Striae account to access your projects and data';
32
+ const APP_CANONICAL_ORIGIN = 'https://app.striae.org';
33
+ const SOCIAL_IMAGE_PATH = '/social-image.png';
34
+ const SOCIAL_IMAGE_ALT = 'Striae forensic annotation and comparison workspace';
35
+ const LOGIN_PATH_ALIASES = new Set(['/auth', '/auth/', '/auth/login', '/auth/login/']);
36
+
37
+ type AuthMetaContent = {
38
+ title: string;
39
+ description: string;
40
+ robots: string;
41
+ };
42
+
43
+ const getCanonicalPath = (pathname: string): string => {
44
+ if (!pathname || LOGIN_PATH_ALIASES.has(pathname)) {
45
+ return '/';
46
+ }
47
+
48
+ return pathname.startsWith('/') ? pathname : `/${pathname}`;
49
+ };
50
+
51
+ const getAuthMetaContent = (mode: string | null, hasActionCode: boolean): AuthMetaContent => {
52
+ if (!mode && !hasActionCode) {
53
+ return {
54
+ title: 'Striae | Secure Login for Firearms Examiners',
55
+ description: 'Sign in to Striae to access your forensic annotation workspace, case files, and comparison tools.',
56
+ robots: 'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1',
57
+ };
58
+ }
59
+
60
+ if (mode === 'resetPassword') {
61
+ return {
62
+ title: 'Striae | Reset Your Password',
63
+ description: 'Use this secure page to reset your Striae account password and restore access to your workspace.',
64
+ robots: 'noindex,nofollow,noarchive',
65
+ };
66
+ }
67
+
68
+ if (mode === 'verifyEmail') {
69
+ return {
70
+ title: 'Striae | Verify Your Email Address',
71
+ description: 'Confirm your email address to complete Striae account activation and continue securely.',
72
+ robots: 'noindex,nofollow,noarchive',
73
+ };
74
+ }
75
+
76
+ if (mode === 'recoverEmail') {
77
+ return {
78
+ title: 'Striae | Recover Email Access',
79
+ description: 'Complete your Striae account email recovery steps securely.',
80
+ robots: 'noindex,nofollow,noarchive',
81
+ };
82
+ }
83
+
84
+ return {
85
+ title: 'Striae | Account Action',
86
+ description: 'Complete your Striae account action securely.',
87
+ robots: 'noindex,nofollow,noarchive',
88
+ };
89
+ };
90
+
91
+ export const meta: MetaFunction = ({ location }) => {
92
+ const searchParams = new URLSearchParams(location.search);
93
+ const mode = searchParams.get('mode');
94
+ const hasActionCode = Boolean(searchParams.get('oobCode'));
95
+
96
+ const canonicalPath = getCanonicalPath(location.pathname);
97
+ const canonicalHref = `${APP_CANONICAL_ORIGIN}${canonicalPath}`;
98
+ const socialImageHref = `${APP_CANONICAL_ORIGIN}${SOCIAL_IMAGE_PATH}`;
99
+ const { title, description, robots } = getAuthMetaContent(mode, hasActionCode);
34
100
 
35
101
  return [
36
- { title: titleText },
102
+ { title },
37
103
  { name: 'description', content: description },
104
+ { name: 'robots', content: robots },
105
+ { property: 'og:site_name', content: 'Striae' },
106
+ { property: 'og:type', content: 'website' },
107
+ { property: 'og:url', content: canonicalHref },
108
+ { property: 'og:title', content: title },
109
+ { property: 'og:description', content: description },
110
+ { property: 'og:image', content: socialImageHref },
111
+ { property: 'og:image:secure_url', content: socialImageHref },
112
+ { property: 'og:image:alt', content: SOCIAL_IMAGE_ALT },
113
+ { name: 'twitter:card', content: 'summary_large_image' },
114
+ { name: 'twitter:title', content: title },
115
+ { name: 'twitter:description', content: description },
116
+ { name: 'twitter:image', content: socialImageHref },
117
+ { name: 'twitter:image:alt', content: SOCIAL_IMAGE_ALT },
118
+ { tagName: 'link', rel: 'canonical', href: canonicalHref },
38
119
  ];
39
120
  };
40
121
 
41
122
  const SUPPORTED_EMAIL_ACTION_MODES = new Set(['resetPassword', 'verifyEmail', 'recoverEmail']);
42
123
 
124
+ const getUserFirstName = (user: User): string => {
125
+ const displayName = user.displayName?.trim();
126
+ if (displayName) {
127
+ const [firstName] = displayName.split(/\s+/);
128
+ if (firstName) {
129
+ return firstName;
130
+ }
131
+ }
132
+
133
+ const emailPrefix = user.email?.split('@')[0]?.trim();
134
+ if (emailPrefix) {
135
+ return emailPrefix;
136
+ }
137
+
138
+ return 'User';
139
+ };
140
+
43
141
  export const Login = () => {
44
142
  const [searchParams] = useSearchParams();
143
+ const shouldShowWelcomeToastRef = useRef(false);
45
144
 
46
145
  const [error, setError] = useState('');
47
146
  const [success, setSuccess] = useState('');
147
+ const [welcomeToastMessage, setWelcomeToastMessage] = useState('');
148
+ const [isWelcomeToastVisible, setIsWelcomeToastVisible] = useState(false);
48
149
  const [isLogin, setIsLogin] = useState(true);
49
150
  const [isLoading, setIsLoading] = useState(false);
50
151
  const [isCheckingUser, setIsCheckingUser] = useState(false);
@@ -113,11 +214,9 @@ export const Login = () => {
113
214
  };
114
215
 
115
216
  // Check if user exists in the USER_DB using centralized function
116
- const checkUserExists = async (uid: string): Promise<boolean> => {
217
+ const checkUserExists = async (currentUser: User): Promise<boolean> => {
117
218
  try {
118
- // Create a minimal user object for the centralized function
119
- const tempUser = { uid } as User;
120
- const userData = await getUserData(tempUser);
219
+ const userData = await getUserData(currentUser);
121
220
 
122
221
  return userData !== null;
123
222
  } catch (error) {
@@ -156,7 +255,7 @@ export const Login = () => {
156
255
  // Check if user exists in the USER_DB
157
256
  setIsCheckingUser(true);
158
257
  try {
159
- const userExists = await checkUserExists(currentUser.uid);
258
+ const userExists = await checkUserExists(currentUser);
160
259
  setIsCheckingUser(false);
161
260
 
162
261
  if (!userExists) {
@@ -180,6 +279,12 @@ export const Login = () => {
180
279
 
181
280
  console.log("User signed in:", currentUser.email);
182
281
  setShowMfaEnrollment(false);
282
+
283
+ if (shouldShowWelcomeToastRef.current) {
284
+ setWelcomeToastMessage(`Welcome to Striae, ${getUserFirstName(currentUser)}!`);
285
+ setIsWelcomeToastVisible(true);
286
+ shouldShowWelcomeToastRef.current = false;
287
+ }
183
288
 
184
289
  // Log successful login audit
185
290
  try {
@@ -198,6 +303,8 @@ export const Login = () => {
198
303
  setUser(null);
199
304
  setShowMfaEnrollment(false);
200
305
  setIsCheckingUser(false);
306
+ setIsWelcomeToastVisible(false);
307
+ shouldShowWelcomeToastRef.current = false;
201
308
  }
202
309
  });
203
310
 
@@ -339,6 +446,7 @@ export const Login = () => {
339
446
  // Don't sign out - let user stay logged in but unverified to see verification screen
340
447
  } else {
341
448
  // Login
449
+ shouldShowWelcomeToastRef.current = true;
342
450
  try {
343
451
  await signInWithEmailAndPassword(auth, email, password);
344
452
  } catch (loginError: unknown) {
@@ -356,10 +464,12 @@ export const Login = () => {
356
464
  setIsLoading(false);
357
465
  return;
358
466
  }
467
+ shouldShowWelcomeToastRef.current = false;
359
468
  throw loginError; // Re-throw non-MFA errors
360
469
  }
361
470
  }
362
471
  } catch (err) {
472
+ shouldShowWelcomeToastRef.current = false;
363
473
  const { message } = handleAuthError(err);
364
474
  setError(message);
365
475
 
@@ -408,6 +518,8 @@ export const Login = () => {
408
518
  setShowMfaEnrollment(false);
409
519
  setShowMfaVerification(false);
410
520
  setMfaResolver(null);
521
+ setIsWelcomeToastVisible(false);
522
+ shouldShowWelcomeToastRef.current = false;
411
523
  } catch (err) {
412
524
  console.error('Sign out error:', err);
413
525
  }
@@ -648,6 +760,15 @@ export const Login = () => {
648
760
  mandatory={true}
649
761
  />
650
762
  )}
763
+
764
+ {!shouldHandleEmailAction && (
765
+ <Toast
766
+ message={welcomeToastMessage}
767
+ type="success"
768
+ isVisible={isWelcomeToastVisible}
769
+ onClose={() => setIsWelcomeToastVisible(false)}
770
+ />
771
+ )}
651
772
 
652
773
  </>
653
774
  );
@@ -1,7 +1,8 @@
1
- import { redirect } from 'react-router';
1
+ import { redirect, type LoaderFunctionArgs } from 'react-router';
2
2
 
3
- export const loader = async () => {
4
- throw redirect('/');
3
+ export const loader = async ({ request }: LoaderFunctionArgs) => {
4
+ const requestUrl = new URL(request.url);
5
+ throw redirect(`/${requestUrl.search}`);
5
6
  };
6
7
 
7
8
  export { Login as default, meta } from './login';
@@ -7,11 +7,10 @@ import { Toast } from '~/components/toast/toast';
7
7
  import { getImageUrl } from '~/components/actions/image-manage';
8
8
  import { getNotes, saveNotes } from '~/components/actions/notes-manage';
9
9
  import { generatePDF } from '~/components/actions/generate-pdf';
10
- import { getUserApiKey } from '~/utils/auth';
10
+ import { fetchUserApi } from '~/utils/user-api-client';
11
11
  import { resolveEarliestAnnotationTimestamp } from '~/utils/annotation-timestamp';
12
12
  import { type AnnotationData, type FileData } from '~/types';
13
13
  import { checkCaseIsReadOnly } from '~/components/actions/case-manage';
14
- import paths from '~/config/config.json';
15
14
  import styles from './striae.module.css';
16
15
 
17
16
  interface StriaePage {
@@ -76,11 +75,8 @@ export const Striae = ({ user }: StriaePage) => {
76
75
  useEffect(() => {
77
76
  const fetchUserCompany = async () => {
78
77
  try {
79
- const apiKey = await getUserApiKey();
80
- const response = await fetch(`${paths.user_worker_url}/${user.uid}`, {
81
- headers: {
82
- 'X-Custom-Auth-Key': apiKey
83
- }
78
+ const response = await fetchUserApi(user, `/${encodeURIComponent(user.uid)}`, {
79
+ method: 'GET'
84
80
  });
85
81
 
86
82
  if (response.ok) {
@@ -96,7 +92,7 @@ export const Striae = ({ user }: StriaePage) => {
96
92
  if (user?.uid) {
97
93
  fetchUserCompany();
98
94
  }
99
- }, [user?.uid]);
95
+ }, [user]);
100
96
 
101
97
  const handleCaseChange = (caseNumber: string) => {
102
98
  setCurrentCase(caseNumber);
@@ -0,0 +1,40 @@
1
+ import type { User } from 'firebase/auth';
2
+ import { auth } from '~/services/firebase';
3
+
4
+ const AUDIT_API_BASE = '/api/audit';
5
+
6
+ function normalizePath(path: string): string {
7
+ if (!path) {
8
+ return '/';
9
+ }
10
+
11
+ return path.startsWith('/') ? path : `/${path}`;
12
+ }
13
+
14
+ export async function fetchAuditApi(path: string, init: RequestInit = {}): Promise<Response> {
15
+ const normalizedPath = normalizePath(path);
16
+ const currentUserWithOptionalToken = auth.currentUser as User & { getIdToken?: () => Promise<string> };
17
+
18
+ if (!currentUserWithOptionalToken || typeof currentUserWithOptionalToken.getIdToken !== 'function') {
19
+ throw new Error('Unable to authenticate audit request: missing Firebase token provider');
20
+ }
21
+
22
+ let idToken: string;
23
+ try {
24
+ idToken = await currentUserWithOptionalToken.getIdToken();
25
+ } catch {
26
+ throw new Error('Unable to authenticate audit request: failed to retrieve Firebase token');
27
+ }
28
+
29
+ if (!idToken) {
30
+ throw new Error('Unable to authenticate audit request: empty Firebase token');
31
+ }
32
+
33
+ const headers = new Headers(init.headers);
34
+ headers.set('Authorization', `Bearer ${idToken}`);
35
+
36
+ return fetch(`${AUDIT_API_BASE}${normalizedPath}`, {
37
+ ...init,
38
+ headers
39
+ });
40
+ }
@@ -1,8 +1,5 @@
1
- import paths from '~/config/config.json';
2
1
  import { type ValidationAuditEntry } from '~/types';
3
- import { getDataApiKey } from '~/utils/auth';
4
-
5
- const AUDIT_WORKER_URL = paths.audit_worker_url;
2
+ import { fetchAuditApi } from './audit-api-client';
6
3
 
7
4
  interface FetchAuditEntriesParams {
8
5
  userId: string;
@@ -35,22 +32,23 @@ export type PersistAuditEntryResult =
35
32
  export async function fetchAuditEntriesForUser(
36
33
  params: FetchAuditEntriesParams
37
34
  ): Promise<ValidationAuditEntry[] | null> {
38
- const apiKey = await getDataApiKey();
39
- const url = new URL(`${AUDIT_WORKER_URL}/audit/`);
40
- url.searchParams.set('userId', params.userId);
35
+ const searchParams = new URLSearchParams();
36
+ searchParams.set('userId', params.userId);
41
37
 
42
38
  if (params.startDate) {
43
- url.searchParams.set('startDate', params.startDate);
39
+ searchParams.set('startDate', params.startDate);
44
40
  }
45
41
 
46
42
  if (params.endDate) {
47
- url.searchParams.set('endDate', params.endDate);
43
+ searchParams.set('endDate', params.endDate);
48
44
  }
49
45
 
50
- const response = await fetch(url.toString(), {
46
+ const requestPath = `/audit/?${searchParams.toString()}`;
47
+
48
+ const response = await fetchAuditApi(requestPath, {
51
49
  method: 'GET',
52
50
  headers: {
53
- 'X-Custom-Auth-Key': apiKey
51
+ 'Accept': 'application/json'
54
52
  }
55
53
  });
56
54
 
@@ -65,15 +63,14 @@ export async function fetchAuditEntriesForUser(
65
63
  export async function persistAuditEntryForUser(
66
64
  entry: ValidationAuditEntry
67
65
  ): Promise<PersistAuditEntryResult> {
68
- const apiKey = await getDataApiKey();
69
- const url = new URL(`${AUDIT_WORKER_URL}/audit/`);
70
- url.searchParams.set('userId', entry.userId);
66
+ const searchParams = new URLSearchParams();
67
+ searchParams.set('userId', entry.userId);
68
+ const requestPath = `/audit/?${searchParams.toString()}`;
71
69
 
72
- const response = await fetch(url.toString(), {
70
+ const response = await fetchAuditApi(requestPath, {
73
71
  method: 'POST',
74
72
  headers: {
75
- 'Content-Type': 'application/json',
76
- 'X-Custom-Auth-Key': apiKey
73
+ 'Content-Type': 'application/json'
77
74
  },
78
75
  body: JSON.stringify(entry)
79
76
  });
@@ -1,47 +1,4 @@
1
1
  @layer layout {
2
-
3
- .container {
4
- width: 100%;
5
- position: relative;
6
- transition: opacity 0.8s var(--bezierFastoutSlowin);
7
-
8
- &[data-loading='true'] {
9
- opacity: 0;
10
- }
11
- }
12
-
13
- .skip {
14
- isolation: isolate;
15
- color: var(--background);
16
- z-index: var(--zIndex4);
17
-
18
- &:focus {
19
- padding: var(--spaceS) var(--spaceM);
20
- position: fixed;
21
- top: var(--spaceM);
22
- left: var(--spaceM);
23
- text-decoration: none;
24
- font-weight: var(--fontWeightMedium);
25
- line-height: 1;
26
- box-shadow: 0 0 0 4px var(--background), 0 0 0 8px var(--text);
27
- }
28
-
29
- &::before {
30
- content: '';
31
- position: absolute;
32
- inset: 0;
33
- background-color: var(--primary);
34
- clip-path: polygon(
35
- 0 0,
36
- 100% 0,
37
- 100% calc(100% - 8px),
38
- calc(100% - 8px) 100%,
39
- 0 100%
40
- );
41
- z-index: -1;
42
- }
43
- }
44
-
45
2
  .errorContainer {
46
3
  display: flex;
47
4
  flex-direction: column;
@@ -69,68 +26,24 @@
69
26
  }
70
27
 
71
28
  .errorLink {
72
- color: var(--primary);
73
- text-decoration: none;
74
- font-size: var(--fontSizeBodyL);
75
- font-weight: var(--fontWeightMedium);
76
- padding: var(--spaceM) var(--spaceL);
77
- border-radius: var(--radiusS);
78
- transition: all var(--durationS) var(--bezierFastoutSlowin);
29
+ -webkit-appearance: none;
30
+ -moz-appearance: none;
31
+ transition:
32
+ background-color var(--durationS) var(--bezierFastoutSlowin),
33
+ border-color var(--durationS) var(--bezierFastoutSlowin),
34
+ color var(--durationS) var(--bezierFastoutSlowin);
79
35
  }
80
36
 
81
37
  .errorLink:hover {
82
- background: color-mix(in lab, var(--primary) 10%, transparent);
83
- color: color-mix(in lab, var(--primary) 85%, var(--black));
84
- }
85
-
86
- .returnToTop {
87
- position: fixed;
88
- right: var(--space2XL);
89
- bottom: var(--spaceM);
90
- width: 50px;
91
- height: 50px;
92
- border: none;
93
- background: transparent;
94
- color: color-mix(in lab, var(--white) 60%, var(--textLight));
95
- display: inline-flex;
96
- align-items: center;
97
- justify-content: center;
98
- z-index: var(--zIndex4);
99
- cursor: pointer;
100
- transition: all var(--durationS) var(--bezierFastoutSlowin);
101
- }
102
-
103
- .returnToTop:hover {
104
- color: var(--primary);
105
- }
106
-
107
- .returnToTopIcon {
108
- transform: rotate(-90deg);
109
- color: color-mix(in lab, var(--white) 60%, var(--textLight));
110
- filter: drop-shadow(0 0 1px color-mix(in lab, var(--black) 45%, transparent))
111
- drop-shadow(0 0 1px color-mix(in lab, var(--white) 45%, transparent));
112
- transition: color var(--durationS) var(--bezierFastoutSlowin);
113
- }
114
-
115
- .returnToTop:hover .returnToTopIcon {
116
- color: var(--primary);
117
- }
118
-
119
- /* Global enhanced button hover effects */
120
- :global(button:not([data-no-enhance]):hover:not(:disabled)) {
121
- transform: translateY(-1px);
122
- transition: all var(--durationS) var(--bezierFastoutSlowin);
123
- }
124
-
125
- :global(button[class*="Button"]:hover:not(:disabled)) {
126
- transform: translateY(-1px);
127
- box-shadow: 0 2px 6px color-mix(in lab, currentColor 20%, transparent);
128
- transition: all var(--durationS) var(--bezierFastoutSlowin);
38
+ background-color: color-mix(in lab, var(--primary) 82%, var(--black));
39
+ border-color: color-mix(in lab, var(--primary) 58%, var(--black));
40
+ color: var(--white);
41
+ text-decoration: none;
129
42
  }
130
43
 
131
- /* Ensure disabled buttons don't get transforms */
132
- :global(button:disabled) {
133
- transform: none !important;
44
+ .errorLink:focus-visible {
45
+ outline: 3px solid color-mix(in lab, var(--white) 65%, var(--primary));
46
+ outline-offset: 3px;
134
47
  }
135
48
 
136
49
  @media (max-width: 768px) {
@@ -142,5 +55,4 @@
142
55
  font-size: var(--fontSizeBodyL);
143
56
  }
144
57
  }
145
-
146
58
  }
package/app/tailwind.css CHANGED
@@ -39,6 +39,10 @@
39
39
  touch-action: manipulation;
40
40
  }
41
41
 
42
+ :where(button:hover:not(:disabled)) {
43
+ transform: translateY(-1px);
44
+ }
45
+
42
46
  :where(svg, img, picture, video, iframe, canvas) {
43
47
  display: block;
44
48
  }
@@ -73,10 +77,13 @@
73
77
  font-family: var(--fontStack);
74
78
  font-weight: var(--fontWeightRegular);
75
79
  color: var(--textBody);
76
- background: linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.95)),
80
+ background:
81
+ linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.95)),
77
82
  url("/assets/striae.jpg") center/cover no-repeat fixed;
78
83
  background-blend-mode: normal;
79
- transition: background var(--durationM) ease, opacity var(--durationM) ease;
84
+ transition:
85
+ background var(--durationM) ease,
86
+ opacity var(--durationM) ease;
80
87
  opacity: 1;
81
88
  }
82
89
 
@@ -120,7 +120,8 @@ export function createManifestSigningPayload(
120
120
  * Verify manifest signature using configured public key(s).
121
121
  */
122
122
  export async function verifyForensicManifestSignature(
123
- manifest: Partial<SignedForensicManifest>
123
+ manifest: Partial<SignedForensicManifest>,
124
+ verificationPublicKeyPem?: string
124
125
  ): Promise<ManifestSignatureVerificationResult> {
125
126
  if (!manifest.signature) {
126
127
  return {
@@ -158,6 +159,9 @@ export async function verifyForensicManifestSignature(
158
159
  noVerificationKeyPrefix: 'No verification key configured for key ID',
159
160
  invalidPublicKeyError: 'Manifest signature verification failed: invalid public key',
160
161
  verificationFailedError: 'Manifest signature verification failed'
162
+ },
163
+ {
164
+ verificationPublicKeyPem
161
165
  }
162
166
  );
163
167
  }
package/app/utils/auth.ts CHANGED
@@ -1,38 +1,11 @@
1
1
  import paths from '~/config/config.json';
2
2
 
3
- const KEYS_URL = paths.keys_url;
4
- const KEYS_AUTH = paths.keys_auth;
5
-
6
- type KeyType = 'USER_DB_AUTH' | 'R2_KEY_SECRET' | 'IMAGES_API_TOKEN' | 'ACCOUNT_HASH' | 'PDF_WORKER_AUTH';
7
-
8
- async function getApiKey(keyType: KeyType): Promise<string> {
9
- const keyResponse = await fetch(`${KEYS_URL}/${keyType}`, {
10
- headers: {
11
- 'X-Custom-Auth-Key': KEYS_AUTH
12
- }
13
- });
14
- if (!keyResponse.ok) {
15
- throw new Error(`Failed to retrieve ${keyType}`);
16
- }
17
- return keyResponse.text();
18
- }
19
-
20
- export async function getUserApiKey(): Promise<string> {
21
- return getApiKey('USER_DB_AUTH');
22
- }
23
-
24
- export async function getDataApiKey(): Promise<string> {
25
- return getApiKey('R2_KEY_SECRET');
26
- }
27
-
28
- export async function getImageApiKey(): Promise<string> {
29
- return getApiKey('IMAGES_API_TOKEN');
30
- }
3
+ const ACCOUNT_HASH = typeof paths.account_hash === 'string' ? paths.account_hash.trim() : '';
31
4
 
32
5
  export async function getAccountHash(): Promise<string> {
33
- return getApiKey('ACCOUNT_HASH');
34
- }
6
+ if (!ACCOUNT_HASH) {
7
+ throw new Error('ACCOUNT_HASH is not configured in app/config/config.json');
8
+ }
35
9
 
36
- export async function getPdfApiKey(): Promise<string> {
37
- return getApiKey('PDF_WORKER_AUTH');
10
+ return ACCOUNT_HASH;
38
11
  }
@@ -148,7 +148,8 @@ export function createConfirmationSigningPayload(
148
148
  }
149
149
 
150
150
  export async function verifyConfirmationSignature(
151
- confirmationData: Partial<ConfirmationImportData>
151
+ confirmationData: Partial<ConfirmationImportData>,
152
+ verificationPublicKeyPem?: string
152
153
  ): Promise<ManifestSignatureVerificationResult> {
153
154
  const signature = confirmationData.metadata?.signature as ForensicManifestSignature | undefined;
154
155
  const signatureVersion = confirmationData.metadata?.signatureVersion;
@@ -188,6 +189,9 @@ export async function verifyConfirmationSignature(
188
189
  noVerificationKeyPrefix: 'No verification key configured for key ID',
189
190
  invalidPublicKeyError: 'Confirmation signature verification failed: invalid public key',
190
191
  verificationFailedError: 'Confirmation signature verification failed'
192
+ },
193
+ {
194
+ verificationPublicKeyPem
191
195
  }
192
196
  );
193
197
  }
@@ -0,0 +1,43 @@
1
+ import type { User } from 'firebase/auth';
2
+
3
+ const DATA_API_BASE = '/api/data';
4
+
5
+ function normalizePath(path: string): string {
6
+ if (!path) {
7
+ return '/';
8
+ }
9
+
10
+ return path.startsWith('/') ? path : `/${path}`;
11
+ }
12
+
13
+ export async function fetchDataApi(
14
+ user: User,
15
+ path: string,
16
+ init: RequestInit = {}
17
+ ): Promise<Response> {
18
+ const normalizedPath = normalizePath(path);
19
+ const userWithOptionalToken = user as User & { getIdToken?: () => Promise<string> };
20
+
21
+ if (typeof userWithOptionalToken.getIdToken !== 'function') {
22
+ throw new Error('Unable to authenticate request: missing Firebase token provider');
23
+ }
24
+
25
+ let idToken: string;
26
+ try {
27
+ idToken = await userWithOptionalToken.getIdToken();
28
+ } catch {
29
+ throw new Error('Unable to authenticate request: failed to retrieve Firebase token');
30
+ }
31
+
32
+ if (!idToken) {
33
+ throw new Error('Unable to authenticate request: empty Firebase token');
34
+ }
35
+
36
+ const headers = new Headers(init.headers);
37
+ headers.set('Authorization', `Bearer ${idToken}`);
38
+
39
+ return fetch(`${DATA_API_BASE}${normalizedPath}`, {
40
+ ...init,
41
+ headers
42
+ });
43
+ }