diff --git a/.env.example b/.env.example index 6269d41..d72e936 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ -# Frontend demo context (public Vite variables) -VITE_DEMO_PATIENT_ID= -VITE_DEMO_USER_ID= +# Frontend environment (public Vite variables) +# Auth client uses the root host; REST API calls use the /api path. +VITE_AUTH_BASE_URL=http://localhost:3001 +VITE_API_BASE_URL=http://localhost:3001/api # Feature flags (baked in at build time by Vite) # Defaults are safe — omit these lines to use the defaults shown below. -VITE_DEMO_MODE=true # true = demo auth active (default). false = unsupported until JWT is added. VITE_PIPELINE_ENABLED=true # true = AI document pipeline active (default). false = upload page disabled. VITE_INTEGRATIONS_ENABLED=false # false = no outbound integration calls (default). Reserved for future use. diff --git a/docs/runbook/postgres-backup-and-recovery.md b/docs/runbook/postgres-backup-and-recovery.md index d7ea120..1cc4d76 100644 --- a/docs/runbook/postgres-backup-and-recovery.md +++ b/docs/runbook/postgres-backup-and-recovery.md @@ -102,11 +102,12 @@ ssh -i "$KEY_PATH" "$ADMIN_USER"@"$STATIC_IP" \ The script auto-selects the most recent backup, prompts for confirmation, drops and recreates the database, restores, and prints row counts. Fill in the [Restore Tested Once](#restore-tested-once-h-010-pre-launch-confirmation) table below. -After the restore test passes, the app can be started for the first time: +After the restore test passes, fill in the [Restore Tested Once](#restore-tested-once-h-010-pre-launch-confirmation) table, commit the change, then copy and run the deploy script (single entry point — handles build, migrate, seed, and systemd start): ```bash +scp -i "$KEY_PATH" infra/deploy.sh "$ADMIN_USER"@"$STATIC_IP":/tmp/deploy.sh ssh -i "$KEY_PATH" "$ADMIN_USER"@"$STATIC_IP" \ - "sudo systemctl start havenhold-app" + "sudo bash /tmp/deploy.sh" ``` ## D0 Verification Checklist (H-007 — infra only) @@ -128,7 +129,7 @@ Complete these during the H-010 initial deploy, after app code is on the server - [ ] `npx prisma db seed` completed — demo patient/user rows exist. - [ ] First manual backup written to `/var/backups/havenhold/` with non-zero size. - [ ] `postgres-restore.sh` run against the most recent backup; row-count validation passed. -- [ ] [Restore Tested Once](#restore-tested-once-h-010-pre-launch-confirmation) table filled in and committed. +- [ ] [Restore Tested Once](#restore-tested-once-h-010-pre-launch-confirmation) table filled in and committed **before** running `infra/deploy.sh` for the first time. ## Evidence Commands @@ -149,7 +150,7 @@ sudo ss -tulpen | grep 5432 sudo ufw status verbose | grep 5432 || echo "No 5432 rule — correct" # Lightsail firewall (run locally, not on host) -aws lightsail get-instance-port-states --instance-name havenhold-app-01 \ +aws lightsail get-instance-port-states --instance-name havenhold-api-01 \ | grep -i fromport | grep 5432 || echo "No 5432 Lightsail rule — correct" # Backup directory and files @@ -228,7 +229,7 @@ BACKUP_FILE=/var/backups/havenhold/havenhold_20260528T023001Z.sql \ - **`postgres-setup.sh` failed mid-way:** Re-run — the script is idempotent. - **pg_dump fails in cron:** Check `/var/log/havenhold-backup.log`. Common causes: disk full (`df -h`), PostgreSQL service stopped (`systemctl status postgresql`). Resolve and run manually to verify. -- **`postgres-restore.sh` fails at DROP — active connections persist:** Stop the application first (`sudo systemctl stop havenhold-app`), then retry. +- **`postgres-restore.sh` fails at DROP — active connections persist:** Stop the application first (`sudo systemctl stop havenhold-api`), then retry. - **Restore SQL errors (`ON_ERROR_STOP` exits):** The backup may be corrupt or from an incompatible schema version. Try the previous day's backup from `/var/backups/havenhold/`. - **`pg_hba.conf` conflict on a partially provisioned host:** The validation step at the end of `postgres-setup.sh` catches this. Inspect the file manually (`sudo cat /etc/postgresql/16/main/pg_hba.conf`) and remove duplicate or conflicting entries before re-running. - **All local backups lost (disk failure):** No S3 offload exists at D0. Restore the Lightsail instance snapshot from the AWS console, then re-run `postgres-setup.sh` with the original `DB_PASSWORD`. Pending migrations can be replayed with `npx prisma migrate deploy`. diff --git a/infra/deploy.sh b/infra/deploy.sh new file mode 100755 index 0000000..0d60e6f --- /dev/null +++ b/infra/deploy.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# deploy.sh — build, migrate, seed, and start the Havenhold API. +# Run as a sudoer on the app host. Assumes app code is already checked out. +set -euo pipefail + +APP_DIR="${APP_DIR:-/opt/havenhold}" +BRANCH="${BRANCH:-main}" +SERVER_DIR="$APP_DIR/server" +HEALTH_URL="http://127.0.0.1:3001/api/health" +SYSTEMD_UNIT="havenhold-api" +SERVICE_FILE="$APP_DIR/infra/systemd/havenhold-api.service" + +log() { echo "[deploy] $*"; } + +# ── 1. Prerequisites ────────────────────────────────────────────────────────── +log "1/12 Checking prerequisites" +command -v node >/dev/null 2>&1 || { echo "ERROR: node not found"; exit 1; } +command -v npm >/dev/null 2>&1 || { echo "ERROR: npm not found"; exit 1; } +command -v git >/dev/null 2>&1 || { echo "ERROR: git not found"; exit 1; } +[[ -f "$SERVER_DIR/.env" ]] || { echo "ERROR: $SERVER_DIR/.env not found — create it first"; exit 1; } + +# Frontend env vars are baked in at build time — require the .env file and validate +# required vars before building. +FRONTEND_ENV="$APP_DIR/.env" +[[ -f "$FRONTEND_ENV" ]] \ + || { echo "ERROR: $FRONTEND_ENV not found — create it from .env.example before deploying"; exit 1; } +grep -qE '^VITE_AUTH_BASE_URL=.+' "$FRONTEND_ENV" \ + || { echo "ERROR: VITE_AUTH_BASE_URL not set in $FRONTEND_ENV — auth will silently target localhost in production browsers"; exit 1; } +grep -qE '^VITE_API_BASE_URL=.+' "$FRONTEND_ENV" \ + || { echo "ERROR: VITE_API_BASE_URL not set in $FRONTEND_ENV"; exit 1; } + +# ── 2. Pull latest code ─────────────────────────────────────────────────────── +log "2/12 Pulling $BRANCH" +git -C "$APP_DIR" pull --ff-only origin "$BRANCH" + +# ── 3. Install frontend dependencies (needs devDeps for Vite build) ─────────── +log "3/12 Installing frontend dependencies" +npm --prefix "$APP_DIR" ci + +# ── 4. Build frontend ───────────────────────────────────────────────────────── +log "4/12 Building frontend" +npm --prefix "$APP_DIR" run build + +# ── 5. Install backend dependencies (needs tsc, prisma CLI, tsx) ───────────── +log "5/12 Installing backend dependencies" +npm --prefix "$SERVER_DIR" ci + +# ── 6. Build backend ────────────────────────────────────────────────────────── +log "6/12 Building backend" +npm --prefix "$SERVER_DIR" run build + +# ── 7. Run Prisma migrations ────────────────────────────────────────────────── +log "7/12 Running Prisma migrations" +cd "$SERVER_DIR" +npx prisma migrate deploy || { echo "ERROR: Prisma migration failed — aborting deploy"; exit 1; } + +# ── 8. Seed database ────────────────────────────────────────────────────────── +log "8/12 Seeding database (idempotent)" +npx prisma db seed + +# ── 9. Restore evidence gate ────────────────────────────────────────────────── +log "9/12 Checking restore evidence gate" +RUNBOOK="$APP_DIR/docs/runbook/postgres-backup-and-recovery.md" +if grep -q '_________________' "$RUNBOOK" 2>/dev/null; then + if [[ "${REQUIRE_RESTORE_EVIDENCE:-false}" == "true" ]]; then + echo "ERROR: Restore Tested Once table in runbook has unfilled entries." + echo " Fill in the table and commit before deploying with REQUIRE_RESTORE_EVIDENCE=true." + exit 1 + else + echo "WARN: Restore Tested Once table has unfilled entries." + echo " Complete Step E in the runbook and commit evidence before the first production deploy." + fi +else + log " Restore evidence: OK" +fi + +# ── 10. Install systemd unit ────────────────────────────────────────────────── +log "10/12 Installing systemd unit" +if [[ -f "$SERVICE_FILE" ]]; then + cp "$SERVICE_FILE" "/etc/systemd/system/$SYSTEMD_UNIT.service" + systemctl daemon-reload + systemctl enable "$SYSTEMD_UNIT" +else + echo "WARN: $SERVICE_FILE not found — skipping systemd unit install" +fi + +# ── 11. Restart service ─────────────────────────────────────────────────────── +log "11/12 Restarting $SYSTEMD_UNIT" +systemctl restart "$SYSTEMD_UNIT" + +# ── 12. Health check ────────────────────────────────────────────────────────── +log "12/12 Polling health endpoint (30s timeout)" +DEADLINE=$(( $(date +%s) + 30 )) +until curl -sf "$HEALTH_URL" >/dev/null 2>&1; do + if [[ $(date +%s) -ge $DEADLINE ]]; then + echo "ERROR: Health check timed out after 30s — check: journalctl -u $SYSTEMD_UNIT -n 50" + exit 1 + fi + sleep 2 +done + +log "Deploy complete. $SYSTEMD_UNIT is healthy." diff --git a/infra/systemd/havenhold-api.service b/infra/systemd/havenhold-api.service new file mode 100644 index 0000000..da41700 --- /dev/null +++ b/infra/systemd/havenhold-api.service @@ -0,0 +1,20 @@ +[Unit] +Description=Havenhold API Server +After=network.target postgresql.service +Requires=postgresql.service + +[Service] +Type=simple +User=havenhold +Group=havenhold +WorkingDirectory=/opt/havenhold/server +EnvironmentFile=/opt/havenhold/server/.env +ExecStart=/usr/bin/node dist/index.js +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=havenhold-api + +[Install] +WantedBy=multi-user.target diff --git a/package-lock.json b/package-lock.json index 4f9c909..49a31aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-query": "^5.83.0", + "better-auth": "^1.6.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -138,6 +139,20 @@ "node": ">=6.9.0" } }, + "node_modules/@better-auth/utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.0.tgz", + "integrity": "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -938,6 +953,30 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -973,6 +1012,15 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.132.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", @@ -3265,6 +3313,12 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.15.40", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.40.tgz", @@ -3675,7 +3729,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -3758,7 +3812,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/estree": { @@ -4158,7 +4212,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", @@ -4175,7 +4229,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tinyrainbow": "^2.0.0" @@ -4188,7 +4242,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/utils": "3.2.4", @@ -4203,7 +4257,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "3.2.4", @@ -4218,7 +4272,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tinyspy": "^4.0.3" @@ -4231,7 +4285,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "3.2.4", @@ -4412,7 +4466,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -4492,6 +4546,258 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.11.tgz", + "integrity": "sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.11", + "@better-auth/drizzle-adapter": "1.6.11", + "@better-auth/kysely-adapter": "1.6.11", + "@better-auth/memory-adapter": "1.6.11", + "@better-auth/mongo-adapter": "1.6.11", + "@better-auth/prisma-adapter": "1.6.11", + "@better-auth/telemetry": "1.6.11", + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.5", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.17", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": "^0.45.2", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/core": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.11.tgz", + "integrity": "sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.5", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.11.tgz", + "integrity": "sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/kysely-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.11.tgz", + "integrity": "sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "kysely": "^0.28.17" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/memory-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.11.tgz", + "integrity": "sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0" + } + }, + "node_modules/better-auth/node_modules/@better-auth/mongo-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.11.tgz", + "integrity": "sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/prisma-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.11.tgz", + "integrity": "sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/telemetry": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.11.tgz", + "integrity": "sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21" + } + }, + "node_modules/better-auth/node_modules/better-call": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.5.tgz", + "integrity": "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.4.0", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4565,7 +4871,7 @@ "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4639,7 +4945,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", @@ -4713,7 +5019,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 16" @@ -5111,7 +5417,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -5124,6 +5430,12 @@ "dev": true, "license": "MIT" }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5302,7 +5614,7 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -5338,7 +5650,7 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -5616,7 +5928,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -5642,7 +5954,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -6288,6 +6600,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6384,6 +6705,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kysely": { + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", + "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6732,7 +7062,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/lucide-react": { @@ -6759,7 +7089,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -7473,6 +7803,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.3.0.tgz", + "integrity": "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7665,14 +8010,14 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/pathval": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 14.16" @@ -8458,7 +8803,7 @@ "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -8503,7 +8848,13 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, + "devOptional": true, + "license": "MIT" + }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", "license": "MIT" }, "node_modules/run-parallel": { @@ -8571,6 +8922,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8598,7 +8955,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/sonner": { @@ -8645,14 +9002,14 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/stringify-entities": { @@ -8699,7 +9056,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "js-tokens": "^9.0.1" @@ -8712,7 +9069,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/style-to-js": { @@ -8887,14 +9244,14 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tinyglobby": { @@ -8946,7 +9303,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" @@ -8956,7 +9313,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -8966,7 +9323,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -9468,7 +9825,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", @@ -9491,7 +9848,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -9524,7 +9881,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -9537,7 +9894,7 @@ "version": "7.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -9640,7 +9997,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", @@ -9713,7 +10070,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/spy": "3.2.4", @@ -9740,7 +10097,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -9773,7 +10130,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -9786,7 +10143,7 @@ "version": "7.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -9938,7 +10295,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", diff --git a/package.json b/package.json index 3654909..cc7b1fa 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-query": "^5.83.0", + "better-auth": "^1.6.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/server/.env.example b/server/.env.example index e57dac8..11635a0 100644 --- a/server/.env.example +++ b/server/.env.example @@ -2,12 +2,14 @@ DATABASE_URL="" ANTHROPIC_API_KEY="" PORT=3001 -DEMO_PATIENT_ID="" -DEMO_USER_ID="" + +# better-auth session secret — generate with: openssl rand -base64 32 +BETTER_AUTH_SECRET="" +# better-auth canonical API origin (used for callbacks/redirects) +BETTER_AUTH_URL=http://localhost:3001 # Feature flags # Defaults are safe — omit these lines to use the defaults shown below. -DEMO_MODE=true # true = demo auth middleware mounted (default). false = server refuses to start (no JWT auth yet). PIPELINE_ENABLED=true # true = AI document pipeline active (default). false = /documents/upload returns 503. INTEGRATIONS_ENABLED=false # false = no outbound integration calls (default). Reserved for future use. diff --git a/server/package-lock.json b/server/package-lock.json index 0427789..e168f28 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,7 +9,9 @@ "version": "0.0.0", "dependencies": { "@anthropic-ai/sdk": "^0.30.0", + "@better-auth/prisma-adapter": "^1.6.11", "@prisma/client": "^5.22.0", + "better-auth": "^1.6.11", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", @@ -57,6 +59,138 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/@better-auth/core": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.11.tgz", + "integrity": "sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.5", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.11.tgz", + "integrity": "sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.11.tgz", + "integrity": "sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "kysely": "^0.28.17" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.11.tgz", + "integrity": "sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.11.tgz", + "integrity": "sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.11.tgz", + "integrity": "sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.11.tgz", + "integrity": "sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.0.tgz", + "integrity": "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -499,6 +633,39 @@ "node": ">=18" } }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@prisma/client": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", @@ -567,6 +734,12 @@ "@prisma/debug": "5.22.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -759,6 +932,131 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/better-auth": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.11.tgz", + "integrity": "sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.11", + "@better-auth/drizzle-adapter": "1.6.11", + "@better-auth/kysely-adapter": "1.6.11", + "@better-auth/memory-adapter": "1.6.11", + "@better-auth/mongo-adapter": "1.6.11", + "@better-auth/prisma-adapter": "1.6.11", + "@better-auth/telemetry": "1.6.11", + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.5", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.17", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": "^0.45.2", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.5.tgz", + "integrity": "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.4.0", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -927,6 +1225,12 @@ "ms": "2.0.0" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1228,6 +1532,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1391,6 +1696,24 @@ "node": ">= 0.10" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/kysely": { + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", + "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1485,6 +1808,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/nanostores": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.3.0.tgz", + "integrity": "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1690,6 +2028,12 @@ "node": ">= 6" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1761,6 +2105,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1995,6 +2345,15 @@ "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/server/package.json b/server/package.json index be9f9fb..0bca3ea 100644 --- a/server/package.json +++ b/server/package.json @@ -12,9 +12,14 @@ "db:seed": "tsx prisma/seed.ts", "db:studio": "prisma studio" }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, "dependencies": { "@anthropic-ai/sdk": "^0.30.0", + "@better-auth/prisma-adapter": "^1.6.11", "@prisma/client": "^5.22.0", + "better-auth": "^1.6.11", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", diff --git a/server/prisma/migrations/20260528075433_add_better_auth_tables/migration.sql b/server/prisma/migrations/20260528075433_add_better_auth_tables/migration.sql new file mode 100644 index 0000000..a39a86d --- /dev/null +++ b/server/prisma/migrations/20260528075433_add_better_auth_tables/migration.sql @@ -0,0 +1,63 @@ +-- AlterTable: seed existing rows with current timestamp, then drop the default +-- so Prisma's @updatedAt handles it going forward +ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "image" TEXT, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE "User" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "userId" TEXT NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "password" TEXT, + "accessToken" TEXT, + "refreshToken" TEXT, + "idToken" TEXT, + "accessTokenExpiresAt" TIMESTAMP(3), + "refreshTokenExpiresAt" TIMESTAMP(3), + "scope" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Verification" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Verification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_providerId_accountId_key" ON "Account"("providerId", "accountId"); + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 34641fe..1998be5 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -8,15 +8,60 @@ datasource db { } model User { - id String @id @default(cuid()) - name String - email String @unique - role Role @default(FAMILY_MEMBER) - avatarUrl String? - createdAt DateTime @default(now()) - patientId String? - patient Patient? @relation(fields: [patientId], references: [id]) - comments Comment[] + id String @id @default(cuid()) + name String + email String @unique + emailVerified Boolean @default(false) + image String? + role Role @default(FAMILY_MEMBER) + avatarUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + patientId String? + patient Patient? @relation(fields: [patientId], references: [id]) + comments Comment[] + sessions Session[] + accounts Account[] +} + +model Session { + id String @id @default(cuid()) + expiresAt DateTime + token String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ipAddress String? + userAgent String? + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model Account { + id String @id @default(cuid()) + accountId String + providerId String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + password String? + accessToken String? + refreshToken String? + idToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([providerId, accountId]) +} + +model Verification { + id String @id @default(cuid()) + identifier String + value String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } enum Role { diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index 82fde8f..444545a 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -1,94 +1,129 @@ import { PrismaClient } from '@prisma/client'; +import { auth } from '../src/lib/auth'; +import { hashPassword } from 'better-auth/crypto'; const prisma = new PrismaClient(); -async function main() { - console.log('Seeding Havenhold demo data...'); +// Stable IDs — deterministic upserts for entities without natural unique keys +const PATIENT_ID = 'seed-patient-margaret'; +const MED_LISI_ID = 'seed-med-lisinopril'; +const MED_MET_ID = 'seed-med-metformin'; +const APPT_CARDIO_ID = 'seed-appt-cardiology'; +const APPT_REFILL_ID = 'seed-appt-refill'; +const INTER_ID = 'seed-inter-lisi-met'; - // Patient - const margaret = await prisma.patient.create({ - data: { name: 'Margaret Chen', dateOfBirth: new Date('1945-03-12') }, +async function upsertUser(name: string, email: string, patientId: string, role: string) { + const existing = await prisma.user.findUnique({ + where: { email }, + include: { accounts: { where: { providerId: 'credential' } } }, }); - // Family members - await prisma.user.createMany({ - data: [ - { - name: 'David Chen', - email: 'david@example.com', - role: 'PRIMARY_CAREGIVER', - patientId: margaret.id, + if (!existing) { + // New user: let better-auth create the User + Account with correct scrypt hashing + await auth.api.signUpEmail({ body: { name, email, password: 'devpassword123' } }); + } else if (existing.accounts.length === 0) { + // User exists (pre-auth migration) but has no credential Account — create it now + const hashed = await hashPassword('devpassword123'); + await prisma.account.create({ + data: { + accountId: existing.id, + providerId: 'credential', + userId: existing.id, + password: hashed, }, - { - name: 'Sarah Chen', - email: 'sarah@example.com', - role: 'FAMILY_MEMBER', - patientId: margaret.id, - }, - { - name: 'Michael Chen', - email: 'michael@example.com', - role: 'FAMILY_MEMBER', - patientId: margaret.id, - }, - ], + }); + } + + await prisma.user.update({ + where: { email }, + data: { patientId, role, emailVerified: true, updatedAt: new Date() }, }); +} - const primaryCaregiver = await prisma.user.findUnique({ - where: { email: 'david@example.com' }, +async function main() { + console.log('Seeding Havenhold demo data...'); + + // Patient + const margaret = await prisma.patient.upsert({ + where: { id: PATIENT_ID }, + update: {}, + create: { id: PATIENT_ID, name: 'Margaret Chen', dateOfBirth: new Date('1945-03-12') }, }); - // Existing medications - const lisinopril = await prisma.medication.create({ - data: { + // Family members — created via better-auth so passwords use the correct hashing algorithm + await upsertUser('David Chen', 'david@example.com', margaret.id, 'PRIMARY_CAREGIVER'); + await upsertUser('Sarah Chen', 'sarah@example.com', margaret.id, 'FAMILY_MEMBER'); + await upsertUser('Michael Chen', 'michael@example.com', margaret.id, 'FAMILY_MEMBER'); + + const primaryCaregiver = await prisma.user.findUnique({ where: { email: 'david@example.com' } }); + + // Medications + const lisinopril = await prisma.medication.upsert({ + where: { id: MED_LISI_ID }, + update: {}, + create: { + id: MED_LISI_ID, patientId: margaret.id, name: 'Lisinopril', dosage: '10mg', frequency: 'Once daily', prescribingDoctor: 'Dr. Patricia Wong', aiDescription: - 'Lisinopril is a blood pressure medication that helps keep your mom\'s heart from working too hard. It relaxes the blood vessels so her heart pumps more easily. Common things to watch for include a dry cough, dizziness when standing up quickly, and occasional headaches.', + "Lisinopril is a blood pressure medication that helps keep your mom's heart from working too hard. It relaxes the blood vessels so her heart pumps more easily. Common things to watch for include a dry cough, dizziness when standing up quickly, and occasional headaches.", }, }); - const metformin = await prisma.medication.create({ - data: { + const metformin = await prisma.medication.upsert({ + where: { id: MED_MET_ID }, + update: {}, + create: { + id: MED_MET_ID, patientId: margaret.id, name: 'Metformin', dosage: '500mg', frequency: 'Twice daily with meals', prescribingDoctor: 'Dr. James Park', aiDescription: - 'Metformin helps control your mom\'s blood sugar levels for her type 2 diabetes. It\'s best taken with food to avoid an upset stomach. Watch for nausea or diarrhea when she first starts, and make sure she\'s eating regularly — skipping meals can cause low blood sugar.', + "Metformin helps control your mom's blood sugar levels for her type 2 diabetes. It's best taken with food to avoid an upset stomach. Watch for nausea or diarrhea when she first starts, and make sure she's eating regularly — skipping meals can cause low blood sugar.", }, }); - // Upcoming appointments - await prisma.appointment.createMany({ - data: [ - { - patientId: margaret.id, - title: 'Cardiology Follow-up', - doctor: 'Dr. Patricia Wong', - specialty: 'Cardiology', - datetime: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 1 week - location: 'Sunrise Medical Center, Room 204', - notes: 'Bring updated medication list. Fasting bloodwork required.', - }, - { - patientId: margaret.id, - title: 'Prescription Refill — Metformin', - doctor: 'Dr. James Park', - specialty: 'Primary Care', - datetime: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 2 weeks - location: 'Parkview Family Medicine', - }, - ], + // Appointments + await prisma.appointment.upsert({ + where: { id: APPT_CARDIO_ID }, + update: {}, + create: { + id: APPT_CARDIO_ID, + patientId: margaret.id, + title: 'Cardiology Follow-up', + doctor: 'Dr. Patricia Wong', + specialty: 'Cardiology', + datetime: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + location: 'Sunrise Medical Center, Room 204', + notes: 'Bring updated medication list. Fasting bloodwork required.', + }, + }); + + await prisma.appointment.upsert({ + where: { id: APPT_REFILL_ID }, + update: {}, + create: { + id: APPT_REFILL_ID, + patientId: margaret.id, + title: 'Prescription Refill — Metformin', + doctor: 'Dr. James Park', + specialty: 'Primary Care', + datetime: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), + location: 'Parkview Family Medicine', + }, }); - // A known interaction between the two medications - await prisma.medicationInteraction.create({ - data: { + // Medication interaction + await prisma.medicationInteraction.upsert({ + where: { id: INTER_ID }, + update: {}, + create: { + id: INTER_ID, patientId: margaret.id, medicationAId: lisinopril.id, medicationBId: metformin.id, @@ -99,11 +134,11 @@ async function main() { }); console.log(`✓ Patient: ${margaret.name} (id: ${margaret.id})`); - console.log('✓ 3 family members'); + console.log('✓ 3 family members (login: david@example.com / devpassword123)'); console.log('✓ 2 medications with interaction'); console.log('✓ 2 upcoming appointments'); console.log('\nPatient ID for API calls:', margaret.id); - console.log('Demo user ID for API calls:', primaryCaregiver?.id ?? 'not found'); + console.log('Primary caregiver ID:', primaryCaregiver?.id ?? 'not found'); } main() diff --git a/server/src/config/flags.test.ts b/server/src/config/flags.test.ts index 6bd8b2c..3ded720 100644 --- a/server/src/config/flags.test.ts +++ b/server/src/config/flags.test.ts @@ -38,14 +38,9 @@ test('parseBool falls back to default for unrecognised values', () => { }); // --------------------------------------------------------------------------- -// Safe-default contract tests (verify flags module defaults without side-effects) +// Safe-default contract tests // --------------------------------------------------------------------------- -test('DEMO_MODE defaults to true when env var is unset', () => { - // parseBool is the single source of truth for defaults; this test pins the contract. - assert.equal(parseBool(undefined, true), true, 'DEMO_MODE safe default must be true'); -}); - test('PIPELINE_ENABLED defaults to true when env var is unset', () => { assert.equal(parseBool(undefined, true), true, 'PIPELINE_ENABLED safe default must be true'); }); diff --git a/server/src/config/flags.ts b/server/src/config/flags.ts index 2d5e8f6..f6b8958 100644 --- a/server/src/config/flags.ts +++ b/server/src/config/flags.ts @@ -5,8 +5,6 @@ * Safe defaults are chosen so the server runs correctly out of the box * with a minimal .env (i.e. for local dev and demo deployments). * - * DEMO_MODE default: true — enables demo auth middleware. - * Must remain true until JWT auth is implemented. * PIPELINE_ENABLED default: true — enables the AI document processing pipeline. * false blocks /documents/upload entirely (503). * INTEGRATIONS_ENABLED default: false — reserved for future third-party integrations @@ -29,7 +27,6 @@ export function parseBool(value: string | undefined, defaultValue: boolean): boo } export const flags = { - DEMO_MODE: parseBool(process.env.DEMO_MODE, true), PIPELINE_ENABLED: parseBool(process.env.PIPELINE_ENABLED, true), INTEGRATIONS_ENABLED: parseBool(process.env.INTEGRATIONS_ENABLED, false), } as const; diff --git a/server/src/index.ts b/server/src/index.ts index 6153f5e..817b64c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,8 +1,10 @@ import 'dotenv/config'; import express from 'express'; import cors from 'cors'; +import { toNodeHandler } from 'better-auth/node'; +import { auth } from './lib/auth'; +import { requireAuth } from './middleware/sessionAuth'; import { flags } from './config/flags'; -import { demoAuth } from './middleware/demoAuth'; import { appointmentsRouter } from './routes/appointments'; import { medicationsRouter } from './routes/medications'; import { documentsRouter } from './routes/documents'; @@ -10,52 +12,42 @@ import { feedRouter } from './routes/feed'; import { commentsRouter } from './routes/comments'; import { familyRouter } from './routes/family'; -// --------------------------------------------------------------------------- -// Startup: validate flags before any middleware or routes are registered -// --------------------------------------------------------------------------- console.log( - `[flags] DEMO_MODE=${flags.DEMO_MODE} PIPELINE_ENABLED=${flags.PIPELINE_ENABLED} INTEGRATIONS_ENABLED=${flags.INTEGRATIONS_ENABLED}`, + `[flags] PIPELINE_ENABLED=${flags.PIPELINE_ENABLED} INTEGRATIONS_ENABLED=${flags.INTEGRATIONS_ENABLED}`, ); -if (!flags.DEMO_MODE) { - // No JWT auth implementation exists yet. Running without demo auth means every - // request would be unauthenticated. Fail fast rather than silently serve open routes. - console.error( - '[startup] FATAL: DEMO_MODE=false but no JWT auth is configured. ' + - 'Refusing to start. Set DEMO_MODE=true or implement JWT middleware first.', - ); - process.exit(1); -} - const app = express(); const PORT = process.env.PORT ?? 3001; const devOrigins = ['http://localhost:5173', 'http://localhost:8080', 'http://localhost:3000']; const prodOrigins = - process.env.NODE_ENV !== 'development' && process.env.CORS_ORIGIN + process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',').map(o => o.trim()).filter(Boolean) : []; -// devOrigins are always included while DEMO_MODE is the only auth layer. -// When real auth lands, restrict to prodOrigins-only in production (remove devOrigins spread). -app.use(cors({ origin: [...devOrigins, ...prodOrigins] })); -app.use(express.json()); +const allowedOrigins = + process.env.NODE_ENV === 'production' ? prodOrigins : [...devOrigins, ...prodOrigins]; -// Demo auth: sets req.user on every request. -// Replace with real JWT verification before any real deployment. -// Mounting is conditional on DEMO_MODE (guarded above at startup). -app.use(demoAuth); +app.use(cors({ + origin: allowedOrigins, + credentials: true, +})); -app.use('/api/appointments', appointmentsRouter); -app.use('/api/medications', medicationsRouter); -app.use('/api/documents', documentsRouter); -app.use('/api/feed', feedRouter); -app.use('/api/comments', commentsRouter); -app.use('/api/family', familyRouter); +// better-auth handler must be mounted before express.json() — it parses its own bodies +app.all('/api/auth/*', toNodeHandler(auth)); + +app.use(express.json()); app.get('/api/health', (_req, res) => { res.json({ status: 'ok' }); }); +app.use('/api/appointments', requireAuth, appointmentsRouter); +app.use('/api/medications', requireAuth, medicationsRouter); +app.use('/api/documents', requireAuth, documentsRouter); +app.use('/api/feed', requireAuth, feedRouter); +app.use('/api/comments', requireAuth, commentsRouter); +app.use('/api/family', requireAuth, familyRouter); + app.listen(PORT, () => { console.log(`Havenhold server running on http://localhost:${PORT}`); }); diff --git a/server/src/lib/auth.ts b/server/src/lib/auth.ts new file mode 100644 index 0000000..7d804fe --- /dev/null +++ b/server/src/lib/auth.ts @@ -0,0 +1,26 @@ +import { betterAuth } from 'better-auth'; +import { prismaAdapter } from 'better-auth/adapters/prisma'; +import { prisma } from './prisma'; + +export const auth = betterAuth({ + database: prismaAdapter(prisma, { provider: 'postgresql' }), + secret: process.env.BETTER_AUTH_SECRET, + trustedOrigins: process.env.CORS_ORIGIN + ? process.env.CORS_ORIGIN.split(',').map(o => o.trim()) + : ['http://localhost:5173', 'http://localhost:8080', 'http://localhost:3000'], + emailAndPassword: { + enabled: true, + }, + user: { + additionalFields: { + patientId: { type: 'string', required: false, input: false }, + role: { type: 'string', required: false, input: false }, + }, + }, + session: { + expiresIn: 60 * 60 * 24 * 7, + }, + advanced: { + cookiePrefix: 'havenhold', + }, +}); diff --git a/server/src/middleware/demoAuth.ts b/server/src/middleware/demoAuth.ts deleted file mode 100644 index 6c31164..0000000 --- a/server/src/middleware/demoAuth.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; - -/** - * Demo-only auth middleware. - * Hardcodes a single user context so all requests are scoped to one patient. - * - * Production replacement: verify JWT, load user from DB, enforce - * that req.user.patientId matches the requested patientId on every route. - */ - -export interface AuthUser { - id: string; - patientId: string; - role: 'PRIMARY_CAREGIVER' | 'FAMILY_MEMBER'; -} - -// Populated by the seed script — update if you re-seed -const DEMO_USER: AuthUser = { - id: process.env.DEMO_USER_ID ?? 'demo-user', - patientId: process.env.DEMO_PATIENT_ID ?? 'demo-patient', - role: 'PRIMARY_CAREGIVER', -}; - -declare module 'express-serve-static-core' { - interface Request { - user?: AuthUser; - } -} - -export function demoAuth(req: Request, _res: Response, next: NextFunction) { - req.user = DEMO_USER; - next(); -} - -/** - * Guards a route so only requests for the demo patient's data are allowed. - * Prevents IDOR: a patientId in the URL that doesn't match the session is rejected. - */ -export function requirePatientAccess( - req: Request, - res: Response, - next: NextFunction -) { - if (!req.user) { - return res.status(401).json({ error: 'Authentication required' }); - } - - const patientId = req.params.patientId ?? req.body?.patientId; - if (patientId && patientId !== req.user.patientId) { - return res.status(403).json({ error: 'Access denied' }); - } - next(); -} diff --git a/server/src/middleware/sessionAuth.ts b/server/src/middleware/sessionAuth.ts new file mode 100644 index 0000000..39cf143 --- /dev/null +++ b/server/src/middleware/sessionAuth.ts @@ -0,0 +1,65 @@ +import { Request, Response, NextFunction } from 'express'; +import { fromNodeHeaders } from 'better-auth/node'; +import { auth } from '../lib/auth'; + +export interface AuthUser { + id: string; + patientId: string; + role: 'PRIMARY_CAREGIVER' | 'FAMILY_MEMBER'; +} + +// better-auth session.user base type extended with our additionalFields +interface BetterAuthSessionUser { + id: string; + patientId?: string | null; + role?: string | null; +} + +declare module 'express-serve-static-core' { + interface Request { + user?: AuthUser; + } +} + +export async function requireAuth( + req: Request, + res: Response, + next: NextFunction +) { + try { + const session = await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); + + if (!session?.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const sessionUser = session.user as unknown as BetterAuthSessionUser; + req.user = { + id: session.user.id, + patientId: sessionUser.patientId ?? '', + role: (sessionUser.role as AuthUser['role']) ?? 'FAMILY_MEMBER', + }; + + next(); + } catch (err) { + next(err); + } +} + +export function requirePatientAccess( + req: Request, + res: Response, + next: NextFunction +) { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const patientId = (req.params.patientId ?? req.body?.patientId) as string | undefined; + if (!patientId || patientId !== req.user.patientId) { + return res.status(403).json({ error: 'Access denied' }); + } + next(); +} diff --git a/server/src/routes/appointments.ts b/server/src/routes/appointments.ts index 0af49f6..6de4013 100644 --- a/server/src/routes/appointments.ts +++ b/server/src/routes/appointments.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { prisma } from '../lib/prisma'; -import { requirePatientAccess } from '../middleware/demoAuth'; +import { requirePatientAccess } from '../middleware/sessionAuth'; export const appointmentsRouter = Router(); @@ -24,7 +24,7 @@ appointmentsRouter.get('/:patientId', requirePatientAccess, async (req, res) => }); // Create appointment manually -appointmentsRouter.post('/', async (req, res) => { +appointmentsRouter.post('/', requirePatientAccess, async (req, res) => { try { const { patientId, title, doctor, specialty, datetime, location, notes } = req.body; const appointment = await prisma.appointment.create({ @@ -39,6 +39,13 @@ appointmentsRouter.post('/', async (req, res) => { // Update appointment — only allow mutable user-facing fields appointmentsRouter.patch('/:id', async (req, res) => { try { + const appt = await prisma.appointment.findUnique({ + where: { id: req.params.id }, + select: { patientId: true }, + }); + if (!appt) return res.status(404).json({ error: 'Not found' }); + if (appt.patientId !== req.user!.patientId) return res.status(403).json({ error: 'Access denied' }); + const { title, doctor, specialty, datetime, location, notes } = req.body; const appointment = await prisma.appointment.update({ where: { id: req.params.id }, @@ -60,6 +67,13 @@ appointmentsRouter.patch('/:id', async (req, res) => { // Review AI-extracted appointment — confirm or reject appointmentsRouter.patch('/:id/review', async (req, res) => { try { + const appt = await prisma.appointment.findUnique({ + where: { id: req.params.id }, + select: { patientId: true }, + }); + if (!appt) return res.status(404).json({ error: 'Not found' }); + if (appt.patientId !== req.user!.patientId) return res.status(403).json({ error: 'Access denied' }); + const { action } = req.body as { action: 'confirm' | 'reject' }; if (!['confirm', 'reject'].includes(action)) { return res.status(400).json({ error: 'action must be confirm or reject' }); @@ -77,9 +91,11 @@ appointmentsRouter.patch('/:id/review', async (req, res) => { // Export single appointment as iCal appointmentsRouter.get('/:id/ical', async (req, res) => { try { - const appt = await prisma.appointment.findUniqueOrThrow({ + const appt = await prisma.appointment.findUnique({ where: { id: req.params.id }, }); + if (!appt) return res.status(404).json({ error: 'Not found' }); + if (appt.patientId !== req.user!.patientId) return res.status(403).json({ error: 'Access denied' }); const start = new Date(appt.datetime); const end = new Date(start.getTime() + 60 * 60 * 1000); // 1hr default diff --git a/server/src/routes/comments.ts b/server/src/routes/comments.ts index 905f826..4be03fe 100644 --- a/server/src/routes/comments.ts +++ b/server/src/routes/comments.ts @@ -3,15 +3,49 @@ import { prisma } from '../lib/prisma'; export const commentsRouter = Router(); +async function resolveEntityPatientId( + entityType: string, + entityId: string +): Promise { + if (entityType === 'document') { + const doc = await prisma.document.findUnique({ where: { id: entityId }, select: { patientId: true } }); + return doc?.patientId ?? null; + } + if (entityType === 'appointment') { + const appt = await prisma.appointment.findUnique({ where: { id: entityId }, select: { patientId: true } }); + return appt?.patientId ?? null; + } + if (entityType === 'medication') { + const med = await prisma.medication.findUnique({ where: { id: entityId }, select: { patientId: true } }); + return med?.patientId ?? null; + } + return null; +} + // Add a comment — authorId comes from the session, not the request body commentsRouter.post('/', async (req, res) => { try { const { body, documentId, appointmentId, medicationId } = req.body; - if (!req.user) return res.status(401).json({ error: 'Authentication required' }); - const authorId = req.user.id; + + const provided: { type: 'document' | 'appointment' | 'medication'; id: string }[] = [ + ...(documentId ? [{ type: 'document' as const, id: documentId }] : []), + ...(appointmentId ? [{ type: 'appointment' as const, id: appointmentId }] : []), + ...(medicationId ? [{ type: 'medication' as const, id: medicationId }] : []), + ]; + + if (provided.length === 0) { + return res.status(400).json({ error: 'A documentId, appointmentId, or medicationId is required' }); + } + + // Verify ownership for every provided entity — prevents cross-tenant writes + for (const { type, id } of provided) { + const ownerPatientId = await resolveEntityPatientId(type, id); + if (!ownerPatientId) return res.status(404).json({ error: `${type} not found` }); + if (ownerPatientId !== req.user!.patientId) return res.status(403).json({ error: 'Access denied' }); + } const comment = await prisma.comment.create({ - data: { authorId, body, documentId, appointmentId, medicationId }, + data: { authorId: req.user!.id, body, documentId, appointmentId, medicationId }, include: { author: true }, }); res.status(201).json(comment); @@ -24,12 +58,19 @@ commentsRouter.post('/', async (req, res) => { commentsRouter.get('/:entityType/:entityId', async (req, res) => { try { const { entityType, entityId } = req.params; - const where: Record = {}; + if (!['document', 'appointment', 'medication'].includes(entityType)) { + return res.status(400).json({ error: 'Invalid entity type' }); + } + + const ownerPatientId = await resolveEntityPatientId(entityType, entityId); + if (!ownerPatientId) return res.status(404).json({ error: 'Entity not found' }); + if (ownerPatientId !== req.user!.patientId) return res.status(403).json({ error: 'Access denied' }); + + const where: Record = {}; if (entityType === 'document') where.documentId = entityId; else if (entityType === 'appointment') where.appointmentId = entityId; - else if (entityType === 'medication') where.medicationId = entityId; - else return res.status(400).json({ error: 'Invalid entity type' }); + else where.medicationId = entityId; const comments = await prisma.comment.findMany({ where, diff --git a/server/src/routes/documents.ts b/server/src/routes/documents.ts index 333430a..d74ddd5 100644 --- a/server/src/routes/documents.ts +++ b/server/src/routes/documents.ts @@ -7,6 +7,7 @@ import { prisma } from '../lib/prisma'; import { runPipeline } from '../lib/pipeline'; import { getAnalysisType } from '../lib/validation'; import { broadcastFeedEvent } from './feed'; +import { requirePatientAccess } from '../middleware/sessionAuth'; export const documentsRouter = Router(); @@ -37,6 +38,8 @@ const upload = multer({ // Upload a document and kick off the AI pipeline. // The pipeline gate runs BEFORE multer so no file is written when the flag is off. +// requirePatientAccess cannot run before multer (body is unparsed for multipart), +// so the ownership check is done inline after multer parses the form fields. documentsRouter.post( '/upload', (req, res, next) => { @@ -47,52 +50,63 @@ documentsRouter.post( }, upload.single('file'), async (req, res) => { - try { - if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + const cleanup = () => { if (req.file?.path) fs.unlink(req.file.path, () => {}); }; + try { + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); - const { patientId, analysisType } = req.body; - if (!patientId) return res.status(400).json({ error: 'patientId is required' }); + const { patientId, analysisType } = req.body; + if (!patientId) { + cleanup(); + return res.status(400).json({ error: 'patientId is required' }); + } - const safeAnalysisType = getAnalysisType(analysisType); + // Ownership check — must happen after multer so req.body is populated + if (patientId !== req.user!.patientId) { + cleanup(); + return res.status(403).json({ error: 'Access denied' }); + } - // Extract text — use async fs.promises to avoid blocking the event loop - let rawText = ''; - if (req.file.mimetype === 'application/pdf') { - const pdfParse = await import('pdf-parse'); - const buffer = await fs.promises.readFile(req.file.path); - const parsed = await pdfParse.default(buffer); - rawText = parsed.text; - } else { - rawText = await fs.promises.readFile(req.file.path, 'utf-8'); - } + const safeAnalysisType = getAnalysisType(analysisType); - // Store rawText only for pipeline use — never returned in API responses - const document = await prisma.document.create({ - data: { - patientId, - filename: req.file.originalname, - fileUrl: `/uploads/${req.file.filename}`, - processingStatus: 'PENDING', - analysisType: safeAnalysisType, - rawText, - }, - }); + // Extract text — use async fs.promises to avoid blocking the event loop + let rawText = ''; + if (req.file.mimetype === 'application/pdf') { + const pdfParse = await import('pdf-parse'); + const buffer = await fs.promises.readFile(req.file.path); + const parsed = await pdfParse.default(buffer); + rawText = parsed.text; + } else { + rawText = await fs.promises.readFile(req.file.path, 'utf-8'); + } - broadcastFeedEvent(patientId, { type: 'feed_refresh', patientId }); + // Store rawText only for pipeline use — never returned in API responses + const document = await prisma.document.create({ + data: { + patientId, + filename: req.file.originalname, + fileUrl: `/uploads/${req.file.filename}`, + processingStatus: 'PENDING', + analysisType: safeAnalysisType, + rawText, + }, + }); - // Fire-and-forget — respond immediately, pipeline runs async - runPipeline(document.id, rawText, patientId, safeAnalysisType).catch(console.error); + broadcastFeedEvent(patientId, { type: 'feed_refresh', patientId }); - res.status(201).json({ documentId: document.id, status: 'PENDING' }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Upload failed' }); - } -}, + // Fire-and-forget — respond immediately, pipeline runs async + runPipeline(document.id, rawText, patientId, safeAnalysisType).catch(console.error); + + res.status(201).json({ documentId: document.id, status: 'PENDING' }); + } catch (err) { + cleanup(); + console.error(err); + res.status(500).json({ error: 'Upload failed' }); + } + }, ); // List documents for a patient — must be before /:id to avoid route conflict -documentsRouter.get('/list/:patientId', async (req, res) => { +documentsRouter.get('/list/:patientId', requirePatientAccess, async (req, res) => { try { const documents = await prisma.document.findMany({ where: { patientId: req.params.patientId as string }, @@ -113,7 +127,7 @@ documentsRouter.get('/list/:patientId', async (req, res) => { // Get a single document — rawText excluded documentsRouter.get('/:id', async (req, res) => { try { - const document = await prisma.document.findUniqueOrThrow({ + const document = await prisma.document.findUnique({ where: { id: req.params.id }, select: { id: true, @@ -129,13 +143,15 @@ documentsRouter.get('/:id', async (req, res) => { medications: true, }, }); + if (!document) return res.status(404).json({ error: 'Document not found' }); + if (document.patientId !== req.user!.patientId) return res.status(403).json({ error: 'Access denied' }); res.json(document); } catch (err) { - res.status(404).json({ error: 'Document not found' }); + res.status(500).json({ error: 'Failed to fetch document' }); } }); // SSE stream for pipeline status — delegates to feed SSE -documentsRouter.get('/events/:patientId', (req, res) => { +documentsRouter.get('/events/:patientId', requirePatientAccess, (req, res) => { res.redirect(`/api/feed/events/${req.params.patientId}`); }); diff --git a/server/src/routes/family.ts b/server/src/routes/family.ts index 2f780d9..e23622b 100644 --- a/server/src/routes/family.ts +++ b/server/src/routes/family.ts @@ -1,13 +1,14 @@ import { Router } from 'express'; import { prisma } from '../lib/prisma'; +import { requirePatientAccess } from '../middleware/sessionAuth'; export const familyRouter = Router(); // List family members for a patient -familyRouter.get('/:patientId', async (req, res) => { +familyRouter.get('/:patientId', requirePatientAccess, async (req, res) => { try { const members = await prisma.user.findMany({ - where: { patientId: req.params.patientId }, + where: { patientId: req.params.patientId as string }, orderBy: { role: 'asc' }, }); res.json(members); diff --git a/server/src/routes/feed.ts b/server/src/routes/feed.ts index bc96270..c90a9aa 100644 --- a/server/src/routes/feed.ts +++ b/server/src/routes/feed.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../lib/prisma'; -import { requirePatientAccess } from '../middleware/demoAuth'; +import { requirePatientAccess } from '../middleware/sessionAuth'; export const feedRouter = Router(); @@ -86,4 +86,3 @@ feedRouter.get('/:patientId', requirePatientAccess, async (req, res) => { res.status(500).json({ error: 'Failed to fetch feed' }); } }); - diff --git a/server/src/routes/medications.ts b/server/src/routes/medications.ts index 89242af..6a600c7 100644 --- a/server/src/routes/medications.ts +++ b/server/src/routes/medications.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { prisma } from '../lib/prisma'; -import { requirePatientAccess } from '../middleware/demoAuth'; +import { requirePatientAccess } from '../middleware/sessionAuth'; export const medicationsRouter = Router(); @@ -23,7 +23,7 @@ medicationsRouter.get('/:patientId', requirePatientAccess, async (req, res) => { }); // Create medication manually -medicationsRouter.post('/', async (req, res) => { +medicationsRouter.post('/', requirePatientAccess, async (req, res) => { try { const { patientId, name, dosage, frequency, prescribingDoctor } = req.body; const medication = await prisma.medication.create({ @@ -38,6 +38,13 @@ medicationsRouter.post('/', async (req, res) => { // Review AI-extracted medication — confirm or reject medicationsRouter.patch('/:id/review', async (req, res) => { try { + const med = await prisma.medication.findUnique({ + where: { id: req.params.id }, + select: { patientId: true }, + }); + if (!med) return res.status(404).json({ error: 'Not found' }); + if (med.patientId !== req.user!.patientId) return res.status(403).json({ error: 'Access denied' }); + const { action } = req.body as { action: 'confirm' | 'reject' }; if (!['confirm', 'reject'].includes(action)) { return res.status(400).json({ error: 'action must be confirm or reject' }); @@ -55,6 +62,13 @@ medicationsRouter.patch('/:id/review', async (req, res) => { // Update medication — only allow mutable user-facing fields medicationsRouter.patch('/:id', async (req, res) => { try { + const med = await prisma.medication.findUnique({ + where: { id: req.params.id }, + select: { patientId: true }, + }); + if (!med) return res.status(404).json({ error: 'Not found' }); + if (med.patientId !== req.user!.patientId) return res.status(403).json({ error: 'Access denied' }); + const { name, dosage, frequency, prescribingDoctor, active } = req.body; const medication = await prisma.medication.update({ where: { id: req.params.id }, diff --git a/src/App.tsx b/src/App.tsx index ef3dc9f..d0e57cf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "./contexts/AuthContext"; +import ProtectedRoute from "./components/ProtectedRoute"; import AppLayout from "./components/AppLayout"; +import LoginPage from "./pages/LoginPage"; import FeedPage from "./pages/FeedPage"; import AppointmentsPage from "./pages/AppointmentsPage"; import MedicationsPage from "./pages/MedicationsPage"; @@ -16,19 +19,31 @@ const queryClient = new QueryClient(); const App = () => ( - + - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> - + ); diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 0abbbe5..1a5e7ac 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,6 +1,7 @@ import { ReactNode } from "react"; -import { Link, useLocation } from "react-router-dom"; -import { Newspaper, CalendarDays, Pill, FolderOpen, Heart } from "lucide-react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { Newspaper, CalendarDays, Pill, FolderOpen, Heart, LogOut } from "lucide-react"; +import { signOut } from "@/lib/auth-client"; const tabs = [ { path: "/", label: "Feed", icon: Newspaper }, @@ -11,6 +12,12 @@ const tabs = [ export default function AppLayout({ children }: { children: ReactNode }) { const { pathname } = useLocation(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await signOut(); + navigate("/login", { replace: true }); + }; return (
@@ -23,9 +30,13 @@ export default function AppLayout({ children }: { children: ReactNode }) { TendWell
-

- Care, together. -

+ diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..9a504dd --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,20 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; + +export default function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
Loading…
+
+ ); + } + + if (!user) { + return ; + } + + return <>{children}; +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..b10e251 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,53 @@ +import { createContext, useContext, ReactNode } from 'react'; +import { useSession } from '@/lib/auth-client'; + +export interface AuthUser { + id: string; + name: string; + email: string; + patientId: string; + role: 'PRIMARY_CAREGIVER' | 'FAMILY_MEMBER'; +} + +// better-auth session.user base type extended with our additionalFields +interface BetterAuthSessionUser { + id: string; + name: string; + email: string; + patientId?: string | null; + role?: string | null; +} + +interface AuthContextValue { + user: AuthUser | null; + isLoading: boolean; +} + +const AuthContext = createContext({ user: null, isLoading: true }); + +export function AuthProvider({ children }: { children: ReactNode }) { + const { data: session, isPending } = useSession(); + + const user: AuthUser | null = session?.user + ? (() => { + const u = session.user as unknown as BetterAuthSessionUser; + return { + id: u.id, + name: u.name, + email: u.email, + patientId: u.patientId ?? '', + role: (u.role as AuthUser['role']) ?? 'FAMILY_MEMBER', + }; + })() + : null; + + return ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index ec61924..6eb2e06 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,24 +1,26 @@ -const BASE_URL = 'http://localhost:3001/api'; - -// Demo only — in production this comes from auth session -export const PATIENT_ID = import.meta.env.VITE_DEMO_PATIENT_ID as string; -export const USER_ID = import.meta.env.VITE_DEMO_USER_ID as string; +const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api'; async function request(path: string, options?: RequestInit): Promise { const res = await fetch(`${BASE_URL}${path}`, { headers: { 'Content-Type': 'application/json' }, + credentials: 'include', ...options, }); + if (res.status === 401) { + window.location.href = '/login'; + throw new Error('Unauthenticated'); + } if (!res.ok) throw new Error(`API error ${res.status}`); return res.json(); } // Feed -export const fetchFeed = () => request(`/feed/${PATIENT_ID}`); +export const fetchFeed = (patientId: string) => + request(`/feed/${patientId}`); // Appointments -export const fetchAppointments = () => - request(`/appointments/${PATIENT_ID}`); +export const fetchAppointments = (patientId: string) => + request(`/appointments/${patientId}`); export const createAppointment = (data: CreateAppointmentInput) => request('/appointments', { method: 'POST', body: JSON.stringify(data) }); @@ -27,24 +29,36 @@ export const exportIcal = (id: string) => window.open(`${BASE_URL}/appointments/${id}/ical`, '_blank'); // Medications -export const fetchMedications = () => - request(`/medications/${PATIENT_ID}`); +export const fetchMedications = (patientId: string) => + request(`/medications/${patientId}`); // Documents -export const fetchDocuments = () => +export const fetchDocuments = (patientId: string) => request[]>( - `/documents/list/${PATIENT_ID}` + `/documents/list/${patientId}` ); export const fetchDocument = (id: string) => request(`/documents/${id}`); -export const uploadDocument = async (file: File, analysisType = 'BALANCED'): Promise<{ documentId: string }> => { +export const uploadDocument = async ( + patientId: string, + file: File, + analysisType = 'BALANCED' +): Promise<{ documentId: string }> => { const form = new FormData(); form.append('file', file); - form.append('patientId', PATIENT_ID); + form.append('patientId', patientId); form.append('analysisType', analysisType); - const res = await fetch(`${BASE_URL}/documents/upload`, { method: 'POST', body: form }); + const res = await fetch(`${BASE_URL}/documents/upload`, { + method: 'POST', + body: form, + credentials: 'include', + }); + if (res.status === 401) { + window.location.href = '/login'; + throw new Error('Unauthenticated'); + } if (!res.ok) throw new Error(`Upload failed ${res.status}`); return res.json(); }; @@ -64,11 +78,17 @@ export const fetchComments = (entityType: string, entityId: string) => request(`/comments/${entityType}/${entityId}`); // Family -export const fetchFamily = () => request(`/family/${PATIENT_ID}`); +export const fetchFamily = (patientId: string) => + request(`/family/${patientId}`); // SSE helper — returns a cleanup function -export function subscribeToFeed(onEvent: (event: SSEEvent) => void): () => void { - const es = new EventSource(`${BASE_URL}/feed/events/${PATIENT_ID}`); +export function subscribeToFeed( + patientId: string, + onEvent: (event: SSEEvent) => void +): () => void { + const es = new EventSource(`${BASE_URL}/feed/events/${patientId}`, { + withCredentials: true, + }); es.onmessage = (e) => { try { onEvent(JSON.parse(e.data)); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..a3c8028 --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,7 @@ +import { createAuthClient } from 'better-auth/react'; + +export const authClient = createAuthClient({ + baseURL: import.meta.env.VITE_AUTH_BASE_URL ?? 'http://localhost:3001', +}); + +export const { useSession, signIn, signOut } = authClient; diff --git a/src/lib/flags.ts b/src/lib/flags.ts index e884fa1..7560544 100644 --- a/src/lib/flags.ts +++ b/src/lib/flags.ts @@ -5,7 +5,6 @@ * Safe defaults match the backend so the app works out of the box * without any extra env configuration. * - * VITE_DEMO_MODE default: true * VITE_PIPELINE_ENABLED default: true * VITE_INTEGRATIONS_ENABLED default: false */ @@ -19,7 +18,6 @@ function parseBool(value: string | undefined, defaultValue: boolean): boolean { } export const flags = { - DEMO_MODE: parseBool(import.meta.env.VITE_DEMO_MODE, true), PIPELINE_ENABLED: parseBool(import.meta.env.VITE_PIPELINE_ENABLED, true), INTEGRATIONS_ENABLED: parseBool(import.meta.env.VITE_INTEGRATIONS_ENABLED, false), } as const; diff --git a/src/pages/AddAppointmentPage.tsx b/src/pages/AddAppointmentPage.tsx index 235ccbe..d0c3b45 100644 --- a/src/pages/AddAppointmentPage.tsx +++ b/src/pages/AddAppointmentPage.tsx @@ -2,17 +2,19 @@ import { useState } from "react"; import { useNavigate, Link } from "react-router-dom"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Loader2 } from "lucide-react"; -import { createAppointment, PATIENT_ID } from "@/lib/api"; +import { createAppointment } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; export default function AddAppointmentPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { user } = useAuth(); const [form, setForm] = useState({ title: "", doctor: "", specialty: "", datetime: "", location: "", notes: "", }); const mutation = useMutation({ - mutationFn: () => createAppointment({ ...form, patientId: PATIENT_ID }), + mutationFn: () => createAppointment({ ...form, patientId: user!.patientId }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["appointments"] }); queryClient.invalidateQueries({ queryKey: ["feed"] }); diff --git a/src/pages/AppointmentsPage.tsx b/src/pages/AppointmentsPage.tsx index e8f0458..3c12ede 100644 --- a/src/pages/AppointmentsPage.tsx +++ b/src/pages/AppointmentsPage.tsx @@ -4,12 +4,15 @@ import { useQuery } from "@tanstack/react-query"; import { CalendarDays, MapPin, ChevronDown, ChevronUp, Download, Plus, StickyNote, Clock } from "lucide-react"; import { fetchAppointments, exportIcal, type Appointment } from "@/lib/api"; import { format } from "date-fns"; +import { useAuth } from "@/contexts/AuthContext"; export default function AppointmentsPage() { const [expanded, setExpanded] = useState(null); + const { user } = useAuth(); const { data: appointments = [], isLoading } = useQuery({ - queryKey: ["appointments"], - queryFn: fetchAppointments, + queryKey: ["appointments", user?.patientId], + queryFn: () => fetchAppointments(user!.patientId), + enabled: !!user?.patientId, }); const grouped = appointments.reduce>((acc, apt) => { diff --git a/src/pages/DocumentUploadPage.tsx b/src/pages/DocumentUploadPage.tsx index 7fb6a61..4db1b9b 100644 --- a/src/pages/DocumentUploadPage.tsx +++ b/src/pages/DocumentUploadPage.tsx @@ -6,6 +6,7 @@ import { } from "lucide-react"; import { uploadDocument, subscribeToFeed, type ProcessingStatus } from "@/lib/api"; import { flags } from "@/lib/flags"; +import { useAuth } from "@/contexts/AuthContext"; const STEPS: { status: ProcessingStatus; icon: typeof Search; title: string; description: string }[] = [ { status: "EXTRACTING", icon: Search, title: "Extracting Data", description: "Scanning for appointments, medications, and instructions…" }, @@ -68,6 +69,7 @@ const ANALYSIS_OPTIONS: { value: AnalysisType; label: string; description: strin export default function DocumentUploadPage() { const navigate = useNavigate(); + const { user } = useAuth(); const [file, setFile] = useState(null); const [analysisType, setAnalysisType] = useState("BALANCED"); const [documentId, setDocumentId] = useState(null); @@ -82,15 +84,15 @@ export default function DocumentUploadPage() { // Subscribe to SSE once we have a documentId useEffect(() => { - if (!documentId) return; - return subscribeToFeed((event) => { + if (!documentId || !user?.patientId) return; + return subscribeToFeed(user.patientId, (event) => { if (event.type === "pipeline" && event.documentId === documentId) { if (event.step) setCurrentStatus(event.step); if (event.appointmentsAdded) setAppointmentsAdded(event.appointmentsAdded); if (event.medicationsAdded) setMedicationsAdded(event.medicationsAdded); } }); - }, [documentId]); + }, [documentId, user?.patientId]); const handleFileChange = (e: React.ChangeEvent) => { const f = e.target.files?.[0]; @@ -98,10 +100,10 @@ export default function DocumentUploadPage() { }; const handleUpload = async () => { - if (!file) return; + if (!file || !user?.patientId) return; setError(null); try { - const { documentId: id } = await uploadDocument(file, analysisType); + const { documentId: id } = await uploadDocument(user.patientId, file, analysisType); setDocumentId(id); setCurrentStatus("PENDING"); } catch { diff --git a/src/pages/DocumentsPage.tsx b/src/pages/DocumentsPage.tsx index 529cb44..7702a07 100644 --- a/src/pages/DocumentsPage.tsx +++ b/src/pages/DocumentsPage.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { FileText, Upload, Clock, CheckCircle2, Loader2, AlertCircle } from "lucide-react"; import { fetchDocuments } from "@/lib/api"; import { formatDistanceToNow } from "date-fns"; +import { useAuth } from "@/contexts/AuthContext"; const statusIcon = { COMPLETE: , @@ -25,9 +26,11 @@ const statusLabel = { }; export default function DocumentsPage() { + const { user } = useAuth(); const { data: documents = [], isLoading } = useQuery({ - queryKey: ["documents"], - queryFn: fetchDocuments, + queryKey: ["documents", user?.patientId], + queryFn: () => fetchDocuments(user!.patientId), + enabled: !!user?.patientId, }); return ( diff --git a/src/pages/FamilyPage.tsx b/src/pages/FamilyPage.tsx index 974eb22..3c7f30c 100644 --- a/src/pages/FamilyPage.tsx +++ b/src/pages/FamilyPage.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { Shield, User, Clock } from "lucide-react"; import { fetchFamily, type User as FamilyMember } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; const roleLabel: Record = { PRIMARY_CAREGIVER: "Primary Caregiver", @@ -8,9 +9,11 @@ const roleLabel: Record = { }; export default function FamilyPage() { + const { user } = useAuth(); const { data: members = [], isLoading } = useQuery({ - queryKey: ["family"], - queryFn: fetchFamily, + queryKey: ["family", user?.patientId], + queryFn: () => fetchFamily(user!.patientId), + enabled: !!user?.patientId, }); return ( diff --git a/src/pages/FeedPage.tsx b/src/pages/FeedPage.tsx index 68768ff..cb521e1 100644 --- a/src/pages/FeedPage.tsx +++ b/src/pages/FeedPage.tsx @@ -7,6 +7,7 @@ import { fetchFeed, subscribeToFeed, type FeedItem, type Appointment, type Medication, type Document, } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; const iconMap = { document: FileText, appointment: CalendarPlus, medication: Pill }; const colorMap = { @@ -39,15 +40,21 @@ function feedDescription(item: FeedItem): string { export default function FeedPage() { const queryClient = useQueryClient(); - const { data: feed = [], isLoading } = useQuery({ queryKey: ["feed"], queryFn: fetchFeed }); + const { user } = useAuth(); + const { data: feed = [], isLoading } = useQuery({ + queryKey: ["feed", user?.patientId], + queryFn: () => fetchFeed(user!.patientId), + enabled: !!user?.patientId, + }); useEffect(() => { - return subscribeToFeed((event) => { + if (!user?.patientId) return; + return subscribeToFeed(user.patientId, (event) => { if (event.type === "feed_refresh") { queryClient.invalidateQueries({ queryKey: ["feed"] }); } }); - }, [queryClient]); + }, [user?.patientId, queryClient]); return (
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..7cabeca --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,108 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Heart } from 'lucide-react'; +import { signIn } from '@/lib/auth-client'; +import { useAuth } from '@/contexts/AuthContext'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; + +const loginSchema = z.object({ + email: z.string().email('Enter a valid email'), + password: z.string().min(1, 'Password is required'), +}); + +type LoginInput = z.infer; + +export default function LoginPage() { + const navigate = useNavigate(); + const { user, isLoading } = useAuth(); + const [serverError, setServerError] = useState(null); + + // Redirect once the session is confirmed — covers both post-login and already-authenticated visits + useEffect(() => { + if (!isLoading && user) navigate('/', { replace: true }); + }, [user, isLoading, navigate]); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(loginSchema) }); + + const onSubmit = async (data: LoginInput) => { + setServerError(null); + const { error } = await signIn.email({ + email: data.email, + password: data.password, + fetchOptions: { + onSuccess: () => navigate('/', { replace: true }), + }, + }); + if (error) { + setServerError(error.message ?? 'Login failed. Check your credentials and try again.'); + } + }; + + return ( +
+
+
+ + + TendWell + +
+ + + + Sign in + Enter your email and password to continue. + + +
+
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+ + {serverError && ( +

{serverError}

+ )} + + +
+
+
+
+
+ ); +} diff --git a/src/pages/MedicationsPage.tsx b/src/pages/MedicationsPage.tsx index 9332c25..ff6d313 100644 --- a/src/pages/MedicationsPage.tsx +++ b/src/pages/MedicationsPage.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import ReactMarkdown from "react-markdown"; import { Pill, ChevronDown, ChevronUp, AlertTriangle, User, Clock, Info } from "lucide-react"; import { fetchMedications, type Medication } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; const severityColor = { MILD: "text-yellow-600 bg-yellow-50 border-yellow-200", @@ -18,9 +19,11 @@ function getAllInteractions(med: Medication) { export default function MedicationsPage() { const [expanded, setExpanded] = useState(null); + const { user } = useAuth(); const { data: medications = [], isLoading } = useQuery({ - queryKey: ["medications"], - queryFn: fetchMedications, + queryKey: ["medications", user?.patientId], + queryFn: () => fetchMedications(user!.patientId), + enabled: !!user?.patientId, }); return (