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.
475 lines
13 KiB
TypeScript
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;
|
|
}
|
|
},
|
|
}));
|