onboarding page
This commit is contained in:
@@ -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