Extract auth into hooks/useAuth; move form state into its pages
Build and Push Docker Image / build-and-push (push) Successful in 6m28s
Build and Push Docker Image / build-and-push (push) Successful in 6m28s
- useAuth owns authUser/authServerDown, the startup session check, and signIn/logout, so every sign-in path runs the same follow-up (switch local storage to the account, load reader ink). Previously reader ink only loaded on a page refresh, not right after logging in. - Login/2FA form state now lives in AuthPage, and 2FA setup, password change, and podcast name state in SettingsPage (23 fewer context values; typing in these forms no longer re-renders all of App). - Add tests for form sign-in, 2FA, session restore + logout, and the Settings podcast-name prefill. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
009fa1e78f
commit
49ffefed54
5 files changed
+162
-113
No files matched your search
+18
-83
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { AppContext, useApp } from './context/AppContext.js';
|
||||
import { useAudio } from './hooks/useAudio.js';
|
||||
import { useAuth } from './hooks/useAuth.js';
|
||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||
import HomePage from './pages/HomePage.jsx';
|
||||
|
||||
@@ -42,8 +43,6 @@ import {
|
||||
deleteRemoteProject,
|
||||
listRemoteProjects,
|
||||
loadRemoteProject,
|
||||
getCurrentUser,
|
||||
logoutUser,
|
||||
getShareStatus,
|
||||
enableSharing,
|
||||
disableSharing,
|
||||
@@ -81,30 +80,6 @@ const App = () => {
|
||||
title: 'Titus 1 Study',
|
||||
});
|
||||
const [titleEdited, setTitleEdited] = useState(false);
|
||||
// authUser: undefined = still checking · null = signed out · object = signed in
|
||||
const [authUser, setAuthUser] = useState(undefined);
|
||||
const [authServerDown, setAuthServerDown] = useState(false);
|
||||
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
|
||||
const [authForm, setAuthForm] = useState({ email: '', password: '' });
|
||||
const [authError, setAuthError] = useState('');
|
||||
const [authBusy, setAuthBusy] = useState(false);
|
||||
const [authMfaPending, setAuthMfaPending] = useState(false); // password ok, waiting on TOTP/backup code
|
||||
const [authMfaCode, setAuthMfaCode] = useState('');
|
||||
const [authMfaUseBackup, setAuthMfaUseBackup] = useState(false);
|
||||
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState('');
|
||||
const [mfaSetupError, setMfaSetupError] = useState('');
|
||||
const [mfaSetupBusy, setMfaSetupBusy] = useState(false);
|
||||
const [mfaBackupCodes, setMfaBackupCodes] = useState(null); // shown once, right after enabling
|
||||
const [mfaDisablePassword, setMfaDisablePassword] = useState('');
|
||||
const [mfaDisableError, setMfaDisableError] = useState('');
|
||||
const [podcastNameInput, setPodcastNameInput] = useState('');
|
||||
const [podcastNameSaving, setPodcastNameSaving] = useState(false);
|
||||
const [podcastNameSaved, setPodcastNameSaved] = useState(false);
|
||||
const [changePasswordForm, setChangePasswordForm] = useState({ current: '', next: '', confirm: '' });
|
||||
const [changePasswordBusy, setChangePasswordBusy] = useState(false);
|
||||
const [changePasswordError, setChangePasswordError] = useState('');
|
||||
const [changePasswordSaved, setChangePasswordSaved] = useState(false);
|
||||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||
// Read-only share links
|
||||
const [shareToken, setShareToken] = useState(null);
|
||||
@@ -168,6 +143,20 @@ const App = () => {
|
||||
const [importError, setImportError] = useState('');
|
||||
const reader = useBibleReader(currentPage);
|
||||
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
||||
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
|
||||
onSignIn: (user) => {
|
||||
setProjectIndex(switchStorageUser(user.id));
|
||||
// Load reader ink from the server; server wins over any stale localStorage copy
|
||||
fetch('/api/reader/ink')
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((serverInk) => { if (serverInk) reader.mergeServerInk(serverInk); })
|
||||
.catch(() => {});
|
||||
},
|
||||
onSignOut: () => {
|
||||
setProjectIndex(switchStorageUser(null));
|
||||
goHome();
|
||||
},
|
||||
});
|
||||
const [studyLayout, setStudyLayout] = useState(() => {
|
||||
const saved = localStorage.getItem('studyLayout');
|
||||
return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
|
||||
@@ -288,34 +277,8 @@ const App = () => {
|
||||
useEffect(() => {
|
||||
migrateOldStorageKeys();
|
||||
setProjectIndex(loadProjectIndex());
|
||||
|
||||
getCurrentUser().then((result) => {
|
||||
if (result.ok) {
|
||||
setAuthUser(result.user);
|
||||
if (result.user) {
|
||||
setProjectIndex(switchStorageUser(result.user.id));
|
||||
// Load reader ink from the server; server wins over any stale localStorage copy
|
||||
fetch('/api/reader/ink')
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((serverInk) => {
|
||||
if (!serverInk) return;
|
||||
reader.mergeServerInk(serverInk);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} else {
|
||||
// Genuine network failure (server unreachable) — fall back to local-only mode.
|
||||
setAuthServerDown(true);
|
||||
setAuthUser(null);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keep the Settings page's podcast-name field in sync with whichever account is signed in.
|
||||
useEffect(() => {
|
||||
setPodcastNameInput(authUser?.podcastName ?? '');
|
||||
}, [authUser?.id]);
|
||||
|
||||
// A ?share=TOKEN URL loads a read-only view of someone else's project — no
|
||||
// login required, so this runs independent of auth state entirely.
|
||||
useEffect(() => {
|
||||
@@ -2028,12 +1991,7 @@ const deleteProject = (id) => {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await logoutUser();
|
||||
setAuthUser(null);
|
||||
setProjectIndex(switchStorageUser(null));
|
||||
goHome();
|
||||
}}
|
||||
onClick={logout}
|
||||
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
|
||||
@@ -2216,33 +2174,10 @@ const deleteProject = (id) => {
|
||||
sharedViewToken,
|
||||
sharedProject,
|
||||
sharedError,
|
||||
// Auth
|
||||
// Auth (login/2FA/settings form state lives in AuthPage and SettingsPage)
|
||||
authUser, setAuthUser,
|
||||
authStatus,
|
||||
authMode, setAuthMode,
|
||||
authForm, setAuthForm,
|
||||
authError, setAuthError,
|
||||
authBusy, setAuthBusy,
|
||||
authMfaPending, setAuthMfaPending,
|
||||
authMfaCode, setAuthMfaCode,
|
||||
authMfaUseBackup, setAuthMfaUseBackup,
|
||||
setProjectIndex,
|
||||
// Settings — MFA
|
||||
mfaSetup, setMfaSetup,
|
||||
mfaSetupCode, setMfaSetupCode,
|
||||
mfaSetupError, setMfaSetupError,
|
||||
mfaSetupBusy, setMfaSetupBusy,
|
||||
mfaBackupCodes, setMfaBackupCodes,
|
||||
mfaDisablePassword, setMfaDisablePassword,
|
||||
mfaDisableError, setMfaDisableError,
|
||||
// Settings — password / podcast
|
||||
podcastNameInput, setPodcastNameInput,
|
||||
podcastNameSaving, setPodcastNameSaving,
|
||||
podcastNameSaved, setPodcastNameSaved,
|
||||
changePasswordForm, setChangePasswordForm,
|
||||
changePasswordBusy, setChangePasswordBusy,
|
||||
changePasswordError, setChangePasswordError,
|
||||
changePasswordSaved, setChangePasswordSaved,
|
||||
signIn,
|
||||
// Home
|
||||
projectIndex,
|
||||
autoRestoredCount,
|
||||
|
||||
Reference in new issue
Block a user