Add multi-user accounts with per-user data scoping and 2FA

Projects were previously global to anyone who could reach the server.
Adds email/password accounts with httpOnly cookie sessions, scopes
every project (both SQLite and localStorage) to the signed-in user,
and auto-claims pre-existing unowned projects for whoever registers
first. Also adds optional TOTP two-factor auth with backup codes,
managed from a new Account Settings page, since there's no
password-reset flow to fall back on otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-06 08:49:33 -04:00
parent bf83dc7cc4
commit 5140958305
13 changed files with 1524 additions and 127 deletions
+7
View File
@@ -7,6 +7,13 @@
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev"],
"port": 5173, "port": 5173,
"autoPort": false "autoPort": false
},
{
"name": "study-app-server",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev:server"],
"port": 3001,
"autoPort": false
} }
] ]
} }
+38 -53
View File
@@ -1,95 +1,80 @@
# Study App Improvement Suggestions # 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 ## Features
### Study Tools ### Study Tools
- **Bible comparison mode** — show two translations side-by-side (API already supports it) - **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** — annotate individual verses, not just chunks - **Verse-level notes** — annotations are still chunk-level only (OIA fields); no way to attach a note to a single verse within a chunk
- **Tagging / themes** — tag chunks with themes (e.g. "faith", "grace"), filter/search across projects - **Progress tracking** — no "in progress"/"complete" marker per chunk and no progress bar on the home project card
- **Progress tracking** — mark chunks as "in progress" / "complete"; show progress bar on home card - **Study templates** — no guiding prompts pre-filled for new users starting their first OIA entry
- **Study templates** — pre-fill OIA fields with guiding prompts for new users - **Word/character count** on the OIA and Final Script textareas — encourages note depth, useful for episode-length planning
- **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
### Export / Sharing ### Export / Sharing
- **PDF export** — "Export PDF" button using `jsPDF` or `window.print()` - **PDF export** / print stylesheet — still no `window.print()` CSS or PDF button anywhere in the app
- **Share link** — read-only shareable URL pointing to a project ID on the server - **Share link** — no read-only shareable URL for a project (useful for co-teachers reviewing an episode)
- **Copy individual chunk** — "copy this chunk's notes" button alongside full "Prepare for Claude" - **Markdown export** — HTML/DOCX/Claude-prompt exports exist; no plain Markdown output for Obsidian-style tools
- **Markdown export** — useful for Obsidian and similar note-taking apps - **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 ## UX / UI
### Navigation ### Navigation
- **Keyboard shortcuts** — `←`/`→` to navigate chunks; `Ctrl+S` to save; `Escape` to close modals - **Breadcrumb in header** — study page header only shows the project title (`App.jsx:4172`); no persistent "Genesis 1:15" reference next to it so users can tell at a glance where they are without checking the scripture panel
- **"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
### Study Page ### Study Page
- **Collapsible sections** — collapse OIA, Cross-References, and Greek Word Studies independently - **Word/char counters** — see above
- **Word/character count** on each textarea to encourage note depth - **Sticky bottom nav** — top Prev/Next chunk nav shipped (commit `103e20c`); a matching sticky bottom bar for long chunks would avoid scroll-back
- **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
### Home Page ### Home Page
- **Search/filter projects** — text filter on the project list - Search/filter/sort/rename are all implemented — nothing open here currently
- **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
--- ---
## Code Architecture ## Code Architecture
### State Management ### State Management
- **`App.jsx` is ~2,250 lines** — biggest maintainability issue. Split into: - **`App.jsx` is now ~5,100 lines** (up from ~2,250 when this doc was last written) — still one component from line 9035044. 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
- `pages/HomePage.jsx` - **`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
- `pages/SetupPage.jsx`
- `pages/StudyPage.jsx`
- `components/ChunkEditor.jsx`
- `components/GreekWordStudy.jsx`
- `components/SuggestModal.jsx`
- **Custom hooks** — extract logic into `useProject()`, `useGreekLookup()`, `useAutosave()`
### Sync / Persistence ### 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 - ~~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
- **Conflict resolution is basic** — only compares `lastEdited` timestamps. Add a "which version do you want to keep?" UI to prevent silent data loss - 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
- **Offline-first** — use a service worker / `workbox` so the app works offline and syncs when back online - **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) ### Security (OWASP)
- **XSS via `dangerouslySetInnerHTML`**`word.definitionHtml` is rendered raw. Add DOMPurify sanitization - ~~XSS via `dangerouslySetInnerHTML`~~fixed; `DOMPurify.sanitize()` now wraps both render paths (`App.jsx:4784`, `App.jsx:5020`)
- **No input validation on server** — add max-length and character validation on `id`/`title` fields (SQL injection is prevented by parameterized queries, but still) - **No input validation on server** — still no max-length/character validation on `id`/`title` in `server/index.js`
- **CORS** — server has no CORS headers; any origin can call the API in production - **CORS** — still no CORS headers configured
--- ---
## Performance ## 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 - **Verse data stored in project JSON** — still true; full verse text is saved per chapter in both localStorage and SQLite
- **JSON files cached in refs** — `nt-strongs-gloss.json` and `nt-strongs-concordance.json` should be served with proper `Cache-Control` headers - **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
- **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 - **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 ## Testing
- Add tests for: - Migration and prompt-building tests now exist (`migrateChunk`, `migrateProject`, `buildClaudePrompt`, `parseBibleChapter` are all covered in `src/utils.test.js`) — this section is essentially done
- `migrateProject` with the old flat format - Still missing: autosave debounce behavior, and coverage for the newer features (DOCX episode import, cross-ref auto-suggest, commentary loading)
- `buildClaudePrompt` output structure
- `parseBibleChapter` with both API response shapes
- Autosave debounce behavior
--- ---
## Developer Experience ## Developer Experience
- **No ESLint config** — add ESLint with `eslint-plugin-react` and `eslint-plugin-react-hooks` to catch missing `useEffect` deps - **No ESLint config** — still true; no `.eslintrc*` or `eslint.config.*` in the repo
- **No TypeScript** — JSDoc types or a TS migration would catch shape mismatches between old/new project formats at compile time - **No TypeScript** — still true
- **No `docker-compose.yml`** — Dockerfile exists but there's no compose file for one-command local dev with server + SQLite volume - **No `docker-compose.yml`** — still true; Dockerfile exists but no one-command local dev with server + SQLite volume
+10 -2
View File
@@ -75,8 +75,16 @@ chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# ── systemd unit ────────────────────────────────────────────────────────────── # ── systemd unit ──────────────────────────────────────────────────────────────
echo "Installing 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#" \ EXISTING_UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
"$ROOT_DIR/deploy/study-app.service" > "/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 daemon-reload
systemctl enable "$SERVICE_NAME" systemctl enable "$SERVICE_NAME"
+3
View File
@@ -10,6 +10,9 @@ Group=study-app
WorkingDirectory=/opt/study-app WorkingDirectory=/opt/study-app
Environment=NODE_ENV=production Environment=NODE_ENV=production
Environment=PORT=3001 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 ExecStart=/usr/bin/node server/index.js
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
+389
View File
@@ -8,12 +8,16 @@
"name": "bible-study-app", "name": "bible-study-app",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0", "better-sqlite3": "^12.10.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"docx": "^9.7.0", "docx": "^9.7.0",
"dompurify": "^3.4.9", "dompurify": "^3.4.9",
"express": "^4.19.2", "express": "^4.19.2",
"express-session": "^1.19.0",
"mammoth": "^1.12.0", "mammoth": "^1.12.0",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
@@ -1107,6 +1111,56 @@
"node": ">= 8" "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": { "node_modules/@oxc-project/types": {
"version": "0.132.0", "version": "0.132.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
@@ -2325,6 +2379,15 @@
"node": ">=6.0.0" "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": { "node_modules/better-sqlite3": {
"version": "12.10.0", "version": "12.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
@@ -2550,6 +2613,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/camelcase-css": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "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": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -2984,6 +3065,12 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "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": { "node_modules/dingbat-to-unicode": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
@@ -3297,6 +3384,64 @@
"url": "https://opencollective.com/express" "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": { "node_modules/express/node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -3424,6 +3569,19 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -4256,6 +4414,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -4663,6 +4833,15 @@
"node": ">= 0.8" "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": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -4678,6 +4857,53 @@
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause" "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": { "node_modules/pako": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@@ -4706,6 +4932,15 @@
"node": ">= 0.8" "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": { "node_modules/path-is-absolute": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -4775,6 +5010,15 @@
"node": ">= 6" "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": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -5020,6 +5264,104 @@
"node": ">=6" "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": { "node_modules/qs": {
"version": "6.15.2", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -5056,6 +5398,15 @@
], ],
"license": "MIT" "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": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -5205,6 +5556,12 @@
"node": ">=0.10.0" "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": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -5461,6 +5818,12 @@
"node": ">= 0.8.0" "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": { "node_modules/setimmediate": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
@@ -5868,6 +6231,14 @@
"node": ">=0.8" "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": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -6058,6 +6429,18 @@
"node": ">= 0.6" "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": { "node_modules/underscore": {
"version": "1.13.8", "version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", "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": "^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": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+4
View File
@@ -15,12 +15,16 @@
"coverage": "vitest run --coverage" "coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0", "better-sqlite3": "^12.10.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"docx": "^9.7.0", "docx": "^9.7.0",
"dompurify": "^3.4.9", "dompurify": "^3.4.9",
"express": "^4.19.2", "express": "^4.19.2",
"express-session": "^1.19.0",
"mammoth": "^1.12.0", "mammoth": "^1.12.0",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
+71
View File
@@ -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;
}
+150 -17
View File
@@ -10,7 +10,7 @@ const DB_PATH = join(DATA_DIR, 'projects.db');
let 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() { export function initDb() {
mkdirSync(DATA_DIR, { recursive: true }); mkdirSync(DATA_DIR, { recursive: true });
@@ -27,8 +27,37 @@ export function initDb() {
chapter_summary TEXT, chapter_summary TEXT,
data TEXT NOT NULL 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}`); 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. * Does NOT return full project data to keep the response small.
*/ */
export function getAllProjects() { export function getAllProjects(userId) {
const rows = db.prepare(` const rows = db.prepare(`
SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary
FROM projects FROM projects
WHERE user_id = ?
ORDER BY last_edited DESC ORDER BY last_edited DESC
`).all(); `).all(userId);
return rows; 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) { export function getProject(id, userId) {
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id); const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
if (!row) return null; if (!row) return null;
try { try {
return JSON.parse(row.data); 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 lastEdited = project.lastEdited ?? Date.now();
const chapterSummary = buildSummary(project); const chapterSummary = buildSummary(project);
const updated = { ...project, lastEdited }; const updated = { ...project, lastEdited };
db.prepare(` db.prepare(`
INSERT INTO projects (id, title, last_edited, chapter_summary, data) INSERT INTO projects (id, title, last_edited, chapter_summary, data, user_id)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
title = excluded.title, title = excluded.title,
last_edited = excluded.last_edited, last_edited = excluded.last_edited,
chapter_summary = excluded.chapter_summary, chapter_summary = excluded.chapter_summary,
data = excluded.data 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 }; 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) { export function deleteProject(id, userId) {
db.prepare('DELETE FROM projects WHERE id = ?').run(id); 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());
}
+227 -16
View File
@@ -1,13 +1,47 @@
import express from 'express'; import express from 'express';
import session from 'express-session';
import QRCode from 'qrcode';
import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; 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 __dirname = dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; 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(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 // 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 { 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); res.json(projects);
} catch (err) { } catch (err) {
console.error('GET /api/projects error:', 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 { 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.' }); if (!project) return res.status(404).json({ error: 'Project not found.' });
res.json(project); res.json(project);
} catch (err) { } 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 { try {
const body = req.body; const body = req.body;
if (!body || typeof body !== 'object') { if (!body || typeof body !== 'object') {
@@ -58,7 +266,10 @@ app.put('/api/projects/:id', (req, res) => {
if (body.id !== req.params.id) { if (body.id !== req.params.id) {
return res.status(400).json({ error: 'URL id does not match body 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); res.json(saved);
} catch (err) { } catch (err) {
console.error('PUT /api/projects/:id error:', 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 { try {
deleteProject(req.params.id); deleteProject(req.params.id, req.session.userId);
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
console.error('DELETE /api/projects/:id error:', 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) // Serve Vite production build (when NODE_ENV=production)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
if (process.env.NODE_ENV === 'production') { if (isProd) {
const distPath = join(__dirname, '..', 'dist'); const distPath = join(__dirname, '..', 'dist');
app.use(express.static(distPath)); app.use(express.static(distPath));
app.get('*', (_req, res) => { app.get('*', (_req, res) => {
@@ -96,7 +307,7 @@ if (process.env.NODE_ENV === 'production') {
initDb(); initDb();
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Bible Study API running on http://localhost:${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'); console.log('Serving Vite build from /dist');
} }
}); });
+48
View File
@@ -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);
}
}
+502 -26
View File
@@ -22,6 +22,14 @@ import {
deleteRemoteProject, deleteRemoteProject,
listRemoteProjects, listRemoteProjects,
loadRemoteProject, loadRemoteProject,
getCurrentUser,
registerUser,
loginUser,
logoutUser,
verifyMfaLogin,
startMfaSetup,
confirmMfaSetup,
disableMfa,
} from './syncService.js'; } from './syncService.js';
const COMMENTARY_OPTIONS = [ const COMMENTARY_OPTIONS = [
@@ -248,12 +256,60 @@ export function migrateProject(raw) {
// Storage helpers // Storage helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const INDEX_KEY = 'bible-study-index'; // Pre-multi-user, un-namespaced keys. Kept around only so existing local data
const projectKey = (id) => `bible-study-project-${id}`; // (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() { function loadProjectIndex() {
try { try {
const raw = window.localStorage.getItem(INDEX_KEY); const raw = window.localStorage.getItem(indexKey());
return raw ? JSON.parse(raw) : []; return raw ? JSON.parse(raw) : [];
} catch { } catch {
return []; return [];
@@ -286,14 +342,14 @@ function saveProjectToStorage(project) {
} else { } else {
index.push(summary); 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 return updated; // caller can use this exact object for the server PUT
} }
function deleteProjectFromStorage(id) { function deleteProjectFromStorage(id) {
window.localStorage.removeItem(projectKey(id)); window.localStorage.removeItem(projectKey(id));
const index = loadProjectIndex().filter((e) => e.id !== 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) { function loadProjectById(id) {
@@ -308,7 +364,7 @@ function loadProjectById(id) {
function migrateOldStorageKeys() { function migrateOldStorageKeys() {
const keys = Object.keys(window.localStorage); const keys = Object.keys(window.localStorage);
keys.forEach((key) => { 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 { try {
const raw = JSON.parse(window.localStorage.getItem(key)); const raw = JSON.parse(window.localStorage.getItem(key));
if (!raw || !raw.book) return; if (!raw || !raw.book) return;
@@ -910,8 +966,25 @@ const App = () => {
title: 'Titus 1 Study', title: 'Titus 1 Study',
}); });
const [titleEdited, setTitleEdited] = useState(false); 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); const [project, setProject] = useState(null);
// 'home' | 'setup' | 'study' // 'home' | 'setup' | 'study' | 'settings'
const [currentPage, setCurrentPage] = useState('home'); const [currentPage, setCurrentPage] = useState('home');
const [projectIndex, setProjectIndex] = useState([]); const [projectIndex, setProjectIndex] = useState([]);
const [activeChapterIndex, setActiveChapterIndex] = useState(0); const [activeChapterIndex, setActiveChapterIndex] = useState(0);
@@ -1300,21 +1373,42 @@ const App = () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
useEffect(() => { useEffect(() => {
migrateOldStorageKeys(); migrateOldStorageKeys();
const localIndex = loadProjectIndex(); setProjectIndex(loadProjectIndex());
setProjectIndex(localIndex);
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) => { listRemoteProjects().then((result) => {
if (!result.ok) return; if (!result.ok) return;
const localMap = new Map(localIndex.map((e) => [e.id, e])); const localMap = new Map(localIndex.map((e) => [e.id, e]));
const missing = result.data.filter((e) => !localMap.has(e.id)); 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 stale = result.data.filter((e) => {
const local = localMap.get(e.id); const local = localMap.get(e.id);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0); return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
}); });
if (stale.length > 0) setStaleLocalProjects(stale); setStaleLocalProjects(stale);
}); });
}, []); }, [authUser]);
useEffect(() => { useEffect(() => {
fetch('https://bible.helloao.org/api/available_translations.json') fetch('https://bible.helloao.org/api/available_translations.json')
@@ -3026,6 +3120,31 @@ const restoreRemoteProject = async (id) => {
setStatusMessage(''); setStatusMessage('');
}; };
const authStatus = authUser && (
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="hidden sm:inline">{authUser.email}</span>
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={async () => {
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
</button>
</div>
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared header // Shared header
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -3124,9 +3243,359 @@ const restoreRemoteProject = async (id) => {
</div> </div>
)} )}
</div> </div>
{authStatus}
</div> </div>
); );
// ---------------------------------------------------------------------------
// Auth gate — shown when the server is reachable but no session is present
// ---------------------------------------------------------------------------
if (authUser === undefined && !authServerDown) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-900">
<p className="text-sm text-slate-400">Loading</p>
</div>
);
}
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 (
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4">
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<h1 className="mt-2 text-xl font-semibold text-slate-900">Two-factor verification</h1>
<p className="mt-2 text-sm text-slate-500">
{authMfaUseBackup
? 'Enter one of your saved backup codes.'
: 'Enter the 6-digit code from your authenticator app.'}
</p>
<form onSubmit={submitMfa} className="mt-6 space-y-4">
<input
type="text"
required
autoFocus
inputMode={authMfaUseBackup ? 'text' : 'numeric'}
placeholder={authMfaUseBackup ? 'xxxxxxxxxx' : '123456'}
value={authMfaCode}
onChange={(e) => 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 && <p className="text-sm text-rose-600">{authError}</p>}
<button
type="submit"
disabled={authBusy}
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{authBusy ? 'Verifying…' : 'Verify'}
</button>
</form>
<button
type="button"
onClick={() => {
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"}
</button>
<button
type="button"
onClick={async () => {
await logoutUser();
setAuthMfaPending(false);
setAuthMfaCode('');
setAuthError('');
}}
className="mt-2 w-full text-center text-sm text-slate-400 underline hover:text-slate-600"
>
Cancel
</button>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4">
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<h1 className="mt-2 text-xl font-semibold text-slate-900">
{authMode === 'login' ? 'Sign in' : 'Create an account'}
</h1>
<form onSubmit={submitAuth} className="mt-6 space-y-4">
<label className="block text-sm font-medium text-slate-700">
Email
<input
type="email"
required
autoComplete="email"
value={authForm.email}
onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="block text-sm font-medium text-slate-700">
Password
<input
type="password"
required
minLength={8}
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
value={authForm.password}
onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
{authError && <p className="text-sm text-rose-600">{authError}</p>}
<button
type="submit"
disabled={authBusy}
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
</button>
</form>
<button
type="button"
onClick={() => {
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'}
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">Account Settings</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={goHome}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
Back
</button>
{authStatus}
</div>
</div>
</header>
<main className="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-2">
<h2 className="text-lg font-semibold text-slate-900">Account</h2>
<p className="text-sm text-slate-500">
Signed in as <span className="font-medium text-slate-700">{authUser.email}</span>
</p>
</section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2>
<p className="text-sm text-slate-500">
{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.'}
</p>
</div>
{mfaBackupCodes ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 space-y-3">
<p className="text-sm font-semibold text-amber-800">
Save these backup codes now each works once if you ever lose your device. They won't be shown again.
</p>
<div className="grid grid-cols-2 gap-2 font-mono text-sm text-slate-800">
{mfaBackupCodes.map((code) => (
<div key={code} className="rounded-lg border border-amber-200 bg-white px-3 py-1.5">{code}</div>
))}
</div>
<button
type="button"
onClick={() => 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
</button>
</div>
) : authUser.totpEnabled ? (
<form onSubmit={submitDisable} className="space-y-3">
<label className="block max-w-xs text-sm font-medium text-slate-700">
Enter your password to disable 2FA
<input
type="password"
required
value={mfaDisablePassword}
onChange={(e) => setMfaDisablePassword(e.target.value)}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
{mfaDisableError && <p className="text-sm text-rose-600">{mfaDisableError}</p>}
<button
type="submit"
className="rounded-md bg-rose-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-rose-400"
>
Disable 2FA
</button>
</form>
) : mfaSetup ? (
<form onSubmit={confirmSetup} className="space-y-4">
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
<img src={mfaSetup.qrCodeDataUrl} alt="2FA QR code" className="h-40 w-40 rounded-xl border border-slate-200" />
<div className="space-y-1 text-sm text-slate-600">
<p>Scan this with your authenticator app, or enter the code manually:</p>
<p className="break-all rounded-lg bg-slate-100 px-2 py-1 font-mono text-xs">{mfaSetup.secret}</p>
</div>
</div>
<label className="block max-w-xs text-sm font-medium text-slate-700">
Enter the 6-digit code it shows
<input
type="text"
required
inputMode="numeric"
placeholder="123456"
value={mfaSetupCode}
onChange={(e) => setMfaSetupCode(e.target.value)}
className="mt-1 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"
/>
</label>
{mfaSetupError && <p className="text-sm text-rose-600">{mfaSetupError}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={mfaSetupBusy}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{mfaSetupBusy ? 'Verifying…' : 'Confirm & enable'}
</button>
<button
type="button"
onClick={cancelSetup}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50"
>
Cancel
</button>
</div>
</form>
) : (
<div className="space-y-2">
<button
type="button"
onClick={startSetup}
disabled={mfaSetupBusy}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{mfaSetupBusy ? 'Starting…' : 'Enable 2FA'}
</button>
{mfaSetupError && <p className="text-sm text-rose-600">{mfaSetupError}</p>}
</div>
)}
</section>
</main>
</div>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// HOME PAGE // HOME PAGE
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -3161,6 +3630,7 @@ const restoreRemoteProject = async (id) => {
> >
+ New Project + New Project
</button> </button>
{authStatus}
</div> </div>
</div> </div>
</header> </header>
@@ -3445,13 +3915,16 @@ const restoreRemoteProject = async (id) => {
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p> <p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">Read the Bible (BSB)</h1> <h1 className="mt-2 text-2xl font-semibold">Read the Bible (BSB)</h1>
</div> </div>
<button <div className="flex items-center gap-3">
type="button" <button
onClick={goHome} type="button"
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700" onClick={goHome}
> className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
Back to Studies >
</button> Back to Studies
</button>
{authStatus}
</div>
</div> </div>
</header> </header>
<main className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8"> <main className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
@@ -3725,13 +4198,16 @@ const restoreRemoteProject = async (id) => {
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p> <p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">Import Episode List</h1> <h1 className="mt-2 text-2xl font-semibold">Import Episode List</h1>
</div> </div>
<button <div className="flex items-center gap-3">
type="button" <button
onClick={() => setCurrentPage('home')} type="button"
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700" onClick={() => 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 >
</button> Back
</button>
{authStatus}
</div>
</div> </div>
</header> </header>
+20 -11
View File
@@ -63,9 +63,18 @@ afterEach(() => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // 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(<App />);
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
}
async function loadChapter() { async function loadChapter() {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i })); await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i); await screen.findByText(/Scripture & Chunks/i);
@@ -102,24 +111,24 @@ function findChunkCounter(n, total) {
// Initial render // Initial render
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('Initial render', () => { describe('Initial render', () => {
test('shows the home page with "My Studies" heading', () => { test('shows the home page with "My Studies" heading', async () => {
render(<App />); await renderApp();
expect(screen.getByText('My Studies')).toBeInTheDocument(); expect(screen.getByText('My Studies')).toBeInTheDocument();
}); });
test('shows "No projects yet" when storage is empty', () => { test('shows "No projects yet" when storage is empty', async () => {
render(<App />); await renderApp();
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument(); expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
}); });
test('shows "New Project" button on home page', () => { test('shows "New Project" button on home page', async () => {
render(<App />); await renderApp();
expect(screen.getAllByRole('button', { name: /new project/i }).length).toBeGreaterThan(0); expect(screen.getAllByRole('button', { name: /new project/i }).length).toBeGreaterThan(0);
}); });
test('clicking "New Project" shows the project setup form', async () => { test('clicking "New Project" shows the project setup form', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(screen.getByText('Project Setup')).toBeInTheDocument(); expect(screen.getByText('Project Setup')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /load chapter/i })).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 () => { test('setup form shows translation, book, and chapter inputs', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(screen.getByText('Translation')).toBeInTheDocument(); expect(screen.getByText('Translation')).toBeInTheDocument();
expect(screen.getByText('Book')).toBeInTheDocument(); expect(screen.getByText('Book')).toBeInTheDocument();
@@ -162,7 +171,7 @@ describe('Loading a chapter', () => {
return Promise.resolve({ ok: false }); return Promise.resolve({ ok: false });
})); }));
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i })); await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/unable to 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 () => { test('shows an error message when chapter data contains no verses', async () => {
vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } })); vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } }));
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i })); await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/invalid bible data/i); await screen.findByText(/invalid bible data/i);
+55 -2
View File
@@ -15,16 +15,17 @@ async function request(method, path, body) {
const opts = { const opts = {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include',
}; };
if (body !== undefined) opts.body = JSON.stringify(body); if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts); const res = await fetch(`${BASE}${path}`, opts);
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok) { 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 }; return { ok: true, data };
} catch (err) { } 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 { } catch {
return false; 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 });
} }