Build and Push Docker Image / build-and-push (push) Successful in 10m22s
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph - Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs - Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts - Settings split into Account, Preferences, Notifications, Security, Your data tabs - ntfy server field: catch a topic typed into the server box Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
321 lines
13 KiB
JavaScript
321 lines
13 KiB
JavaScript
/**
|
|
* syncService.js
|
|
*
|
|
* Thin wrapper around the /api/projects endpoints.
|
|
* All functions are fire-and-forget friendly: they never throw — they
|
|
* return { ok: true, data } or { ok: false, error }.
|
|
*
|
|
* The caller decides whether to surface the error to the user.
|
|
*/
|
|
|
|
const BASE = '/api';
|
|
|
|
async function request(method, path, body, { keepalive = false, headers = {} } = {}) {
|
|
try {
|
|
const opts = {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json', ...headers },
|
|
credentials: 'include',
|
|
};
|
|
if (keepalive) opts.keepalive = true; // lets the request finish even as the tab closes
|
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
const res = await fetch(`${BASE}${path}`, opts);
|
|
const data = await res.json().catch(() => null);
|
|
if (!res.ok) {
|
|
return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}`, data };
|
|
}
|
|
return { ok: true, data };
|
|
} catch (err) {
|
|
return { ok: false, status: null, error: err?.message ?? 'Network error' };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* List all remote project summaries.
|
|
* Returns { ok, data: Array<{ id, title, lastEdited, chapterSummary }> }
|
|
*/
|
|
export async function listRemoteProjects() {
|
|
return request('GET', '/projects');
|
|
}
|
|
|
|
/**
|
|
* Fetch a single full project by id.
|
|
* Returns { ok, data: project }
|
|
*/
|
|
export async function loadRemoteProject(id) {
|
|
return request('GET', `/projects/${id}`);
|
|
}
|
|
|
|
/**
|
|
* Save (create or update) a project on the server.
|
|
* `baseEdited` is the server copy's lastEdited this device last saw: if the server has moved on, the
|
|
* save is refused with { ok: false, status: 409, data: { serverEdited } }. `force` overwrites anyway.
|
|
* Returns { ok, data: summary }
|
|
*/
|
|
export async function saveRemoteProject(project, { baseEdited, force = false } = {}) {
|
|
return request('PUT', `/projects/${project.id}${force ? '?force=1' : ''}`, project, {
|
|
headers: Number.isFinite(baseEdited) ? { 'X-Base-Edited': String(baseEdited) } : {},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete a project from the server.
|
|
* Returns { ok }
|
|
*/
|
|
export async function deleteRemoteProject(id) {
|
|
return request('DELETE', `/projects/${id}`, undefined, { keepalive: true });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reading plan progress
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Returns the signed-in user's whole reading-plan progress list. */
|
|
export async function getRemoteReadingProgress() {
|
|
return request('GET', '/reading-plans');
|
|
}
|
|
|
|
/** Overwrites the signed-in user's whole reading-plan progress list. */
|
|
export async function saveRemoteReadingProgress(progress) {
|
|
return request('PUT', '/reading-plans', progress);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Read-only share links
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Returns { shareToken } — the project's current share token, or null if sharing is off. */
|
|
export async function getShareStatus(id) {
|
|
return request('GET', `/projects/${id}/share`);
|
|
}
|
|
|
|
/** Enables sharing (or returns the existing token if already enabled). Returns { shareToken }. */
|
|
export async function enableSharing(id, options = {}) {
|
|
return request('POST', `/projects/${id}/share`, options);
|
|
}
|
|
|
|
/** Revokes a project's share link. */
|
|
export async function disableSharing(id) {
|
|
return request('DELETE', `/projects/${id}/share`);
|
|
}
|
|
|
|
// ---- earlier versions of a study ----
|
|
export async function listStudyVersions(id) { return request('GET', `/projects/${id}/versions`); }
|
|
export async function getStudyVersion(id, versionId) { return request('GET', `/projects/${id}/versions/${versionId}`); }
|
|
/** Puts an earlier version back; returns the study as it now is. */
|
|
export async function restoreStudyVersion(id, versionId) { return request('POST', `/projects/${id}/versions/${versionId}/restore`); }
|
|
|
|
// ---- co-teachers: other accounts that may edit a study ----
|
|
export async function getCollaborators(id) { return request('GET', `/projects/${id}/collaborators`); }
|
|
/** A single-use link (valid 7 days) that lets whoever opens it, signed in, edit this study. -> { token, expiresAt } */
|
|
export async function createEditInvite(id) { return request('POST', `/projects/${id}/collaborators/invite`); }
|
|
export async function removeCollaborator(id, userId) { return request('DELETE', `/projects/${id}/collaborators/${userId}`); }
|
|
/** Uses an invite link: -> { projectId, title } */
|
|
export async function joinStudy(token) { return request('POST', `/join/${encodeURIComponent(token)}`); }
|
|
|
|
/** The project's read-only outline link: { shareToken } (null when off). A separate link from the study share. */
|
|
export async function getOutlineShareStatus(id) {
|
|
return request('GET', `/projects/${id}/outline-share`);
|
|
}
|
|
export async function enableOutlineSharing(id, options = {}) {
|
|
return request('POST', `/projects/${id}/outline-share`, options);
|
|
}
|
|
export async function disableOutlineSharing(id) {
|
|
return request('DELETE', `/projects/${id}/outline-share`);
|
|
}
|
|
/** Public lookup — no session. Returns { title, translation, outlines: [{ scope, label, tree }] }. */
|
|
export async function getSharedOutline(token, passcode) {
|
|
return request('GET', `/share-outline/${token}`, undefined, { headers: passcode ? { 'X-Share-Passcode': passcode } : {} });
|
|
}
|
|
|
|
/** Public lookup — no session required. Returns the full project for a valid share token. */
|
|
export async function getSharedProject(token, passcode) {
|
|
return request('GET', `/share/${token}`, undefined, { headers: passcode ? { 'X-Share-Passcode': passcode } : {} });
|
|
}
|
|
|
|
/**
|
|
* Check whether the server is reachable.
|
|
* Returns true / false.
|
|
*/
|
|
export async function isServerReachable() {
|
|
try {
|
|
const res = await fetch(`${BASE}/health`, { method: 'GET' });
|
|
return res.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auth
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns the signed-in user, or null if not signed in.
|
|
* Returns { ok: false } (with status: null) only on a genuine network failure,
|
|
* so callers can distinguish "not logged in" from "server unreachable".
|
|
*/
|
|
export async function getCurrentUser() {
|
|
const result = await request('GET', '/auth/me');
|
|
if (result.ok) return { ok: true, user: result.data };
|
|
if (result.status === 401) return { ok: true, user: null };
|
|
return result;
|
|
}
|
|
|
|
export async function registerUser(email, password, invite) {
|
|
return request('POST', '/auth/register', { email, password, invite });
|
|
}
|
|
|
|
/** Who may sign up here: { mode: 'open' | 'invite' | 'closed' }. */
|
|
export async function getSignupMode() {
|
|
return request('GET', '/auth/signup-mode');
|
|
}
|
|
|
|
export async function loginUser(email, password) {
|
|
return request('POST', '/auth/login', { email, password });
|
|
}
|
|
|
|
// Passkeys and signed-in devices
|
|
export const passkeySignInOptions = () => request('POST', '/auth/passkey/options');
|
|
export const passkeySignInVerify = (response) => request('POST', '/auth/passkey/verify', response);
|
|
export const listPasskeys = () => request('GET', '/passkeys');
|
|
export const passkeyAddOptions = () => request('POST', '/passkeys/options');
|
|
export const passkeyAdd = (response, name) => request('POST', '/passkeys', { response, name });
|
|
export const passkeyRename = (id, name) => request('PATCH', `/passkeys/${encodeURIComponent(id)}`, { name });
|
|
export const passkeyRemove = (id) => request('DELETE', `/passkeys/${encodeURIComponent(id)}`);
|
|
export const listDevices = () => request('GET', '/sessions');
|
|
export const signOutDevice = (id) => request('DELETE', `/sessions/${id}`);
|
|
export const signOutOtherDevices = () => request('POST', '/sessions/sign-out-others');
|
|
export const setLoginAlerts = (enabled) => request('PUT', '/auth/login-alerts', { enabled });
|
|
|
|
/** Asks for a password-reset code to be sent to the account's notification topic. */
|
|
export async function requestPasswordReset(email) {
|
|
return request('POST', '/auth/forgot', { email });
|
|
}
|
|
|
|
/** Sets a new password using the code that was sent. `token` is the authenticator code, when 2FA is on. */
|
|
export async function resetPassword({ email, code, newPassword, token }) {
|
|
return request('POST', '/auth/reset', { email, code, newPassword, token });
|
|
}
|
|
|
|
export async function logoutUser() {
|
|
return request('POST', '/auth/logout');
|
|
}
|
|
|
|
/** Submits the code from the auth gate's post-password MFA step. Pass token or backupCode. */
|
|
export async function verifyMfaLogin({ token, backupCode }) {
|
|
return request('POST', '/auth/mfa/verify', { token, backupCode });
|
|
}
|
|
|
|
/** Updates account-level profile settings (currently just the podcast/show name). */
|
|
export async function updateProfile({ podcastName }) {
|
|
return request('PATCH', '/auth/profile', { podcastName });
|
|
}
|
|
|
|
/** Self-service password change. Requires the current password; signs out every other session. */
|
|
export async function changePassword(currentPassword, newPassword) {
|
|
return request('POST', '/auth/change-password', { currentPassword, newPassword });
|
|
}
|
|
|
|
/** The signed-in user's synced display preferences: { prefs: { key: { v, at } } }. */
|
|
export async function getRemotePrefs() {
|
|
return request('GET', '/prefs');
|
|
}
|
|
|
|
/** Merges changed preferences into the account ({ key: { v, at } }); newest change per key wins. */
|
|
export async function saveRemotePrefs(prefs) {
|
|
return request('PUT', '/prefs', { prefs });
|
|
}
|
|
|
|
/** The signed-in user's bookmarks + highlights: { bookmarks, highlights, updatedAt }. */
|
|
export async function getRemoteMarks() {
|
|
return request('GET', '/reader/marks');
|
|
}
|
|
|
|
/** Replaces the user's bookmarks + highlights ({ bookmarks, highlights }); returns { updatedAt }. */
|
|
export async function saveRemoteMarks(marks) {
|
|
return request('PUT', '/reader/marks', marks);
|
|
}
|
|
|
|
/** The prayer journal: { prayers: [...] }. */
|
|
export async function getRemotePrayers() { return request('GET', '/prayers'); }
|
|
/** Merges this device's prayers into the account's (the newer version of each wins); returns the combined list. */
|
|
export async function saveRemotePrayers(prayers) { return request('PUT', '/prayers', { prayers }); }
|
|
|
|
/** What the person has read: { chapters, days }. */
|
|
export async function getRemoteHistory() { return request('GET', '/reader/history'); }
|
|
/** Merges this device's reading history into the account's; returns the combined history. */
|
|
export async function saveRemoteHistory(history) { return request('PUT', '/reader/history', history); }
|
|
|
|
/** Sends a test notification to the given ntfy topic/server (or the saved ones). */
|
|
export async function sendReminderTest(settings) {
|
|
return request('POST', '/auth/reminder-test', settings);
|
|
}
|
|
|
|
/** Sets the daily reading reminder sent via ntfy.sh ({ reminderEnabled, ntfyUrl, ntfyTopic, reminderTime }). */
|
|
export async function updateReminderSettings(settings) {
|
|
return request('PATCH', '/auth/reminder-settings', settings);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Two-factor auth setup (Account Settings page)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Starts 2FA setup: returns { secret, qrCodeDataUrl } for the user to scan. */
|
|
export async function startMfaSetup() {
|
|
return request('POST', '/auth/mfa/setup');
|
|
}
|
|
|
|
/** Confirms the scanned code and turns 2FA on. Returns { backupCodes } (shown once). */
|
|
export async function confirmMfaSetup(token) {
|
|
return request('POST', '/auth/mfa/enable', { token });
|
|
}
|
|
|
|
/** Turns 2FA off. Requires the current password as a safety check. */
|
|
export async function disableMfa(password) {
|
|
return request('POST', '/auth/mfa/disable', { password });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin (restricted server-side to the designated admin account)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function adminListUsers() {
|
|
return request('GET', '/admin/users');
|
|
}
|
|
|
|
export const adminGetHealth = () => request('GET', '/admin/health');
|
|
export const adminBackupNow = () => request('POST', '/admin/backups');
|
|
export const adminRestoreSnapshot = (name) => request('POST', `/admin/backups/${encodeURIComponent(name)}/restore`, { confirm: name });
|
|
export const adminSnapshotUrl = (name) => `${BASE}/admin/backups/${encodeURIComponent(name)}`;
|
|
|
|
export async function adminDeleteUser(id) {
|
|
return request('DELETE', `/admin/users/${id}`);
|
|
}
|
|
|
|
/** Sets a random temporary password for a user and signs them out everywhere. Returns { temporaryPassword }. */
|
|
export async function adminResetPassword(id) {
|
|
return request('POST', `/admin/users/${id}/reset-password`);
|
|
}
|
|
|
|
/** The administrator's sign-up setting and invite codes. */
|
|
export async function adminGetSignup() { return request('GET', '/admin/signup'); }
|
|
export async function adminSetSignupMode(mode) { return request('PUT', '/admin/signup', { mode }); }
|
|
export async function adminCreateInvite({ note, days } = {}) { return request('POST', '/admin/invites', { note, days }); }
|
|
export async function adminDeleteInvite(code) { return request('DELETE', `/admin/invites/${encodeURIComponent(code)}`); }
|
|
|
|
export async function adminListProjects() {
|
|
return request('GET', '/admin/projects');
|
|
}
|
|
|
|
export async function adminGetProject(id) {
|
|
return request('GET', `/admin/projects/${id}`);
|
|
}
|
|
|
|
export async function adminDeleteProject(id) {
|
|
return request('DELETE', `/admin/projects/${id}`);
|
|
} |