working auth in new home

This commit is contained in:
nathanAndrinoid 2026-02-15 08:42:01 +00:00
parent 5b7fe5a1d2
commit 19678b3b22
23 changed files with 681 additions and 212 deletions

View File

@ -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);
}

14
bootstrap-serve.ts Normal file
View File

@ -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);

57
build-publish.sh Executable file
View File

@ -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

View File

@ -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

View File

@ -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",

110
dev.ts Normal file
View File

@ -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<boolean> {
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();

View File

@ -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,
});
} as Config);

View File

@ -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<string> {
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<string> {
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<string | null> {
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<string | null> {
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 {

View File

@ -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;

View File

@ -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(
<h2 class="theme-text list-title">
Your Obligations
</h2>
<button
type="button"
class="list-action-button list-action-button--center"
onClick={handleExportObligations}
>
Export JSON
</button>
<button
type="button"
class="list-action-button"

View File

@ -6,16 +6,6 @@ function applyTheme(root: Element, mode: string): void {
root.setAttribute("data-theme-mode", mode);
}
function readTheme(): string {
try {
const stored = localStorage.getItem(THEME_KEY);
if (stored === "light" || stored === "dark") return stored;
} catch {
// ignore
}
return "dark";
}
function syncToggleLabels(root: Element): void {
const isDark = root.getAttribute("data-theme-mode") !== "light";
const label = isDark ? "Switch to light mode" : "Switch to dark mode";
@ -26,9 +16,22 @@ function syncToggleLabels(root: Element): void {
});
}
const THEME_COOKIE = "cyberhybrid-theme";
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year
function setThemeCookie(mode: string): void {
try {
document.cookie =
`${THEME_COOKIE}=${mode}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`;
} catch {
// ignore
}
}
function persistTheme(mode: string): void {
try {
localStorage.setItem(THEME_KEY, mode);
setThemeCookie(mode);
} catch {
// ignore
}
@ -42,7 +45,12 @@ export default function ThemeHydration() {
useEffect(() => {
const root = document.documentElement;
applyTheme(root, readTheme());
// Trust server-rendered theme (from cookie) on first paint; do not overwrite
// with localStorage to avoid flicker. Sync storage to match so next load is correct.
const serverTheme = root.getAttribute("data-theme-mode") === "light"
? "light"
: "dark";
persistTheme(serverTheme);
syncToggleLabels(root);
const handleClick = (event: Event) => {

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "preact/hooks";
import { WelcomeBarProps, WelcomeBarState } from "./welcome-types.ts";
import { useEncryption } from "./useEncryption.ts";
import { useLogoutCleanup } from "./useServiceWorker.ts";
import { useLogoutCleanup, useServiceWorker } from "./useServiceWorker.ts";
import EncryptionModal from "./EncryptionModal.tsx";
import NavigationMenu from "./NavigationMenu.tsx";
import LockIcon from "./LockIcon.tsx";
@ -46,6 +46,7 @@ export default function WelcomeBar(
};
useLogoutCleanup();
useServiceWorker();
useEffect(() => {
const syncRoute = () => {

View File

@ -8,7 +8,7 @@ import {
getUserEncryptionKey,
getUserSkipKey,
getUserSkipValue,
tryDecryptDataWith,
tryDecryptDataWithPin,
} from "../../helpers.ts";
// Store encryption password in localStorage
@ -92,6 +92,8 @@ export function useEncryption(
const isMountedRef = useRef(true);
// AbortController ref for cancelling fetch operations on unmount
const abortControllerRef = useRef<AbortController | null>(null);
// Ref for persist-across-devices so submit sees latest value (avoids stale state when user checks box then clicks Continue immediately)
const persistAcrossDevicesRef = useRef(false);
// Consolidated encryption state
const [state, setState] = useState({
@ -146,6 +148,9 @@ export function useEncryption(
// Check localStorage directly to see if password is already set
const existingPassword = getEncryptionPassword(userId);
if (existingPassword) {
console.log(
"[Encryption] useEncryption: local PIN already set for userId",
);
updateState({
isPasswordSet: true,
isSkipped: false,
@ -167,6 +172,7 @@ export function useEncryption(
if (isEncrypted) {
const me = await fetch("/api/user/get", {
credentials: "include",
cache: "no-store",
signal: abortController.signal,
});
@ -176,19 +182,24 @@ export function useEncryption(
if (me.ok) {
const userData = await me.json() as User;
if (userData.id && userData.encrypted_data) {
//console.log("User data found, checking local pin");
console.log(
"[Encryption] useEncryption: user has encrypted_data, checking local pin",
);
const localPin = getEncryptionPassword(userId);
if (localPin) {
//console.log("Local pin found, decrypting data");
console.log(
"[Encryption] useEncryption: local pin found, decrypting user data",
);
const decryptedData = await decryptDataWithStoredPassword(
userId,
userData.encrypted_data,
);
if (decryptedData) {
const decryptedUserData = JSON.parse(decryptedData);
//console.log("Checking user data", decryptedUserData);
//console.log("user id ", userData.id);
if (decryptedUserData.id === userData.id) {
console.log(
"[Encryption] useEncryption: local decrypt ok, user id match",
);
updateState({
isPasswordSet: true,
isSkipped: false,
@ -196,11 +207,18 @@ export function useEncryption(
});
return;
}
} else {
console.log(
"[Encryption] useEncryption: local decrypt returned empty",
);
}
} else {
//console.log("No local pin found, checking server");
console.log(
"[Encryption] useEncryption: no local pin, fetching from server",
);
const response = await fetch("/api/sync-pin/get", {
credentials: "include",
cache: "no-store",
signal: abortController.signal,
});
@ -209,23 +227,28 @@ export function useEncryption(
if (response.ok) {
const serverPinData = await response.json();
//console.log("Server pin found, decrypting data", serverPinData);
console.log(
"[Encryption] useEncryption: server pin response ok",
serverPinData.pin ? "pin present" : "no pin",
);
// Server has a pin, store it in localStorage for local usage
if (serverPinData.pin) {
const decryptedData = await tryDecryptDataWith(
const decryptedData = await tryDecryptDataWithPin(
serverPinData.pin,
userData.encrypted_data,
);
if (decryptedData) {
const decryptedUserData = JSON.parse(decryptedData);
//console.log("Decrypted data found, checking user ID", decryptedUserData.id);
//console.log("user id ", userData.id);
if (decryptedUserData.id === userData.id) {
console.log(
"[Encryption] useEncryption: server pin decrypt ok, storing locally",
);
const localSetSuccess = setEncryptionPassword(
userId,
serverPinData.pin,
);
if (localSetSuccess) {
persistAcrossDevicesRef.current = true;
updateState({
isPasswordSet: true,
isSkipped: false,
@ -235,6 +258,10 @@ export function useEncryption(
return;
}
}
} else {
console.log(
"[Encryption] useEncryption: server pin decrypt failed or no user id match",
);
}
// Server pin was not usable for this encrypted data, fall back to asking user
@ -336,6 +363,7 @@ export function useEncryption(
//console.log(`[Encryption] Fetching user data for PIN handling`);
const userResponse = await fetch("/api/user/get", {
credentials: "include",
cache: "no-store",
});
if (!userResponse.ok) {
@ -390,6 +418,7 @@ export function useEncryption(
const response = await fetch("/api/user/set", {
method: "POST",
credentials: "include",
cache: "no-store",
headers: {
"Content-Type": "application/json",
},
@ -415,12 +444,14 @@ export function useEncryption(
}
}
// If persistAcrossDevices is true, sync to server
if (state.persistAcrossDevices) {
// If persist across devices was checked, sync to server (use ref so we see latest value even if user checked then clicked Continue immediately)
const shouldPersist = persistAcrossDevicesRef.current;
if (shouldPersist) {
try {
const response = await fetch("/api/sync-pin/set", {
method: "POST",
credentials: "include",
cache: "no-store",
headers: {
"Content-Type": "application/json",
},
@ -435,6 +466,7 @@ export function useEncryption(
}
} catch (_serverError) {
// Still allow local usage even if server sync fails
persistAcrossDevicesRef.current = false;
updateState({
passwordError: "PIN set locally but failed to sync across devices",
persistAcrossDevices: false,
@ -486,8 +518,9 @@ export function useEncryption(
}
};
// Handle opening change PIN modal
// Handle opening change PIN modal (sync ref from state so submit uses current persist choice)
const handleChangePin = () => {
persistAcrossDevicesRef.current = state.persistAcrossDevices;
updateState({
showChangePinModal: true,
showRemovePinConfirm: false,
@ -523,6 +556,7 @@ export function useEncryption(
const _response = await fetch("/api/sync-pin/delete", {
method: "POST",
credentials: "include",
cache: "no-store",
headers: {
"Content-Type": "application/json",
},
@ -584,12 +618,14 @@ export function useEncryption(
throw new Error("Failed to store PIN locally");
}
// If persistAcrossDevices is true, sync to server
if (state.persistAcrossDevices) {
// If persist across devices is checked, sync to server (use ref for same-tick checkbox + submit)
const shouldPersist = persistAcrossDevicesRef.current;
if (shouldPersist) {
try {
const response = await fetch("/api/sync-pin/set", {
method: "POST",
credentials: "include",
cache: "no-store",
headers: {
"Content-Type": "application/json",
},
@ -608,6 +644,7 @@ export function useEncryption(
}
} catch (_serverError) {
// Still allow local usage even if server sync fails
persistAcrossDevicesRef.current = false;
updateState({
passwordError:
"PIN updated locally but failed to sync across devices",
@ -628,8 +665,9 @@ export function useEncryption(
}
};
// Handle persist across devices checkbox change
// Handle persist across devices checkbox change (ref updated synchronously so submit sees it even if user clicks Continue immediately)
const handlePersistAcrossDevicesChange = (persist: boolean) => {
persistAcrossDevicesRef.current = persist;
updateState({ persistAcrossDevices: persist });
};

View File

@ -32,88 +32,40 @@ function safeSessionStorage() {
}
/**
* Custom hook to manage Service Worker registration
* Handles registration, unregistration, and session-based updates
* Includes robust error handling for network issues and broken pipes
*
* @example
* ```tsx
* function MyComponent() {
* useServiceWorker();
* return <div>Component content</div>;
* }
* ```
* Ensures no Service Worker is used (temporary).
* Unregisters all service workers and verifies none remain.
*/
export function useServiceWorker() {
useEffect(() => {
if ("serviceWorker" in navigator) {
// Check if this is a new user experience (session start)
const storage = safeSessionStorage();
const isNewSession = !storage?.getItem("sw_registered");
if (!("serviceWorker" in navigator)) return;
// Add timeout for service worker operations
// const registrationTimeout = setTimeout(() => {
// console.warn("Service Worker registration taking longer than expected");
// }, 10000);
navigator.serviceWorker.getRegistrations().then((registrations) => {
const activeSW = registrations.find((reg) =>
reg.active?.scriptURL.includes("fetch_sw.js")
);
if (activeSW && !isNewSession) {
// Service worker already active and not a new session - skip registration
// console.log("Service Worker already active.");
// clearTimeout(registrationTimeout);
return;
navigator.serviceWorker.getRegistrations()
.then((registrations) => {
if (registrations.length === 0) {
console.log("[SW] No service workers registered (OK).");
return [] as ServiceWorkerRegistration[];
}
// New session or no active SW - unregister old versions and register new
const unregisterPromises = registrations.map((registration) =>
registration.unregister().catch((_err) => {
// Log but don't fail if unregistration fails
// console.warn("Failed to unregister old service worker:", err);
return Promise.resolve();
const unregisterPromises = registrations.map((reg) =>
reg.unregister().then(() => {
console.log("[SW] Unregistered:", reg.scope);
}).catch((err) => {
console.warn("[SW] Unregister failed for", reg.scope, err);
})
);
return Promise.all(unregisterPromises).then(() => {
// Register the new service worker after unregistering old ones
return navigator.serviceWorker.register("/fetch_sw.js");
}).then(() => {
// console.log("Service Worker registered.");
const swStorage = safeSessionStorage();
swStorage?.setItem("sw_registered", "true");
// clearTimeout(registrationTimeout);
}).catch((_regError) => {
// Handle registration errors gracefully
// console.error("Service Worker registration failed:", regError);
// clearTimeout(registrationTimeout);
// Don't throw - allow app to continue without service worker
});
}).catch((_error) => {
//clearTimeout(registrationTimeout);
// Handle different error types
// const errorMessage = error instanceof Error ? error.message : String(error);
// if (error instanceof TypeError || errorMessage.includes("network")) {
// console.error("Network error during service worker setup:", error);
// } else if (errorMessage.includes("broken pipe")) {
// console.error("Connection broken during service worker setup:", error);
// } else {
// console.error("Service Worker registration failed:", error);
// }
// App should continue functioning without service worker
return Promise.all(unregisterPromises).then(() =>
navigator.serviceWorker.getRegistrations()
);
})
.then((after) => {
if (after && after.length > 0) {
console.warn("[SW] Still registered after unregister:", after.length);
} else {
console.log("[SW] Verified: no service workers registered.");
}
})
.catch((err) => {
console.warn("[SW] Check failed:", err);
});
}
// else {
// console.log("Service Worker not supported in this browser.");
// }
// Cleanup function
return () => {
// No cleanup needed for service worker registration
};
}, []);
}

View File

@ -29,8 +29,10 @@ export default define.page(async function App(props: PageProps) {
(now.getMonth() + 1).toString().padStart(2, "0")
}`;
const themeMode = state.themePreference ?? "dark";
return (
<html data-theme-mode="dark">
<html data-theme-mode={themeMode}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@ -84,8 +84,8 @@ export const handler = define.handlers({
await setOnboardingStep(user_id, 2);
return new Response("", {
status: 302,
return new Response(null, {
status: 303,
headers: { Location: "/landlord/onboarding/property-setup" },
});
},

BIN
server Executable file

Binary file not shown.

View File

@ -65,9 +65,7 @@ export function InitiPGSessionMiddleware() {
const url = new URL(ctx.req.url);
console.log("Pathname:", ctx.req.method, url.pathname);
if (url.hostname === "cyberhybridhub.com") {
return Response.redirect("https://portal.cyberhybridhub.com", 302);
}
if (
url.pathname.startsWith("/api/ai-images/file/") ||
url.pathname.startsWith("/api/audio-blob/") ||
@ -188,6 +186,11 @@ export function InitiPGSessionMiddleware() {
ctx.state.stateId = sessionCookieParams.stateId ?? "";
ctx.state.sealKey = theStateKey ?? undefined; // Store the validated seal key for logout
const themeCookie = getCookies(ctx.req.headers)["cyberhybrid-theme"];
if (themeCookie === "light" || themeCookie === "dark") {
ctx.state.themePreference = themeCookie;
}
// Inject full user data into ctx.state.user for route handlers
let userData: User | null = null;
if (sessionData && sessionData.user_id) {

0
setup-postregs-db.sh Normal file → Executable file
View File

View File

@ -1,3 +1,3 @@
#!/bin/bash
cd /home/nathan/nathanAndrinoid/cyberhybridhub.com
cd /home/chh/cyberhybridhub.com
deno task dev

View File

@ -81,53 +81,5 @@ self.addEventListener("message", async (event) => {
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (event.request.method === "GET" && url.pathname === "/api/sync-pin/get") {
event.respondWith(
(async () => {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(event.request);
if (cachedResponse) {
const clonedCache = cachedResponse.clone();
const cachedText = await clonedCache.text();
const cachedHash = await hashText(cachedText);
// Create a new URL with naunce parameter (don't modify the original)
const checkUrl = new URL(event.request.url);
checkUrl.searchParams.set("naunce", cachedHash);
const checkResponse = await fetch(checkUrl.toString(), {
credentials: "include",
});
if (checkResponse.ok) {
const cloneResponse = checkResponse.clone();
const cloneText = await cloneResponse.text();
if (cloneText && cloneText.length > 0) {
await cache.put(event.request, checkResponse.clone());
return checkResponse;
}
}
return cachedResponse;
}
const networkResponse = await fetch(event.request, {
credentials: "include",
});
if (networkResponse.ok) {
await cache.put(event.request, networkResponse.clone());
}
return networkResponse;
})(),
);
}
// For all other requests, fall back to the network without cloning the body
event.respondWith(fetch(event.request));
// For all other non-API requests, let them handle by default browser behavior
});

View File

@ -20,6 +20,8 @@ export interface State {
user?: User | null;
auth?: AuthState;
authError?: string;
/** Theme for initial HTML to avoid flash; set from cookie in middleware. */
themePreference?: "light" | "dark";
}
export type PageProps<Data = unknown> = FreshPageProps<Data, State>;

View File

@ -83,13 +83,11 @@ const EDGE_ALLOWED_ORIGINS = (() => {
})();
const ISLAND_ALLOWED_ORIGINS = new Set([
"https://studymylanguage.com",
"http://studymylanguage.com",
"https://data1.cyberhybridhub.com",
"http://data1.cyberhybridhub.com",
"https://cyberhybridhub.com",
"http://cyberhybridhub.com",
"http://localhost:8080",
"http://96.3.221.225",
"https://96.3.221.225",
"http://96.3.218.128",
"https://96.3.218.126",
]);
function mergeVary(current: string | null, incoming: string): string {