mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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>
102 lines
2.7 KiB
TypeScript
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 });
|
|
});
|