fix(auth): move onboarding redirect from AuthShell useEffect to proxy

The post-hydration redirect for users with `onboardingCompletedAt ===
null` caused a brief dashboard flash before the client-side effect
could fire. The redirect now lives in `src/proxy.ts` so it lands on the
first server response.

The proxy runs in the Edge runtime and can't reach Prisma, so the auth
routes (login / passkey-verify / register / password / /api/auth/me)
mirror the DB `onboardingCompletedAt` column into a non-httpOnly
`hl_onboarding` cookie. The cookie is a UX hint, not a security
signal — a user editing it locally only skips the dashboard flash;
they still can't bypass any server check. `/api/onboarding/complete`
clears the cookie so the next navigation drops the redirect
immediately, and `destroySession` + expired-session purge wipe it
alongside the session cookie.

The e2e onboarding-flicker spec can return to testing the actual
incomplete-onboarding case: the proxy reads the cookie (absent for
the fully-onboarded storage-state user), not the mocked `/api/auth/me`
response, so the test now sets `onboardingCompletedAt: null` honestly
instead of pinning it to a non-null value as a workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 21:29:17 +02:00
1 parent b281c0655b
commit 15796c148b
14 files changed
+208 -32

No files matched your search

@@ -17,6 +17,7 @@ vi.mock("@/lib/auth/password", () => ({
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue("session-id"),
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
+4 -1
View File
@@ -1,7 +1,7 @@
import { prisma } from "@/lib/db";
import { loginPasswordSchema } from "@/lib/validations/auth";
import { verifyPassword } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { createSession, setOnboardingPendingCookie } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
@@ -77,6 +77,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const ua = request.headers.get("user-agent");
await createSession(user.id, ip, ua);
// v1.4.22 C4 — mirror the user's onboarding status into a cookie so
// the proxy can short-circuit the redirect before the page hydrates.
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
await auditLog("auth.login.password", {
userId: user.id,
+7
View File
@@ -2,12 +2,19 @@ import { apiSuccess } from "@/lib/api-response";
import { getGravatarUrl } from "@/lib/gravatar";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.4.22 C4 — keep the proxy-readable onboarding cookie in sync
// with the DB state on every /me roundtrip. Old sessions that
// predate the cookie pick it up on first dashboard load; the
// /onboarding/complete handler always wins on the cleared edge.
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
annotate({ action: { name: "auth.me" } });
return apiSuccess({
@@ -15,6 +15,7 @@ vi.mock("@/lib/auth/passkey", () => ({
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue("session-id"),
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
@@ -1,5 +1,5 @@
import { verifyAuthentication } from "@/lib/auth/passkey";
import { createSession } from "@/lib/auth/session";
import { createSession, setOnboardingPendingCookie } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
@@ -67,6 +67,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const ua = request.headers.get("user-agent");
await createSession(user.id, ip, ua);
// v1.4.22 C4 — mirror onboarding status into the proxy-readable cookie
// so the redirect short-circuits before the page hydrates.
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
await auditLog("auth.login.passkey", {
userId: user.id,
+8 -1
View File
@@ -16,7 +16,11 @@ import {
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { destroyAllSessions, createSession } from "@/lib/auth/session";
import {
destroyAllSessions,
createSession,
setOnboardingPendingCookie,
} from "@/lib/auth/session";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -82,6 +86,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
getClientIp(request),
request.headers.get("user-agent"),
);
// v1.4.22 C4 — re-anchor the onboarding cookie to the user's real
// status after the session rebuild.
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
await auditLog("auth.password.change", {
userId: user.id,
+5 -1
View File
@@ -1,7 +1,7 @@
import { prisma } from "@/lib/db";
import { registerSchema } from "@/lib/validations/auth";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { createSession, setOnboardingPendingCookie } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
@@ -97,6 +97,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Create session immediately
const ua = request.headers.get("user-agent");
await createSession(user.id, ip, ua);
// v1.4.22 C4 — fresh users are always onboarding-pending. The cookie
// lets the proxy redirect on first navigation instead of waiting for
// `<AuthShell>` to hydrate and trigger a useEffect bounce.
await setOnboardingPendingCookie(true);
await auditLog("auth.register", {
userId: user.id,
+5
View File
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -56,5 +57,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
data,
});
// v1.4.22 C4 — clear the proxy-readable onboarding cookie so the
// next navigation drops the /onboarding redirect immediately.
await setOnboardingPendingCookie(false);
return apiSuccess({ completed: true });
});