diff --git a/assets/style.css b/assets/style.css index a0eb52a..566571c 100644 --- a/assets/style.css +++ b/assets/style.css @@ -4303,6 +4303,12 @@ body { cursor: not-allowed; } +.list-action-button--center { + left: 50%; + right: auto; + transform: translate(-50%, -50%); +} + .dialog-offset { padding-top: calc(50vh - 400px); } diff --git a/bootstrap-serve.ts b/bootstrap-serve.ts new file mode 100644 index 0000000..7516d17 --- /dev/null +++ b/bootstrap-serve.ts @@ -0,0 +1,14 @@ +/** + * Entry point for the compiled server binary. + * Sets FRESH_ROOT so the server finds _fresh/client and _fresh/server next to the binary, then starts Deno.serve. + */ +import { join, dirname } from "https://deno.land/std@0.224.0/path/mod.ts"; + +// Must run before importing the server so the bundle uses this root at load time +const binDir = dirname(Deno.execPath()); +Deno.env.set("FRESH_ROOT", join(binDir, "_fresh")); + +const { default: handler } = await import("./_fresh/server.js"); + +const port = Number(Deno.env.get("PORT")) || 8080; +Deno.serve({ port }, handler.fetch); diff --git a/build-publish.sh b/build-publish.sh new file mode 100755 index 0000000..fe02a61 --- /dev/null +++ b/build-publish.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Build a publish directory: Linux binary (server) + _fresh for deployment. .env is not copied (publish dir is not recreated). +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PUBLISH_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/publish" +cd "$SCRIPT_DIR" + +echo "Building Fresh app..." +deno task build + +# So the compiled binary finds _fresh at runtime (not compile-time path) +echo "Patching server-entry to use FRESH_ROOT at runtime..." +sed -i 's|const root = join(import.meta.dirname, "..");|const root = Deno.env.get("FRESH_ROOT") ?? join(import.meta.dirname, "..");|' _fresh/server/server-entry.mjs + +echo "Ensuring publish directory exists (parent of project)..." +mkdir -p "$PUBLISH_DIR" + +echo "Compiling Linux binary (server)..." +deno compile -A --no-check --include ./_fresh/client --target x86_64-unknown-linux-gnu -o "$PUBLISH_DIR/server" bootstrap-serve.ts + +echo "Copying _fresh into publish/..." +# Full _fresh (server + client assets) so the binary finds favicon, static files, etc. +cp -r _fresh "$PUBLISH_DIR/" + +# Create systemd service file (install with: sudo cp ... /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now cyberhybridhub) +SERVICE_NAME="cyberhybridhub" +SERVICE_FILE="$PUBLISH_DIR/${SERVICE_NAME}.service" +cat > "$SERVICE_FILE" << EOF +[Unit] +Description=CyberHybridHub web app +After=network.target + +[Service] +Type=simple +WorkingDirectory=$PUBLISH_DIR +Environment=PORT=80 +ExecStart=$PUBLISH_DIR/server +Restart=on-failure +# Binding to port 80 typically requires root; run 'sudo systemctl start $SERVICE_NAME' when installing + +[Install] +WantedBy=multi-user.target +EOF +chmod 644 "$SERVICE_FILE" +echo "Created $SERVICE_FILE" + +echo "Installing and reinitializing systemd service..." +sudo cp "$SERVICE_FILE" "/etc/systemd/system/" +sudo systemctl daemon-reload +sudo systemctl stop "$SERVICE_NAME" 2>/dev/null || true +sudo systemctl enable --now "$SERVICE_NAME" +echo "Service $SERVICE_NAME is enabled and running (port 80)." + +echo "Done. Contents of $PUBLISH_DIR:" +ls -la "$PUBLISH_DIR/" +ls -la "$PUBLISH_DIR/_fresh" 2>/dev/null || true diff --git a/cyberhybridhub-web.sh b/cyberhybridhub-web.sh index 4e08a92..be19aac 100755 --- a/cyberhybridhub-web.sh +++ b/cyberhybridhub-web.sh @@ -6,7 +6,7 @@ set -e # Configuration -APP_DIR="/home/nanderson/nathanAndrinoid/WebSites/cyberhybridhub.com/publish" +APP_DIR="/home/chh/cyberhybridhub.com/publish" APP_NAME="cyberhybridhub-web" PID_FILE="$APP_DIR/.cyberhybridhub-web.pid" LOG_FILE="$APP_DIR/.cyberhybridhub-web.log" @@ -84,13 +84,13 @@ After=network.target [Service] Type=simple -User=nanderson +User=chh WorkingDirectory=$APP_DIR -ExecStart=/bin/bash -c 'set -a && source $APP_DIR/../.env 2>/dev/null || true && set +a && exec $APP_DIR/server -A' +ExecStart=/bin/bash -c 'set -a && source $APP_DIR/.env 2>/dev/null || true && set +a && exec $APP_DIR/server -A' Restart=always RestartSec=10 -StandardOutput=append:$LOG_FILE -StandardError=append:$LOG_FILE +StandardOutput=append:$APP_DIR/.cyberhybridhub-web.log +StandardError=append:$APP_DIR/.cyberhybridhub-web.log [Install] WantedBy=multi-user.target diff --git a/deno.json b/deno.json index 9bc7579..547cc39 100644 --- a/deno.json +++ b/deno.json @@ -3,14 +3,16 @@ "tasks": { "check": "deno fmt && deno fmt --check . && deno lint . && deno check", "dev": "vite", - "build": "deno install -r --allow-scripts && vite build", + "dev:no-vite": "deno run -A dev.ts", + "build": "vite build", + "build:full": "deno install -r --allow-scripts && vite build", "start": "deno serve -A --port 8080 _fresh/server.js", "update": "deno run -A -r jsr:@fresh/update .", "e2e": "deno run -A npm:playwright test", "e2e:ui": "deno run -A npm:playwright test --ui", "e2e:report": "deno run -A npm:playwright show-report", "db:generate": "deno run -A drizzle-kit generate", - "db:migrate": "deno run -A drizzle-kit migrate", + "db:migrate": "deno --env -A --node-modules-dir npm:drizzle-kit migrate", "db:push": "deno run -A drizzle-kit push", "db:studio": "deno run -A drizzle-kit studio", "test:auth-callback-db": "./tests/run-auth-callback-db-test.sh" @@ -23,10 +25,10 @@ "@preact/signals": "npm:@preact/signals@^2.8.0", "@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.0.8", "vite": "npm:vite@^7.3.1", - "@types/pg": "npm:@types/pg@^8.15.4", + "@types/pg": "npm:@types/pg@^8.16.0", "drizzle-kit": "npm:drizzle-kit@^0.31.9", - "drizzle-orm": "npm:drizzle-orm@^0.44.7", - "pg": "npm:pg@^8.16.3", + "drizzle-orm": "npm:drizzle-orm@^0.45.1", + "pg": "npm:pg@^8.18.0", "iron": "npm:iron-webcrypto", "$std/": "https://deno.land/std@0.224.0/", "djwt": "https://deno.land/x/djwt@v3.0.2/mod.ts", diff --git a/dev.ts b/dev.ts new file mode 100644 index 0000000..ee5390f --- /dev/null +++ b/dev.ts @@ -0,0 +1,110 @@ +#!/usr/bin/env -S deno run -A +/** + * Dev server without the Vite dev server: build + serve the Fresh app with + * inspector for debugging, and rebuild/restart on file changes. + * Use this when "deno task dev" (Vite) breaks form redirects or other behavior. + * + * Run: deno task dev:no-vite + * Debug: attach to ws://localhost:9229 (Chrome DevTools, VS Code, etc.) + */ + +const PORT = Deno.env.get("PORT") || "8080"; +const INSPECT = Deno.env.get("INSPECT") !== "false"; // set INSPECT=false to disable +const WATCH_DEBOUNCE_MS = 400; + +const WATCH_PATHS = [ + "routes", + "islands", + "main.ts", + "services", + "db", + "utils.ts", + "utils", + "helpers.ts", + "client.ts", + "assets", + "static", +]; + +async function build(): Promise { + const c = new Deno.Command("deno", { + args: ["task", "build"], + cwd: Deno.cwd(), + stdout: "inherit", + stderr: "inherit", + }); + const child = c.spawn(); + const status = await child.status; + return status.success; +} + +function startServer(): Deno.ChildProcess { + const args = [ + "serve", + "-A", + "--port", + PORT, + "_fresh/server.js", + ]; + if (INSPECT) { + args.splice(2, 0, "--inspect=0.0.0.0:9229"); + } + const c = new Deno.Command("deno", { + args, + cwd: Deno.cwd(), + stdout: "inherit", + stderr: "inherit", + }); + return c.spawn(); +} + +async function main() { + console.log("Building..."); + if (!(await build())) { + console.error("Build failed."); + Deno.exit(1); + } + + let serverProcess: Deno.ChildProcess | null = startServer(); + console.log( + `Server running at http://localhost:${PORT} (no Vite dev server).`, + ); + if (INSPECT) { + console.log("Debugger: attach to ws://localhost:9229"); + } + console.log("Watching for changes (rebuild + restart on save)...\n"); + + let debounceTimer: number | undefined; + const watcher = Deno.watchFs(WATCH_PATHS); + + try { + for await (const event of watcher) { + const path = event.paths[0] ?? ""; + if ( + path.includes("node_modules") || + path.includes("/_fresh/") || + path.endsWith(".map") + ) { + continue; + } + clearTimeout(debounceTimer); + debounceTimer = setTimeout(async () => { + debounceTimer = undefined; + console.log("\nChange detected, rebuilding..."); + serverProcess?.kill(); + serverProcess = null; + if (await build()) { + serverProcess = startServer(); + console.log("Restarted.\n"); + } else { + console.error("Build failed, not restarting.\n"); + } + }, WATCH_DEBOUNCE_MS); + } + } finally { + watcher.close(); + serverProcess?.kill(); + } +} + +main(); diff --git a/drizzle.config.ts b/drizzle.config.ts index be2822d..9c279e7 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from "drizzle-kit"; +import { Config, defineConfig } from "drizzle-kit"; -const dbUrl = Deno.env.get("POSTGRES_OPTIONS") +const dbUrl = Deno.env.get("POSTGRES_OPTIONS"); export default defineConfig({ schema: "./db/schema.ts", @@ -12,4 +12,4 @@ export default defineConfig({ schemaFilter: ["public"], strict: true, verbose: true, -}); \ No newline at end of file +} as Config); diff --git a/helpers.ts b/helpers.ts index aa39204..041b01a 100644 --- a/helpers.ts +++ b/helpers.ts @@ -28,54 +28,216 @@ export function getDaysInBaseMonth(year: number, month: number): number { return new Date(year, month + 1, 0).getDate(); } -// Encryption functions (placeholders - these should be properly implemented) +// --------------------------------------------------------------------------- +// Encryption: envelope format { salt, iv, data } (base64), AES-GCM + PBKDF2 +// --------------------------------------------------------------------------- + +const PBKDF2_ITERATIONS = 100_000; +const AES_KEY_LENGTH = 256; + +/** Decode base64 to a new Uint8Array (ArrayBuffer-backed for Web Crypto). */ +function base64ToBytes(base64: string): Uint8Array { + const bin = atob(base64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return new Uint8Array(bytes); +} + +function bytesToBase64(bytes: Uint8Array): string { + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} + +/** Decrypt AES-GCM ciphertext using key derived from password + salt. */ +async function decryptEnvelope( + password: string, + envelope: { salt: string; iv: string; data: string }, +): Promise { + const saltBytes = base64ToBytes(envelope.salt); + const ivBytes = base64ToBytes(envelope.iv); + const ciphertextBytes = base64ToBytes(envelope.data); + + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + "PBKDF2", + false, + ["deriveKey"], + ); + + const key = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: saltBytes as BufferSource, + iterations: PBKDF2_ITERATIONS, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: AES_KEY_LENGTH }, + false, + ["decrypt"], + ); + + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: ivBytes as BufferSource }, + key, + ciphertextBytes as BufferSource, + ); + return new TextDecoder().decode(plaintext); +} + +/** True if the decoded payload is an encrypted envelope we can decrypt. */ +function isEnvelope( + obj: unknown, +): obj is { salt: string; iv: string; data: string } { + return ( + typeof obj === "object" && + obj !== null && + "salt" in obj && + "iv" in obj && + "data" in obj && + typeof (obj as { salt: unknown }).salt === "string" && + typeof (obj as { iv: unknown }).iv === "string" && + typeof (obj as { data: unknown }).data === "string" + ); +} + export function encryptDataWithStoredPassword( _userId: string, data: string, ): string { - // TODO: Implement proper encryption - // For now, return base64 encoded data as placeholder + // Legacy: plain base64 for backward compatibility when no envelope is used return btoa(data); } -export function decryptDataWithStoredPassword( - _userId: string, - encryptedData: string, -): string { - // TODO: Implement proper decryption - // For now, return base64 decoded data as placeholder - try { - return atob(encryptedData); - } catch { - return encryptedData; // Return as-is if not base64 - } -} - -export function tryDecryptDataWith( +/** + * Decrypt data using the PIN stored in localStorage for this user. + * Supports: (1) encrypted envelope { salt, iv, data } then AES-GCM decrypt; + * (2) legacy plain base64 string. + */ +export async function decryptDataWithStoredPassword( userId: string, encryptedData: string, -): string | null { +): Promise { + const password = getEncryptionPassword(userId); + if (!password) { + console.log( + "[Encryption] decryptDataWithStoredPassword: no PIN for userId, skipping decrypt", + ); + return ""; + } try { - return decryptDataWithStoredPassword(userId, encryptedData); + const decoded = atob(encryptedData); + let parsed: unknown; + try { + parsed = JSON.parse(decoded) as unknown; + } catch { + // Not JSON: treat as legacy plain base64 payload + console.log( + "[Encryption] decryptDataWithStoredPassword: legacy base64, returning decoded", + ); + return decoded; + } + if (isEnvelope(parsed)) { + const out = await decryptEnvelope(password, parsed); + console.log( + "[Encryption] decryptDataWithStoredPassword: envelope decrypted ok", + ); + return out; + } + // JSON but not an envelope: legacy + console.log( + "[Encryption] decryptDataWithStoredPassword: JSON payload, returning as-is", + ); + return decoded; + } catch (e) { + console.log("[Encryption] decryptDataWithStoredPassword: failed", e); + return ""; + } +} + +/** Decrypt using stored PIN (from localStorage by userId). Returns null on failure. */ +export async function tryDecryptDataWith( + userId: string, + encryptedData: string, +): Promise { + try { + const out = await decryptDataWithStoredPassword(userId, encryptedData); + return out || null; } catch { return null; } } +/** Decrypt using a raw PIN string (e.g. from server sync). Supports envelope or legacy base64. */ +export async function tryDecryptDataWithPin( + pin: string, + encryptedData: string, +): Promise { + if (!pin) return null; + try { + const decoded = atob(encryptedData); + let parsed: unknown; + try { + parsed = JSON.parse(decoded) as unknown; + } catch { + return decoded; + } + if (isEnvelope(parsed)) { + return await decryptEnvelope(pin, parsed); + } + return decoded; + } catch { + return null; + } +} + +const ENCRYPTION_KEY_PREFIX = "encryption_password_"; + export function getEncryptionPassword(userId: string): string | null { - // TODO: Implement proper password retrieval - // For now, return null as placeholder - const userKey = `encryption_password_${userId}`; + if (!userId) { + console.log("[Encryption] getEncryptionPassword: no userId"); + return null; + } try { - return globalThis.localStorage?.getItem(userKey); + const pin = globalThis.localStorage?.getItem( + ENCRYPTION_KEY_PREFIX + userId, + ); + console.log( + "[Encryption] getEncryptionPassword:", + pin ? "PIN available" : "no PIN", + "key=" + ENCRYPTION_KEY_PREFIX + userId, + ); + return pin; } catch { return null; } } -export function getUserEncryptionKey(_userId: string): string | null { - // TODO: Implement proper key retrieval - return null; +/** Store encryption PIN in localStorage for this user (used by WelcomeBar and calendar). */ +export function setEncryptionPassword( + userId: string, + password: string, +): boolean { + if (!userId) { + console.log("[Encryption] setEncryptionPassword: no userId, skip"); + return false; + } + try { + const key = getUserEncryptionKey(userId); + if (key) globalThis.localStorage?.setItem(key, password); + console.log("[Encryption] setEncryptionPassword: stored for key=" + key); + return true; + } catch (e) { + console.log("[Encryption] setEncryptionPassword: failed", e); + return false; + } +} + +export function getUserEncryptionKey(userId: string): string | null { + if (!userId) return null; + return ENCRYPTION_KEY_PREFIX + userId; } export function getUserSkipKey(userId: string): string { diff --git a/islands/FinancialCalendar.tsx b/islands/FinancialCalendar.tsx index ec5d54d..76aa1c3 100644 --- a/islands/FinancialCalendar.tsx +++ b/islands/FinancialCalendar.tsx @@ -8,6 +8,7 @@ import { getMonth, getYear, MakeNewDate, + setEncryptionPassword, } from "../helpers.ts"; import { Income } from "../services/financhalServices.ts"; @@ -196,21 +197,67 @@ export default function FinancialCalendar( } const processObligations = async () => { - const encryptionPassword = getEncryptionPassword(userId); + let encryptionPassword = getEncryptionPassword(userId); + const hasEncrypted = obligations.some((o) => o.encrypted_data) || + incomes.some((i) => i.encrypted_data); + console.log( + "[Calendar] processObligations: userId=" + + (userId ? "set" : "missing") + " pin=" + + (encryptionPassword ? "available" : "missing") + " hasEncrypted=" + + hasEncrypted, + ); + + // If no local PIN but we have encrypted data, try to fetch PIN from server (e.g. new device or calendar opened first) + if (!encryptionPassword && hasEncrypted) { + try { + console.log( + "[Calendar] processObligations: fetching PIN from /api/sync-pin/get", + ); + const res = await fetch("/api/sync-pin/get", { + credentials: "include", + cache: "no-store", + }); + if (res.ok) { + const data = await res.json(); + if (data.pin && setEncryptionPassword(userId, data.pin)) { + encryptionPassword = data.pin; + console.log( + "[Calendar] processObligations: PIN stored from server, using for decrypt", + ); + } else { + console.log( + "[Calendar] processObligations: server response ok but no pin or set failed", + ); + } + } else { + console.log( + "[Calendar] processObligations: sync-pin/get status=" + + res.status, + ); + } + } catch (_e) { + console.log( + "[Calendar] processObligations: sync-pin/get fetch failed", + _e, + ); + } + } if (!encryptionPassword) { - // No encryption key available, use obligations as-is + console.log( + "[Calendar] processObligations: no PIN, using obligations as-is", + ); dispatch({ type: "SET_PROCESSED_OBLIGATIONS", payload: obligations }); return; } const updatedObligations: Obligation[] = []; + let decryptedCount = 0; for (const obligation of obligations) { const processedObligation = { ...obligation }; if (obligation.encrypted_data) { - // Try to decrypt existing encrypted data try { const decryptedData = await decryptDataWithStoredPassword( userId, @@ -218,28 +265,39 @@ export default function FinancialCalendar( ); if (decryptedData) { const parsed = JSON.parse(decryptedData); + console.log("[Calendar] decrypted obligation JSON:", parsed); if (parsed.amount !== undefined) { processedObligation.amount = parsed.amount; } if (parsed.description !== undefined) { processedObligation.description = parsed.description; } + decryptedCount++; } } catch (error) { - console.error("Failed to decrypt obligation data:", error); + console.error( + "[Calendar] Failed to decrypt obligation data:", + error, + ); } } updatedObligations.push(processedObligation); } + if (decryptedCount > 0) { + console.log( + "[Calendar] processObligations: decrypted " + decryptedCount + + " obligation(s) with PIN", + ); + } dispatch({ type: "SET_PROCESSED_OBLIGATIONS", payload: updatedObligations, }); }; processObligations(); - }, [obligations, userId]); + }, [obligations, incomes, userId]); // Process incomes for decryption on component mount and when data changes useEffect(() => { @@ -249,21 +307,62 @@ export default function FinancialCalendar( } const processIncomes = async () => { - const encryptionPassword = getEncryptionPassword(userId); + let encryptionPassword = getEncryptionPassword(userId); + const hasEncrypted = obligations.some((o) => o.encrypted_data) || + incomes.some((i) => i.encrypted_data); + console.log( + "[Calendar] processIncomes: userId=" + (userId ? "set" : "missing") + + " pin=" + (encryptionPassword ? "available" : "missing") + + " hasEncrypted=" + hasEncrypted, + ); + + if (!encryptionPassword && hasEncrypted) { + try { + console.log( + "[Calendar] processIncomes: fetching PIN from /api/sync-pin/get", + ); + const res = await fetch("/api/sync-pin/get", { + credentials: "include", + cache: "no-store", + }); + if (res.ok) { + const data = await res.json(); + if (data.pin && setEncryptionPassword(userId, data.pin)) { + encryptionPassword = data.pin; + console.log( + "[Calendar] processIncomes: PIN stored from server, using for decrypt", + ); + } else { + console.log( + "[Calendar] processIncomes: server response ok but no pin or set failed", + ); + } + } else { + console.log( + "[Calendar] processIncomes: sync-pin/get status=" + res.status, + ); + } + } catch (_e) { + console.log( + "[Calendar] processIncomes: sync-pin/get fetch failed", + _e, + ); + } + } if (!encryptionPassword) { - // No encryption key available, use incomes as-is + console.log("[Calendar] processIncomes: no PIN, using incomes as-is"); dispatch({ type: "SET_PROCESSED_INCOMES", payload: incomes }); return; } const updatedIncomes: Income[] = []; + let decryptedCount = 0; for (const income of incomes) { const processedIncome = { ...income }; if (income.encrypted_data) { - // Try to decrypt existing encrypted data try { const decryptedData = await decryptDataWithStoredPassword( userId, @@ -271,26 +370,34 @@ export default function FinancialCalendar( ); if (decryptedData) { const parsed = JSON.parse(decryptedData); + console.log("[Calendar] decrypted income JSON:", parsed); if (parsed.amount !== undefined) { processedIncome.amount = parsed.amount; } if (parsed.source !== undefined) { processedIncome.source = parsed.source; } + decryptedCount++; } } catch (error) { - console.error("Failed to decrypt income data:", error); + console.error("[Calendar] Failed to decrypt income data:", error); } } updatedIncomes.push(processedIncome); } + if (decryptedCount > 0) { + console.log( + "[Calendar] processIncomes: decrypted " + decryptedCount + + " income(s) with PIN", + ); + } dispatch({ type: "SET_PROCESSED_INCOMES", payload: updatedIncomes }); }; processIncomes(); - }, [incomes, userId]); + }, [incomes, obligations, userId]); // Remove old form state variables - now managed in consolidated state const baseMonth = monthNum - 1; diff --git a/islands/ObligationList.tsx b/islands/ObligationList.tsx index 6da0321..1181139 100644 --- a/islands/ObligationList.tsx +++ b/islands/ObligationList.tsx @@ -166,6 +166,54 @@ export default function ObligationList( }; }, []); + async function handleExportObligations() { + const exportData = processedObligations.map((ob) => ({ + id: ob.id, + description: ob.description ?? "", + amount: ob.amount ?? 0, + frequency: ob.frequency ?? "", + due_date: ob.due_date + ? new Date(ob.due_date).toISOString().split("T")[0] + : null, + })); + const json = JSON.stringify(exportData, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const suggestedName = `budget-obligations-${ + new Date().toISOString().split("T")[0] + }.json`; + + if (typeof globalThis.showSaveFilePicker === "function") { + try { + const handle = await globalThis.showSaveFilePicker({ + suggestedName, + types: [{ + description: "JSON file", + accept: { "application/json": [".json"] }, + }], + }); + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); + } catch (err) { + if ((err as Error).name !== "AbortError") { + console.error("Export failed:", err); + fallbackDownload(blob, suggestedName); + } + } + } else { + fallbackDownload(blob, suggestedName); + } + } + + function fallbackDownload(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } + return ( <> {popupAddObligationDialog} @@ -174,6 +222,13 @@ export default function ObligationList(

Your Obligations

+