onboarding page

This commit is contained in:
KingKDot
2026-07-14 21:54:14 -05:00
parent 51431b637b
commit c8d334ff80
6 changed files with 205 additions and 0 deletions
+34
View File
@@ -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");
+14
View File
@@ -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;
}
+10
View File
@@ -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) {