Add hosted login persistence
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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
|
## 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/`.
|
- 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/`.
|
||||||
|
|||||||
+5
-3
@@ -79,12 +79,14 @@ Definition of done:
|
|||||||
|
|
||||||
Goal: make the hosted app personally usable without relying on browser `localStorage`.
|
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:
|
Tasks:
|
||||||
|
|
||||||
- Add a minimal server API for sessions, notes, settings, timelines, and active workspace state.
|
- 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.
|
- 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.
|
- 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.
|
- Keep local browser preview and Tauri desktop able to run without accounts.
|
||||||
- Add basic backup expectations for the server-side user database.
|
- Add basic backup expectations for the server-side user database.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"version": "0.1.29",
|
"version": "0.1.30",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"version": "0.1.29",
|
"version": "0.1.30",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "libre-study",
|
"name": "libre-study",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.29",
|
"version": "0.1.30",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
+11
-1
@@ -2,7 +2,9 @@ param(
|
|||||||
[string]$HostName = "christunscripted",
|
[string]$HostName = "christunscripted",
|
||||||
[string]$RemoteRoot = "/home/jbird/libre-study",
|
[string]$RemoteRoot = "/home/jbird/libre-study",
|
||||||
[string]$RemoteBibleDataRoot = "/home/jbird/libre-bible-data/packages/json",
|
[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"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -12,6 +14,10 @@ function ConvertTo-WslPath([string]$Path) {
|
|||||||
return (wsl wslpath -a "$resolved").Trim()
|
return (wsl wslpath -a "$resolved").Trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConvertTo-BashSingleQuoted([string]$Value) {
|
||||||
|
return "'" + $Value.Replace("'", "'\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
$dataRepo = Resolve-Path (Join-Path $repoRoot "..\libre-bible-data")
|
$dataRepo = Resolve-Path (Join-Path $repoRoot "..\libre-bible-data")
|
||||||
$repoRootWsl = ConvertTo-WslPath $repoRoot
|
$repoRootWsl = ConvertTo-WslPath $repoRoot
|
||||||
@@ -41,6 +47,8 @@ $deployInfo = @{
|
|||||||
$deployInfoPath = Join-Path $env:TEMP "libre-study-deploy-info.json"
|
$deployInfoPath = Join-Path $env:TEMP "libre-study-deploy-info.json"
|
||||||
Set-Content -Path $deployInfoPath -Value $deployInfo -NoNewline
|
Set-Content -Path $deployInfoPath -Value $deployInfo -NoNewline
|
||||||
$deployInfoPathWsl = ConvertTo-WslPath $deployInfoPath
|
$deployInfoPathWsl = ConvertTo-WslPath $deployInfoPath
|
||||||
|
$bootstrapUserQuoted = ConvertTo-BashSingleQuoted $BootstrapUser
|
||||||
|
$bootstrapPasswordQuoted = ConvertTo-BashSingleQuoted $BootstrapPassword
|
||||||
|
|
||||||
wsl ssh $HostName "mkdir -p '$RemoteRoot/app' '$RemoteRoot/server' '$RemoteRoot/state' '$RemoteBibleDataRoot'"
|
wsl ssh $HostName "mkdir -p '$RemoteRoot/app' '$RemoteRoot/server' '$RemoteRoot/state' '$RemoteBibleDataRoot'"
|
||||||
if ($LASTEXITCODE -ne 0) { throw "Remote directory preparation failed." }
|
if ($LASTEXITCODE -ne 0) { throw "Remote directory preparation failed." }
|
||||||
@@ -55,6 +63,8 @@ if ($LASTEXITCODE -ne 0) { throw "LibreBible rsync failed." }
|
|||||||
|
|
||||||
$pm2Command = @"
|
$pm2Command = @"
|
||||||
cd '$RemoteRoot' &&
|
cd '$RemoteRoot' &&
|
||||||
|
export LIBRESTUDY_BOOTSTRAP_USER=$bootstrapUserQuoted &&
|
||||||
|
export LIBRESTUDY_BOOTSTRAP_PASSWORD=$bootstrapPasswordQuoted &&
|
||||||
if pm2 describe libre-study-web >/dev/null 2>&1; then
|
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;
|
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
|
else
|
||||||
|
|||||||
@@ -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 { createReadStream, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
|
||||||
import { extname, join, normalize, resolve } from 'node:path'
|
import { extname, join, normalize, resolve } from 'node:path'
|
||||||
import { execFileSync } from 'node:child_process'
|
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 deployInfoPath = process.env.DEPLOY_INFO_PATH ? resolve(process.env.DEPLOY_INFO_PATH) : ''
|
||||||
const sqliteBin = process.env.SQLITE_BIN ?? 'sqlite3'
|
const sqliteBin = process.env.SQLITE_BIN ?? 'sqlite3'
|
||||||
const maxBodyBytes = Number(process.env.MAX_BODY_BYTES ?? 4 * 1024 * 1024)
|
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([
|
const mimeTypes = new Map([
|
||||||
['.css', 'text/css; charset=utf-8'],
|
['.css', 'text/css; charset=utf-8'],
|
||||||
@@ -29,12 +32,30 @@ const mimeTypes = new Map([
|
|||||||
|
|
||||||
mkdirSync(stateDir, { recursive: true })
|
mkdirSync(stateDir, { recursive: true })
|
||||||
sqlite(`
|
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 (
|
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,
|
state_json TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
ensureWorkspaceStateSchema()
|
||||||
|
bootstrapAccount()
|
||||||
|
migrateLegacyWorkspaceToBootstrap()
|
||||||
|
|
||||||
function sqlite(sql) {
|
function sqlite(sql) {
|
||||||
const output = execFileSync(sqliteBin, ['-json', dbPath, sql], {
|
const output = execFileSync(sqliteBin, ['-json', dbPath, sql], {
|
||||||
@@ -48,6 +69,83 @@ function quoteSql(value) {
|
|||||||
return `'${String(value).replaceAll("'", "''")}'`
|
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) {
|
function sendJson(response, status, value) {
|
||||||
const body = JSON.stringify(value)
|
const body = JSON.stringify(value)
|
||||||
response.writeHead(status, {
|
response.writeHead(status, {
|
||||||
@@ -58,6 +156,17 @@ function sendJson(response, status, value) {
|
|||||||
response.end(body)
|
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) {
|
function sendText(response, status, value) {
|
||||||
response.writeHead(status, {
|
response.writeHead(status, {
|
||||||
'Content-Type': 'text/plain; charset=utf-8',
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
@@ -141,8 +250,60 @@ async function handleApi(request, response, url) {
|
|||||||
return
|
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') {
|
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) {
|
if (!rows.length) {
|
||||||
response.writeHead(204, { 'Cache-Control': 'no-store' })
|
response.writeHead(204, { 'Cache-Control': 'no-store' })
|
||||||
response.end()
|
response.end()
|
||||||
@@ -158,6 +319,8 @@ async function handleApi(request, response, url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/workspace' && request.method === 'PUT') {
|
if (url.pathname === '/api/workspace' && request.method === 'PUT') {
|
||||||
|
const user = requireUser(request, response)
|
||||||
|
if (!user) return
|
||||||
const body = await readBody(request)
|
const body = await readBody(request)
|
||||||
let parsed
|
let parsed
|
||||||
try {
|
try {
|
||||||
@@ -168,9 +331,9 @@ async function handleApi(request, response, url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sqlite(
|
sqlite(
|
||||||
`INSERT INTO workspace_state (id, state_json, updated_at)
|
`INSERT INTO workspace_state (user_id, state_json, updated_at)
|
||||||
VALUES (1, json(${quoteSql(JSON.stringify(parsed))}), CURRENT_TIMESTAMP)
|
VALUES (${user.id}, json(${quoteSql(JSON.stringify(parsed))}), CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = CURRENT_TIMESTAMP;`,
|
ON CONFLICT(user_id) DO UPDATE SET state_json = excluded.state_json, updated_at = CURRENT_TIMESTAMP;`,
|
||||||
)
|
)
|
||||||
sendJson(response, 200, {
|
sendJson(response, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -182,6 +345,28 @@ async function handleApi(request, response, url) {
|
|||||||
sendJson(response, 404, { error: 'Unknown API route.' })
|
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 server = http.createServer((request, response) => {
|
||||||
const url = new URL(request.url ?? '/', 'http://localhost')
|
const url = new URL(request.url ?? '/', 'http://localhost')
|
||||||
|
|
||||||
|
|||||||
+119
@@ -7,6 +7,83 @@
|
|||||||
color: #202124;
|
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 {
|
.app-shell.menu-open .reader {
|
||||||
filter: blur(1.5px) brightness(0.62) saturate(0.72);
|
filter: blur(1.5px) brightness(0.62) saturate(0.72);
|
||||||
}
|
}
|
||||||
@@ -260,6 +337,11 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
select:focus-visible,
|
select:focus-visible,
|
||||||
@@ -1531,6 +1613,29 @@ textarea:focus-visible {
|
|||||||
color: #eee8d8;
|
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 {
|
.theme-dark .reader {
|
||||||
background: #161a18;
|
background: #161a18;
|
||||||
}
|
}
|
||||||
@@ -1965,6 +2070,20 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@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 {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-12
@@ -15,12 +15,15 @@ import {
|
|||||||
createSession,
|
createSession,
|
||||||
defaultTranslationId,
|
defaultTranslationId,
|
||||||
hostedWorkspaceEnabled,
|
hostedWorkspaceEnabled,
|
||||||
|
loadHostedSession,
|
||||||
loadWorkspaceSnapshot,
|
loadWorkspaceSnapshot,
|
||||||
|
loginHostedWorkspace,
|
||||||
|
logoutHostedWorkspace,
|
||||||
saveWorkspaceSnapshot,
|
saveWorkspaceSnapshot,
|
||||||
timelineId,
|
timelineId,
|
||||||
timelineLimit,
|
timelineLimit,
|
||||||
} from './workspace'
|
} from './workspace'
|
||||||
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
import type { DragEvent, FormEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
||||||
import type {
|
import type {
|
||||||
AudioBibleContext,
|
AudioBibleContext,
|
||||||
AudioBibleSegment,
|
AudioBibleSegment,
|
||||||
@@ -32,6 +35,7 @@ import type {
|
|||||||
} from './types'
|
} from './types'
|
||||||
import type {
|
import type {
|
||||||
StrongDisplayMode,
|
StrongDisplayMode,
|
||||||
|
HostedUser,
|
||||||
StudySession,
|
StudySession,
|
||||||
StudySettings,
|
StudySettings,
|
||||||
ThemeMode,
|
ThemeMode,
|
||||||
@@ -245,6 +249,12 @@ function App() {
|
|||||||
const [audioSpeed, setAudioSpeed] = useState(1)
|
const [audioSpeed, setAudioSpeed] = useState(1)
|
||||||
const [marginResourcesLoading, setMarginResourcesLoading] = useState(false)
|
const [marginResourcesLoading, setMarginResourcesLoading] = useState(false)
|
||||||
const [mapResourcesLoading, setMapResourcesLoading] = 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 [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const restoredSessionId = useRef('')
|
const restoredSessionId = useRef('')
|
||||||
@@ -445,33 +455,45 @@ function App() {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
async function restoreWorkspace(cancelled = () => false) {
|
||||||
let 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreWorkspace() {
|
|
||||||
const workspace = await loadWorkspaceSnapshot()
|
const workspace = await loadWorkspaceSnapshot()
|
||||||
const selectedSession =
|
const selectedSession =
|
||||||
workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0]
|
workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0]
|
||||||
|
|
||||||
if (cancelled) return
|
if (cancelled()) return
|
||||||
applyStudySettings(workspace.studySettings)
|
applyStudySettings(workspace.studySettings)
|
||||||
setSessions(workspace.sessions)
|
setSessions(workspace.sessions)
|
||||||
setActiveSessionId(workspace.activeSessionId)
|
setActiveSessionId(workspace.activeSessionId)
|
||||||
getTranslations().then(setTranslations).catch((reason) => setError(String(reason)))
|
getTranslations().then(setTranslations).catch((reason) => setError(String(reason)))
|
||||||
|
|
||||||
loadLibrary(selectedSession.baseTranslation)
|
await loadLibrary(selectedSession.baseTranslation)
|
||||||
.catch((reason) => setError(String(reason)))
|
if (cancelled()) return
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
workspaceHydrated.current = true
|
workspaceHydrated.current = true
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreWorkspace().catch((reason) => {
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
restoreWorkspace(() => cancelled).catch((reason) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(String(reason))
|
setError(String(reason))
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -1042,6 +1064,33 @@ function App() {
|
|||||||
URL.revokeObjectURL(url)
|
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) {
|
function restoreTimelineEntry(entry: TimelineEntry) {
|
||||||
if (!entry.reference) return
|
if (!entry.reference) return
|
||||||
const [bookPart, rest] = entry.reference.split(/ (?=\d+:\d+$)/)
|
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() {
|
function renderTimelineModal() {
|
||||||
if (!timelineModalOpen) return null
|
if (!timelineModalOpen) return null
|
||||||
|
|
||||||
@@ -2460,6 +2547,10 @@ function App() {
|
|||||||
|
|
||||||
const splitTemplate = `${splitPercent}fr 6px ${100 - splitPercent}fr`
|
const splitTemplate = `${splitPercent}fr 6px ${100 - splitPercent}fr`
|
||||||
|
|
||||||
|
if (authRequired && hostedWorkspaceEnabled()) {
|
||||||
|
return renderHostedLogin()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
className={[
|
className={[
|
||||||
@@ -2494,6 +2585,16 @@ function App() {
|
|||||||
<p className="rail-description">
|
<p className="rail-description">
|
||||||
A readable Bible page, study margin, notes workspace, and linked public Bible data.
|
A readable Bible page, study margin, notes workspace, and linked public Bible data.
|
||||||
</p>
|
</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)}>
|
<button type="button" title="Session management" onClick={() => setSessionModalOpen(true)}>
|
||||||
<span className="rail-icon">S</span>
|
<span className="rail-icon">S</span>
|
||||||
<span className="rail-copy"><strong>Sessions</strong><small>Save, export, and configure</small></span>
|
<span className="rail-copy"><strong>Sessions</strong><small>Save, export, and configure</small></span>
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ export type WorkspaceSnapshot = {
|
|||||||
studySettings: StudySettings
|
studySettings: StudySettings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HostedUser = {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HostedSession = {
|
||||||
|
authenticated: boolean
|
||||||
|
user: HostedUser | null
|
||||||
|
}
|
||||||
|
|
||||||
export function hostedWorkspaceEnabled() {
|
export function hostedWorkspaceEnabled() {
|
||||||
return remoteWorkspaceEnabled
|
return remoteWorkspaceEnabled
|
||||||
}
|
}
|
||||||
@@ -223,3 +233,40 @@ async function saveRemoteWorkspaceSnapshot(snapshot: WorkspaceSnapshot) {
|
|||||||
throw new Error(`Unable to save hosted workspace: ${response.status} ${response.statusText}`)
|
throw new Error(`Unable to save hosted workspace: ${response.status} ${response.statusText}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadHostedSession(): Promise<HostedSession> {
|
||||||
|
if (!remoteWorkspaceEnabled) return { authenticated: false, user: null }
|
||||||
|
const response = await fetch(`${remoteWorkspaceApiBase}/session`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Unable to check hosted login: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json() as Promise<HostedSession>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginHostedWorkspace(username: string, password: string): Promise<HostedSession> {
|
||||||
|
const response = await fetch(`${remoteWorkspaceApiBase}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.status === 401 ? 'Invalid username or password.' : `Unable to log in: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json() as Promise<HostedSession>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutHostedWorkspace(): Promise<void> {
|
||||||
|
if (!remoteWorkspaceEnabled) return
|
||||||
|
const response = await fetch(`${remoteWorkspaceApiBase}/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Unable to log out: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user