Libre Study
+Sign in to load your private sessions, notes, settings, and study timeline.
+diff --git a/CHANGELOG.md b/CHANGELOG.md
index 228c6b8..16843cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 0.1.30 - 2026-07-15
+
+- Started Phase 2 hosted accounts with a server-side user table, password hashing, login sessions, and authenticated per-user workspace persistence.
+- Added hosted-only login and logout UI while keeping local browser preview and future desktop mode account-free.
+- Added optional deployment bootstrap credentials through `LIBRESTUDY_BOOTSTRAP_USER` and `LIBRESTUDY_BOOTSTRAP_PASSWORD`.
+
## 0.1.29 - 2026-07-15
- Changed the Vite dev server to bind to `0.0.0.0` and allow the `desktop` MagicDNS host so the hot view can be reached from Pinky at `http://desktop:5174/`.
diff --git a/ROADMAP.md b/ROADMAP.md
index ab69452..8a9b5f3 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -79,12 +79,14 @@ Definition of done:
Goal: make the hosted app personally usable without relying on browser `localStorage`.
+Current foundation note: hosted mode now has username/password login, signed cookie sessions, and per-user SQLite workspace persistence. Account creation is intentionally bootstrap/manual-only for the private alpha.
+
Tasks:
-- Add a minimal server API for sessions, notes, settings, timelines, and active workspace state.
-- Add a simple login path, starting with manually created accounts or invite-only access.
+- Add a minimal server API for sessions, notes, settings, timelines, and active workspace state. Started in `server/libre-study-server.mjs`.
+- Add a simple login path, starting with manually created accounts or invite-only access. Started with bootstrap-created users.
- Store only necessary account data: username or email, password hash, sessions, notes, settings, timeline events, created/updated timestamps.
-- Move hosted sessions from `localStorage` to server persistence.
+- Move hosted sessions from `localStorage` to server persistence. Started through the authenticated workspace snapshot endpoint.
- Keep local browser preview and Tauri desktop able to run without accounts.
- Add basic backup expectations for the server-side user database.
diff --git a/package-lock.json b/package-lock.json
index f932829..6035db3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "libre-study",
- "version": "0.1.29",
+ "version": "0.1.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "libre-study",
- "version": "0.1.29",
+ "version": "0.1.30",
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"jszip": "^3.10.1",
diff --git a/package.json b/package.json
index 404d0f1..de2e3aa 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "libre-study",
"private": true,
- "version": "0.1.29",
+ "version": "0.1.30",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
diff --git a/scripts/deploy-web.ps1 b/scripts/deploy-web.ps1
index 8ec9566..340a654 100644
--- a/scripts/deploy-web.ps1
+++ b/scripts/deploy-web.ps1
@@ -2,7 +2,9 @@ param(
[string]$HostName = "christunscripted",
[string]$RemoteRoot = "/home/jbird/libre-study",
[string]$RemoteBibleDataRoot = "/home/jbird/libre-bible-data/packages/json",
- [int]$Port = 9174
+ [int]$Port = 9174,
+ [string]$BootstrapUser = $env:LIBRESTUDY_BOOTSTRAP_USER,
+ [string]$BootstrapPassword = $env:LIBRESTUDY_BOOTSTRAP_PASSWORD
)
$ErrorActionPreference = "Stop"
@@ -12,6 +14,10 @@ function ConvertTo-WslPath([string]$Path) {
return (wsl wslpath -a "$resolved").Trim()
}
+function ConvertTo-BashSingleQuoted([string]$Value) {
+ return "'" + $Value.Replace("'", "'\''") + "'"
+}
+
$repoRoot = Split-Path -Parent $PSScriptRoot
$dataRepo = Resolve-Path (Join-Path $repoRoot "..\libre-bible-data")
$repoRootWsl = ConvertTo-WslPath $repoRoot
@@ -41,6 +47,8 @@ $deployInfo = @{
$deployInfoPath = Join-Path $env:TEMP "libre-study-deploy-info.json"
Set-Content -Path $deployInfoPath -Value $deployInfo -NoNewline
$deployInfoPathWsl = ConvertTo-WslPath $deployInfoPath
+$bootstrapUserQuoted = ConvertTo-BashSingleQuoted $BootstrapUser
+$bootstrapPasswordQuoted = ConvertTo-BashSingleQuoted $BootstrapPassword
wsl ssh $HostName "mkdir -p '$RemoteRoot/app' '$RemoteRoot/server' '$RemoteRoot/state' '$RemoteBibleDataRoot'"
if ($LASTEXITCODE -ne 0) { throw "Remote directory preparation failed." }
@@ -55,6 +63,8 @@ if ($LASTEXITCODE -ne 0) { throw "LibreBible rsync failed." }
$pm2Command = @"
cd '$RemoteRoot' &&
+export LIBRESTUDY_BOOTSTRAP_USER=$bootstrapUserQuoted &&
+export LIBRESTUDY_BOOTSTRAP_PASSWORD=$bootstrapPasswordQuoted &&
if pm2 describe libre-study-web >/dev/null 2>&1; then
PORT=$Port PUBLIC_DIR='$RemoteRoot/app' LIBREBIBLE_DATA_DIR='$RemoteBibleDataRoot' STATE_DIR='$RemoteRoot/state' DB_PATH='$RemoteRoot/state/libre-study-web.sqlite3' DEPLOY_INFO_PATH='$RemoteRoot/deploy-info.json' pm2 restart libre-study-web --update-env;
else
diff --git a/server/libre-study-server.mjs b/server/libre-study-server.mjs
index 28038f6..bd5f030 100644
--- a/server/libre-study-server.mjs
+++ b/server/libre-study-server.mjs
@@ -1,4 +1,4 @@
-import { createHash } from 'node:crypto'
+import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
import { createReadStream, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
import { extname, join, normalize, resolve } from 'node:path'
import { execFileSync } from 'node:child_process'
@@ -12,6 +12,9 @@ const dbPath = resolve(process.env.DB_PATH ?? join(stateDir, 'libre-study-web.sq
const deployInfoPath = process.env.DEPLOY_INFO_PATH ? resolve(process.env.DEPLOY_INFO_PATH) : ''
const sqliteBin = process.env.SQLITE_BIN ?? 'sqlite3'
const maxBodyBytes = Number(process.env.MAX_BODY_BYTES ?? 4 * 1024 * 1024)
+const sessionCookieName = process.env.SESSION_COOKIE_NAME ?? 'libre_study_session'
+const bootstrapUser = process.env.LIBRESTUDY_BOOTSTRAP_USER ?? ''
+const bootstrapPassword = process.env.LIBRESTUDY_BOOTSTRAP_PASSWORD ?? ''
const mimeTypes = new Map([
['.css', 'text/css; charset=utf-8'],
@@ -29,12 +32,30 @@ const mimeTypes = new Map([
mkdirSync(stateDir, { recursive: true })
sqlite(`
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS login_sessions (
+ token_hash TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires_at TEXT NOT NULL
+);
+
CREATE TABLE IF NOT EXISTS workspace_state (
- id INTEGER PRIMARY KEY CHECK (id = 1),
+ user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
state_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`)
+ensureWorkspaceStateSchema()
+bootstrapAccount()
+migrateLegacyWorkspaceToBootstrap()
function sqlite(sql) {
const output = execFileSync(sqliteBin, ['-json', dbPath, sql], {
@@ -48,6 +69,83 @@ function quoteSql(value) {
return `'${String(value).replaceAll("'", "''")}'`
}
+function passwordHash(password) {
+ const salt = randomBytes(16).toString('hex')
+ const hash = scryptSync(password, salt, 64).toString('hex')
+ return `scrypt:${salt}:${hash}`
+}
+
+function verifyPassword(password, stored) {
+ const [scheme, salt, expected] = String(stored).split(':')
+ if (scheme !== 'scrypt' || !salt || !expected) return false
+ const actual = scryptSync(password, salt, 64)
+ const expectedBuffer = Buffer.from(expected, 'hex')
+ return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer)
+}
+
+function tokenHash(token) {
+ return createHash('sha256').update(token).digest('hex')
+}
+
+function parseCookies(header = '') {
+ const cookies = new Map()
+ for (const pair of header.split(';')) {
+ const [name, ...valueParts] = pair.trim().split('=')
+ if (!name || !valueParts.length) continue
+ cookies.set(name, decodeURIComponent(valueParts.join('=')))
+ }
+ return cookies
+}
+
+function sessionCookie(token, expiresAt) {
+ return `${sessionCookieName}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Expires=${new Date(expiresAt).toUTCString()}`
+}
+
+function expiredSessionCookie() {
+ return `${sessionCookieName}=; Path=/; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT`
+}
+
+function bootstrapAccount() {
+ if (!bootstrapUser || !bootstrapPassword) return
+ const existing = sqlite(`SELECT id FROM users WHERE username = ${quoteSql(bootstrapUser)} LIMIT 1;`)
+ if (existing.length) return
+ sqlite(
+ `INSERT INTO users (username, password_hash)
+ VALUES (${quoteSql(bootstrapUser)}, ${quoteSql(passwordHash(bootstrapPassword))});`,
+ )
+}
+
+function ensureWorkspaceStateSchema() {
+ const columns = sqlite('PRAGMA table_info(workspace_state);')
+ if (columns.some((column) => column.name === 'user_id')) return
+ sqlite('ALTER TABLE workspace_state RENAME TO workspace_state_legacy;')
+ sqlite(`
+CREATE TABLE workspace_state (
+ user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ state_json TEXT NOT NULL,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+`)
+}
+
+function migrateLegacyWorkspaceToBootstrap() {
+ if (!bootstrapUser) return
+ const legacyTable = sqlite(
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'workspace_state_legacy';`,
+ )
+ if (!legacyTable.length) return
+ const user = sqlite(`SELECT id FROM users WHERE username = ${quoteSql(bootstrapUser)} LIMIT 1;`)
+ if (!user.length) return
+ const existing = sqlite(`SELECT user_id FROM workspace_state WHERE user_id = ${Number(user[0].id)};`)
+ if (existing.length) return
+ const legacy = sqlite(`SELECT state_json FROM workspace_state_legacy ORDER BY updated_at DESC LIMIT 1;`)
+ if (!legacy.length) return
+ sqlite(
+ `INSERT INTO workspace_state (user_id, state_json, updated_at)
+ VALUES (${Number(user[0].id)}, json(${quoteSql(legacy[0].state_json)}), CURRENT_TIMESTAMP);`,
+ )
+}
+
function sendJson(response, status, value) {
const body = JSON.stringify(value)
response.writeHead(status, {
@@ -58,6 +156,17 @@ function sendJson(response, status, value) {
response.end(body)
}
+function sendJsonWithHeaders(response, status, value, headers = {}) {
+ const body = JSON.stringify(value)
+ response.writeHead(status, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Content-Length': Buffer.byteLength(body),
+ 'Cache-Control': 'no-store',
+ ...headers,
+ })
+ response.end(body)
+}
+
function sendText(response, status, value) {
response.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
@@ -141,8 +250,60 @@ async function handleApi(request, response, url) {
return
}
+ if (url.pathname === '/api/session' && request.method === 'GET') {
+ const user = currentUser(request)
+ sendJson(response, 200, { authenticated: Boolean(user), user })
+ return
+ }
+
+ if (url.pathname === '/api/login' && request.method === 'POST') {
+ const body = await readBody(request)
+ let parsed
+ try {
+ parsed = JSON.parse(body)
+ } catch {
+ sendJson(response, 400, { error: 'Login payload must be valid JSON.' })
+ return
+ }
+
+ const username = String(parsed.username ?? '').trim()
+ const password = String(parsed.password ?? '')
+ const rows = username
+ ? sqlite(`SELECT id, username, password_hash FROM users WHERE username = ${quoteSql(username)} LIMIT 1;`)
+ : []
+
+ if (!rows.length || !verifyPassword(password, rows[0].password_hash)) {
+ sendJson(response, 401, { error: 'Invalid username or password.' })
+ return
+ }
+
+ const token = randomBytes(32).toString('base64url')
+ const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString()
+ sqlite(
+ `INSERT INTO login_sessions (token_hash, user_id, expires_at)
+ VALUES (${quoteSql(tokenHash(token))}, ${Number(rows[0].id)}, ${quoteSql(expiresAt)});`,
+ )
+ sendJsonWithHeaders(
+ response,
+ 200,
+ { authenticated: true, user: { id: Number(rows[0].id), username: rows[0].username } },
+ { 'Set-Cookie': sessionCookie(token, expiresAt) },
+ )
+ return
+ }
+
+ if (url.pathname === '/api/logout' && request.method === 'POST') {
+ const cookies = parseCookies(request.headers.cookie)
+ const token = cookies.get(sessionCookieName)
+ if (token) sqlite(`DELETE FROM login_sessions WHERE token_hash = ${quoteSql(tokenHash(token))};`)
+ sendJsonWithHeaders(response, 200, { ok: true }, { 'Set-Cookie': expiredSessionCookie() })
+ return
+ }
+
if (url.pathname === '/api/workspace' && request.method === 'GET') {
- const rows = sqlite('SELECT state_json FROM workspace_state WHERE id = 1;')
+ const user = requireUser(request, response)
+ if (!user) return
+ const rows = sqlite(`SELECT state_json FROM workspace_state WHERE user_id = ${user.id};`)
if (!rows.length) {
response.writeHead(204, { 'Cache-Control': 'no-store' })
response.end()
@@ -158,6 +319,8 @@ async function handleApi(request, response, url) {
}
if (url.pathname === '/api/workspace' && request.method === 'PUT') {
+ const user = requireUser(request, response)
+ if (!user) return
const body = await readBody(request)
let parsed
try {
@@ -168,9 +331,9 @@ async function handleApi(request, response, url) {
}
sqlite(
- `INSERT INTO workspace_state (id, state_json, updated_at)
- VALUES (1, json(${quoteSql(JSON.stringify(parsed))}), CURRENT_TIMESTAMP)
- ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = CURRENT_TIMESTAMP;`,
+ `INSERT INTO workspace_state (user_id, state_json, updated_at)
+ VALUES (${user.id}, json(${quoteSql(JSON.stringify(parsed))}), CURRENT_TIMESTAMP)
+ ON CONFLICT(user_id) DO UPDATE SET state_json = excluded.state_json, updated_at = CURRENT_TIMESTAMP;`,
)
sendJson(response, 200, {
ok: true,
@@ -182,6 +345,28 @@ async function handleApi(request, response, url) {
sendJson(response, 404, { error: 'Unknown API route.' })
}
+function currentUser(request) {
+ const cookies = parseCookies(request.headers.cookie)
+ const token = cookies.get(sessionCookieName)
+ if (!token) return null
+ const rows = sqlite(
+ `SELECT users.id, users.username
+ FROM login_sessions
+ JOIN users ON users.id = login_sessions.user_id
+ WHERE login_sessions.token_hash = ${quoteSql(tokenHash(token))}
+ AND login_sessions.expires_at > CURRENT_TIMESTAMP
+ LIMIT 1;`,
+ )
+ if (!rows.length) return null
+ return { id: Number(rows[0].id), username: rows[0].username }
+}
+
+function requireUser(request, response) {
+ const user = currentUser(request)
+ if (!user) sendJson(response, 401, { error: 'Login is required.' })
+ return user
+}
+
const server = http.createServer((request, response) => {
const url = new URL(request.url ?? '/', 'http://localhost')
diff --git a/src/App.css b/src/App.css
index 5c0e4f9..584b900 100644
--- a/src/App.css
+++ b/src/App.css
@@ -7,6 +7,83 @@
color: #202124;
}
+.login-shell {
+ display: grid;
+ min-height: 100vh;
+ place-items: center;
+ padding: 28px;
+ background:
+ linear-gradient(135deg, rgba(47, 111, 94, 0.16), transparent 36%),
+ #ece9df;
+ color: #202124;
+ box-sizing: border-box;
+}
+
+.login-panel {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(260px, 340px);
+ gap: 28px;
+ width: min(760px, 100%);
+ padding: 28px;
+ border: 1px solid #d4cdbd;
+ border-radius: 8px;
+ background: #fffef8;
+ box-shadow: 0 20px 50px rgba(44, 48, 42, 0.14);
+ box-sizing: border-box;
+}
+
+.login-panel h1 {
+ margin: 12px 0 10px;
+ font-size: 42px;
+ line-height: 1.05;
+ letter-spacing: 0;
+}
+
+.login-panel p {
+ margin: 0;
+ color: #657061;
+ line-height: 1.5;
+}
+
+.login-panel form,
+.login-panel label {
+ display: grid;
+ gap: 10px;
+}
+
+.login-panel form {
+ align-content: start;
+ padding-top: 4px;
+}
+
+.login-panel label {
+ color: #657061;
+ font-size: 12px;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.login-panel input {
+ min-height: 40px;
+ font-size: 15px;
+}
+
+.login-panel button {
+ min-height: 40px;
+ background: #2f6f5e;
+ color: #fffef8;
+ font-size: 14px;
+}
+
+.login-error {
+ padding: 10px 12px;
+ border: 1px solid #d6a5a0;
+ border-radius: 6px;
+ background: #fff4f2;
+ color: #8b231a;
+ font-weight: 700;
+}
+
.app-shell.menu-open .reader {
filter: blur(1.5px) brightness(0.62) saturate(0.72);
}
@@ -260,6 +337,11 @@ button {
cursor: pointer;
}
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.62;
+}
+
button:focus-visible,
input:focus-visible,
select:focus-visible,
@@ -1531,6 +1613,29 @@ textarea:focus-visible {
color: #eee8d8;
}
+.theme-dark.login-shell {
+ background:
+ linear-gradient(135deg, rgba(47, 111, 94, 0.24), transparent 36%),
+ #161a18;
+}
+
+.theme-dark .login-panel {
+ border-color: #3f493f;
+ background: #222820;
+ color: #eee8d8;
+}
+
+.theme-dark .login-panel p,
+.theme-dark .login-panel label {
+ color: #c4cbbb;
+}
+
+.theme-dark .login-error {
+ border-color: #71443d;
+ background: #35221f;
+ color: #f2b0a8;
+}
+
.theme-dark .reader {
background: #161a18;
}
@@ -1965,6 +2070,20 @@ textarea:focus-visible {
}
@media (max-width: 760px) {
+ .login-shell {
+ padding: 18px;
+ }
+
+ .login-panel {
+ grid-template-columns: 1fr;
+ gap: 22px;
+ padding: 22px;
+ }
+
+ .login-panel h1 {
+ font-size: 34px;
+ }
+
.app-shell {
grid-template-columns: 1fr;
}
diff --git a/src/App.tsx b/src/App.tsx
index beb96e5..458b4ca 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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 Sign in to load your private sessions, notes, settings, and study timeline.Libre Study
+