@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.
- package/.env.example +1 -1
- package/app/components/actions/case-export/core-export.ts +5 -2
- package/app/components/actions/case-export/download-handlers.ts +51 -3
- package/app/components/actions/case-import/confirmation-import.ts +65 -40
- package/app/components/actions/case-import/confirmation-package.ts +86 -0
- package/app/components/actions/case-import/image-operations.ts +20 -49
- package/app/components/actions/case-import/index.ts +1 -0
- package/app/components/actions/case-import/orchestrator.ts +13 -3
- package/app/components/actions/case-import/storage-operations.ts +54 -89
- package/app/components/actions/case-import/validation.ts +7 -111
- package/app/components/actions/case-import/zip-processing.ts +44 -2
- package/app/components/actions/case-manage.ts +15 -27
- package/app/components/actions/confirm-export.ts +44 -13
- 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/form/form-button.tsx +1 -1
- package/app/components/form/form.module.css +9 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +163 -49
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +365 -88
- package/app/components/sidebar/case-export/case-export.tsx +13 -60
- package/app/components/sidebar/case-import/case-import.tsx +18 -6
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +6 -4
- package/app/components/sidebar/case-import/utils/file-validation.ts +57 -2
- package/app/components/sidebar/cases/case-sidebar.tsx +122 -52
- package/app/components/sidebar/cases/cases.module.css +101 -18
- package/app/components/sidebar/notes/notes.module.css +33 -13
- package/app/components/sidebar/sidebar.module.css +0 -2
- package/app/components/user/delete-account.tsx +7 -7
- package/app/components/user/manage-profile.tsx +1 -1
- package/app/components/user/mfa-phone-update.tsx +15 -12
- package/app/config-example/config.json +2 -8
- package/app/hooks/useInactivityTimeout.ts +2 -5
- package/app/root.tsx +96 -65
- package/app/routes/auth/login.tsx +132 -11
- 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/SHA256.ts +5 -1
- package/app/utils/auth.ts +5 -32
- package/app/utils/confirmation-signature.ts +5 -1
- package/app/utils/data-api-client.ts +43 -0
- package/app/utils/data-operations.ts +59 -75
- package/app/utils/export-verification.ts +353 -0
- 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/signature-utils.ts +74 -4
- 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 +8 -4
- package/public/favicon.ico +0 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.json +39 -0
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/react-router.config.ts +5 -0
- 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/scripts/generate-assets.js +94 -0
- 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
- 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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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 (
|
|
217
|
+
const checkUserExists = async (currentUser: User): Promise<boolean> => {
|
|
117
218
|
try {
|
|
118
|
-
|
|
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
|
|
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
|
);
|
package/app/routes/auth/route.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
80
|
-
|
|
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
|
|
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 {
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
39
|
+
searchParams.set('startDate', params.startDate);
|
|
44
40
|
}
|
|
45
41
|
|
|
46
42
|
if (params.endDate) {
|
|
47
|
-
|
|
43
|
+
searchParams.set('endDate', params.endDate);
|
|
48
44
|
}
|
|
49
45
|
|
|
50
|
-
const
|
|
46
|
+
const requestPath = `/audit/?${searchParams.toString()}`;
|
|
47
|
+
|
|
48
|
+
const response = await fetchAuditApi(requestPath, {
|
|
51
49
|
method: 'GET',
|
|
52
50
|
headers: {
|
|
53
|
-
'
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
const searchParams = new URLSearchParams();
|
|
67
|
+
searchParams.set('userId', entry.userId);
|
|
68
|
+
const requestPath = `/audit/?${searchParams.toString()}`;
|
|
71
69
|
|
|
72
|
-
const response = await
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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)
|
|
83
|
-
color: color-mix(in lab, var(--primary)
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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:
|
|
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:
|
|
84
|
+
transition:
|
|
85
|
+
background var(--durationM) ease,
|
|
86
|
+
opacity var(--durationM) ease;
|
|
80
87
|
opacity: 1;
|
|
81
88
|
}
|
|
82
89
|
|
package/app/utils/SHA256.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
34
|
-
|
|
6
|
+
if (!ACCOUNT_HASH) {
|
|
7
|
+
throw new Error('ACCOUNT_HASH is not configured in app/config/config.json');
|
|
8
|
+
}
|
|
35
9
|
|
|
36
|
-
|
|
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
|
+
}
|