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

+ + +
+
+ +
+ `; + + 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) {