@striae-org/striae 3.3.0 → 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 (53) 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-import/confirmation-import.ts +24 -23
  4. package/app/components/actions/case-import/image-operations.ts +20 -49
  5. package/app/components/actions/case-import/orchestrator.ts +1 -1
  6. package/app/components/actions/case-import/storage-operations.ts +54 -89
  7. package/app/components/actions/case-import/validation.ts +2 -13
  8. package/app/components/actions/case-manage.ts +15 -27
  9. package/app/components/actions/generate-pdf.ts +3 -7
  10. package/app/components/actions/image-manage.ts +63 -129
  11. package/app/components/button/button.module.css +12 -8
  12. package/app/components/sidebar/case-export/case-export.tsx +11 -6
  13. package/app/components/sidebar/cases/case-sidebar.tsx +21 -6
  14. package/app/components/sidebar/sidebar.module.css +0 -2
  15. package/app/components/user/delete-account.tsx +7 -7
  16. package/app/config-example/config.json +2 -8
  17. package/app/hooks/useInactivityTimeout.ts +2 -5
  18. package/app/root.tsx +94 -63
  19. package/app/routes/auth/login.tsx +3 -5
  20. package/app/routes/auth/route.ts +4 -3
  21. package/app/routes/striae/striae.tsx +4 -8
  22. package/app/services/audit/audit-api-client.ts +40 -0
  23. package/app/services/audit/audit-worker-client.ts +14 -17
  24. package/app/styles/root.module.css +13 -101
  25. package/app/tailwind.css +9 -2
  26. package/app/utils/auth.ts +5 -32
  27. package/app/utils/data-api-client.ts +43 -0
  28. package/app/utils/data-operations.ts +59 -75
  29. package/app/utils/image-api-client.ts +130 -0
  30. package/app/utils/pdf-api-client.ts +43 -0
  31. package/app/utils/permissions.ts +10 -23
  32. package/app/utils/user-api-client.ts +90 -0
  33. package/functions/api/_shared/firebase-auth.ts +255 -0
  34. package/functions/api/audit/[[path]].ts +150 -0
  35. package/functions/api/data/[[path]].ts +141 -0
  36. package/functions/api/image/[[path]].ts +127 -0
  37. package/functions/api/pdf/[[path]].ts +110 -0
  38. package/functions/api/user/[[path]].ts +196 -0
  39. package/package.json +2 -1
  40. package/scripts/deploy-all.sh +22 -8
  41. package/scripts/deploy-config.sh +143 -148
  42. package/scripts/deploy-pages-secrets.sh +231 -0
  43. package/scripts/deploy-worker-secrets.sh +1 -1
  44. package/workers/audit-worker/wrangler.jsonc.example +1 -8
  45. package/workers/data-worker/wrangler.jsonc.example +1 -8
  46. package/workers/image-worker/wrangler.jsonc.example +1 -8
  47. package/workers/keys-worker/wrangler.jsonc.example +2 -9
  48. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  49. package/workers/pdf-worker/wrangler.jsonc.example +1 -8
  50. package/workers/user-worker/src/user-worker.example.ts +121 -41
  51. package/workers/user-worker/wrangler.jsonc.example +1 -8
  52. package/wrangler.toml.example +1 -1
  53. package/app/styles/legal-pages.module.css +0 -113
package/app/root.tsx CHANGED
@@ -7,8 +7,6 @@ import {
7
7
  ScrollRestoration,
8
8
  isRouteErrorResponse,
9
9
  useRouteError,
10
- Link,
11
- useLocation,
12
10
  useMatches,
13
11
  } from 'react-router';
14
12
  import {
@@ -16,6 +14,7 @@ import {
16
14
  themeStyles
17
15
  } from '~/components/theme-provider/theme-provider';
18
16
  import { AuthProvider } from '~/components/auth/auth-provider';
17
+ import { auth } from '~/services/firebase';
19
18
  import styles from '~/styles/root.module.css';
20
19
  import './tailwind.css';
21
20
 
@@ -88,80 +87,112 @@ export function Layout({ children }: { children: React.ReactNode }) {
88
87
  }
89
88
 
90
89
  export default function App() {
91
- const matches = useMatches();
92
- const location = useLocation();
93
- const isAuthRoute = matches.some(match =>
94
- match.id.includes('auth') ||
95
- match.pathname?.includes('/auth')
96
- ) || location.pathname === '/';
90
+ return (
91
+ <AuthProvider>
92
+ <Outlet />
93
+ </AuthProvider>
94
+ );
95
+ }
97
96
 
98
- if (isAuthRoute) {
99
- return (
100
- <AuthProvider>
101
- <Outlet />
102
- </AuthProvider>
103
- );
97
+ interface ErrorBoundaryShellProps {
98
+ title: string;
99
+ children: React.ReactNode;
100
+ }
101
+
102
+ const LOGIN_REDIRECT_PATH = '/';
103
+
104
+ const errorActionStyle = {
105
+ alignItems: 'center',
106
+ appearance: 'none',
107
+ backgroundColor: '#0d6efd',
108
+ border: '1px solid #0b5ed7',
109
+ borderRadius: '8px',
110
+ color: '#ffffff',
111
+ cursor: 'pointer',
112
+ display: 'inline-flex',
113
+ fontSize: '1rem',
114
+ fontWeight: 600,
115
+ justifyContent: 'center',
116
+ lineHeight: 1,
117
+ marginTop: '1rem',
118
+ minWidth: '220px',
119
+ padding: '0.9rem 1.6rem',
120
+ textDecoration: 'none',
121
+ } as const;
122
+
123
+ async function returnToLogin() {
124
+ try {
125
+ await auth.signOut();
126
+ } catch (error) {
127
+ console.error('Error boundary sign out failed:', error);
128
+ } finally {
129
+ if (typeof window !== 'undefined') {
130
+ localStorage.clear();
131
+ window.location.href = LOGIN_REDIRECT_PATH;
132
+ }
104
133
  }
134
+ }
105
135
 
106
- return <Outlet />;
136
+ function ErrorBoundaryShell({ title, children }: ErrorBoundaryShellProps) {
137
+ return (
138
+ <html lang="en" data-theme="light">
139
+ <head>
140
+ <meta charSet="utf-8" />
141
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
142
+ <meta name="theme-color" content="#377087" />
143
+ <meta name="color-scheme" content="light" />
144
+ <style dangerouslySetInnerHTML={{ __html: themeStyles }} />
145
+ <title>{title}</title>
146
+ <Meta />
147
+ <Links />
148
+ </head>
149
+ <body className="flex flex-col h-screen w-full overflow-x-hidden">
150
+ <ThemeProvider theme="light" className="">
151
+ <main>{children}</main>
152
+ </ThemeProvider>
153
+ <ScrollRestoration />
154
+ <Scripts />
155
+ </body>
156
+ </html>
157
+ );
107
158
  }
108
159
 
109
160
  export function ErrorBoundary() {
110
161
  const error = useRouteError();
111
162
 
112
163
  if (isRouteErrorResponse(error)) {
164
+ const statusText = error.statusText || 'Unexpected error';
165
+
113
166
  return (
114
- <html lang="en">
115
- <head>
116
- <title>{`${error.status} ${error.statusText}`}</title>
117
- </head>
118
- <body className="flex flex-col h-screen">
119
- <ThemeProvider theme="light" className="">
120
- <main>
121
- <div className={styles.errorContainer}>
122
- <div className={styles.errorTitle}>{error.status}</div>
123
- <p className={styles.errorMessage}>{error.statusText}</p>
124
- <Link
125
- viewTransition
126
- prefetch="intent"
127
- to="https://striae.org"
128
- className={styles.errorLink}>
129
- Return Home
130
- </Link>
131
- </div>
132
- </main>
133
- </ThemeProvider>
134
- <ScrollRestoration />
135
- <Scripts />
136
- </body>
137
- </html>
167
+ <ErrorBoundaryShell title={`${error.status} ${statusText}`}>
168
+ <div className={styles.errorContainer}>
169
+ <div className={styles.errorTitle}>{error.status}</div>
170
+ <p className={styles.errorMessage}>{statusText}</p>
171
+ <button
172
+ type="button"
173
+ onClick={() => void returnToLogin()}
174
+ style={errorActionStyle}
175
+ className={styles.errorLink}>
176
+ Return to Login
177
+ </button>
178
+ </div>
179
+ </ErrorBoundaryShell>
138
180
  );
139
181
  }
140
182
 
141
183
  return (
142
- <html lang="en">
143
- <head>
144
- <title>Oops! Something went wrong</title>
145
- </head>
146
- <body className="flex flex-col h-screen">
147
- <ThemeProvider theme="light" className="">
148
- <main>
149
- <div className={styles.errorContainer}>
150
- <div className={styles.errorTitle}>500</div>
151
- <p className={styles.errorMessage}>Something went wrong. Please try again later.</p>
152
- <Link
153
- viewTransition
154
- prefetch="intent"
155
- to="https://striae.org"
156
- className={styles.errorLink}>
157
- Return Home
158
- </Link>
159
- </div>
160
- </main>
161
- </ThemeProvider>
162
- <ScrollRestoration />
163
- <Scripts />
164
- </body>
165
- </html>
184
+ <ErrorBoundaryShell title="Oops! Something went wrong">
185
+ <div className={styles.errorContainer}>
186
+ <div className={styles.errorTitle}>500</div>
187
+ <p className={styles.errorMessage}>Something went wrong. Please try again later.</p>
188
+ <button
189
+ type="button"
190
+ onClick={() => void returnToLogin()}
191
+ style={errorActionStyle}
192
+ className={styles.errorLink}>
193
+ Return to Login
194
+ </button>
195
+ </div>
196
+ </ErrorBoundaryShell>
166
197
  );
167
198
  }
@@ -214,11 +214,9 @@ export const Login = () => {
214
214
  };
215
215
 
216
216
  // Check if user exists in the USER_DB using centralized function
217
- const checkUserExists = async (uid: string): Promise<boolean> => {
217
+ const checkUserExists = async (currentUser: User): Promise<boolean> => {
218
218
  try {
219
- // Create a minimal user object for the centralized function
220
- const tempUser = { uid } as User;
221
- const userData = await getUserData(tempUser);
219
+ const userData = await getUserData(currentUser);
222
220
 
223
221
  return userData !== null;
224
222
  } catch (error) {
@@ -257,7 +255,7 @@ export const Login = () => {
257
255
  // Check if user exists in the USER_DB
258
256
  setIsCheckingUser(true);
259
257
  try {
260
- const userExists = await checkUserExists(currentUser.uid);
258
+ const userExists = await checkUserExists(currentUser);
261
259
  setIsCheckingUser(false);
262
260
 
263
261
  if (!userExists) {
@@ -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
 
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
  }
@@ -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
+ }