Add hosted login persistence

This commit is contained in:
2026-07-15 15:33:47 -05:00
parent 02ffa1dfc4
commit e62ec49c59
9 changed files with 508 additions and 38 deletions
+126 -25
View File
@@ -15,12 +15,15 @@ import {
createSession,
defaultTranslationId,
hostedWorkspaceEnabled,
loadHostedSession,
loadWorkspaceSnapshot,
loginHostedWorkspace,
logoutHostedWorkspace,
saveWorkspaceSnapshot,
timelineId,
timelineLimit,
} from './workspace'
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
import type { DragEvent, FormEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
import type {
AudioBibleContext,
AudioBibleSegment,
@@ -32,6 +35,7 @@ import type {
} from './types'
import type {
StrongDisplayMode,
HostedUser,
StudySession,
StudySettings,
ThemeMode,
@@ -245,6 +249,12 @@ function App() {
const [audioSpeed, setAudioSpeed] = useState(1)
const [marginResourcesLoading, setMarginResourcesLoading] = useState(false)
const [mapResourcesLoading, setMapResourcesLoading] = useState(false)
const [authRequired, setAuthRequired] = useState(false)
const [hostedUser, setHostedUser] = useState<HostedUser | null>(null)
const [loginUsername, setLoginUsername] = useState('')
const [loginPassword, setLoginPassword] = useState('')
const [loginBusy, setLoginBusy] = useState(false)
const [loginError, setLoginError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const restoredSessionId = useRef('')
@@ -445,33 +455,45 @@ function App() {
],
)
async function restoreWorkspace(cancelled = () => false) {
setLoading(true)
setError(null)
setAuthRequired(false)
workspaceHydrated.current = false
if (hostedWorkspaceEnabled()) {
const session = await loadHostedSession()
if (cancelled()) return
setHostedUser(session.user)
if (!session.authenticated) {
setAuthRequired(true)
setLoading(false)
return
}
}
const workspace = await loadWorkspaceSnapshot()
const selectedSession =
workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0]
if (cancelled()) return
applyStudySettings(workspace.studySettings)
setSessions(workspace.sessions)
setActiveSessionId(workspace.activeSessionId)
getTranslations().then(setTranslations).catch((reason) => setError(String(reason)))
await loadLibrary(selectedSession.baseTranslation)
if (cancelled()) return
setLoading(false)
window.setTimeout(() => {
workspaceHydrated.current = true
}, 0)
}
useEffect(() => {
let cancelled = false
async function restoreWorkspace() {
const workspace = await loadWorkspaceSnapshot()
const selectedSession =
workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0]
if (cancelled) return
applyStudySettings(workspace.studySettings)
setSessions(workspace.sessions)
setActiveSessionId(workspace.activeSessionId)
getTranslations().then(setTranslations).catch((reason) => setError(String(reason)))
loadLibrary(selectedSession.baseTranslation)
.catch((reason) => setError(String(reason)))
.finally(() => {
if (!cancelled) {
setLoading(false)
window.setTimeout(() => {
workspaceHydrated.current = true
}, 0)
}
})
}
restoreWorkspace().catch((reason) => {
restoreWorkspace(() => cancelled).catch((reason) => {
if (!cancelled) {
setError(String(reason))
setLoading(false)
@@ -1042,6 +1064,33 @@ function App() {
URL.revokeObjectURL(url)
}
async function submitHostedLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setLoginBusy(true)
setLoginError(null)
try {
const session = await loginHostedWorkspace(loginUsername.trim(), loginPassword)
setHostedUser(session.user)
setLoginPassword('')
await restoreWorkspace()
} catch (reason) {
setLoginError(String(reason))
} finally {
setLoginBusy(false)
}
}
async function signOutHostedWorkspace() {
setError(null)
await logoutHostedWorkspace()
setHostedUser(null)
setSessions([])
setActiveSessionId('')
setAuthRequired(true)
setLoading(false)
workspaceHydrated.current = false
}
function restoreTimelineEntry(entry: TimelineEntry) {
if (!entry.reference) return
const [bookPart, rest] = entry.reference.split(/ (?=\d+:\d+$)/)
@@ -1897,6 +1946,44 @@ function App() {
)
}
function renderHostedLogin() {
return (
<main className={`login-shell ${themeMode === 'dark' ? 'theme-dark' : 'theme-light'}`}>
<section className="login-panel" aria-label="Libre Study sign in">
<div>
<span className="brand-mark">LS</span>
<span className="eyebrow">Hosted Workspace</span>
<h1>Libre Study</h1>
<p>Sign in to load your private sessions, notes, settings, and study timeline.</p>
</div>
<form onSubmit={submitHostedLogin}>
<label>
Username
<input
autoComplete="username"
value={loginUsername}
onChange={(event) => setLoginUsername(event.target.value)}
/>
</label>
<label>
Password
<input
autoComplete="current-password"
type="password"
value={loginPassword}
onChange={(event) => setLoginPassword(event.target.value)}
/>
</label>
{loginError ? <p className="login-error">{loginError}</p> : null}
<button type="submit" disabled={loginBusy || !loginUsername.trim() || !loginPassword}>
{loginBusy ? 'Signing in...' : 'Sign in'}
</button>
</form>
</section>
</main>
)
}
function renderTimelineModal() {
if (!timelineModalOpen) return null
@@ -2460,6 +2547,10 @@ function App() {
const splitTemplate = `${splitPercent}fr 6px ${100 - splitPercent}fr`
if (authRequired && hostedWorkspaceEnabled()) {
return renderHostedLogin()
}
return (
<main
className={[
@@ -2494,6 +2585,16 @@ function App() {
<p className="rail-description">
A readable Bible page, study margin, notes workspace, and linked public Bible data.
</p>
{hostedUser ? (
<button
type="button"
title="Sign out"
onClick={() => signOutHostedWorkspace().catch((reason) => setError(String(reason)))}
>
<span className="rail-icon">U</span>
<span className="rail-copy"><strong>{hostedUser.username}</strong><small>Sign out</small></span>
</button>
) : null}
<button type="button" title="Session management" onClick={() => setSessionModalOpen(true)}>
<span className="rail-icon">S</span>
<span className="rail-copy"><strong>Sessions</strong><small>Save, export, and configure</small></span>