diff --git a/.claude/launch.json b/.claude/launch.json index 152f91a..9b23a15 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -7,6 +7,13 @@ "runtimeArgs": ["run", "dev"], "port": 5173, "autoPort": false + }, + { + "name": "study-app-server", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:server"], + "port": 3001, + "autoPort": false } ] } diff --git a/SUGGESTIONS.md b/SUGGESTIONS.md index a0849e1..2699fdd 100644 --- a/SUGGESTIONS.md +++ b/SUGGESTIONS.md @@ -1,95 +1,80 @@ # Study App Improvement Suggestions +_Refreshed 2026-07-06 — the previous version of this file predated ~40 commits of feature work (OT support, tagging, DOCX import/export, split-view, commentary, cross-ref suggestions, and the full Bible Reader with audio/bookmarks/interlinear/search). Items already shipped have been removed; this reflects what's actually still open._ + ## Features ### Study Tools -- **Bible comparison mode** — show two translations side-by-side (API already supports it) -- **Verse-level notes** — annotate individual verses, not just chunks -- **Tagging / themes** — tag chunks with themes (e.g. "faith", "grace"), filter/search across projects -- **Progress tracking** — mark chunks as "in progress" / "complete"; show progress bar on home card -- **Study templates** — pre-fill OIA fields with guiding prompts for new users -- **Print view** — clean print-optimized CSS layout -- **Old Testament support** — only NT books listed; HelloAO API supports OT. Greek suggest is NT-only but the rest could support OT with Hebrew lookup -- **Verse search** — search bar to find a verse by keyword across the loaded chapter +- **Bible comparison mode** — show two translations side-by-side. `availableTranslations` is already fetched and a translation is already selectable per project (`App.jsx:904`), but only one renders at a time — no split/parallel view +- **Verse-level notes** — annotations are still chunk-level only (OIA fields); no way to attach a note to a single verse within a chunk +- **Progress tracking** — no "in progress"/"complete" marker per chunk and no progress bar on the home project card +- **Study templates** — no guiding prompts pre-filled for new users starting their first OIA entry +- **Word/character count** on the OIA and Final Script textareas — encourages note depth, useful for episode-length planning ### Export / Sharing -- **PDF export** — "Export PDF" button using `jsPDF` or `window.print()` -- **Share link** — read-only shareable URL pointing to a project ID on the server -- **Copy individual chunk** — "copy this chunk's notes" button alongside full "Prepare for Claude" -- **Markdown export** — useful for Obsidian and similar note-taking apps +- **PDF export** / print stylesheet — still no `window.print()` CSS or PDF button anywhere in the app +- **Share link** — no read-only shareable URL for a project (useful for co-teachers reviewing an episode) +- **Markdown export** — HTML/DOCX/Claude-prompt exports exist; no plain Markdown output for Obsidian-style tools +- **Episode length estimate** — Final Script field exists per chunk; a word-count-based "~X minutes read aloud" estimate would help podcast planning + +### Chunk Builder (Setup Page) +- **Drag-to-select verses** — still click-then-shift-click; no click-and-drag range selection +- **Auto-chunk** — no "split by paragraph/section" button; every chunk boundary is manual or typed --- ## UX / UI ### Navigation -- **Keyboard shortcuts** — `←`/`→` to navigate chunks; `Ctrl+S` to save; `Escape` to close modals -- **"Jump to chunk" dropdown** — for projects with many chunks, a select menu is faster than scrolling -- **Breadcrumb in header** — show `Book Chapter:Verse range` so users always know where they are - -### Chunk Builder (Setup Page) -- **Drag-to-select verses** — click-and-drag instead of click then shift-click -- **Auto-chunk** — button to split chapter into chunks by paragraph/section breaks -- **Visual overlap indicator** — already-chunked verses are shaded but there's no tooltip explaining why you can't select them +- **Breadcrumb in header** — study page header only shows the project title (`App.jsx:4172`); no persistent "Genesis 1:1–5" reference next to it so users can tell at a glance where they are without checking the scripture panel ### Study Page -- **Collapsible sections** — collapse OIA, Cross-References, and Greek Word Studies independently -- **Word/character count** on each textarea to encourage note depth -- **Inline verse reference popup** — hover popover on cross-references showing verse text (from HelloAO) -- **Sticky chunk navigation** — Previous/Next chunk buttons should be sticky, not only at the bottom +- **Word/char counters** — see above +- **Sticky bottom nav** — top Prev/Next chunk nav shipped (commit `103e20c`); a matching sticky bottom bar for long chunks would avoid scroll-back ### Home Page -- **Search/filter projects** — text filter on the project list -- **Sort options** — sort by name, date, or passage -- **Project rename** — title is only set at creation; allow renaming from home card -- **Last opened chunk** — resume directly to the study page, not the setup page +- Search/filter/sort/rename are all implemented — nothing open here currently --- ## Code Architecture ### State Management -- **`App.jsx` is ~2,250 lines** — biggest maintainability issue. Split into: - - `pages/HomePage.jsx` - - `pages/SetupPage.jsx` - - `pages/StudyPage.jsx` - - `components/ChunkEditor.jsx` - - `components/GreekWordStudy.jsx` - - `components/SuggestModal.jsx` -- **Custom hooks** — extract logic into `useProject()`, `useGreekLookup()`, `useAutosave()` +- **`App.jsx` is now ~5,100 lines** (up from ~2,250 when this doc was last written) — still one component from line 903–5044. Splitting into `pages/HomePage.jsx`, `pages/SetupPage.jsx`, `pages/StudyPage.jsx`, `pages/BibleReaderPage.jsx`, plus extracted hooks (`useProject`, `useGreekLookup`, `useAutosave`) is more valuable now than it was before, given the size increase +- **`commentarySource` doesn't persist** — resets to `'matthew-henry'` every session (`App.jsx:1107`), unlike `studyLayout`/`activeStudyTab` which do persist to localStorage via the same pattern ### Sync / Persistence -- **No auth** — the server has zero authentication. Any user who can reach the server can read/overwrite/delete any project. Add at minimum an API key (env var in middleware) or user accounts -- **Conflict resolution is basic** — only compares `lastEdited` timestamps. Add a "which version do you want to keep?" UI to prevent silent data loss -- **Offline-first** — use a service worker / `workbox` so the app works offline and syncs when back online +- ~~No auth on the backend~~ — fixed; email/password accounts with httpOnly cookie sessions (`server/auth.js`, `server/sessionStore.js`), projects scoped per-user in both SQLite (`server/db.js`) and localStorage (`App.jsx` `switchStorageUser`/namespaced keys), and pre-existing local projects auto-claimed by the first registered account +- No rate-limiting on `/api/auth/*` — a determined attacker could brute-force a weak password; worth adding if this is ever reachable beyond a small trusted group +- **Conflict resolution is still last-write-wins** — only `lastEdited` timestamps are compared; no "which version do you want to keep?" UI +- **"Restore"/"Pull latest from server" don't open the project** (`App.jsx:2997-3017`) — they refresh the local index but leave the user on the Home page instead of jumping into the study +- **Offline-first** — still no service worker; app requires a live connection to `bible.helloao.org` for chapter/audio/commentary loads with no cached fallback if that API is down ### Security (OWASP) -- **XSS via `dangerouslySetInnerHTML`** — `word.definitionHtml` is rendered raw. Add DOMPurify sanitization -- **No input validation on server** — add max-length and character validation on `id`/`title` fields (SQL injection is prevented by parameterized queries, but still) -- **CORS** — server has no CORS headers; any origin can call the API in production +- ~~XSS via `dangerouslySetInnerHTML`~~ — fixed; `DOMPurify.sanitize()` now wraps both render paths (`App.jsx:4784`, `App.jsx:5020`) +- **No input validation on server** — still no max-length/character validation on `id`/`title` in `server/index.js` +- **CORS** — still no CORS headers configured --- ## Performance -- **Verse data stored in project JSON** — full verse text is saved in localStorage and SQLite for every chapter. For multi-chapter projects this grows large. Consider storing only the chapter reference and re-fetching verses on load -- **JSON files cached in refs** — `nt-strongs-gloss.json` and `nt-strongs-concordance.json` should be served with proper `Cache-Control` headers -- **Autosave fires on all state changes** — the `[project]` dependency is too broad; it fires even when just selecting a chunk. Debounce only on content field changes +- **Verse data stored in project JSON** — still true; full verse text is saved per chapter in both localStorage and SQLite +- **Reader bookmark icon is unclear** — shows a 🏷️ tag emoji before bookmarking and only switches to 🔖 after (`App.jsx:3641`), but the help text says "bookmark icon to save" — a plain outline bookmark icon would read more clearly from the start +- **No audio playback speed control** — chapter/reader audio only has play/pause/stop (`App.jsx:1037`); a 0.75x/1x/1.5x toggle would help slow, careful study listening +- **Hardcoded external API, no fallback** — audio and commentary both call `bible.helloao.org` directly (`App.jsx:985`, `App.jsx:1129`) with no retry UI if the free API is briefly down --- ## Testing -- Add tests for: - - `migrateProject` with the old flat format - - `buildClaudePrompt` output structure - - `parseBibleChapter` with both API response shapes - - Autosave debounce behavior +- Migration and prompt-building tests now exist (`migrateChunk`, `migrateProject`, `buildClaudePrompt`, `parseBibleChapter` are all covered in `src/utils.test.js`) — this section is essentially done +- Still missing: autosave debounce behavior, and coverage for the newer features (DOCX episode import, cross-ref auto-suggest, commentary loading) --- ## Developer Experience -- **No ESLint config** — add ESLint with `eslint-plugin-react` and `eslint-plugin-react-hooks` to catch missing `useEffect` deps -- **No TypeScript** — JSDoc types or a TS migration would catch shape mismatches between old/new project formats at compile time -- **No `docker-compose.yml`** — Dockerfile exists but there's no compose file for one-command local dev with server + SQLite volume +- **No ESLint config** — still true; no `.eslintrc*` or `eslint.config.*` in the repo +- **No TypeScript** — still true +- **No `docker-compose.yml`** — still true; Dockerfile exists but no one-command local dev with server + SQLite volume diff --git a/deploy/install.sh b/deploy/install.sh index a24f05c..e52d7e3 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -75,8 +75,16 @@ chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR" # ── systemd unit ────────────────────────────────────────────────────────────── echo "Installing systemd unit..." -sed "s#/opt/study-app#$INSTALL_DIR#g; s#User=study-app#User=$SERVICE_USER#; s#Group=study-app#Group=$SERVICE_USER#" \ - "$ROOT_DIR/deploy/study-app.service" > "/etc/systemd/system/${SERVICE_NAME}.service" +EXISTING_UNIT="/etc/systemd/system/${SERVICE_NAME}.service" +# Reuse the existing session secret across re-installs (upgrades) so signed-in +# users aren't logged out; only generate a new one on first install. +if [ -f "$EXISTING_UNIT" ] && grep -q '^Environment=SESSION_SECRET=' "$EXISTING_UNIT"; then + SESSION_SECRET="$(grep '^Environment=SESSION_SECRET=' "$EXISTING_UNIT" | head -1 | cut -d= -f3-)" +else + SESSION_SECRET="$(openssl rand -hex 32)" +fi +sed "s#/opt/study-app#$INSTALL_DIR#g; s#User=study-app#User=$SERVICE_USER#; s#Group=study-app#Group=$SERVICE_USER#; s#__SESSION_SECRET__#$SESSION_SECRET#" \ + "$ROOT_DIR/deploy/study-app.service" > "$EXISTING_UNIT" systemctl daemon-reload systemctl enable "$SERVICE_NAME" diff --git a/deploy/study-app.service b/deploy/study-app.service index f1653f9..ed31406 100644 --- a/deploy/study-app.service +++ b/deploy/study-app.service @@ -10,6 +10,9 @@ Group=study-app WorkingDirectory=/opt/study-app Environment=NODE_ENV=production Environment=PORT=3001 +# Replaced with a generated value by install.sh — keep this secret and stable +# across deploys, or every existing login session gets invalidated. +Environment=SESSION_SECRET=__SESSION_SECRET__ ExecStart=/usr/bin/node server/index.js Restart=on-failure RestartSec=5 diff --git a/package-lock.json b/package-lock.json index 9ccdff6..ec5a360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,16 @@ "name": "bible-study-app", "version": "0.0.1", "dependencies": { + "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.0", "concurrently": "^8.2.2", "docx": "^9.7.0", "dompurify": "^3.4.9", "express": "^4.19.2", + "express-session": "^1.19.0", "mammoth": "^1.12.0", + "otplib": "^12.0.1", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -1107,6 +1111,56 @@ "node": ">= 8" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==", + "license": "MIT" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/@oxc-project/types": { "version": "0.132.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", @@ -2325,6 +2379,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/better-sqlite3": { "version": "12.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", @@ -2550,6 +2613,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -2908,6 +2980,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -2984,6 +3065,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dingbat-to-unicode": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", @@ -3297,6 +3384,64 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/express-session/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -3424,6 +3569,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4256,6 +4414,18 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -4663,6 +4833,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4678,6 +4857,53 @@ "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", "license": "BSD-2-Clause" }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -4706,6 +4932,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -4775,6 +5010,15 @@ "node": ">= 6" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -5020,6 +5264,104 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -5056,6 +5398,15 @@ ], "license": "MIT" }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -5205,6 +5556,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -5461,6 +5818,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -5868,6 +6231,14 @@ "node": ">=0.8" } }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6058,6 +6429,18 @@ "node": ">= 0.6" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/underscore": { "version": "1.13.8", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", @@ -6460,6 +6843,12 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index d70275f..f7407b7 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,16 @@ "coverage": "vitest run --coverage" }, "dependencies": { + "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.0", "concurrently": "^8.2.2", "docx": "^9.7.0", "dompurify": "^3.4.9", "express": "^4.19.2", + "express-session": "^1.19.0", "mammoth": "^1.12.0", + "otplib": "^12.0.1", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..7e8d0c3 --- /dev/null +++ b/server/auth.js @@ -0,0 +1,71 @@ +import bcrypt from 'bcryptjs'; +import { authenticator } from 'otplib'; +import { randomBytes } from 'crypto'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function isValidEmail(email) { + return typeof email === 'string' && email.length <= 254 && EMAIL_RE.test(email); +} + +export function isValidPassword(password) { + return typeof password === 'string' && password.length >= 8 && password.length <= 200; +} + +export function hashPassword(password) { + return bcrypt.hash(password, 10); +} + +export function verifyPassword(password, hash) { + return bcrypt.compare(password, hash); +} + +/** Blocks the request unless a logged-in session is present. */ +export function requireAuth(req, res, next) { + if (!req.session?.userId) { + return res.status(401).json({ error: 'Not signed in.' }); + } + next(); +} + +// --------------------------------------------------------------------------- +// Two-factor auth (TOTP, RFC 6238 — compatible with any authenticator app) +// --------------------------------------------------------------------------- + +export function generateTotpSecret() { + return authenticator.generateSecret(); +} + +export function totpKeyUri(email, secret) { + return authenticator.keyuri(email, 'Bible Study Project', secret); +} + +export function verifyTotpToken(token, secret) { + if (typeof token !== 'string' || !/^\d{6}$/.test(token)) return false; + try { + return authenticator.verify({ token, secret }); + } catch { + return false; + } +} + +/** Returns { codes: string[] } plaintext codes to show the user once, for hashBackupCodes(). */ +export function generateBackupCodes(count = 8) { + return Array.from({ length: count }, () => randomBytes(5).toString('hex')); +} + +export async function hashBackupCodes(codes) { + return Promise.all(codes.map((code) => bcrypt.hash(code, 10))); +} + +/** Checks a submitted backup code against stored hashes; returns the remaining hashes if it matched, else null. */ +export async function consumeBackupCode(submitted, hashes) { + if (typeof submitted !== 'string' || !Array.isArray(hashes)) return null; + const normalized = submitted.trim().toLowerCase(); + for (let i = 0; i < hashes.length; i++) { + if (await bcrypt.compare(normalized, hashes[i])) { + return [...hashes.slice(0, i), ...hashes.slice(i + 1)]; + } + } + return null; +} diff --git a/server/db.js b/server/db.js index 6cf0e9e..d3a795e 100644 --- a/server/db.js +++ b/server/db.js @@ -10,7 +10,7 @@ const DB_PATH = join(DATA_DIR, 'projects.db'); let db; // --------------------------------------------------------------------------- -// Init — create tables if they don't exist +// Init — create tables if they don't exist, migrate older schemas // --------------------------------------------------------------------------- export function initDb() { mkdirSync(DATA_DIR, { recursive: true }); @@ -27,8 +27,37 @@ export function initDb() { chapter_summary TEXT, data TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + sid TEXT PRIMARY KEY, + sess TEXT NOT NULL, + expires INTEGER NOT NULL + ); `); + // Older databases predate multi-user support — add the ownership column. + const projectCols = db.prepare('PRAGMA table_info(projects)').all(); + if (!projectCols.some((c) => c.name === 'user_id')) { + db.exec('ALTER TABLE projects ADD COLUMN user_id TEXT REFERENCES users(id)'); + } + + // Older databases predate 2FA support — add the TOTP columns. + const userCols = db.prepare('PRAGMA table_info(users)').all(); + if (!userCols.some((c) => c.name === 'totp_secret')) { + db.exec(` + ALTER TABLE users ADD COLUMN totp_secret TEXT; + ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0; + ALTER TABLE users ADD COLUMN backup_codes TEXT; + `); + } + console.log(`SQLite database ready at ${DB_PATH}`); } @@ -42,27 +71,95 @@ function buildSummary(project) { } // --------------------------------------------------------------------------- -// Queries +// Users +// --------------------------------------------------------------------------- + +export function countUsers() { + return db.prepare('SELECT COUNT(*) AS n FROM users').get().n; +} + +export function createUser({ id, email, passwordHash }) { + const createdAt = Date.now(); + db.prepare(` + INSERT INTO users (id, email, password_hash, created_at) + VALUES (?, ?, ?, ?) + `).run(id, email, passwordHash, createdAt); + return { id, email, createdAt }; +} + +function parseUserRow(row) { + if (!row) return null; + return { + ...row, + totpEnabled: !!row.totpEnabled, + backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [], + }; +} + +const USER_SELECT = ` + SELECT id, email, password_hash AS passwordHash, created_at AS createdAt, + totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw + FROM users +`; + +export function getUserByEmail(email) { + return parseUserRow(db.prepare(`${USER_SELECT} WHERE email = ?`).get(email)); +} + +export function getUserById(id) { + return parseUserRow(db.prepare(`${USER_SELECT} WHERE id = ?`).get(id)); +} + +/** Persists a confirmed TOTP secret + one-time backup code hashes, turning 2FA on. */ +export function enableTotp(userId, secret, backupCodeHashes) { + db.prepare(` + UPDATE users SET totp_secret = ?, totp_enabled = 1, backup_codes = ? WHERE id = ? + `).run(secret, JSON.stringify(backupCodeHashes), userId); +} + +/** Turns 2FA off and forgets the secret/backup codes entirely. */ +export function disableTotp(userId) { + db.prepare(` + UPDATE users SET totp_secret = NULL, totp_enabled = 0, backup_codes = NULL WHERE id = ? + `).run(userId); +} + +/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */ +export function setBackupCodeHashes(userId, backupCodeHashes) { + db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId); +} + +/** + * Assigns any pre-existing, unowned projects (from before multi-user support) + * to the given user. Intended to run once, right after the first account is created. + */ +export function claimOrphanProjects(userId) { + db.prepare('UPDATE projects SET user_id = ? WHERE user_id IS NULL').run(userId); +} + +// --------------------------------------------------------------------------- +// Project queries — all scoped to the owning user // --------------------------------------------------------------------------- /** - * Returns all project summaries (id, title, lastEdited, chapterSummary). + * Returns all project summaries owned by userId (id, title, lastEdited, chapterSummary). * Does NOT return full project data to keep the response small. */ -export function getAllProjects() { +export function getAllProjects(userId) { const rows = db.prepare(` SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary FROM projects + WHERE user_id = ? ORDER BY last_edited DESC - `).all(); + `).all(userId); return rows; } /** - * Returns a single full project by id, or null if not found. + * Returns a single full project by id, scoped to userId, or null if not found/not owned. */ -export function getProject(id) { - const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id); +export function getProject(id, userId) { + const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(id, userId); if (!row) return null; try { return JSON.parse(row.data); @@ -72,29 +169,65 @@ export function getProject(id) { } /** - * Insert or replace a project. Returns the summary. + * Insert or replace a project owned by userId. + * Returns the summary, or null if the id already belongs to a different user. */ -export function upsertProject(project) { +export function upsertProject(project, userId) { + const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id); + if (existing && existing.userId !== userId) { + return null; + } + const lastEdited = project.lastEdited ?? Date.now(); const chapterSummary = buildSummary(project); const updated = { ...project, lastEdited }; db.prepare(` - INSERT INTO projects (id, title, last_edited, chapter_summary, data) - VALUES (?, ?, ?, ?, ?) + INSERT INTO projects (id, title, last_edited, chapter_summary, data, user_id) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, last_edited = excluded.last_edited, chapter_summary = excluded.chapter_summary, data = excluded.data - `).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated)); + `).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated), userId); return { id: project.id, title: project.title, lastEdited, chapterSummary }; } /** - * Delete a project by id. No-op if not found. + * Delete a project by id, scoped to userId. No-op if not found/not owned. */ -export function deleteProject(id) { - db.prepare('DELETE FROM projects WHERE id = ?').run(id); -} \ No newline at end of file +export function deleteProject(id, userId) { + db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId); +} + +// --------------------------------------------------------------------------- +// Session store backing (used by server/sessionStore.js) +// --------------------------------------------------------------------------- + +export function getSession(sid) { + const row = db.prepare('SELECT sess, expires FROM sessions WHERE sid = ?').get(sid); + if (!row || row.expires < Date.now()) return null; + try { + return JSON.parse(row.sess); + } catch { + return null; + } +} + +export function setSession(sid, sess, expires) { + db.prepare(` + INSERT INTO sessions (sid, sess, expires) + VALUES (?, ?, ?) + ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expires = excluded.expires + `).run(sid, JSON.stringify(sess), expires); +} + +export function destroySession(sid) { + db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); +} + +export function pruneExpiredSessions() { + db.prepare('DELETE FROM sessions WHERE expires < ?').run(Date.now()); +} diff --git a/server/index.js b/server/index.js index 8d6c002..e25e0a6 100644 --- a/server/index.js +++ b/server/index.js @@ -1,13 +1,47 @@ import express from 'express'; +import session from 'express-session'; +import QRCode from 'qrcode'; +import { randomUUID } from 'crypto'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; -import { initDb, getAllProjects, getProject, upsertProject, deleteProject } from './db.js'; +import { + initDb, getAllProjects, getProject, upsertProject, deleteProject, + countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects, + enableTotp, disableTotp, setBackupCodeHashes, +} from './db.js'; +import { SqliteSessionStore } from './sessionStore.js'; +import { + isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, + generateTotpSecret, totpKeyUri, verifyTotpToken, + generateBackupCodes, hashBackupCodes, consumeBackupCode, +} from './auth.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const app = express(); const PORT = process.env.PORT || 3001; +const isProd = process.env.NODE_ENV === 'production'; + +if (isProd && !process.env.SESSION_SECRET) { + console.warn('WARNING: SESSION_SECRET is not set. Set it to a long random string in production.'); +} + +// Trust the reverse proxy (needed for secure cookies to work behind nginx/etc). +app.set('trust proxy', 1); app.use(express.json({ limit: '10mb' })); +app.use(session({ + store: new SqliteSessionStore(), + secret: process.env.SESSION_SECRET || 'dev-only-secret-change-me', + resave: false, + saveUninitialized: false, + rolling: true, + cookie: { + httpOnly: true, + secure: isProd, + sameSite: 'lax', + maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days + }, +})); // --------------------------------------------------------------------------- // Health check @@ -17,11 +51,185 @@ app.get('/api/health', (_req, res) => { }); // --------------------------------------------------------------------------- -// GET /api/projects — list all project summaries (no full data) +// Auth // --------------------------------------------------------------------------- -app.get('/api/projects', (_req, res) => { + +app.post('/api/auth/register', async (req, res) => { try { - const projects = getAllProjects(); + const email = String(req.body?.email ?? '').trim().toLowerCase(); + const password = String(req.body?.password ?? ''); + + if (!isValidEmail(email)) { + return res.status(400).json({ error: 'Enter a valid email address.' }); + } + if (!isValidPassword(password)) { + return res.status(400).json({ error: 'Password must be at least 8 characters.' }); + } + if (getUserByEmail(email)) { + return res.status(409).json({ error: 'An account with that email already exists.' }); + } + + const passwordHash = await hashPassword(password); + const user = createUser({ id: randomUUID(), email, passwordHash }); + + // The very first account inherits any projects created before multi-user support existed. + if (countUsers() === 1) { + claimOrphanProjects(user.id); + } + + req.session.regenerate((err) => { + if (err) return res.status(500).json({ error: 'Could not create session.' }); + req.session.userId = user.id; + res.json({ id: user.id, email: user.email, totpEnabled: false }); + }); + } catch (err) { + console.error('POST /api/auth/register error:', err); + res.status(500).json({ error: 'Failed to register.' }); + } +}); + +app.post('/api/auth/login', async (req, res) => { + try { + const email = String(req.body?.email ?? '').trim().toLowerCase(); + const password = String(req.body?.password ?? ''); + + const user = getUserByEmail(email); + const valid = user && await verifyPassword(password, user.passwordHash); + if (!valid) { + return res.status(401).json({ error: 'Incorrect email or password.' }); + } + + req.session.regenerate((err) => { + if (err) return res.status(500).json({ error: 'Could not create session.' }); + if (user.totpEnabled) { + // Password is correct, but the session stays unauthenticated (no userId) + // until a valid TOTP/backup code lands on /api/auth/mfa/verify. + req.session.pendingUserId = user.id; + return res.json({ mfaRequired: true }); + } + req.session.userId = user.id; + res.json({ id: user.id, email: user.email, totpEnabled: false }); + }); + } catch (err) { + console.error('POST /api/auth/login error:', err); + res.status(500).json({ error: 'Failed to log in.' }); + } +}); + +app.post('/api/auth/mfa/verify', async (req, res) => { + try { + const pendingUserId = req.session?.pendingUserId; + if (!pendingUserId) { + return res.status(400).json({ error: 'No sign-in in progress.' }); + } + const user = getUserById(pendingUserId); + if (!user || !user.totpEnabled) { + return res.status(400).json({ error: 'No sign-in in progress.' }); + } + + const token = req.body?.token; + const backupCode = req.body?.backupCode; + let ok = token ? verifyTotpToken(String(token), user.totpSecret) : false; + + if (!ok && backupCode) { + const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes); + if (remaining) { + setBackupCodeHashes(user.id, remaining); + ok = true; + } + } + + if (!ok) { + return res.status(401).json({ error: 'Invalid code.' }); + } + + req.session.regenerate((err) => { + if (err) return res.status(500).json({ error: 'Could not create session.' }); + req.session.userId = user.id; + res.json({ id: user.id, email: user.email, totpEnabled: true }); + }); + } catch (err) { + console.error('POST /api/auth/mfa/verify error:', err); + res.status(500).json({ error: 'Failed to verify code.' }); + } +}); + +app.post('/api/auth/logout', (req, res) => { + req.session.destroy(() => { + res.clearCookie('connect.sid'); + res.json({ ok: true }); + }); +}); + +app.get('/api/auth/me', (req, res) => { + const user = req.session?.userId ? getUserById(req.session.userId) : null; + if (!user) return res.status(401).json({ error: 'Not signed in.' }); + res.json({ id: user.id, email: user.email, totpEnabled: user.totpEnabled }); +}); + +// --------------------------------------------------------------------------- +// Two-factor auth setup (requires an already-authenticated session) +// --------------------------------------------------------------------------- + +app.post('/api/auth/mfa/setup', requireAuth, (req, res) => { + try { + const user = getUserById(req.session.userId); + const secret = generateTotpSecret(); + // Held only in the session until confirmed with a real code — never written + // to the DB (and 2FA never turned on) unless /mfa/enable succeeds below. + req.session.pendingTotpSecret = secret; + QRCode.toDataURL(totpKeyUri(user.email, secret), (err, qrCodeDataUrl) => { + if (err) return res.status(500).json({ error: 'Failed to generate QR code.' }); + res.json({ secret, qrCodeDataUrl }); + }); + } catch (err) { + console.error('POST /api/auth/mfa/setup error:', err); + res.status(500).json({ error: 'Failed to start 2FA setup.' }); + } +}); + +app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => { + try { + const secret = req.session.pendingTotpSecret; + if (!secret) { + return res.status(400).json({ error: 'Start 2FA setup first.' }); + } + if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) { + return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' }); + } + + const backupCodes = generateBackupCodes(); + const backupCodeHashes = await hashBackupCodes(backupCodes); + enableTotp(req.session.userId, secret, backupCodeHashes); + delete req.session.pendingTotpSecret; + res.json({ backupCodes }); + } catch (err) { + console.error('POST /api/auth/mfa/enable error:', err); + res.status(500).json({ error: 'Failed to enable 2FA.' }); + } +}); + +app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => { + try { + const user = getUserById(req.session.userId); + const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash); + if (!valid) { + return res.status(401).json({ error: 'Incorrect password.' }); + } + disableTotp(user.id); + res.json({ ok: true }); + } catch (err) { + console.error('POST /api/auth/mfa/disable error:', err); + res.status(500).json({ error: 'Failed to disable 2FA.' }); + } +}); + +// --------------------------------------------------------------------------- +// GET /api/projects — list all project summaries owned by the current user +// --------------------------------------------------------------------------- +app.get('/api/projects', requireAuth, (req, res) => { + try { + const projects = getAllProjects(req.session.userId); res.json(projects); } catch (err) { console.error('GET /api/projects error:', err); @@ -30,11 +238,11 @@ app.get('/api/projects', (_req, res) => { }); // --------------------------------------------------------------------------- -// GET /api/projects/:id — fetch a single full project +// GET /api/projects/:id — fetch a single full project owned by the current user // --------------------------------------------------------------------------- -app.get('/api/projects/:id', (req, res) => { +app.get('/api/projects/:id', requireAuth, (req, res) => { try { - const project = getProject(req.params.id); + const project = getProject(req.params.id, req.session.userId); if (!project) return res.status(404).json({ error: 'Project not found.' }); res.json(project); } catch (err) { @@ -44,9 +252,9 @@ app.get('/api/projects/:id', (req, res) => { }); // --------------------------------------------------------------------------- -// PUT /api/projects/:id — create or update a project +// PUT /api/projects/:id — create or update a project owned by the current user // --------------------------------------------------------------------------- -app.put('/api/projects/:id', (req, res) => { +app.put('/api/projects/:id', requireAuth, (req, res) => { try { const body = req.body; if (!body || typeof body !== 'object') { @@ -58,7 +266,10 @@ app.put('/api/projects/:id', (req, res) => { if (body.id !== req.params.id) { return res.status(400).json({ error: 'URL id does not match body id.' }); } - const saved = upsertProject(body); + const saved = upsertProject(body, req.session.userId); + if (!saved) { + return res.status(403).json({ error: 'That project belongs to a different account.' }); + } res.json(saved); } catch (err) { console.error('PUT /api/projects/:id error:', err); @@ -67,11 +278,11 @@ app.put('/api/projects/:id', (req, res) => { }); // --------------------------------------------------------------------------- -// DELETE /api/projects/:id — remove a project +// DELETE /api/projects/:id — remove a project owned by the current user // --------------------------------------------------------------------------- -app.delete('/api/projects/:id', (req, res) => { +app.delete('/api/projects/:id', requireAuth, (req, res) => { try { - deleteProject(req.params.id); + deleteProject(req.params.id, req.session.userId); res.json({ ok: true }); } catch (err) { console.error('DELETE /api/projects/:id error:', err); @@ -82,7 +293,7 @@ app.delete('/api/projects/:id', (req, res) => { // --------------------------------------------------------------------------- // Serve Vite production build (when NODE_ENV=production) // --------------------------------------------------------------------------- -if (process.env.NODE_ENV === 'production') { +if (isProd) { const distPath = join(__dirname, '..', 'dist'); app.use(express.static(distPath)); app.get('*', (_req, res) => { @@ -96,7 +307,7 @@ if (process.env.NODE_ENV === 'production') { initDb(); app.listen(PORT, () => { console.log(`Bible Study API running on http://localhost:${PORT}`); - if (process.env.NODE_ENV === 'production') { + if (isProd) { console.log('Serving Vite build from /dist'); } -}); \ No newline at end of file +}); diff --git a/server/sessionStore.js b/server/sessionStore.js new file mode 100644 index 0000000..8a18c91 --- /dev/null +++ b/server/sessionStore.js @@ -0,0 +1,48 @@ +import session from 'express-session'; +import { getSession, setSession, destroySession, pruneExpiredSessions } from './db.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * express-session store backed by the same SQLite database as everything else, + * so logins survive a server restart without adding another dependency. + */ +export class SqliteSessionStore extends session.Store { + constructor() { + super(); + // Sweep expired sessions periodically instead of on every request. + this._interval = setInterval(() => pruneExpiredSessions(), DAY_MS); + this._interval.unref?.(); + } + + get(sid, cb) { + try { + cb(null, getSession(sid)); + } catch (err) { + cb(err); + } + } + + set(sid, sessionData, cb) { + try { + const maxAge = sessionData.cookie?.maxAge ?? DAY_MS * 30; + setSession(sid, sessionData, Date.now() + maxAge); + cb?.(null); + } catch (err) { + cb?.(err); + } + } + + destroy(sid, cb) { + try { + destroySession(sid); + cb?.(null); + } catch (err) { + cb?.(err); + } + } + + touch(sid, sessionData, cb) { + this.set(sid, sessionData, cb); + } +} diff --git a/src/App.jsx b/src/App.jsx index 47887fd..150824f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -22,6 +22,14 @@ import { deleteRemoteProject, listRemoteProjects, loadRemoteProject, + getCurrentUser, + registerUser, + loginUser, + logoutUser, + verifyMfaLogin, + startMfaSetup, + confirmMfaSetup, + disableMfa, } from './syncService.js'; const COMMENTARY_OPTIONS = [ @@ -248,12 +256,60 @@ export function migrateProject(raw) { // Storage helpers // --------------------------------------------------------------------------- -const INDEX_KEY = 'bible-study-index'; -const projectKey = (id) => `bible-study-project-${id}`; +// Pre-multi-user, un-namespaced keys. Kept around only so existing local data +// (from before accounts existed) can be claimed by the first user who signs in +// on a given browser — see switchStorageUser() below. +const INDEX_KEY_LEGACY = 'bible-study-index'; +const projectKeyLegacy = (id) => `bible-study-project-${id}`; + +// The signed-in user's id, or null when signed out / not yet resolved. +// Sets which localStorage namespace loadProjectIndex/saveProjectToStorage/etc. read +// and write, so two accounts sharing one browser never see each other's cached projects. +let activeStorageUserId = null; + +function indexKey() { + return activeStorageUserId ? `bible-study-index:${activeStorageUserId}` : INDEX_KEY_LEGACY; +} + +function projectKey(id) { + return activeStorageUserId ? `bible-study-project:${activeStorageUserId}:${id}` : projectKeyLegacy(id); +} + +/** + * Moves any pre-multi-user local project data into this user's own namespace, + * then deletes the shared legacy keys so no other account can claim them afterward. + * No-op if this user already has their own namespaced index, or if there's nothing legacy to claim. + */ +function migrateLegacyLocalDataToUser(userId) { + if (!userId || window.localStorage.getItem(`bible-study-index:${userId}`)) return; + const legacyRaw = window.localStorage.getItem(INDEX_KEY_LEGACY); + if (!legacyRaw) return; + try { + const legacyIndex = JSON.parse(legacyRaw) ?? []; + legacyIndex.forEach((entry) => { + const raw = window.localStorage.getItem(projectKeyLegacy(entry.id)); + if (raw != null) { + window.localStorage.setItem(`bible-study-project:${userId}:${entry.id}`, raw); + window.localStorage.removeItem(projectKeyLegacy(entry.id)); + } + }); + window.localStorage.setItem(`bible-study-index:${userId}`, legacyRaw); + window.localStorage.removeItem(INDEX_KEY_LEGACY); + } catch { + // Leave legacy data in place if anything goes wrong — better to re-try next login than lose it. + } +} + +/** Switches the active local-storage namespace and returns the freshly loaded index for it. */ +function switchStorageUser(userId) { + activeStorageUserId = userId ?? null; + if (userId) migrateLegacyLocalDataToUser(userId); + return loadProjectIndex(); +} function loadProjectIndex() { try { - const raw = window.localStorage.getItem(INDEX_KEY); + const raw = window.localStorage.getItem(indexKey()); return raw ? JSON.parse(raw) : []; } catch { return []; @@ -286,14 +342,14 @@ function saveProjectToStorage(project) { } else { index.push(summary); } - window.localStorage.setItem(INDEX_KEY, JSON.stringify(index)); + window.localStorage.setItem(indexKey(), JSON.stringify(index)); return updated; // caller can use this exact object for the server PUT } function deleteProjectFromStorage(id) { window.localStorage.removeItem(projectKey(id)); const index = loadProjectIndex().filter((e) => e.id !== id); - window.localStorage.setItem(INDEX_KEY, JSON.stringify(index)); + window.localStorage.setItem(indexKey(), JSON.stringify(index)); } function loadProjectById(id) { @@ -308,7 +364,7 @@ function loadProjectById(id) { function migrateOldStorageKeys() { const keys = Object.keys(window.localStorage); keys.forEach((key) => { - if (!key.startsWith('bible-study-') || key === INDEX_KEY || key.startsWith('bible-study-project-')) return; + if (!key.startsWith('bible-study-') || key.startsWith('bible-study-index') || key.startsWith('bible-study-project')) return; try { const raw = JSON.parse(window.localStorage.getItem(key)); if (!raw || !raw.book) return; @@ -910,8 +966,25 @@ const App = () => { title: 'Titus 1 Study', }); const [titleEdited, setTitleEdited] = useState(false); + // authUser: undefined = still checking · null = signed out · object = signed in + const [authUser, setAuthUser] = useState(undefined); + const [authServerDown, setAuthServerDown] = useState(false); + const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' + const [authForm, setAuthForm] = useState({ email: '', password: '' }); + const [authError, setAuthError] = useState(''); + const [authBusy, setAuthBusy] = useState(false); + const [authMfaPending, setAuthMfaPending] = useState(false); // password ok, waiting on TOTP/backup code + const [authMfaCode, setAuthMfaCode] = useState(''); + const [authMfaUseBackup, setAuthMfaUseBackup] = useState(false); + const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress + const [mfaSetupCode, setMfaSetupCode] = useState(''); + const [mfaSetupError, setMfaSetupError] = useState(''); + const [mfaSetupBusy, setMfaSetupBusy] = useState(false); + const [mfaBackupCodes, setMfaBackupCodes] = useState(null); // shown once, right after enabling + const [mfaDisablePassword, setMfaDisablePassword] = useState(''); + const [mfaDisableError, setMfaDisableError] = useState(''); const [project, setProject] = useState(null); - // 'home' | 'setup' | 'study' + // 'home' | 'setup' | 'study' | 'settings' const [currentPage, setCurrentPage] = useState('home'); const [projectIndex, setProjectIndex] = useState([]); const [activeChapterIndex, setActiveChapterIndex] = useState(0); @@ -1300,21 +1373,42 @@ const App = () => { // --------------------------------------------------------------------------- useEffect(() => { migrateOldStorageKeys(); - const localIndex = loadProjectIndex(); - setProjectIndex(localIndex); + setProjectIndex(loadProjectIndex()); + getCurrentUser().then((result) => { + if (result.ok) { + setAuthUser(result.user); + if (result.user) setProjectIndex(switchStorageUser(result.user.id)); + } else { + // Genuine network failure (server unreachable) — fall back to local-only mode. + setAuthServerDown(true); + setAuthUser(null); + } + }); + }, []); + + // Once we know who's signed in, reconcile the local project index against the server. + // Runs on every authUser change (including logout -> different login) so a previous + // account's remote-only/stale suggestions never linger after switching users. + useEffect(() => { + if (!authUser) { + setRemoteOnlyProjects([]); + setStaleLocalProjects([]); + return; + } + const localIndex = loadProjectIndex(); listRemoteProjects().then((result) => { if (!result.ok) return; const localMap = new Map(localIndex.map((e) => [e.id, e])); const missing = result.data.filter((e) => !localMap.has(e.id)); - if (missing.length > 0) setRemoteOnlyProjects(missing); + setRemoteOnlyProjects(missing); const stale = result.data.filter((e) => { const local = localMap.get(e.id); return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0); }); - if (stale.length > 0) setStaleLocalProjects(stale); + setStaleLocalProjects(stale); }); - }, []); + }, [authUser]); useEffect(() => { fetch('https://bible.helloao.org/api/available_translations.json') @@ -3026,6 +3120,31 @@ const restoreRemoteProject = async (id) => { setStatusMessage(''); }; + const authStatus = authUser && ( +
+ {authUser.email} + + +
+ ); + // --------------------------------------------------------------------------- // Shared header // --------------------------------------------------------------------------- @@ -3124,9 +3243,359 @@ const restoreRemoteProject = async (id) => { )} + {authStatus} ); + // --------------------------------------------------------------------------- + // Auth gate — shown when the server is reachable but no session is present + // --------------------------------------------------------------------------- + if (authUser === undefined && !authServerDown) { + return ( +
+

Loading…

+
+ ); + } + + if (authUser === null && !authServerDown) { + const submitAuth = async (e) => { + e.preventDefault(); + setAuthError(''); + setAuthBusy(true); + const action = authMode === 'login' ? loginUser : registerUser; + const result = await action(authForm.email.trim(), authForm.password); + setAuthBusy(false); + if (!result.ok) { + setAuthError(result.error ?? 'Something went wrong.'); + return; + } + if (result.data?.mfaRequired) { + setAuthMfaPending(true); + return; + } + setAuthUser(result.data); + setProjectIndex(switchStorageUser(result.data.id)); + }; + + const submitMfa = async (e) => { + e.preventDefault(); + setAuthError(''); + setAuthBusy(true); + const result = await verifyMfaLogin( + authMfaUseBackup ? { backupCode: authMfaCode.trim() } : { token: authMfaCode.trim() }, + ); + setAuthBusy(false); + if (!result.ok) { + setAuthError(result.error ?? 'Invalid code.'); + return; + } + setAuthMfaPending(false); + setAuthMfaCode(''); + setAuthUser(result.data); + setProjectIndex(switchStorageUser(result.data.id)); + }; + + if (authMfaPending) { + return ( +
+
+

Bible Study Project

+

Two-factor verification

+

+ {authMfaUseBackup + ? 'Enter one of your saved backup codes.' + : 'Enter the 6-digit code from your authenticator app.'} +

+
+ setAuthMfaCode(e.target.value)} + className="block w-full rounded-xl border border-slate-300 px-3 py-2 text-center text-lg tracking-widest shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" + /> + {authError &&

{authError}

} + +
+ + +
+
+ ); + } + + return ( +
+
+

Bible Study Project

+

+ {authMode === 'login' ? 'Sign in' : 'Create an account'} +

+
+ + + {authError &&

{authError}

} + +
+ +
+
+ ); + } + + // --------------------------------------------------------------------------- + // SETTINGS PAGE + // --------------------------------------------------------------------------- + if (currentPage === 'settings' && authUser) { + const startSetup = async () => { + setMfaSetupError(''); + setMfaSetupBusy(true); + const result = await startMfaSetup(); + setMfaSetupBusy(false); + if (!result.ok) { + setMfaSetupError(result.error ?? 'Could not start 2FA setup.'); + return; + } + setMfaSetup(result.data); + setMfaSetupCode(''); + }; + + const confirmSetup = async (e) => { + e.preventDefault(); + setMfaSetupError(''); + setMfaSetupBusy(true); + const result = await confirmMfaSetup(mfaSetupCode.trim()); + setMfaSetupBusy(false); + if (!result.ok) { + setMfaSetupError(result.error ?? 'Invalid code.'); + return; + } + setMfaSetup(null); + setMfaSetupCode(''); + setMfaBackupCodes(result.data.backupCodes); + setAuthUser((u) => ({ ...u, totpEnabled: true })); + }; + + const cancelSetup = () => { + setMfaSetup(null); + setMfaSetupCode(''); + setMfaSetupError(''); + }; + + const submitDisable = async (e) => { + e.preventDefault(); + setMfaDisableError(''); + const result = await disableMfa(mfaDisablePassword); + if (!result.ok) { + setMfaDisableError(result.error ?? 'Incorrect password.'); + return; + } + setMfaDisablePassword(''); + setAuthUser((u) => ({ ...u, totpEnabled: false })); + }; + + return ( +
+
+
+
+

Bible Study Project

+

Account Settings

+
+
+ + {authStatus} +
+
+
+ +
+
+

Account

+

+ Signed in as {authUser.email} +

+
+ +
+
+

Two-factor authentication

+

+ {authUser.totpEnabled + ? "Enabled — you'll need a code from your authenticator app to sign in." + : 'Add a 6-digit code from an authenticator app (Google Authenticator, Authy, 1Password, etc.) as a second step at sign-in.'} +

+
+ + {mfaBackupCodes ? ( +
+

+ Save these backup codes now — each works once if you ever lose your device. They won't be shown again. +

+
+ {mfaBackupCodes.map((code) => ( +
{code}
+ ))} +
+ +
+ ) : authUser.totpEnabled ? ( +
+ + {mfaDisableError &&

{mfaDisableError}

} + +
+ ) : mfaSetup ? ( +
+
+ 2FA QR code +
+

Scan this with your authenticator app, or enter the code manually:

+

{mfaSetup.secret}

+
+
+ + {mfaSetupError &&

{mfaSetupError}

} +
+ + +
+
+ ) : ( +
+ + {mfaSetupError &&

{mfaSetupError}

} +
+ )} +
+
+
+ ); + } + // --------------------------------------------------------------------------- // HOME PAGE // --------------------------------------------------------------------------- @@ -3161,6 +3630,7 @@ const restoreRemoteProject = async (id) => { > + New Project + {authStatus} @@ -3445,13 +3915,16 @@ const restoreRemoteProject = async (id) => {

Bible Study Project

Read the Bible (BSB)

- +
+ + {authStatus} +
@@ -3725,13 +4198,16 @@ const restoreRemoteProject = async (id) => {

Bible Study Project

Import Episode List

- +
+ + {authStatus} +
diff --git a/src/App.test.jsx b/src/App.test.jsx index 40540c7..7937f4d 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -63,9 +63,18 @@ afterEach(() => { // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- + +// App checks /api/auth/me on mount before deciding whether to show the app or +// a login gate. In tests the mock fetch reports the server as unreachable, so +// it falls back to local-only mode — but that still takes a tick to resolve. +async function renderApp() { + render(); + await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument()); +} + async function loadChapter() { const user = userEvent.setup(); - render(); + await renderApp(); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getByRole('button', { name: /load chapter/i })); await screen.findByText(/Scripture & Chunks/i); @@ -102,24 +111,24 @@ function findChunkCounter(n, total) { // Initial render // --------------------------------------------------------------------------- describe('Initial render', () => { - test('shows the home page with "My Studies" heading', () => { - render(); + test('shows the home page with "My Studies" heading', async () => { + await renderApp(); expect(screen.getByText('My Studies')).toBeInTheDocument(); }); - test('shows "No projects yet" when storage is empty', () => { - render(); + test('shows "No projects yet" when storage is empty', async () => { + await renderApp(); expect(screen.getByText(/no projects yet/i)).toBeInTheDocument(); }); - test('shows "New Project" button on home page', () => { - render(); + test('shows "New Project" button on home page', async () => { + await renderApp(); expect(screen.getAllByRole('button', { name: /new project/i }).length).toBeGreaterThan(0); }); test('clicking "New Project" shows the project setup form', async () => { const user = userEvent.setup(); - render(); + await renderApp(); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); expect(screen.getByText('Project Setup')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument(); @@ -127,7 +136,7 @@ describe('Initial render', () => { test('setup form shows translation, book, and chapter inputs', async () => { const user = userEvent.setup(); - render(); + await renderApp(); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); expect(screen.getByText('Translation')).toBeInTheDocument(); expect(screen.getByText('Book')).toBeInTheDocument(); @@ -162,7 +171,7 @@ describe('Loading a chapter', () => { return Promise.resolve({ ok: false }); })); const user = userEvent.setup(); - render(); + await renderApp(); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getByRole('button', { name: /load chapter/i })); await screen.findByText(/unable to load chapter/i); @@ -171,7 +180,7 @@ describe('Loading a chapter', () => { test('shows an error message when chapter data contains no verses', async () => { vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } })); const user = userEvent.setup(); - render(); + await renderApp(); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getByRole('button', { name: /load chapter/i })); await screen.findByText(/invalid bible data/i); diff --git a/src/syncService.js b/src/syncService.js index 8cd7ff8..42c1d5d 100644 --- a/src/syncService.js +++ b/src/syncService.js @@ -15,16 +15,17 @@ async function request(method, path, body) { const opts = { method, headers: { 'Content-Type': 'application/json' }, + credentials: 'include', }; if (body !== undefined) opts.body = JSON.stringify(body); const res = await fetch(`${BASE}${path}`, opts); const data = await res.json().catch(() => null); if (!res.ok) { - return { ok: false, error: data?.error ?? `HTTP ${res.status}` }; + return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}` }; } return { ok: true, data }; } catch (err) { - return { ok: false, error: err?.message ?? 'Network error' }; + return { ok: false, status: null, error: err?.message ?? 'Network error' }; } } @@ -75,4 +76,56 @@ export async function isServerReachable() { } catch { return false; } +} + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +/** + * Returns the signed-in user, or null if not signed in. + * Returns { ok: false } (with status: null) only on a genuine network failure, + * so callers can distinguish "not logged in" from "server unreachable". + */ +export async function getCurrentUser() { + const result = await request('GET', '/auth/me'); + if (result.ok) return { ok: true, user: result.data }; + if (result.status === 401) return { ok: true, user: null }; + return result; +} + +export async function registerUser(email, password) { + return request('POST', '/auth/register', { email, password }); +} + +export async function loginUser(email, password) { + return request('POST', '/auth/login', { email, password }); +} + +export async function logoutUser() { + return request('POST', '/auth/logout'); +} + +/** Submits the code from the auth gate's post-password MFA step. Pass token or backupCode. */ +export async function verifyMfaLogin({ token, backupCode }) { + return request('POST', '/auth/mfa/verify', { token, backupCode }); +} + +// --------------------------------------------------------------------------- +// Two-factor auth setup (Account Settings page) +// --------------------------------------------------------------------------- + +/** Starts 2FA setup: returns { secret, qrCodeDataUrl } for the user to scan. */ +export async function startMfaSetup() { + return request('POST', '/auth/mfa/setup'); +} + +/** Confirms the scanned code and turns 2FA on. Returns { backupCodes } (shown once). */ +export async function confirmMfaSetup(token) { + return request('POST', '/auth/mfa/enable', { token }); +} + +/** Turns 2FA off. Requires the current password as a safety check. */ +export async function disableMfa(password) { + return request('POST', '/auth/mfa/disable', { password }); } \ No newline at end of file