Files
HealthLog/src/app/api/auth/password/route.ts
T
Marc-André BombeckandClaude Opus 4.7 15796c148b 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>
2026-05-10 21:29:17 +02:00

102 lines
2.7 KiB
TypeScript

import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import {
verifyPassword,
hashPassword,
checkPasswordStrength,
} from "@/lib/auth/password";
import { changePasswordSchema } from "@/lib/validations/auth";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
destroyAllSessions,
createSession,
setOnboardingPendingCookie,
} from "@/lib/auth/session";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`auth:password:${user.id}`,
5,
15 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = changePasswordSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { currentPassword, newPassword } = parsed.data;
if (!user.passwordHash) {
return apiError("No password set for this account", 400);
}
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
if (!currentValid) {
return apiError("Current password is incorrect", 401);
}
if (currentPassword === newPassword) {
return apiError("New password must differ from current password", 422);
}
const locale = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
});
const strength = checkPasswordStrength(
newPassword,
[user.username, user.email ?? ""],
locale,
);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
const newHash = await hashPassword(newPassword);
await prisma.user.update({
where: { id: user.id },
data: { passwordHash: newHash },
});
// Invalidate all existing sessions and create a fresh one
await destroyAllSessions(user.id);
await createSession(
user.id,
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,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "auth.password.change" } });
return apiSuccess({ changed: true });
});