@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
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
return (
|
|
91
|
+
<AuthProvider>
|
|
92
|
+
<Outlet />
|
|
93
|
+
</AuthProvider>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
97
96
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
115
|
-
<
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
<
|
|
143
|
-
<
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 (
|
|
217
|
+
const checkUserExists = async (currentUser: User): Promise<boolean> => {
|
|
218
218
|
try {
|
|
219
|
-
|
|
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
|
|
258
|
+
const userExists = await checkUserExists(currentUser);
|
|
261
259
|
setIsCheckingUser(false);
|
|
262
260
|
|
|
263
261
|
if (!userExists) {
|
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/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
|
}
|
|
@@ -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
|
+
}
|