@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.
- package/.env.example +1 -1
- package/app/components/actions/case-export/core-export.ts +5 -2
- package/app/components/actions/case-import/confirmation-import.ts +24 -23
- package/app/components/actions/case-import/image-operations.ts +20 -49
- package/app/components/actions/case-import/orchestrator.ts +1 -1
- package/app/components/actions/case-import/storage-operations.ts +54 -89
- package/app/components/actions/case-import/validation.ts +2 -13
- package/app/components/actions/case-manage.ts +15 -27
- package/app/components/actions/generate-pdf.ts +3 -7
- package/app/components/actions/image-manage.ts +63 -129
- package/app/components/button/button.module.css +12 -8
- package/app/components/sidebar/case-export/case-export.tsx +11 -6
- package/app/components/sidebar/cases/case-sidebar.tsx +21 -6
- package/app/components/sidebar/sidebar.module.css +0 -2
- package/app/components/user/delete-account.tsx +7 -7
- package/app/config-example/config.json +2 -8
- package/app/hooks/useInactivityTimeout.ts +2 -5
- package/app/root.tsx +94 -63
- package/app/routes/auth/login.tsx +3 -5
- package/app/routes/auth/route.ts +4 -3
- package/app/routes/striae/striae.tsx +4 -8
- package/app/services/audit/audit-api-client.ts +40 -0
- package/app/services/audit/audit-worker-client.ts +14 -17
- package/app/styles/root.module.css +13 -101
- package/app/tailwind.css +9 -2
- package/app/utils/auth.ts +5 -32
- package/app/utils/data-api-client.ts +43 -0
- package/app/utils/data-operations.ts +59 -75
- package/app/utils/image-api-client.ts +130 -0
- package/app/utils/pdf-api-client.ts +43 -0
- package/app/utils/permissions.ts +10 -23
- package/app/utils/user-api-client.ts +90 -0
- package/functions/api/_shared/firebase-auth.ts +255 -0
- package/functions/api/audit/[[path]].ts +150 -0
- package/functions/api/data/[[path]].ts +141 -0
- package/functions/api/image/[[path]].ts +127 -0
- package/functions/api/pdf/[[path]].ts +110 -0
- package/functions/api/user/[[path]].ts +196 -0
- package/package.json +2 -1
- package/scripts/deploy-all.sh +22 -8
- package/scripts/deploy-config.sh +143 -148
- package/scripts/deploy-pages-secrets.sh +231 -0
- package/scripts/deploy-worker-secrets.sh +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -8
- package/workers/data-worker/wrangler.jsonc.example +1 -8
- package/workers/image-worker/wrangler.jsonc.example +1 -8
- package/workers/keys-worker/wrangler.jsonc.example +2 -9
- package/workers/pdf-worker/src/assets/icon-256.png +0 -0
- package/workers/pdf-worker/wrangler.jsonc.example +1 -8
- package/workers/user-worker/src/user-worker.example.ts +121 -41
- package/workers/user-worker/wrangler.jsonc.example +1 -8
- package/wrangler.toml.example +1 -1
- package/app/styles/legal-pages.module.css +0 -113
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { User } from 'firebase/auth';
|
|
8
8
|
import { type CaseData, type AnnotationData, type ConfirmationImportData } from '~/types';
|
|
9
|
-
import
|
|
10
|
-
import { getDataApiKey } from './auth';
|
|
9
|
+
import { fetchDataApi } from './data-api-client';
|
|
11
10
|
import { validateUserSession, canAccessCase, canModifyCase } from './permissions';
|
|
12
11
|
import {
|
|
13
12
|
type ForensicManifestData,
|
|
@@ -21,8 +20,6 @@ import {
|
|
|
21
20
|
isValidAuditExportSigningPayload
|
|
22
21
|
} from './audit-export-signature';
|
|
23
22
|
|
|
24
|
-
const DATA_WORKER_URL = paths.data_worker_url;
|
|
25
|
-
|
|
26
23
|
// ============================================================================
|
|
27
24
|
// INTERFACES AND TYPES
|
|
28
25
|
// ============================================================================
|
|
@@ -101,15 +98,13 @@ export const getCaseData = async (
|
|
|
101
98
|
throw new Error('Invalid case number provided');
|
|
102
99
|
}
|
|
103
100
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
headers: {
|
|
110
|
-
'X-Custom-Auth-Key': apiKey
|
|
101
|
+
const response = await fetchDataApi(
|
|
102
|
+
user,
|
|
103
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
104
|
+
{
|
|
105
|
+
method: 'GET'
|
|
111
106
|
}
|
|
112
|
-
|
|
107
|
+
);
|
|
113
108
|
|
|
114
109
|
if (response.status === 404) {
|
|
115
110
|
return null; // Case not found
|
|
@@ -163,23 +158,23 @@ export const updateCaseData = async (
|
|
|
163
158
|
throw new Error('Invalid case data provided');
|
|
164
159
|
}
|
|
165
160
|
|
|
166
|
-
const apiKey = await getDataApiKey();
|
|
167
|
-
const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`;
|
|
168
|
-
|
|
169
161
|
// Add timestamp if requested (default: true)
|
|
170
162
|
const dataToSave = options.includeTimestamp !== false ? {
|
|
171
163
|
...caseData,
|
|
172
164
|
updatedAt: new Date().toISOString()
|
|
173
165
|
} : caseData;
|
|
174
166
|
|
|
175
|
-
const response = await
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
'
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
167
|
+
const response = await fetchDataApi(
|
|
168
|
+
user,
|
|
169
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
170
|
+
{
|
|
171
|
+
method: 'PUT',
|
|
172
|
+
headers: {
|
|
173
|
+
'Content-Type': 'application/json'
|
|
174
|
+
},
|
|
175
|
+
body: JSON.stringify(dataToSave)
|
|
176
|
+
}
|
|
177
|
+
);
|
|
183
178
|
|
|
184
179
|
if (!response.ok) {
|
|
185
180
|
throw new Error(`Failed to update case data: ${response.status} ${response.statusText}`);
|
|
@@ -216,15 +211,13 @@ export const deleteCaseData = async (
|
|
|
216
211
|
}
|
|
217
212
|
}
|
|
218
213
|
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
headers: {
|
|
225
|
-
'X-Custom-Auth-Key': apiKey
|
|
214
|
+
const response = await fetchDataApi(
|
|
215
|
+
user,
|
|
216
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
217
|
+
{
|
|
218
|
+
method: 'DELETE'
|
|
226
219
|
}
|
|
227
|
-
|
|
220
|
+
);
|
|
228
221
|
|
|
229
222
|
if (!response.ok && response.status !== 404) {
|
|
230
223
|
throw new Error(`Failed to delete case data: ${response.status} ${response.statusText}`);
|
|
@@ -269,15 +262,13 @@ export const getFileAnnotations = async (
|
|
|
269
262
|
throw new Error('Invalid file ID provided');
|
|
270
263
|
}
|
|
271
264
|
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
headers: {
|
|
278
|
-
'X-Custom-Auth-Key': apiKey
|
|
265
|
+
const response = await fetchDataApi(
|
|
266
|
+
user,
|
|
267
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
268
|
+
{
|
|
269
|
+
method: 'GET'
|
|
279
270
|
}
|
|
280
|
-
|
|
271
|
+
);
|
|
281
272
|
|
|
282
273
|
if (response.status === 404) {
|
|
283
274
|
return null; // No annotations found
|
|
@@ -334,16 +325,14 @@ export const saveFileAnnotations = async (
|
|
|
334
325
|
throw new Error('Invalid annotation data provided');
|
|
335
326
|
}
|
|
336
327
|
|
|
337
|
-
const apiKey = await getDataApiKey();
|
|
338
|
-
const url = `${DATA_WORKER_URL}/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`;
|
|
339
|
-
|
|
340
328
|
// Enforce immutability once confirmation data exists on an image.
|
|
341
|
-
const existingResponse = await
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
329
|
+
const existingResponse = await fetchDataApi(
|
|
330
|
+
user,
|
|
331
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
332
|
+
{
|
|
333
|
+
method: 'GET'
|
|
345
334
|
}
|
|
346
|
-
|
|
335
|
+
);
|
|
347
336
|
|
|
348
337
|
if (existingResponse.ok) {
|
|
349
338
|
const existingAnnotations = await existingResponse.json() as AnnotationData;
|
|
@@ -360,14 +349,17 @@ export const saveFileAnnotations = async (
|
|
|
360
349
|
updatedAt: new Date().toISOString()
|
|
361
350
|
};
|
|
362
351
|
|
|
363
|
-
const response = await
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
'
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
352
|
+
const response = await fetchDataApi(
|
|
353
|
+
user,
|
|
354
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
355
|
+
{
|
|
356
|
+
method: 'PUT',
|
|
357
|
+
headers: {
|
|
358
|
+
'Content-Type': 'application/json'
|
|
359
|
+
},
|
|
360
|
+
body: JSON.stringify(dataToSave)
|
|
361
|
+
}
|
|
362
|
+
);
|
|
371
363
|
|
|
372
364
|
if (!response.ok) {
|
|
373
365
|
throw new Error(`Failed to save file annotations: ${response.status} ${response.statusText}`);
|
|
@@ -407,15 +399,13 @@ export const deleteFileAnnotations = async (
|
|
|
407
399
|
}
|
|
408
400
|
}
|
|
409
401
|
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
headers: {
|
|
416
|
-
'X-Custom-Auth-Key': apiKey
|
|
402
|
+
const response = await fetchDataApi(
|
|
403
|
+
user,
|
|
404
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
405
|
+
{
|
|
406
|
+
method: 'DELETE'
|
|
417
407
|
}
|
|
418
|
-
|
|
408
|
+
);
|
|
419
409
|
|
|
420
410
|
if (!response.ok && response.status !== 404) {
|
|
421
411
|
throw new Error(`Failed to delete file annotations: ${response.status} ${response.statusText}`);
|
|
@@ -684,12 +674,10 @@ export const signForensicManifest = async (
|
|
|
684
674
|
throw new Error(`Manifest signing denied: ${accessCheck.reason}`);
|
|
685
675
|
}
|
|
686
676
|
|
|
687
|
-
const
|
|
688
|
-
const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-manifest`, {
|
|
677
|
+
const response = await fetchDataApi(user, '/api/forensic/sign-manifest', {
|
|
689
678
|
method: 'POST',
|
|
690
679
|
headers: {
|
|
691
|
-
'Content-Type': 'application/json'
|
|
692
|
-
'X-Custom-Auth-Key': apiKey
|
|
680
|
+
'Content-Type': 'application/json'
|
|
693
681
|
},
|
|
694
682
|
body: JSON.stringify({
|
|
695
683
|
userId: user.uid,
|
|
@@ -752,12 +740,10 @@ export const signConfirmationData = async (
|
|
|
752
740
|
throw new Error(`Confirmation signing denied: ${accessCheck.reason}`);
|
|
753
741
|
}
|
|
754
742
|
|
|
755
|
-
const
|
|
756
|
-
const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-confirmation`, {
|
|
743
|
+
const response = await fetchDataApi(user, '/api/forensic/sign-confirmation', {
|
|
757
744
|
method: 'POST',
|
|
758
745
|
headers: {
|
|
759
|
-
'Content-Type': 'application/json'
|
|
760
|
-
'X-Custom-Auth-Key': apiKey
|
|
746
|
+
'Content-Type': 'application/json'
|
|
761
747
|
},
|
|
762
748
|
body: JSON.stringify({
|
|
763
749
|
userId: user.uid,
|
|
@@ -828,12 +814,10 @@ export const signAuditExportData = async (
|
|
|
828
814
|
}
|
|
829
815
|
}
|
|
830
816
|
|
|
831
|
-
const
|
|
832
|
-
const response = await fetch(`${DATA_WORKER_URL}/api/forensic/sign-audit-export`, {
|
|
817
|
+
const response = await fetchDataApi(user, '/api/forensic/sign-audit-export', {
|
|
833
818
|
method: 'POST',
|
|
834
819
|
headers: {
|
|
835
|
-
'Content-Type': 'application/json'
|
|
836
|
-
'X-Custom-Auth-Key': apiKey
|
|
820
|
+
'Content-Type': 'application/json'
|
|
837
821
|
},
|
|
838
822
|
body: JSON.stringify({
|
|
839
823
|
userId: user.uid,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import { type ImageUploadResponse } from '~/types';
|
|
3
|
+
|
|
4
|
+
const IMAGE_API_BASE = '/api/image';
|
|
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 fetchImageApi(
|
|
15
|
+
user: User,
|
|
16
|
+
path: string,
|
|
17
|
+
init: RequestInit = {}
|
|
18
|
+
): Promise<Response> {
|
|
19
|
+
const normalizedPath = normalizePath(path);
|
|
20
|
+
const userWithOptionalToken = user as User & { getIdToken?: () => Promise<string> };
|
|
21
|
+
|
|
22
|
+
if (typeof userWithOptionalToken.getIdToken !== 'function') {
|
|
23
|
+
throw new Error('Unable to authenticate request: missing Firebase token provider');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let idToken: string;
|
|
27
|
+
try {
|
|
28
|
+
idToken = await userWithOptionalToken.getIdToken();
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error('Unable to authenticate request: failed to retrieve Firebase token');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!idToken) {
|
|
34
|
+
throw new Error('Unable to authenticate request: empty Firebase token');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const headers = new Headers(init.headers);
|
|
38
|
+
headers.set('Authorization', `Bearer ${idToken}`);
|
|
39
|
+
|
|
40
|
+
return fetch(`${IMAGE_API_BASE}${normalizedPath}`, {
|
|
41
|
+
...init,
|
|
42
|
+
headers
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface XhrUploadResult {
|
|
47
|
+
status: number;
|
|
48
|
+
responseText: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function uploadWithXhr(
|
|
52
|
+
targetUrl: string,
|
|
53
|
+
authorizationValue: string,
|
|
54
|
+
file: File,
|
|
55
|
+
onProgress?: (progress: number) => void
|
|
56
|
+
): Promise<XhrUploadResult> {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const xhr = new XMLHttpRequest();
|
|
59
|
+
const formData = new FormData();
|
|
60
|
+
formData.append('file', file);
|
|
61
|
+
|
|
62
|
+
xhr.upload.addEventListener('progress', (event) => {
|
|
63
|
+
if (event.lengthComputable && onProgress) {
|
|
64
|
+
const progress = Math.round((event.loaded / event.total) * 100);
|
|
65
|
+
onProgress(progress);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
xhr.addEventListener('load', () => {
|
|
70
|
+
resolve({
|
|
71
|
+
status: xhr.status,
|
|
72
|
+
responseText: xhr.responseText
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
xhr.addEventListener('error', () => {
|
|
77
|
+
reject(new Error('Upload failed'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
xhr.open('POST', targetUrl);
|
|
81
|
+
xhr.setRequestHeader('Authorization', authorizationValue);
|
|
82
|
+
xhr.send(formData);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseUploadResponse(payload: string): ImageUploadResponse {
|
|
87
|
+
const parsed = JSON.parse(payload) as ImageUploadResponse;
|
|
88
|
+
if (!parsed.success || !parsed.result?.id) {
|
|
89
|
+
const errorMessage = parsed.errors?.map((entry) => entry.message).join(', ') || 'Upload failed';
|
|
90
|
+
throw new Error(errorMessage);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return parsed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function uploadImageApi(
|
|
97
|
+
user: User,
|
|
98
|
+
file: File,
|
|
99
|
+
onProgress?: (progress: number) => void
|
|
100
|
+
): Promise<ImageUploadResponse> {
|
|
101
|
+
const userWithOptionalToken = user as User & { getIdToken?: () => Promise<string> };
|
|
102
|
+
|
|
103
|
+
if (typeof userWithOptionalToken.getIdToken !== 'function') {
|
|
104
|
+
throw new Error('Unable to authenticate upload: missing Firebase token provider');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let idToken: string;
|
|
108
|
+
try {
|
|
109
|
+
idToken = await userWithOptionalToken.getIdToken();
|
|
110
|
+
} catch {
|
|
111
|
+
throw new Error('Unable to authenticate upload: failed to retrieve Firebase token');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!idToken) {
|
|
115
|
+
throw new Error('Unable to authenticate upload: empty Firebase token');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const proxyUploadResult = await uploadWithXhr(
|
|
119
|
+
`${IMAGE_API_BASE}/`,
|
|
120
|
+
`Bearer ${idToken}`,
|
|
121
|
+
file,
|
|
122
|
+
onProgress
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (proxyUploadResult.status < 200 || proxyUploadResult.status >= 300) {
|
|
126
|
+
throw new Error(`Upload failed with status ${proxyUploadResult.status}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return parseUploadResponse(proxyUploadResult.responseText);
|
|
130
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
|
|
3
|
+
const PDF_API_BASE = '/api/pdf';
|
|
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 fetchPdfApi(
|
|
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(`${PDF_API_BASE}${normalizedPath}`, {
|
|
40
|
+
...init,
|
|
41
|
+
headers
|
|
42
|
+
});
|
|
43
|
+
}
|
package/app/utils/permissions.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
2
|
import type { UserData, ExtendedUserData, UserLimits, ReadOnlyCaseMetadata } from '~/types';
|
|
3
3
|
import paths from '~/config/config.json';
|
|
4
|
-
import {
|
|
4
|
+
import { fetchUserApi } from './user-api-client';
|
|
5
5
|
|
|
6
|
-
const USER_WORKER_URL = paths.user_worker_url;
|
|
7
6
|
const MAX_CASES_REVIEW = paths.max_cases_review;
|
|
8
7
|
const MAX_FILES_PER_CASE_REVIEW = paths.max_files_per_case_review;
|
|
9
8
|
|
|
@@ -32,12 +31,8 @@ export interface CaseMetadata {
|
|
|
32
31
|
*/
|
|
33
32
|
export const getUserData = async (user: User): Promise<UserData | null> => {
|
|
34
33
|
try {
|
|
35
|
-
const
|
|
36
|
-
const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
|
|
34
|
+
const response = await fetchUserApi(user, `/${encodeURIComponent(user.uid)}`, {
|
|
37
35
|
method: 'GET',
|
|
38
|
-
headers: {
|
|
39
|
-
'X-Custom-Auth-Key': apiKey
|
|
40
|
-
}
|
|
41
36
|
});
|
|
42
37
|
|
|
43
38
|
if (response.ok) {
|
|
@@ -121,12 +116,10 @@ export const createUser = async (
|
|
|
121
116
|
createdAt: new Date().toISOString()
|
|
122
117
|
};
|
|
123
118
|
|
|
124
|
-
const
|
|
125
|
-
const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
|
|
119
|
+
const response = await fetchUserApi(user, `/${encodeURIComponent(user.uid)}`, {
|
|
126
120
|
method: 'PUT',
|
|
127
121
|
headers: {
|
|
128
|
-
'Content-Type': 'application/json'
|
|
129
|
-
'X-Custom-Auth-Key': apiKey
|
|
122
|
+
'Content-Type': 'application/json'
|
|
130
123
|
},
|
|
131
124
|
body: JSON.stringify(userData)
|
|
132
125
|
});
|
|
@@ -280,12 +273,10 @@ export const updateUserData = async (user: User, updates: Partial<UserData>): Pr
|
|
|
280
273
|
};
|
|
281
274
|
|
|
282
275
|
// Perform the update with API key management
|
|
283
|
-
const
|
|
284
|
-
const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}`, {
|
|
276
|
+
const response = await fetchUserApi(user, `/${encodeURIComponent(user.uid)}`, {
|
|
285
277
|
method: 'PUT',
|
|
286
278
|
headers: {
|
|
287
|
-
'Content-Type': 'application/json'
|
|
288
|
-
'X-Custom-Auth-Key': apiKey
|
|
279
|
+
'Content-Type': 'application/json'
|
|
289
280
|
},
|
|
290
281
|
body: JSON.stringify(updatedUserData)
|
|
291
282
|
});
|
|
@@ -489,12 +480,10 @@ export const addUserCase = async (user: User, caseData: CaseMetadata): Promise<v
|
|
|
489
480
|
}
|
|
490
481
|
|
|
491
482
|
// Use the dedicated /cases endpoint to add the case
|
|
492
|
-
const
|
|
493
|
-
const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}/cases`, {
|
|
483
|
+
const response = await fetchUserApi(user, `/${encodeURIComponent(user.uid)}/cases`, {
|
|
494
484
|
method: 'PUT',
|
|
495
485
|
headers: {
|
|
496
|
-
'Content-Type': 'application/json'
|
|
497
|
-
'X-Custom-Auth-Key': apiKey
|
|
486
|
+
'Content-Type': 'application/json'
|
|
498
487
|
},
|
|
499
488
|
body: JSON.stringify({
|
|
500
489
|
cases: [caseData]
|
|
@@ -536,12 +525,10 @@ export const removeUserCase = async (user: User, caseNumber: string): Promise<vo
|
|
|
536
525
|
}
|
|
537
526
|
|
|
538
527
|
// Use the dedicated /cases DELETE endpoint to remove the case
|
|
539
|
-
const
|
|
540
|
-
const response = await fetch(`${USER_WORKER_URL}/${encodeURIComponent(user.uid)}/cases`, {
|
|
528
|
+
const response = await fetchUserApi(user, `/${encodeURIComponent(user.uid)}/cases`, {
|
|
541
529
|
method: 'DELETE',
|
|
542
530
|
headers: {
|
|
543
|
-
'Content-Type': 'application/json'
|
|
544
|
-
'X-Custom-Auth-Key': apiKey
|
|
531
|
+
'Content-Type': 'application/json'
|
|
545
532
|
},
|
|
546
533
|
body: JSON.stringify({
|
|
547
534
|
casesToDelete: [caseNumber]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
|
|
3
|
+
const USER_API_BASE = '/api/user';
|
|
4
|
+
const USER_EXISTS_API_BASE = '/api/user/exists';
|
|
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 fetchUserApi(
|
|
15
|
+
user: User,
|
|
16
|
+
path: string,
|
|
17
|
+
init: RequestInit = {}
|
|
18
|
+
): Promise<Response> {
|
|
19
|
+
const normalizedPath = normalizePath(path);
|
|
20
|
+
const userWithOptionalToken = user as User & { getIdToken?: () => Promise<string> };
|
|
21
|
+
|
|
22
|
+
if (typeof userWithOptionalToken.getIdToken !== 'function') {
|
|
23
|
+
throw new Error('Unable to authenticate request: missing Firebase token provider');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let idToken: string;
|
|
27
|
+
try {
|
|
28
|
+
idToken = await userWithOptionalToken.getIdToken();
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error('Unable to authenticate request: failed to retrieve Firebase token');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!idToken) {
|
|
34
|
+
throw new Error('Unable to authenticate request: empty Firebase token');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const headers = new Headers(init.headers);
|
|
38
|
+
headers.set('Authorization', `Bearer ${idToken}`);
|
|
39
|
+
|
|
40
|
+
return fetch(`${USER_API_BASE}${normalizedPath}`, {
|
|
41
|
+
...init,
|
|
42
|
+
headers
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function checkUserExistsApi(user: User, targetUid: string): Promise<boolean> {
|
|
47
|
+
const encodedTargetUid = encodeURIComponent(targetUid);
|
|
48
|
+
const userWithOptionalToken = user as User & { getIdToken?: () => Promise<string> };
|
|
49
|
+
|
|
50
|
+
if (typeof userWithOptionalToken.getIdToken !== 'function') {
|
|
51
|
+
throw new Error('Unable to authenticate request: missing Firebase token provider');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let idToken: string;
|
|
55
|
+
try {
|
|
56
|
+
idToken = await userWithOptionalToken.getIdToken();
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error('Unable to authenticate request: failed to retrieve Firebase token');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!idToken) {
|
|
62
|
+
throw new Error('Unable to authenticate request: empty Firebase token');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const proxyResponse = await fetch(`${USER_EXISTS_API_BASE}/${encodedTargetUid}`, {
|
|
66
|
+
method: 'GET',
|
|
67
|
+
headers: {
|
|
68
|
+
'Authorization': `Bearer ${idToken}`,
|
|
69
|
+
'Accept': 'application/json'
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (proxyResponse.status === 404) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!proxyResponse.ok) {
|
|
78
|
+
throw new Error(`Failed to verify user existence: ${proxyResponse.status}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const responseData = await proxyResponse.json().catch(() => null) as {
|
|
82
|
+
exists?: boolean;
|
|
83
|
+
} | null;
|
|
84
|
+
|
|
85
|
+
if (typeof responseData?.exists === 'boolean') {
|
|
86
|
+
return responseData.exists;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return true;
|
|
90
|
+
}
|