@striae-org/striae 3.0.4
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 +100 -0
- package/LICENSE +190 -0
- package/NOTICE +18 -0
- package/README.md +133 -0
- package/app/components/actions/case-export/core-export.ts +328 -0
- package/app/components/actions/case-export/data-processing.ts +167 -0
- package/app/components/actions/case-export/download-handlers.ts +900 -0
- package/app/components/actions/case-export/index.ts +41 -0
- package/app/components/actions/case-export/metadata-helpers.ts +107 -0
- package/app/components/actions/case-export/types-constants.ts +56 -0
- package/app/components/actions/case-export/validation-utils.ts +25 -0
- package/app/components/actions/case-export.ts +4 -0
- package/app/components/actions/case-import/annotation-import.ts +35 -0
- package/app/components/actions/case-import/confirmation-import.ts +363 -0
- package/app/components/actions/case-import/image-operations.ts +61 -0
- package/app/components/actions/case-import/index.ts +39 -0
- package/app/components/actions/case-import/orchestrator.ts +420 -0
- package/app/components/actions/case-import/storage-operations.ts +270 -0
- package/app/components/actions/case-import/validation.ts +189 -0
- package/app/components/actions/case-import/zip-processing.ts +413 -0
- package/app/components/actions/case-manage.ts +524 -0
- package/app/components/actions/case-review.ts +4 -0
- package/app/components/actions/confirm-export.ts +351 -0
- package/app/components/actions/generate-pdf.ts +210 -0
- package/app/components/actions/image-manage.ts +385 -0
- package/app/components/actions/notes-manage.ts +33 -0
- package/app/components/actions/signout.module.css +15 -0
- package/app/components/actions/signout.tsx +50 -0
- package/app/components/audit/user-audit-viewer.tsx +975 -0
- package/app/components/audit/user-audit.module.css +568 -0
- package/app/components/auth/auth-provider.tsx +78 -0
- package/app/components/auth/mfa-enrollment.module.css +268 -0
- package/app/components/auth/mfa-enrollment.tsx +398 -0
- package/app/components/auth/mfa-verification.module.css +251 -0
- package/app/components/auth/mfa-verification.tsx +295 -0
- package/app/components/button/button.module.css +63 -0
- package/app/components/button/button.tsx +46 -0
- package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
- package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
- package/app/components/canvas/canvas.module.css +314 -0
- package/app/components/canvas/canvas.tsx +449 -0
- package/app/components/canvas/confirmation/confirmation.module.css +187 -0
- package/app/components/canvas/confirmation/confirmation.tsx +214 -0
- package/app/components/colors/colors.module.css +59 -0
- package/app/components/colors/colors.tsx +68 -0
- package/app/components/form/base-form.tsx +21 -0
- package/app/components/form/form-button.tsx +28 -0
- package/app/components/form/form-field.tsx +53 -0
- package/app/components/form/form-message.tsx +17 -0
- package/app/components/form/form-toggle.tsx +23 -0
- package/app/components/form/form.module.css +427 -0
- package/app/components/form/index.ts +6 -0
- package/app/components/icon/icon.module.css +3 -0
- package/app/components/icon/icon.tsx +27 -0
- package/app/components/icon/icons.svg +102 -0
- package/app/components/icon/manifest.json +110 -0
- package/app/components/sidebar/case-export/case-export.module.css +386 -0
- package/app/components/sidebar/case-export/case-export.tsx +317 -0
- package/app/components/sidebar/case-import/case-import.module.css +626 -0
- package/app/components/sidebar/case-import/case-import.tsx +404 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
- package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
- package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
- package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
- package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
- package/app/components/sidebar/case-import/index.ts +18 -0
- package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
- package/app/components/sidebar/cases/cases-modal.module.css +166 -0
- package/app/components/sidebar/cases/cases-modal.tsx +201 -0
- package/app/components/sidebar/cases/cases.module.css +713 -0
- package/app/components/sidebar/files/files-modal.module.css +209 -0
- package/app/components/sidebar/files/files-modal.tsx +239 -0
- package/app/components/sidebar/hash/hash-utility.module.css +366 -0
- package/app/components/sidebar/hash/hash-utility.tsx +982 -0
- package/app/components/sidebar/notes/notes-modal.tsx +51 -0
- package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
- package/app/components/sidebar/notes/notes.module.css +360 -0
- package/app/components/sidebar/sidebar-container.tsx +149 -0
- package/app/components/sidebar/sidebar.module.css +321 -0
- package/app/components/sidebar/sidebar.tsx +215 -0
- package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
- package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
- package/app/components/theme-provider/theme-provider.tsx +131 -0
- package/app/components/theme-provider/theme.ts +155 -0
- package/app/components/toast/toast.module.css +137 -0
- package/app/components/toast/toast.tsx +56 -0
- package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
- package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
- package/app/components/toolbar/toolbar.module.css +42 -0
- package/app/components/toolbar/toolbar.tsx +167 -0
- package/app/components/user/delete-account.module.css +274 -0
- package/app/components/user/delete-account.tsx +471 -0
- package/app/components/user/inactivity-warning.module.css +145 -0
- package/app/components/user/inactivity-warning.tsx +84 -0
- package/app/components/user/manage-profile.module.css +190 -0
- package/app/components/user/manage-profile.tsx +253 -0
- package/app/components/user/mfa-phone-update.tsx +739 -0
- package/app/config-example/admin-service.json +13 -0
- package/app/config-example/config.json +17 -0
- package/app/config-example/firebase.ts +21 -0
- package/app/config-example/inactivity.ts +13 -0
- package/app/config-example/meta-config.json +6 -0
- package/app/contexts/auth.context.ts +12 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +44 -0
- package/app/hooks/useInactivityTimeout.ts +110 -0
- package/app/root.tsx +170 -0
- package/app/routes/_index.tsx +16 -0
- package/app/routes/auth/emailActionHandler.module.css +232 -0
- package/app/routes/auth/emailActionHandler.tsx +405 -0
- package/app/routes/auth/emailVerification.tsx +120 -0
- package/app/routes/auth/login.module.css +523 -0
- package/app/routes/auth/login.tsx +654 -0
- package/app/routes/auth/passwordReset.module.css +274 -0
- package/app/routes/auth/passwordReset.tsx +154 -0
- package/app/routes/auth/route.ts +16 -0
- package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
- package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
- package/app/routes/mobile-prevented/route.ts +14 -0
- package/app/routes/striae/striae.module.css +30 -0
- package/app/routes/striae/striae.tsx +417 -0
- package/app/services/audit-export.service.ts +755 -0
- package/app/services/audit.service.ts +1454 -0
- package/app/services/firebase-errors.ts +106 -0
- package/app/services/firebase.ts +15 -0
- package/app/styles/legal-pages.module.css +113 -0
- package/app/styles/root.module.css +146 -0
- package/app/tailwind.css +225 -0
- package/app/types/annotations.ts +45 -0
- package/app/types/audit.ts +301 -0
- package/app/types/case.ts +90 -0
- package/app/types/export.ts +8 -0
- package/app/types/file.ts +30 -0
- package/app/types/import.ts +107 -0
- package/app/types/index.ts +24 -0
- package/app/types/user.ts +38 -0
- package/app/utils/SHA256.ts +461 -0
- package/app/utils/annotation-timestamp.ts +25 -0
- package/app/utils/audit-export-signature.ts +117 -0
- package/app/utils/auth-action-settings.ts +48 -0
- package/app/utils/auth.ts +34 -0
- package/app/utils/batch-operations.ts +135 -0
- package/app/utils/confirmation-signature.ts +193 -0
- package/app/utils/data-operations.ts +871 -0
- package/app/utils/device-detection.ts +5 -0
- package/app/utils/html-sanitizer.ts +80 -0
- package/app/utils/id-generator.ts +36 -0
- package/app/utils/meta.ts +48 -0
- package/app/utils/mfa-phone.ts +97 -0
- package/app/utils/mfa.ts +79 -0
- package/app/utils/password-policy.ts +28 -0
- package/app/utils/permissions.ts +562 -0
- package/app/utils/signature-utils.ts +160 -0
- package/app/utils/style.ts +83 -0
- package/app/utils/version.ts +5 -0
- package/firebase.json +11 -0
- package/functions/[[path]].ts +10 -0
- package/package.json +138 -0
- package/postcss.config.js +6 -0
- package/public/.well-known/publickey.info@striae.org.asc +17 -0
- package/public/.well-known/security.txt +7 -0
- package/public/_headers +28 -0
- package/public/_routes.json +13 -0
- package/public/assets/striae.jpg +0 -0
- package/public/clear.jpg +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/logo-dark.png +0 -0
- package/public/manifest.json +25 -0
- package/public/oin-badge.png +0 -0
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/striae-ascii.txt +10 -0
- package/scripts/deploy-all.sh +100 -0
- package/scripts/deploy-config.sh +940 -0
- package/scripts/deploy-pages.sh +34 -0
- package/scripts/deploy-worker-secrets.sh +215 -0
- package/scripts/dev.cjs +23 -0
- package/scripts/install-workers.sh +88 -0
- package/scripts/run-eslint.cjs +35 -0
- package/scripts/update-compatibility-dates.cjs +124 -0
- package/scripts/update-markdown-versions.cjs +43 -0
- package/tailwind.config.ts +22 -0
- package/tsconfig.json +33 -0
- package/vite.config.ts +35 -0
- package/worker-configuration.d.ts +7490 -0
- package/workers/audit-worker/package.json +17 -0
- package/workers/audit-worker/src/audit-worker.example.ts +195 -0
- package/workers/audit-worker/worker-configuration.d.ts +7448 -0
- package/workers/audit-worker/wrangler.jsonc.example +29 -0
- package/workers/data-worker/package.json +17 -0
- package/workers/data-worker/src/data-worker.example.ts +267 -0
- package/workers/data-worker/src/signature-utils.ts +79 -0
- package/workers/data-worker/src/signing-payload-utils.ts +290 -0
- package/workers/data-worker/worker-configuration.d.ts +7448 -0
- package/workers/data-worker/wrangler.jsonc.example +30 -0
- package/workers/image-worker/package.json +17 -0
- package/workers/image-worker/src/image-worker.example.ts +180 -0
- package/workers/image-worker/worker-configuration.d.ts +7447 -0
- package/workers/image-worker/wrangler.jsonc.example +22 -0
- package/workers/keys-worker/package.json +17 -0
- package/workers/keys-worker/src/keys.example.ts +66 -0
- package/workers/keys-worker/src/keys.ts +66 -0
- package/workers/keys-worker/worker-configuration.d.ts +7447 -0
- package/workers/keys-worker/wrangler.jsonc.example +22 -0
- package/workers/pdf-worker/package.json +17 -0
- package/workers/pdf-worker/src/format-striae.ts +534 -0
- package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
- package/workers/pdf-worker/src/report-types.ts +69 -0
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
- package/workers/pdf-worker/wrangler.jsonc.example +26 -0
- package/workers/user-worker/package.json +17 -0
- package/workers/user-worker/src/user-worker.example.ts +636 -0
- package/workers/user-worker/worker-configuration.d.ts +7448 -0
- package/workers/user-worker/wrangler.jsonc.example +29 -0
- package/wrangler.toml.example +8 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "PDF_WORKER_NAME",
|
|
3
|
+
"account_id": "ACCOUNT_ID",
|
|
4
|
+
"main": "src/pdf-worker.ts",
|
|
5
|
+
"compatibility_date": "2026-03-09",
|
|
6
|
+
"compatibility_flags": [
|
|
7
|
+
"nodejs_compat"
|
|
8
|
+
],
|
|
9
|
+
|
|
10
|
+
"browser": {
|
|
11
|
+
"binding": "BROWSER"
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
"observability": {
|
|
15
|
+
"enabled": true
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
"routes": [
|
|
19
|
+
{
|
|
20
|
+
"pattern": "PDF_WORKER_DOMAIN",
|
|
21
|
+
"custom_domain": true
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
|
|
25
|
+
"placement": { "mode": "smart" }
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "user-worker",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"deploy": "wrangler deploy",
|
|
7
|
+
"dev": "wrangler dev",
|
|
8
|
+
"start": "wrangler dev",
|
|
9
|
+
"test": "vitest"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@cloudflare/puppeteer": "^1.0.4",
|
|
13
|
+
"@cloudflare/vitest-pool-workers": "^0.12.9",
|
|
14
|
+
"vitest": "~3.2.0",
|
|
15
|
+
"wrangler": "^4.69.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
interface Env {
|
|
2
|
+
USER_DB_AUTH: string;
|
|
3
|
+
USER_DB: KVNamespace;
|
|
4
|
+
R2_KEY_SECRET: string;
|
|
5
|
+
IMAGES_API_TOKEN: string;
|
|
6
|
+
PROJECT_ID: string;
|
|
7
|
+
FIREBASE_SERVICE_ACCOUNT_EMAIL: string;
|
|
8
|
+
FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface UserData {
|
|
12
|
+
uid: string;
|
|
13
|
+
email: string;
|
|
14
|
+
firstName: string;
|
|
15
|
+
lastName: string;
|
|
16
|
+
company: string;
|
|
17
|
+
permitted: boolean;
|
|
18
|
+
cases: CaseItem[];
|
|
19
|
+
readOnlyCases?: ReadOnlyCaseItem[];
|
|
20
|
+
createdAt?: string;
|
|
21
|
+
updatedAt?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CaseItem {
|
|
25
|
+
caseNumber: string;
|
|
26
|
+
caseName?: string;
|
|
27
|
+
[key: string]: any;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ReadOnlyCaseItem {
|
|
31
|
+
caseNumber: string;
|
|
32
|
+
caseName?: string;
|
|
33
|
+
[key: string]: any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface UserRequestData {
|
|
37
|
+
email?: string;
|
|
38
|
+
firstName?: string;
|
|
39
|
+
lastName?: string;
|
|
40
|
+
company?: string;
|
|
41
|
+
permitted?: boolean;
|
|
42
|
+
readOnlyCases?: ReadOnlyCaseItem[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface AddCasesRequest {
|
|
46
|
+
cases: CaseItem[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface DeleteCasesRequest {
|
|
50
|
+
casesToDelete: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface CaseData {
|
|
54
|
+
files?: Array<{ id: string; [key: string]: any }>;
|
|
55
|
+
[key: string]: any;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface AccountDeletionProgressEvent {
|
|
59
|
+
event: 'start' | 'case-start' | 'case-complete' | 'complete' | 'error';
|
|
60
|
+
totalCases: number;
|
|
61
|
+
completedCases: number;
|
|
62
|
+
currentCaseNumber?: string;
|
|
63
|
+
success?: boolean;
|
|
64
|
+
message?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface GoogleOAuthTokenResponse {
|
|
68
|
+
access_token?: string;
|
|
69
|
+
error?: string;
|
|
70
|
+
error_description?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface FirebaseDeleteAccountErrorResponse {
|
|
74
|
+
error?: {
|
|
75
|
+
message?: string;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const corsHeaders: Record<string, string> = {
|
|
80
|
+
'Access-Control-Allow-Origin': 'PAGES_CUSTOM_DOMAIN',
|
|
81
|
+
'Access-Control-Allow-Methods': 'GET, PUT, DELETE, OPTIONS',
|
|
82
|
+
'Access-Control-Allow-Headers': 'Content-Type, X-Custom-Auth-Key',
|
|
83
|
+
'Content-Type': 'application/json'
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Worker URLs - configure these for deployment
|
|
87
|
+
const DATA_WORKER_URL = 'DATA_WORKER_DOMAIN';
|
|
88
|
+
|
|
89
|
+
const IMAGE_WORKER_URL = 'IMAGES_WORKER_DOMAIN';
|
|
90
|
+
|
|
91
|
+
const GOOGLE_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
92
|
+
const FIREBASE_IDENTITY_TOOLKIT_BASE_URL = 'https://identitytoolkit.googleapis.com/v1/projects';
|
|
93
|
+
const GOOGLE_IDENTITY_TOOLKIT_SCOPE = 'https://www.googleapis.com/auth/identitytoolkit';
|
|
94
|
+
const textEncoder = new TextEncoder();
|
|
95
|
+
|
|
96
|
+
async function authenticate(request: Request, env: Env): Promise<void> {
|
|
97
|
+
const authKey = request.headers.get('X-Custom-Auth-Key');
|
|
98
|
+
if (authKey !== env.USER_DB_AUTH) throw new Error('Unauthorized');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function base64UrlEncode(value: string | Uint8Array): string {
|
|
102
|
+
const bytes = typeof value === 'string' ? textEncoder.encode(value) : value;
|
|
103
|
+
let binary = '';
|
|
104
|
+
|
|
105
|
+
for (const byte of bytes) {
|
|
106
|
+
binary += String.fromCharCode(byte);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return btoa(binary)
|
|
110
|
+
.replace(/\+/g, '-')
|
|
111
|
+
.replace(/\//g, '_')
|
|
112
|
+
.replace(/=+$/g, '');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parsePkcs8PrivateKey(privateKey: string): ArrayBuffer {
|
|
116
|
+
const normalizedKey = privateKey
|
|
117
|
+
.trim()
|
|
118
|
+
.replace(/^['"]|['"]$/g, '')
|
|
119
|
+
.replace(/\\n/g, '\n');
|
|
120
|
+
|
|
121
|
+
const pemBody = normalizedKey
|
|
122
|
+
.replace('-----BEGIN PRIVATE KEY-----', '')
|
|
123
|
+
.replace('-----END PRIVATE KEY-----', '')
|
|
124
|
+
.replace(/\s+/g, '');
|
|
125
|
+
|
|
126
|
+
if (!pemBody) {
|
|
127
|
+
throw new Error('Firebase service account private key is invalid');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const binary = atob(pemBody);
|
|
131
|
+
const bytes = new Uint8Array(binary.length);
|
|
132
|
+
|
|
133
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
134
|
+
bytes[index] = binary.charCodeAt(index);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return bytes.buffer;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function buildServiceAccountAssertion(env: Env): Promise<string> {
|
|
141
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
142
|
+
const header = {
|
|
143
|
+
alg: 'RS256',
|
|
144
|
+
typ: 'JWT'
|
|
145
|
+
};
|
|
146
|
+
const payload = {
|
|
147
|
+
iss: env.FIREBASE_SERVICE_ACCOUNT_EMAIL,
|
|
148
|
+
scope: GOOGLE_IDENTITY_TOOLKIT_SCOPE,
|
|
149
|
+
aud: GOOGLE_OAUTH_TOKEN_URL,
|
|
150
|
+
iat: issuedAt,
|
|
151
|
+
exp: issuedAt + 3600
|
|
152
|
+
};
|
|
153
|
+
const unsignedToken = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
|
|
154
|
+
|
|
155
|
+
let signingKey: CryptoKey;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
signingKey = await crypto.subtle.importKey(
|
|
159
|
+
'pkcs8',
|
|
160
|
+
parsePkcs8PrivateKey(env.FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY),
|
|
161
|
+
{
|
|
162
|
+
name: 'RSASSA-PKCS1-v1_5',
|
|
163
|
+
hash: 'SHA-256'
|
|
164
|
+
},
|
|
165
|
+
false,
|
|
166
|
+
['sign']
|
|
167
|
+
);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new Error('Invalid Firebase service account private key format. Use the service account JSON private_key value (PKCS8) and keep newline markers as \\n.');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const signature = await crypto.subtle.sign(
|
|
173
|
+
{ name: 'RSASSA-PKCS1-v1_5' },
|
|
174
|
+
signingKey,
|
|
175
|
+
textEncoder.encode(unsignedToken)
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return `${unsignedToken}.${base64UrlEncode(new Uint8Array(signature))}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function getGoogleAccessToken(env: Env): Promise<string> {
|
|
182
|
+
const assertion = await buildServiceAccountAssertion(env);
|
|
183
|
+
const body = new URLSearchParams({
|
|
184
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
185
|
+
assertion
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const tokenResponse = await fetch(GOOGLE_OAUTH_TOKEN_URL, {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: {
|
|
191
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
192
|
+
},
|
|
193
|
+
body
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const tokenData = await tokenResponse.json().catch(() => ({})) as GoogleOAuthTokenResponse;
|
|
197
|
+
if (!tokenResponse.ok || !tokenData.access_token) {
|
|
198
|
+
const errorReason = tokenData.error_description || tokenData.error || `HTTP ${tokenResponse.status}`;
|
|
199
|
+
throw new Error(`Failed to authorize Firebase admin deletion: ${errorReason}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return tokenData.access_token;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function deleteFirebaseAuthUser(env: Env, userUid: string): Promise<void> {
|
|
206
|
+
if (!env.PROJECT_ID || !env.FIREBASE_SERVICE_ACCOUNT_EMAIL || !env.FIREBASE_SERVICE_ACCOUNT_PRIVATE_KEY) {
|
|
207
|
+
throw new Error('Firebase Auth deletion is not configured in User Worker secrets');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const accessToken = await getGoogleAccessToken(env);
|
|
211
|
+
const deleteResponse = await fetch(
|
|
212
|
+
`${FIREBASE_IDENTITY_TOOLKIT_BASE_URL}/${encodeURIComponent(env.PROJECT_ID)}/accounts:delete`,
|
|
213
|
+
{
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: {
|
|
216
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
217
|
+
'Content-Type': 'application/json'
|
|
218
|
+
},
|
|
219
|
+
body: JSON.stringify({ localId: userUid })
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
if (deleteResponse.ok) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const deleteErrorPayload = await deleteResponse.json().catch(() => ({})) as FirebaseDeleteAccountErrorResponse;
|
|
228
|
+
const deleteErrorMessage = deleteErrorPayload.error?.message || '';
|
|
229
|
+
|
|
230
|
+
if (deleteErrorMessage.includes('USER_NOT_FOUND')) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw new Error(deleteErrorMessage ? `Firebase Auth deletion failed: ${deleteErrorMessage}` : `Firebase Auth deletion failed with status ${deleteResponse.status}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function handleGetUser(env: Env, userUid: string): Promise<Response> {
|
|
238
|
+
try {
|
|
239
|
+
const value = await env.USER_DB.get(userUid);
|
|
240
|
+
if (value === null) {
|
|
241
|
+
return new Response('User not found', {
|
|
242
|
+
status: 404,
|
|
243
|
+
headers: corsHeaders
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return new Response(value, {
|
|
247
|
+
status: 200,
|
|
248
|
+
headers: corsHeaders
|
|
249
|
+
});
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return new Response('Failed to get user data', {
|
|
252
|
+
status: 500,
|
|
253
|
+
headers: corsHeaders
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function handleAddUser(request: Request, env: Env, userUid: string): Promise<Response> {
|
|
259
|
+
try {
|
|
260
|
+
const requestData: UserRequestData = await request.json();
|
|
261
|
+
const { email, firstName, lastName, company, permitted } = requestData;
|
|
262
|
+
|
|
263
|
+
// Check for existing user
|
|
264
|
+
const value = await env.USER_DB.get(userUid);
|
|
265
|
+
|
|
266
|
+
let userData: UserData;
|
|
267
|
+
if (value !== null) {
|
|
268
|
+
// Update existing user, preserving cases
|
|
269
|
+
const existing: UserData = JSON.parse(value);
|
|
270
|
+
userData = {
|
|
271
|
+
...existing,
|
|
272
|
+
// Preserve all existing fields
|
|
273
|
+
email: email || existing.email,
|
|
274
|
+
firstName: firstName || existing.firstName,
|
|
275
|
+
lastName: lastName || existing.lastName,
|
|
276
|
+
company: company || existing.company,
|
|
277
|
+
permitted: permitted !== undefined ? permitted : existing.permitted,
|
|
278
|
+
updatedAt: new Date().toISOString()
|
|
279
|
+
};
|
|
280
|
+
if (requestData.readOnlyCases !== undefined) {
|
|
281
|
+
userData.readOnlyCases = requestData.readOnlyCases;
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
// Create new user
|
|
285
|
+
userData = {
|
|
286
|
+
uid: userUid,
|
|
287
|
+
email: email || '',
|
|
288
|
+
firstName: firstName || '',
|
|
289
|
+
lastName: lastName || '',
|
|
290
|
+
company: company || '',
|
|
291
|
+
permitted: permitted !== undefined ? permitted : true,
|
|
292
|
+
cases: [],
|
|
293
|
+
createdAt: new Date().toISOString()
|
|
294
|
+
};
|
|
295
|
+
if (requestData.readOnlyCases !== undefined) {
|
|
296
|
+
userData.readOnlyCases = requestData.readOnlyCases;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Store value in KV
|
|
301
|
+
await env.USER_DB.put(userUid, JSON.stringify(userData));
|
|
302
|
+
|
|
303
|
+
return new Response(JSON.stringify(userData), {
|
|
304
|
+
status: value !== null ? 200 : 201,
|
|
305
|
+
headers: corsHeaders
|
|
306
|
+
});
|
|
307
|
+
} catch (error) {
|
|
308
|
+
return new Response('Failed to save user data', {
|
|
309
|
+
status: 500,
|
|
310
|
+
headers: corsHeaders
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Function to delete a single case (similar to case-manage.ts deleteCase)
|
|
316
|
+
async function deleteSingleCase(env: Env, userUid: string, caseNumber: string): Promise<void> {
|
|
317
|
+
const dataApiKey = env.R2_KEY_SECRET;
|
|
318
|
+
const imageApiKey = env.IMAGES_API_TOKEN;
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
// Get case data from data worker
|
|
322
|
+
const caseResponse = await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(userUid)}/${encodeURIComponent(caseNumber)}/data.json`, {
|
|
323
|
+
headers: { 'X-Custom-Auth-Key': dataApiKey }
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
if (!caseResponse.ok) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const caseData: CaseData = await caseResponse.json();
|
|
331
|
+
|
|
332
|
+
// Delete all files associated with this case
|
|
333
|
+
if (caseData.files && caseData.files.length > 0) {
|
|
334
|
+
for (const file of caseData.files) {
|
|
335
|
+
try {
|
|
336
|
+
// Delete image file - correct endpoint
|
|
337
|
+
await fetch(`${IMAGE_WORKER_URL}/${encodeURIComponent(file.id)}`, {
|
|
338
|
+
method: 'DELETE',
|
|
339
|
+
headers: {
|
|
340
|
+
'Authorization': `Bearer ${imageApiKey}`
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Delete notes file if exists
|
|
345
|
+
await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(userUid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(file.id)}/data.json`, {
|
|
346
|
+
method: 'DELETE',
|
|
347
|
+
headers: { 'X-Custom-Auth-Key': dataApiKey }
|
|
348
|
+
});
|
|
349
|
+
} catch (fileError) {
|
|
350
|
+
// Continue with other files
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Delete the case data file
|
|
356
|
+
await fetch(`${DATA_WORKER_URL}/${encodeURIComponent(userUid)}/${encodeURIComponent(caseNumber)}/data.json`, {
|
|
357
|
+
method: 'DELETE',
|
|
358
|
+
headers: { 'X-Custom-Auth-Key': dataApiKey }
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
} catch (error) {
|
|
362
|
+
// Continue with user deletion even if case deletion fails
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function executeUserDeletion(
|
|
367
|
+
env: Env,
|
|
368
|
+
userUid: string,
|
|
369
|
+
reportProgress?: (progress: AccountDeletionProgressEvent) => void
|
|
370
|
+
): Promise<{ success: boolean; message: string; totalCases: number; completedCases: number }> {
|
|
371
|
+
const userData = await env.USER_DB.get(userUid);
|
|
372
|
+
if (userData === null) {
|
|
373
|
+
throw new Error('User not found');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const userObject: UserData = JSON.parse(userData);
|
|
377
|
+
const ownedCases = (userObject.cases || []).map((caseItem) => caseItem.caseNumber);
|
|
378
|
+
const readOnlyCases = (userObject.readOnlyCases || []).map((caseItem) => caseItem.caseNumber);
|
|
379
|
+
const allCaseNumbers = [...ownedCases, ...readOnlyCases];
|
|
380
|
+
const totalCases = allCaseNumbers.length;
|
|
381
|
+
let completedCases = 0;
|
|
382
|
+
|
|
383
|
+
await deleteFirebaseAuthUser(env, userUid);
|
|
384
|
+
|
|
385
|
+
reportProgress?.({
|
|
386
|
+
event: 'start',
|
|
387
|
+
totalCases,
|
|
388
|
+
completedCases
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
for (const caseNumber of allCaseNumbers) {
|
|
392
|
+
reportProgress?.({
|
|
393
|
+
event: 'case-start',
|
|
394
|
+
totalCases,
|
|
395
|
+
completedCases,
|
|
396
|
+
currentCaseNumber: caseNumber
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await deleteSingleCase(env, userUid, caseNumber);
|
|
400
|
+
completedCases += 1;
|
|
401
|
+
|
|
402
|
+
reportProgress?.({
|
|
403
|
+
event: 'case-complete',
|
|
404
|
+
totalCases,
|
|
405
|
+
completedCases,
|
|
406
|
+
currentCaseNumber: caseNumber
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Delete the user account from the database
|
|
411
|
+
await env.USER_DB.delete(userUid);
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
success: true,
|
|
415
|
+
message: 'Account successfully deleted',
|
|
416
|
+
totalCases,
|
|
417
|
+
completedCases
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function handleDeleteUser(env: Env, userUid: string): Promise<Response> {
|
|
422
|
+
try {
|
|
423
|
+
const result = await executeUserDeletion(env, userUid);
|
|
424
|
+
|
|
425
|
+
return new Response(JSON.stringify({
|
|
426
|
+
success: result.success,
|
|
427
|
+
message: result.message
|
|
428
|
+
}), {
|
|
429
|
+
status: 200,
|
|
430
|
+
headers: corsHeaders
|
|
431
|
+
});
|
|
432
|
+
} catch (error) {
|
|
433
|
+
console.error('Delete user error:', error);
|
|
434
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
435
|
+
|
|
436
|
+
if (errorMessage === 'User not found') {
|
|
437
|
+
return new Response('User not found', {
|
|
438
|
+
status: 404,
|
|
439
|
+
headers: corsHeaders
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return new Response(JSON.stringify({
|
|
444
|
+
success: false,
|
|
445
|
+
message: 'Failed to delete user account'
|
|
446
|
+
}), {
|
|
447
|
+
status: 500,
|
|
448
|
+
headers: corsHeaders
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function handleDeleteUserWithProgress(env: Env, userUid: string): Response {
|
|
454
|
+
const sseHeaders: Record<string, string> = {
|
|
455
|
+
...corsHeaders,
|
|
456
|
+
'Content-Type': 'text/event-stream',
|
|
457
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
458
|
+
'Connection': 'keep-alive'
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const encoder = new TextEncoder();
|
|
462
|
+
|
|
463
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
464
|
+
async start(controller) {
|
|
465
|
+
const sendEvent = (payload: AccountDeletionProgressEvent) => {
|
|
466
|
+
controller.enqueue(encoder.encode(`event: ${payload.event}\ndata: ${JSON.stringify(payload)}\n\n`));
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
const result = await executeUserDeletion(env, userUid, sendEvent);
|
|
471
|
+
sendEvent({
|
|
472
|
+
event: 'complete',
|
|
473
|
+
totalCases: result.totalCases,
|
|
474
|
+
completedCases: result.completedCases,
|
|
475
|
+
success: result.success,
|
|
476
|
+
message: result.message
|
|
477
|
+
});
|
|
478
|
+
} catch (error) {
|
|
479
|
+
const errorMessage = error instanceof Error ? error.message : 'Failed to delete user account';
|
|
480
|
+
|
|
481
|
+
sendEvent({
|
|
482
|
+
event: 'error',
|
|
483
|
+
totalCases: 0,
|
|
484
|
+
completedCases: 0,
|
|
485
|
+
success: false,
|
|
486
|
+
message: errorMessage
|
|
487
|
+
});
|
|
488
|
+
} finally {
|
|
489
|
+
controller.close();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
return new Response(stream, {
|
|
495
|
+
status: 200,
|
|
496
|
+
headers: sseHeaders
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function handleAddCases(request: Request, env: Env, userUid: string): Promise<Response> {
|
|
501
|
+
try {
|
|
502
|
+
const { cases = [] }: AddCasesRequest = await request.json();
|
|
503
|
+
|
|
504
|
+
// Get current user data
|
|
505
|
+
const value = await env.USER_DB.get(userUid);
|
|
506
|
+
if (!value) {
|
|
507
|
+
return new Response('User not found', {
|
|
508
|
+
status: 404,
|
|
509
|
+
headers: corsHeaders
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Update cases
|
|
514
|
+
const userData: UserData = JSON.parse(value);
|
|
515
|
+
const existingCases = userData.cases || [];
|
|
516
|
+
|
|
517
|
+
// Filter out duplicates
|
|
518
|
+
const newCases = cases.filter(newCase =>
|
|
519
|
+
!existingCases.some(existingCase =>
|
|
520
|
+
existingCase.caseNumber === newCase.caseNumber
|
|
521
|
+
)
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
// Update user data
|
|
525
|
+
userData.cases = [...existingCases, ...newCases];
|
|
526
|
+
userData.updatedAt = new Date().toISOString();
|
|
527
|
+
|
|
528
|
+
// Save to KV
|
|
529
|
+
await env.USER_DB.put(userUid, JSON.stringify(userData));
|
|
530
|
+
|
|
531
|
+
return new Response(JSON.stringify(userData), {
|
|
532
|
+
status: 200,
|
|
533
|
+
headers: corsHeaders
|
|
534
|
+
});
|
|
535
|
+
} catch (error) {
|
|
536
|
+
return new Response('Failed to add cases', {
|
|
537
|
+
status: 500,
|
|
538
|
+
headers: corsHeaders
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function handleDeleteCases(request: Request, env: Env, userUid: string): Promise<Response> {
|
|
544
|
+
try {
|
|
545
|
+
const { casesToDelete }: DeleteCasesRequest = await request.json();
|
|
546
|
+
|
|
547
|
+
// Get current user data
|
|
548
|
+
const value = await env.USER_DB.get(userUid);
|
|
549
|
+
if (!value) {
|
|
550
|
+
return new Response('User not found', {
|
|
551
|
+
status: 404,
|
|
552
|
+
headers: corsHeaders
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Update user data
|
|
557
|
+
const userData: UserData = JSON.parse(value);
|
|
558
|
+
userData.cases = userData.cases.filter(c =>
|
|
559
|
+
!casesToDelete.includes(c.caseNumber)
|
|
560
|
+
);
|
|
561
|
+
userData.updatedAt = new Date().toISOString();
|
|
562
|
+
|
|
563
|
+
// Save to KV
|
|
564
|
+
await env.USER_DB.put(userUid, JSON.stringify(userData));
|
|
565
|
+
|
|
566
|
+
return new Response(JSON.stringify(userData), {
|
|
567
|
+
status: 200,
|
|
568
|
+
headers: corsHeaders
|
|
569
|
+
});
|
|
570
|
+
} catch (error) {
|
|
571
|
+
return new Response('Failed to delete cases', {
|
|
572
|
+
status: 500,
|
|
573
|
+
headers: corsHeaders
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export default {
|
|
579
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
580
|
+
if (request.method === 'OPTIONS') {
|
|
581
|
+
return new Response(null, { headers: corsHeaders });
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
await authenticate(request, env);
|
|
586
|
+
|
|
587
|
+
const url = new URL(request.url);
|
|
588
|
+
const parts = url.pathname.split('/');
|
|
589
|
+
const userUid = parts[1];
|
|
590
|
+
const isCasesEndpoint = parts[2] === 'cases';
|
|
591
|
+
|
|
592
|
+
if (!userUid) {
|
|
593
|
+
return new Response('Not Found', { status: 404 });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Handle regular cases endpoint
|
|
597
|
+
if (isCasesEndpoint) {
|
|
598
|
+
switch (request.method) {
|
|
599
|
+
case 'PUT': return handleAddCases(request, env, userUid);
|
|
600
|
+
case 'DELETE': return handleDeleteCases(request, env, userUid);
|
|
601
|
+
default: return new Response('Method not allowed', {
|
|
602
|
+
status: 405,
|
|
603
|
+
headers: corsHeaders
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Handle user operations
|
|
609
|
+
const acceptsEventStream = request.headers.get('Accept')?.includes('text/event-stream') === true;
|
|
610
|
+
const streamProgress = url.searchParams.get('stream') === 'true' || acceptsEventStream;
|
|
611
|
+
|
|
612
|
+
switch (request.method) {
|
|
613
|
+
case 'GET': return handleGetUser(env, userUid);
|
|
614
|
+
case 'PUT': return handleAddUser(request, env, userUid);
|
|
615
|
+
case 'DELETE': return streamProgress ? handleDeleteUserWithProgress(env, userUid) : handleDeleteUser(env, userUid);
|
|
616
|
+
default: return new Response('Method not allowed', {
|
|
617
|
+
status: 405,
|
|
618
|
+
headers: corsHeaders
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
} catch (error) {
|
|
622
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
623
|
+
if (errorMessage === 'Unauthorized') {
|
|
624
|
+
return new Response('Forbidden', {
|
|
625
|
+
status: 403,
|
|
626
|
+
headers: corsHeaders
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return new Response('Internal Server Error', {
|
|
631
|
+
status: 500,
|
|
632
|
+
headers: corsHeaders
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
};
|