onboarding page
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 = `
|
||||
<section class="account-onboarding__panel">
|
||||
<div class="account-onboarding__hero">
|
||||
<div class="account-onboarding__icon"><i class="fa-solid fa-crown" aria-hidden="true"></i></div>
|
||||
<h1 id="account-onboarding-title">Welcome to Overlord</h1>
|
||||
<p class="account-onboarding__intro">Please review both support resources so you always know exactly where to go.</p>
|
||||
<div class="account-onboarding__progress" aria-label="Onboarding progress">
|
||||
<span class="account-onboarding__dot account-onboarding__dot--active"></span>
|
||||
<span class="account-onboarding__progress-text">Step 1 of 2</span>
|
||||
<span class="account-onboarding__dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="account-onboarding__step"></div>
|
||||
<div class="account-onboarding__footer">
|
||||
<button class="account-onboarding__continue" type="button">Confirm: I know where to get help</button>
|
||||
<p class="account-onboarding__error" role="alert"></p>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="account-onboarding__card">
|
||||
<h2><i class="fa-brands fa-telegram" aria-hidden="true"></i> Need help? Use Telegram</h2>
|
||||
<p><strong>This is the place to ask for help and talk with the community.</strong> Join the Telegram if you have questions, need assistance, or want project updates.</p>
|
||||
<div class="account-onboarding__url-label">Full Telegram link</div>
|
||||
<a class="account-onboarding__url" href="${TELEGRAM_URL}" target="_blank" rel="noopener noreferrer">${TELEGRAM_URL}</a>
|
||||
<a class="account-onboarding__open" href="${TELEGRAM_URL}" target="_blank" rel="noopener noreferrer"><i class="fa-brands fa-telegram" aria-hidden="true"></i> Open Telegram</a>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="account-onboarding__card">
|
||||
<h2><i class="fa-brands fa-github" aria-hidden="true"></i> Found a bug? Use GitHub Issues</h2>
|
||||
<p><strong>This is the official place to report bugs.</strong> Create a GitHub issue with what happened, what you expected, and any useful screenshots or logs so the problem can be tracked and fixed.</p>
|
||||
<div class="account-onboarding__url-label">Full bug report link</div>
|
||||
<a class="account-onboarding__url" href="${GITHUB_ISSUES_URL}" target="_blank" rel="noopener noreferrer">${GITHUB_ISSUES_URL}</a>
|
||||
<a class="account-onboarding__open" href="${GITHUB_ISSUES_URL}" target="_blank" rel="noopener noreferrer"><i class="fa-brands fa-github" aria-hidden="true"></i> Create a GitHub issue</a>
|
||||
</div>
|
||||
`;
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string> | null = null;
|
||||
function getDummyPasswordHash(): Promise<string> {
|
||||
if (!_dummyHashPromise) {
|
||||
|
||||
Reference in New Issue
Block a user