diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js
index 13f4d0b..40dc8e6 100644
--- a/Overlord-Server/public/assets/nav.js
+++ b/Overlord-Server/public/assets/nav.js
@@ -18,6 +18,7 @@ import {
setupTurboNavigation,
turboVisit,
} from "./turbo-navigation.js";
+import { showOnboardingIfNeeded } from "./onboarding.js";
const host = document.getElementById("top-nav");
if (host) {
@@ -165,6 +166,7 @@ if (host) {
const user = await res.json();
applyUserRoleUI(user, refs);
applyThumbnailWallVisibility(user);
+ showOnboardingIfNeeded(user);
if (user.role === "admin" || user.role === "operator") {
try {
diff --git a/Overlord-Server/public/assets/onboarding.js b/Overlord-Server/public/assets/onboarding.js
new file mode 100644
index 0000000..be5d66a
--- /dev/null
+++ b/Overlord-Server/public/assets/onboarding.js
@@ -0,0 +1,133 @@
+const TELEGRAM_URL = "https://t.me/Onimai";
+const GITHUB_ISSUES_URL = "https://github.com/vxaboveground/Overlord/issues/new";
+
+let mounted = false;
+
+export function showOnboardingIfNeeded(user) {
+ if (!user?.needsOnboarding || mounted) return;
+ mounted = true;
+
+ const style = document.createElement("style");
+ style.textContent = `
+ .account-onboarding { position: fixed; inset: 0; z-index: 10000; display: grid; place-items: center; padding: 1.25rem; background: rgba(2, 6, 23, .88); backdrop-filter: blur(10px); }
+ .account-onboarding__panel { width: min(100%, 42rem); overflow: hidden; border: 1px solid rgba(71, 85, 105, .8); border-radius: 1rem; background: #0f172a; color: #e2e8f0; box-shadow: 0 24px 80px rgba(0, 0, 0, .55); }
+ .account-onboarding__hero { padding: 2rem 2rem 1.25rem; text-align: center; background: radial-gradient(circle at top, rgba(14, 165, 233, .2), transparent 65%); }
+ .account-onboarding__icon { display: grid; place-items: center; width: 3.5rem; height: 3.5rem; margin: 0 auto 1rem; border: 1px solid rgba(56, 189, 248, .4); border-radius: .9rem; background: rgba(14, 165, 233, .12); color: #38bdf8; font-size: 1.5rem; }
+ .account-onboarding h1 { margin: 0; color: #f8fafc; font-size: 1.65rem; line-height: 1.2; font-weight: 700; }
+ .account-onboarding__intro { margin: .65rem auto 0; max-width: 33rem; color: #94a3b8; line-height: 1.55; }
+ .account-onboarding__progress { display: flex; align-items: center; justify-content: center; gap: .5rem; margin-top: 1.15rem; color: #64748b; font-size: .8rem; font-weight: 600; }
+ .account-onboarding__dot { width: .55rem; height: .55rem; border-radius: 999px; background: #334155; }
+ .account-onboarding__dot--active { background: #38bdf8; box-shadow: 0 0 0 4px rgba(56, 189, 248, .12); }
+ .account-onboarding__step { padding: .25rem 2rem 1.25rem; }
+ .account-onboarding__card { padding: 1.35rem; border: 1px solid #334155; border-radius: .85rem; background: rgba(30, 41, 59, .65); text-align: center; }
+ .account-onboarding__card h2 { margin: 0 0 .55rem; color: #f8fafc; font-size: 1.2rem; }
+ .account-onboarding__card p { margin: 0; color: #cbd5e1; line-height: 1.55; }
+ .account-onboarding__url-label { margin-top: 1.15rem; color: #94a3b8; font-size: .75rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
+ .account-onboarding__url { display: block; margin-top: .4rem; padding: .7rem .8rem; overflow-wrap: anywhere; border: 1px solid #475569; border-radius: .55rem; background: #020617; color: #7dd3fc; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .88rem; text-decoration: underline; }
+ .account-onboarding__open { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; margin-top: 1rem; padding: .65rem 1rem; border: 1px solid #0369a1; border-radius: .6rem; background: rgba(3, 105, 161, .18); color: #e0f2fe; font-weight: 600; text-decoration: none; transition: background .15s, border-color .15s; }
+ .account-onboarding__open:hover { border-color: #38bdf8; background: rgba(14, 165, 233, .28); }
+ .account-onboarding__footer { padding: .5rem 2rem 1.5rem; text-align: center; }
+ .account-onboarding__continue { width: 100%; padding: .75rem 1rem; border: 0; border-radius: .65rem; background: #0284c7; color: white; font: inherit; font-weight: 600; cursor: pointer; transition: background .15s; }
+ .account-onboarding__continue:hover { background: #0ea5e9; }
+ .account-onboarding__continue:disabled { cursor: wait; opacity: .65; }
+ .account-onboarding__error { min-height: 1.25rem; margin: .55rem 0 0; color: #fca5a5; font-size: .8rem; }
+ @media (max-width: 600px) { .account-onboarding__hero { padding: 1.5rem 1.25rem 1.1rem; } .account-onboarding__step { padding: .2rem 1.25rem 1rem; } .account-onboarding__footer { padding: .5rem 1.25rem 1.25rem; } }
+ `;
+
+ const overlay = document.createElement("div");
+ overlay.className = "account-onboarding";
+ overlay.setAttribute("role", "dialog");
+ overlay.setAttribute("aria-modal", "true");
+ overlay.setAttribute("aria-labelledby", "account-onboarding-title");
+ overlay.innerHTML = `
+
+
+
+
Welcome to Overlord
+
Please review both support resources so you always know exactly where to go.
+
+
+ Step 1 of 2
+
+
+
+
+
+
+ `;
+
+ document.head.appendChild(style);
+ document.body.appendChild(overlay);
+ document.body.style.overflow = "hidden";
+
+ const button = overlay.querySelector(".account-onboarding__continue");
+ const error = overlay.querySelector(".account-onboarding__error");
+ const stepContent = overlay.querySelector(".account-onboarding__step");
+ const progressText = overlay.querySelector(".account-onboarding__progress-text");
+ const progressDots = overlay.querySelectorAll(".account-onboarding__dot");
+ let currentStep = 1;
+
+ const renderStep = () => {
+ if (currentStep === 1) {
+ stepContent.innerHTML = `
+
+
Need help? Use Telegram
+
This is the place to ask for help and talk with the community. Join the Telegram if you have questions, need assistance, or want project updates.
+
Full Telegram link
+
${TELEGRAM_URL}
+
Open Telegram
+
+ `;
+ progressText.textContent = "Step 1 of 2";
+ progressDots[0].classList.add("account-onboarding__dot--active");
+ progressDots[1].classList.remove("account-onboarding__dot--active");
+ button.textContent = "Confirm: I know where to get help";
+ return;
+ }
+
+ stepContent.innerHTML = `
+
+
Found a bug? Use GitHub Issues
+
This is the official place to report bugs. Create a GitHub issue with what happened, what you expected, and any useful screenshots or logs so the problem can be tracked and fixed.
+
Full bug report link
+
${GITHUB_ISSUES_URL}
+
Create a GitHub issue
+
+ `;
+ progressText.textContent = "Step 2 of 2";
+ progressDots[0].classList.remove("account-onboarding__dot--active");
+ progressDots[1].classList.add("account-onboarding__dot--active");
+ button.textContent = "Confirm: I know where to report bugs";
+ };
+
+ renderStep();
+ button?.focus();
+ button?.addEventListener("click", async () => {
+ if (currentStep === 1) {
+ currentStep = 2;
+ renderStep();
+ button.focus();
+ return;
+ }
+
+ button.disabled = true;
+ error.textContent = "";
+ try {
+ const response = await fetch("/api/auth/onboarding/complete", {
+ method: "POST",
+ credentials: "include",
+ });
+ if (!response.ok) throw new Error("Could not save your onboarding status.");
+ overlay.remove();
+ style.remove();
+ document.body.style.overflow = "";
+ } catch (err) {
+ error.textContent = err instanceof Error ? err.message : "Please try again.";
+ button.disabled = false;
+ button.focus();
+ }
+ });
+}
diff --git a/Overlord-Server/src/auth.test.ts b/Overlord-Server/src/auth.test.ts
index aec8edb..e8f4898 100644
--- a/Overlord-Server/src/auth.test.ts
+++ b/Overlord-Server/src/auth.test.ts
@@ -46,6 +46,40 @@ describe("auth token extraction", () => {
});
});
+describe("account onboarding", () => {
+ test("is pending for a new account and remains completed at the account level", async () => {
+ const username = `onboarding_${Date.now().toString(36)}`;
+ const created = await createUser(username, PASSWORD, "viewer", "test");
+ expect(created.success).toBe(true);
+
+ try {
+ const user = getUserById(created.userId!);
+ expect(user).toBeTruthy();
+ const token = await generateToken(user!);
+ const headers = { Cookie: `overlord_token=${token}` };
+
+ const meUrl = new URL("https://localhost/api/auth/me");
+ const before = await handleAuthRoutes(new Request(meUrl, { headers }), meUrl, mockServer);
+ expect(before?.status).toBe(200);
+ expect((await before!.json()).needsOnboarding).toBe(true);
+
+ const completeUrl = new URL("https://localhost/api/auth/onboarding/complete");
+ const completed = await handleAuthRoutes(
+ new Request(completeUrl, { method: "POST", headers }),
+ completeUrl,
+ mockServer,
+ );
+ expect(completed?.status).toBe(200);
+
+ const after = await handleAuthRoutes(new Request(meUrl, { headers }), meUrl, mockServer);
+ expect(after?.status).toBe(200);
+ expect((await after!.json()).needsOnboarding).toBe(false);
+ } finally {
+ deleteUser(created.userId!);
+ }
+ });
+});
+
describe("login branding", () => {
test("returns configured public login branding", async () => {
const url = new URL("https://localhost/api/login/branding");
diff --git a/Overlord-Server/src/db/user-schema.ts b/Overlord-Server/src/db/user-schema.ts
index bafddcb..64fd717 100644
--- a/Overlord-Server/src/db/user-schema.ts
+++ b/Overlord-Server/src/db/user-schema.ts
@@ -47,6 +47,7 @@ export function initializeUserSchema(): void {
role TEXT NOT NULL CHECK(role IN ('admin', 'operator', 'viewer')),
created_at INTEGER NOT NULL,
last_login INTEGER,
+ onboarding_completed_at INTEGER,
created_by TEXT,
must_change_password INTEGER DEFAULT 0,
client_scope TEXT NOT NULL DEFAULT 'none' CHECK(client_scope IN ('none', 'allowlist', 'denylist', 'all'))
@@ -228,6 +229,19 @@ export function initializeUserSchema(): void {
id: "014_users_keylog_archive_enabled",
run: () => addColumnIfMissing("users", `keylog_archive_enabled INTEGER NOT NULL DEFAULT 0`),
},
+ {
+ id: "015_users_onboarding_completed_at",
+ run: () => {
+ addColumnIfMissing("users", `onboarding_completed_at INTEGER DEFAULT NULL`);
+ // Accounts that have already signed in predate onboarding and should not
+ // be prompted. Accounts with no login history remain eligible.
+ db.exec(`
+ UPDATE users
+ SET onboarding_completed_at = COALESCE(last_login, created_at)
+ WHERE onboarding_completed_at IS NULL AND last_login IS NOT NULL
+ `);
+ },
+ },
];
runMigrations(userMigrations);
diff --git a/Overlord-Server/src/server/routes/auth-routes.ts b/Overlord-Server/src/server/routes/auth-routes.ts
index a19d64a..3c15386 100644
--- a/Overlord-Server/src/server/routes/auth-routes.ts
+++ b/Overlord-Server/src/server/routes/auth-routes.ts
@@ -38,6 +38,7 @@ import {
getUserMfaStatus,
isMfaRequiredForUser,
setUserMfaSecret,
+ completeUserOnboarding,
} from "../../users";
import { getUserPermissions, requirePermission } from "../../rbac";
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
@@ -505,6 +506,7 @@ export async function handleAuthRoutes(
role: user.role,
userId: user.userId,
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
+ needsOnboarding: dbUser ? dbUser.onboarding_completed_at === null : false,
canBuild: dbUser ? Boolean(dbUser.can_build) : false,
telegramChatId: dbUser?.telegram_chat_id || "",
inputArchiveEnabled: dbUser ? Boolean((dbUser as any).keylog_archive_enabled) : false,
@@ -517,5 +519,15 @@ export async function handleAuthRoutes(
);
}
+ if (req.method === "POST" && url.pathname === "/api/auth/onboarding/complete") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return Response.json({ error: "Not authenticated" }, { status: 401 });
+ }
+
+ completeUserOnboarding(user.userId);
+ return Response.json({ ok: true, needsOnboarding: false });
+ }
+
return null;
}
diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts
index 662ea26..0f963bc 100644
--- a/Overlord-Server/src/users.ts
+++ b/Overlord-Server/src/users.ts
@@ -15,6 +15,7 @@ export interface User {
role: UserRole;
created_at: number;
last_login: number | null;
+ onboarding_completed_at: number | null;
created_by: string | null;
must_change_password: number;
client_scope: ClientAccessScope;
@@ -864,6 +865,15 @@ export function updateLastLogin(userId: number): void {
);
}
+export function completeUserOnboarding(userId: number): boolean {
+ const result = db
+ .prepare(
+ "UPDATE users SET onboarding_completed_at = ? WHERE id = ? AND onboarding_completed_at IS NULL",
+ )
+ .run(Date.now(), userId);
+ return Number(result.changes) > 0;
+}
+
let _dummyHashPromise: Promise | null = null;
function getDummyPasswordHash(): Promise {
if (!_dummyHashPromise) {