Files
bookhoard/web/src/setup.ts
T
john-okeefe 980aaee0d9 refactor(setup): derive setup-complete status from admin user count
Setup completion was previously tracked by a manually-flipped setup_complete row in system_settings, written via a JWT-protected PUT /api/setup/complete endpoint. This meant any admin user created outside the setup wizard (future CLI, seed scripts, direct DB inserts) would not flip the switch, leaving the app stuck redirecting to /setup.

The trigger is now derived from real data: setup is complete iff at least one admin user exists. This is self-correcting regardless of how users are created, and re-engages setup automatically if all admins are ever removed.

Changes:
- Add internal/setupstatus package with IsSetupComplete() (queries CountAdmins, 10s in-memory cache, fails open on DB error) and Invalidate() to clear the cache. Uses an AdminCounter interface to avoid importing the database package.
- Add CountAdmins sqlc query (SELECT COUNT(*) FROM users WHERE role = 'admin') and regenerate.
- Rewire router/setup.go isSetupComplete() to delegate to setupstatus; drop the old setup_complete setting read, cache vars, and the PUT /api/setup/complete route.
- Call setupstatus.Invalidate() in the auth handler after CreateUser, UpdateUserRole, and DeleteUser so the cache reflects admin-count changes immediately.
- Align first-user promotion in Register to key off !adminExists instead of len(users) == 0, so the two checks cannot diverge.
- Remove the now-dead SetSetupComplete/GetSetupStatus handlers.
- Drop the setup_complete seed row from schema.sql.
- Remove the apiPut('/setup/complete') call from the setup wizard finishSetup(); the admin account created in submitAdmin already marks setup complete server-side.
2026-07-29 11:08:18 -04:00

475 lines
13 KiB
TypeScript

import { Alpine } from "./alpine";
import { applyTheme } from "./theme";
import { setToken, setRefreshToken, getToken, setSelectedLibrary } from "./storage";
import {
apiPost,
apiGet,
apiPut,
apiDelete,
handleResponse,
handleVoidResponse,
handleError,
} from "./api";
import { showToast } from "./toast";
import { initPasswordValidation } from "./password_validation";
interface LibraryEntry {
id: string;
name: string;
type: string;
description: string;
folders: string[];
}
interface ScanJob {
jobId: string;
libraryId: string;
libraryName: string;
progress: number;
statusText: string;
}
let scanPollInterval: ReturnType<typeof setInterval> | undefined;
function initSetup(): void {
const savedTheme = localStorage.getItem("theme");
if (savedTheme && savedTheme !== "tokyo-night") {
applyTheme(savedTheme);
}
}Alpine.data("setupWizard", () => ({
currentStep: 1 as number,
loading: false as boolean,
errorMsg: "" as string,
admin: {
baseUrl: "",
email: "",
username: "",
password: "",
confirmPassword: "",
firstName: "",
lastName: "",
},
libraries: [] as LibraryEntry[],
newLibrary: {
name: "",
type: "ebooks",
description: "",
},
scanStarted: false as boolean,
scanComplete: false as boolean,
scanJobs: [] as ScanJob[],
// Folder browser state
currentBrowsePath: "" as string,
currentBrowseInputId: "" as string,
initSetup,
init() {
initSetup();
this.admin.baseUrl = window.location.origin;
setTimeout(() => {
initPasswordValidation();
}, 100);
const modal = document.getElementById("folder-browser-modal");
if (modal) {
modal.addEventListener("click", (e: Event) => this.handleBrowseClick(e));
}
},
clearError() {
this.errorMsg = "";
},
async submitAdmin() {
this.clearError();
this.loading = true;
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: this.admin.email,
username: this.admin.username,
password: this.admin.password,
first_name: this.admin.firstName,
last_name: this.admin.lastName,
}),
});
if (!response.ok) {
let errMsg = `Registration failed (${response.status})`;
try {
const errData = await response.clone().json();
errMsg = errData.error || errMsg;
} catch {
// Can't parse body
}
if (
(errMsg.includes("already exists") || errMsg.includes("email already") || errMsg.includes("username already")) &&
getToken()
) {
this.currentStep = 2;
this.errorMsg = "";
return;
}
this.errorMsg = errMsg;
return;
}
const data = await response.json();
setToken(data.token || data.access_token);
setRefreshToken(data.refresh_token);
await this.saveBaseUrl();
showToast("Admin account created successfully!", "success");
this.currentStep = 2;
} catch (err) {
this.errorMsg = (err as Error).message || "Network error during registration";
} finally {
this.loading = false;
}
},
async saveBaseUrl() {
if (!this.admin.baseUrl) return;
try {
await handleVoidResponse(
await apiPut("/system/config", { base_url: this.admin.baseUrl })
);
} catch {
showToast("Warning: could not save server URL. You can set it later in Admin Settings.", "warning");
}
},
async addLibrary() {
this.clearError();
this.loading = true;
try {
const response = await apiPost("/libraries", {
name: this.newLibrary.name,
type: this.newLibrary.type,
description: this.newLibrary.description,
});
const result = (await handleResponse(response)) as { id: string; name: string };
this.libraries.push({
id: result.id,
name: result.name,
type: this.newLibrary.type,
description: this.newLibrary.description,
folders: [],
});
this.newLibrary = { name: "", type: "ebooks", description: "" };
showToast("Library created", "success");
} catch (err) {
handleError(err, "Failed to create library");
} finally {
this.loading = false;
}
},
async removeLibrary(idx: number) {
const lib = this.libraries[idx];
if (!lib) return;
if (!confirm(`Remove library "${lib.name}"?`)) return;
try {
const response = await apiDelete(`/libraries/${lib.id}`);
await handleVoidResponse(response);
this.libraries.splice(idx, 1);
showToast("Library removed", "success");
} catch (err) {
handleError(err, "Failed to remove library");
}
},
async addFolderToLibrary(lib: LibraryEntry) {
const inputId = `folderInput-${lib.id}`;
const input = document.getElementById(inputId) as HTMLInputElement;
if (!input) return;
const folderPath = input.value.trim();
if (!folderPath) return;
this.clearError();
this.loading = true;
try {
const response = await apiPost(`/libraries/${lib.id}/folders`, {
folder_path: folderPath,
});
await handleVoidResponse(response);
if (!lib.folders) lib.folders = [];
lib.folders.push(folderPath);
input.value = "";
showToast("Folder added", "success");
} catch (err) {
handleError(err, "Failed to add folder");
} finally {
this.loading = false;
}
},
async removeFolder(libraryId: string, folderPath: string, index: number) {
if (!confirm(`Remove folder "${folderPath}"?`)) return;
try {
const response = await apiDelete(`/libraries/${libraryId}/folders`, {
folder_path: folderPath,
});
await handleVoidResponse(response);
const lib = this.libraries.find((l) => l.id === libraryId);
if (lib && lib.folders) {
lib.folders.splice(index, 1);
}
showToast("Folder removed", "success");
} catch (err) {
handleError(err, "Failed to remove folder");
}
},
// Folder browser
showFolderBrowser(inputId: string) {
this.currentBrowseInputId = inputId;
this.currentBrowsePath = "/";
const modal = document.getElementById("folder-browser-modal");
if (modal) {
modal.classList.remove("hidden");
this.loadBrowseDirectories("/");
}
},
hideFolderBrowser() {
const modal = document.getElementById("folder-browser-modal");
if (modal) {
modal.classList.add("hidden");
}
},
async loadBrowseDirectories(path: string) {
try {
const response = await apiGet(`/libraries/browse?path=${encodeURIComponent(path)}`);
const data = (await handleResponse(response)) as {
current_path: string;
parent_path: string;
directories: string[];
};
this.currentBrowsePath = data.current_path;
this.renderBrowseDirectories(data);
} catch (err) {
handleError(err, "Failed to load directories");
}
},
escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
},
renderBrowseDirectories(data: {
current_path: string;
parent_path: string;
directories: string[];
}) {
const container = document.getElementById("folder-browser-content");
if (!container) return;
let html = `
<div class="flex items-center gap-2 mb-4">
${
data.parent_path
? `<button type="button" data-action="browse-parent" data-path="${this.escapeHtml(data.parent_path)}" class="btn-secondary px-3 py-1 rounded">↑ Parent</button>`
: ""
}
<span class="text-sm" style="color: var(--text-secondary)">${this.escapeHtml(data.current_path)}</span>
</div>
<div class="max-h-64 overflow-y-auto space-y-1">
`;
if (data.directories.length === 0) {
html += '<p style="color: var(--text-secondary)" class="text-center py-4">No subdirectories</p>';
} else {
data.directories.forEach((dir) => {
const fullPath = data.current_path === "/" ? `/${dir}` : `${data.current_path}/${dir}`;
html += `
<div class="p-2 rounded cursor-pointer hover:opacity-80"
style="background-color: var(--bg-primary); color: var(--text-primary)"
data-action="browse-navigate"
data-path="${this.escapeHtml(fullPath)}">
📁 ${this.escapeHtml(dir)}
</div>
`;
});
}
html += `
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" data-action="browse-cancel" class="btn-secondary px-4 py-2 rounded">Cancel</button>
<button type="button" data-action="browse-select" data-path="${this.escapeHtml(data.current_path)}" class="btn-primary px-4 py-2 rounded">Select This Folder</button>
</div>
`;
container.innerHTML = html;
},
handleBrowseClick(e: Event) {
const target = e.target as HTMLElement;
const button = target.closest("button") as HTMLElement;
const div = target.closest("div[data-action]") as HTMLElement;
if (button) {
const action = button.dataset.action;
const path = button.dataset.path;
if (action === "browse-parent" && path) {
this.loadBrowseDirectories(path);
} else if (action === "browse-cancel") {
this.hideFolderBrowser();
} else if (action === "browse-select" && path) {
this.selectBrowseFolder(path);
}
}
if (div && div.dataset.action === "browse-navigate") {
const path = div.dataset.path;
if (path) this.loadBrowseDirectories(path);
}
},
selectBrowseFolder(path: string) {
if (this.currentBrowseInputId) {
const input = document.getElementById(this.currentBrowseInputId) as HTMLInputElement;
if (input) {
input.value = path;
}
}
this.hideFolderBrowser();
},
async startScan() {
this.clearError();
this.loading = true;
this.scanStarted = true;
if (this.libraries.length === 0) {
this.scanComplete = true;
this.loading = false;
return;
}
try {
this.scanJobs = [];
for (const lib of this.libraries) {
const response = await apiPost(`/libraries/${lib.id}/scan`, { force: true });
if (response.ok) {
const result = await response.json();
this.scanJobs.push({
jobId: result.job_id,
libraryId: lib.id,
libraryName: lib.name,
progress: 0,
statusText: "Pending...",
});
} else {
this.scanJobs.push({
jobId: "",
libraryId: lib.id,
libraryName: lib.name,
progress: 0,
statusText: "Failed to start",
});
}
}
if (this.scanJobs.length === 0) {
this.errorMsg = "Failed to start any scans";
return;
}
showToast("Scan started", "success");
this.pollScanProgress();
} catch (err) {
this.errorMsg = (err as Error).message;
showToast("Failed to start scan", "error");
} finally {
this.loading = false;
}
},
pollScanProgress() {
if (scanPollInterval !== undefined) {
clearInterval(scanPollInterval);
}
scanPollInterval = setInterval(async () => {
let allComplete = true;
for (const job of this.scanJobs) {
if (!job.jobId) continue;
try {
const response = await apiGet(`/scanner/status/${job.jobId}`);
if (response.ok) {
const status = await response.json();
job.progress = Math.round((status.progress || 0) * 100);
const statusMessages: Record<string, string> = {
pending: "Pending...",
running: `Scanning... ${Math.round((status.progress || 0) * 100)}%`,
completed: `✓ Complete (${status.new_items || 0} items)`,
failed: "✗ Failed",
};
job.statusText = statusMessages[status.status] || status.status;
if (status.status !== "completed" && status.status !== "failed") {
allComplete = false;
}
} else {
allComplete = false;
}
} catch {
allComplete = false;
}
}
if (allComplete) {
clearInterval(scanPollInterval);
scanPollInterval = undefined;
this.scanComplete = true;
showToast("Scan complete!", "success");
}
}, 2000);
},
async finishSetup() {
this.loading = true;
try {
// Setup completion is derived from the existence of an admin user, so
// there is no separate "complete" endpoint to call. The admin account
// created in submitAdmin already marks setup as done server-side.
if (this.libraries.length > 0) {
setSelectedLibrary(this.libraries[0].id);
}
window.location.href = "/dashboard";
} finally {
this.loading = false;
}
},
}));