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
+20 -11
View File
@@ -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(<App />);
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
}
async function loadChapter() {
const user = userEvent.setup();
render(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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);