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}
+ setCurrentPage('settings')}
+ className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
+ >
+ ⚙ Settings
+
+ {
+ await logoutUser();
+ setAuthUser(null);
+ setProjectIndex(switchStorageUser(null));
+ goHome();
+ }}
+ className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
+ >
+ Log out
+
+
+ );
+
// ---------------------------------------------------------------------------
// 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 (
+
+ );
+ }
+
+ 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.'}
+
+
+
{
+ setAuthMfaUseBackup((v) => !v);
+ setAuthMfaCode('');
+ setAuthError('');
+ }}
+ className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
+ >
+ {authMfaUseBackup ? 'Use your authenticator app instead' : "Lost your device? Use a backup code"}
+
+
{
+ await logoutUser();
+ setAuthMfaPending(false);
+ setAuthMfaCode('');
+ setAuthError('');
+ }}
+ className="mt-2 w-full text-center text-sm text-slate-400 underline hover:text-slate-600"
+ >
+ Cancel
+
+
+
+ );
+ }
+
+ return (
+
+
+
Bible Study Project
+
+ {authMode === 'login' ? 'Sign in' : 'Create an account'}
+
+
+
{
+ setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
+ setAuthError('');
+ }}
+ className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
+ >
+ {authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'}
+
+
+
+ );
+ }
+
+ // ---------------------------------------------------------------------------
+ // 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 (
+
+
+
+
+
+ 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}
+ ))}
+
+
setMfaBackupCodes(null)}
+ className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400"
+ >
+ I've saved these codes
+
+
+ ) : authUser.totpEnabled ? (
+
+ ) : mfaSetup ? (
+
+ ) : (
+
+
+ {mfaSetupBusy ? 'Starting…' : 'Enable 2FA'}
+
+ {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)
-
- ← Back to Studies
-
+
+
+ ← Back to Studies
+
+ {authStatus}
+
@@ -3725,13 +4198,16 @@ const restoreRemoteProject = async (id) => {
Bible Study Project
Import Episode List
- setCurrentPage('home')}
- className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
- >
- ← Back
-
+
+ setCurrentPage('home')}
+ className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
+ >
+ ← Back
+
+ {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