Add hosted login persistence
This commit is contained in:
+126
-25
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user