Phase 6a: local auth — credentials login + password reset flow

MVP auth is email + password (Entra SSO stays coded but env-gated via
NEXT_PUBLIC_AUTH_ENTRA_ENABLED for when the redirect URI is ready).
Uses a custom DB-session endpoint to mirror the existing MSAL pattern
at /api/auth/sso — no NextAuth strategy refactor needed.

API routes:
- POST /api/auth/login — email+password, bcrypt.compare, creates
  Auth.js-compatible DB session + Secure cookie. Constant-time
  behaviour (dummy-hash compare on missing user) to not leak account
  existence. Returns { ok, mustChangePassword } so the client can
  route first-login users to /change-password.
- POST /api/auth/forgot-password — issues a 1-hour single-use reset
  token. Never leaks enumerability (always 200). In dev, returns the
  reset URL in the response so admins can hand it over before SMTP
  is wired up. In prod, the token is only logged server-side.
- POST /api/auth/reset-password — validates token, bcrypt-hashes new
  password, clears token, flips mustChangePassword=false, and
  revokes all existing sessions so a stolen cookie can't linger.
- POST /api/auth/change-password — authenticated user changes their
  own password. Skips the current-password check for users without a
  passwordHash (covers first-time setup for SSO-seeded accounts).
  Clears mustChangePassword.

UI pages:
- (auth)/login — rewrote for email+password form. Entra SSO button
  only renders when NEXT_PUBLIC_AUTH_ENTRA_ENABLED=true. Dow brand
  block on the left ("Dow Jones Studio Tracker").
- (auth)/login/CredentialsLogin — client form, routes first-login
  users to /change-password?first=1.
- (auth)/change-password — forced password change after first login;
  also usable as a plain change-password screen.
- (auth)/forgot-password — email form → reset link. Shows dev link
  in-page when available.
- (auth)/reset-password/[token] — set new password from email link.

Middleware: /forgot-password and /reset-password added to the
authn-bypass allow-list alongside /login.

Minimum password length enforced at 10 chars. All API endpoints
return generic messaging to avoid information disclosure.

Verified: tsc --noEmit ✓ zero errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-20 19:00:15 -04:00
parent 7598f4285e
commit eede696eee
10 changed files with 814 additions and 14 deletions

View file

@ -0,0 +1,133 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
export default function ChangePasswordPage() {
const router = useRouter();
const search = useSearchParams();
const isFirstLogin = search.get("first") === "1";
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (newPassword !== confirmPassword) {
setError("New passwords don't match.");
return;
}
if (newPassword.length < 10) {
setError("Password must be at least 10 characters.");
return;
}
setSubmitting(true);
try {
const res = await fetch(`${BASE_PATH}/api/auth/change-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error ?? "Change failed");
setSubmitting(false);
return;
}
router.push("/dashboard");
router.refresh();
} catch {
setError("Network error — please try again.");
setSubmitting(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--background)] px-8 py-12">
<div className="w-full max-w-[360px]">
<h1 className="font-heading text-lg font-black tracking-[-0.02em]">
{isFirstLogin ? "Set a new password" : "Change password"}
</h1>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{isFirstLogin
? "This is your first sign-in. Please choose a new password before continuing."
: "Use a password you don't use anywhere else."}
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-3">
<div className="space-y-1.5">
<Label htmlFor="currentPassword" className="text-xs">
Current password
</Label>
<Input
id="currentPassword"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="newPassword" className="text-xs">
New password
</Label>
<Input
id="newPassword"
type="password"
autoComplete="new-password"
required
minLength={10}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="h-9 text-sm"
/>
<p className="text-[10px] text-[var(--muted-foreground)]">
Minimum 10 characters.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirmPassword" className="text-xs">
Confirm new password
</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-9 text-sm"
/>
</div>
{error && (
<p className="text-xs text-red-500" role="alert">
{error}
</p>
)}
<Button
type="submit"
className="h-9 w-full text-xs font-semibold"
disabled={submitting || !newPassword || !confirmPassword}
>
{submitting ? "Saving…" : "Save new password"}
</Button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
const [status, setStatus] = useState<"idle" | "sent">("idle");
const [devResetUrl, setDevResetUrl] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitting(true);
try {
const res = await fetch(`${BASE_PATH}/api/auth/forgot-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const data = await res.json().catch(() => ({}));
setStatus("sent");
if (data?.devResetUrl) {
setDevResetUrl(data.devResetUrl);
}
} catch {
// Fall through to success state — this endpoint never reveals failure.
setStatus("sent");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--background)] px-8 py-12">
<div className="w-full max-w-[360px]">
<h1 className="font-heading text-lg font-black tracking-[-0.02em]">
Forgot your password?
</h1>
{status === "idle" ? (
<>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
Enter your email and we'll send a reset link.
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-3">
<div className="space-y-1.5">
<Label htmlFor="email" className="text-xs">
Email
</Label>
<Input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-9 text-sm"
/>
</div>
<Button
type="submit"
disabled={submitting || !email}
className="h-9 w-full text-xs font-semibold"
>
{submitting ? "Sending…" : "Send reset link"}
</Button>
</form>
</>
) : (
<div className="mt-6 space-y-4 text-xs">
<p>
If that email is registered, you'll receive a reset link shortly.
The link expires in one hour.
</p>
{devResetUrl && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)]/40 p-3">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Dev mode copy this link
</p>
<p className="mt-1 break-all font-mono text-[11px]">
{devResetUrl}
</p>
</div>
)}
</div>
)}
<div className="mt-8 text-[11px] text-[var(--muted-foreground)]">
<Link href="/login" className="underline hover:text-[var(--foreground)]">
Back to sign in
</Link>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
export function CredentialsLogin() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const res = await fetch(`${BASE_PATH}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error ?? "Sign in failed");
setSubmitting(false);
return;
}
// Route first-time users to the forced password change.
if (data.mustChangePassword) {
router.push("/change-password?first=1");
} else {
router.push("/dashboard");
}
router.refresh();
} catch {
setError("Network error — please try again.");
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="email" className="text-xs">
Email
</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-9 text-sm"
placeholder="you@oliver.agency"
/>
</div>
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<Label htmlFor="password" className="text-xs">
Password
</Label>
<Link
href="/forgot-password"
className="text-[10px] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
Forgot?
</Link>
</div>
<Input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-9 text-sm"
/>
</div>
{error && (
<p className="text-xs text-red-500" role="alert">
{error}
</p>
)}
<Button
type="submit"
className="h-9 w-full text-xs font-semibold"
disabled={submitting || !email || !password}
>
{submitting ? "Signing in…" : "Sign in"}
</Button>
</form>
);
}

View file

@ -2,6 +2,7 @@ import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { MsalLogin } from "./MsalLogin";
import { CredentialsLogin } from "./CredentialsLogin";
import type { MsalLoginConfig } from "@/lib/msal-config";
export default async function LoginPage() {
@ -11,11 +12,15 @@ export default async function LoginPage() {
redirect("/dashboard");
}
const msalConfig: MsalLoginConfig = {
clientId: process.env.AZURE_CLIENT_ID!,
tenantId: process.env.AZURE_TENANT_ID!,
redirectUri: process.env.AZURE_REDIRECT_URI!,
};
const entraEnabled = process.env.NEXT_PUBLIC_AUTH_ENTRA_ENABLED === "true";
const msalConfig: MsalLoginConfig | null = entraEnabled
? {
clientId: process.env.AZURE_CLIENT_ID!,
tenantId: process.env.AZURE_TENANT_ID!,
redirectUri: process.env.AZURE_REDIRECT_URI!,
}
: null;
return (
<div className="flex min-h-screen bg-[var(--background)]">
@ -28,10 +33,10 @@ export default async function LoginPage() {
</div>
<div>
<h1 className="font-heading text-4xl font-black leading-[1.05] tracking-[-0.03em] text-[var(--primary-foreground)]">
HP CG<br />Production<br />Tracker
Dow Jones<br />Studio<br />Tracker
</h1>
<p className="mt-4 text-[11px] font-medium tracking-[0.06em] uppercase text-[var(--primary-foreground)]/60">
Pipeline management for CG production
Production pipeline for Dow Jones studio
</p>
</div>
<div>
@ -59,15 +64,28 @@ export default async function LoginPage() {
Sign in
</h2>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
Use your Oliver or HP work account to continue.
Enter your email and password to continue.
</p>
</div>
<div className="space-y-2.5">
<Suspense fallback={null}>
<MsalLogin config={msalConfig} />
</Suspense>
</div>
<CredentialsLogin />
{entraEnabled && msalConfig && (
<>
<div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-[var(--border)]" />
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--muted-foreground)]">
or
</span>
<div className="h-px flex-1 bg-[var(--border)]" />
</div>
<div className="space-y-2.5">
<Suspense fallback={null}>
<MsalLogin config={msalConfig} />
</Suspense>
</div>
</>
)}
<div className="mt-10 border-t pt-6">
<p className="text-[9px] font-semibold tracking-[0.12em] uppercase text-[var(--muted-foreground)]/60">

View file

@ -0,0 +1,121 @@
"use client";
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
interface Props {
params: Promise<{ token: string }>;
}
export default function ResetPasswordPage({ params }: Props) {
const { token } = use(params);
const router = useRouter();
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (newPassword !== confirmPassword) {
setError("Passwords don't match.");
return;
}
if (newPassword.length < 10) {
setError("Password must be at least 10 characters.");
return;
}
setSubmitting(true);
try {
const res = await fetch(`${BASE_PATH}/api/auth/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, newPassword }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error ?? "Reset failed");
setSubmitting(false);
return;
}
router.push("/login?reset=1");
} catch {
setError("Network error — please try again.");
setSubmitting(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--background)] px-8 py-12">
<div className="w-full max-w-[360px]">
<h1 className="font-heading text-lg font-black tracking-[-0.02em]">
Choose a new password
</h1>
<form onSubmit={handleSubmit} className="mt-6 space-y-3">
<div className="space-y-1.5">
<Label htmlFor="newPassword" className="text-xs">
New password
</Label>
<Input
id="newPassword"
type="password"
autoComplete="new-password"
required
minLength={10}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="h-9 text-sm"
/>
<p className="text-[10px] text-[var(--muted-foreground)]">
Minimum 10 characters.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirmPassword" className="text-xs">
Confirm password
</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-9 text-sm"
/>
</div>
{error && (
<p className="text-xs text-red-500" role="alert">
{error}
</p>
)}
<Button
type="submit"
className="h-9 w-full text-xs font-semibold"
disabled={submitting || !newPassword || !confirmPassword}
>
{submitting ? "Saving…" : "Save & sign in"}
</Button>
</form>
<div className="mt-8 text-[11px] text-[var(--muted-foreground)]">
<Link href="/login" className="underline hover:text-[var(--foreground)]">
Back to sign in
</Link>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,76 @@
import { type NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/rbac/require-auth";
/**
* POST /api/auth/change-password
*
* Body: { currentPassword: string, newPassword: string }
*
* Lets an authenticated user change their own password. Required to
* complete the mustChangePassword flow that the seed admin + freshly
* invited users land in on first sign-in.
*
* If the user has no existing passwordHash (e.g. an SSO-seeded account
* that later opted into local auth), the `currentPassword` check is
* skipped this covers first-time password setup.
*/
const MIN_PASSWORD_LEN = 10;
export async function POST(request: NextRequest) {
const { session, error } = await requireAuth();
if (error) return error;
const body = await request.json().catch(() => null);
const currentPassword = body?.currentPassword as string | undefined;
const newPassword = body?.newPassword as string | undefined;
if (!newPassword) {
return NextResponse.json(
{ error: "New password is required" },
{ status: 400 }
);
}
if (newPassword.length < MIN_PASSWORD_LEN) {
return NextResponse.json(
{ error: `Password must be at least ${MIN_PASSWORD_LEN} characters` },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { passwordHash: true },
});
if (user?.passwordHash) {
if (!currentPassword) {
return NextResponse.json(
{ error: "Current password is required" },
{ status: 400 }
);
}
const ok = await bcrypt.compare(currentPassword, user.passwordHash);
if (!ok) {
return NextResponse.json(
{ error: "Current password is incorrect" },
{ status: 401 }
);
}
}
const passwordHash = await bcrypt.hash(newPassword, 10);
await prisma.user.update({
where: { id: session.user.id },
data: {
passwordHash,
mustChangePassword: false,
},
});
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,75 @@
import { type NextRequest, NextResponse } from "next/server";
import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma";
/**
* POST /api/auth/forgot-password
*
* Issues a single-use password-reset token for the given email.
*
* Always returns 200 we don't leak whether an email is registered.
* When SMTP is configured the reset link is emailed; until it is, the
* reset URL is returned in the response so an admin can hand it over
* out-of-band. That falls back cleanly once email is wired up.
*
* Token: 32 bytes url-safe. Expires in 1 hour.
*/
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
const email = (body?.email as string | undefined)?.trim().toLowerCase();
if (!email) {
return NextResponse.json(
{ error: "Email is required" },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({
where: { email },
select: { id: true },
});
// Soft-fail response always — don't leak enumerability.
const response = { ok: true as const };
if (!user) {
return NextResponse.json(response);
}
const token = randomBytes(24).toString("base64url");
const expires = new Date(Date.now() + TOKEN_TTL_MS);
await prisma.user.update({
where: { id: user.id },
data: {
passwordResetToken: token,
passwordResetExpires: expires,
},
});
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const origin =
request.headers.get("origin") ??
process.env.NEXT_PUBLIC_APP_URL ??
"";
const resetUrl = `${origin}${basePath}/reset-password/${token}`;
// Placeholder for SMTP send — for now, log to the server console and
// return the URL in dev so an admin can copy/paste.
const devMode = process.env.NODE_ENV !== "production";
console.info(
"[auth] Password reset issued",
JSON.stringify({ userId: user.id, expires: expires.toISOString() })
);
return NextResponse.json({
...response,
// Only return the reset URL in dev — in prod we rely on SMTP so the
// token isn't inadvertently leaked to the requester.
...(devMode ? { devResetUrl: resetUrl } : {}),
});
}

View file

@ -0,0 +1,90 @@
import { type NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { cookies } from "next/headers";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
/**
* POST /api/auth/login
*
* Local email + password authentication the MVP path while Entra SSO is
* pending its redirect URI. Mirrors the existing MSAL session-cookie
* pattern in /api/auth/sso: verifies the password with bcrypt, creates an
* Auth.js-compatible DB session, sets the session cookie. The existing
* middleware + auth() calls don't need to change.
*
* Response:
* 200 { ok: true, mustChangePassword: boolean }
* 400 { error: "..." } missing fields or user has no password set
* 401 { error: "..." } bad credentials
*/
const GENERIC_AUTH_ERROR = "Invalid email or password";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
const email = (body?.email as string | undefined)?.trim().toLowerCase();
const password = body?.password as string | undefined;
if (!email || !password) {
return NextResponse.json(
{ error: "Email and password are required" },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
passwordHash: true,
mustChangePassword: true,
organizationId: true,
},
});
// Constant-time-ish: always run a bcrypt compare even when the user
// doesn't exist, so response timing doesn't leak account existence.
const dummyHash =
"$2a$10$CwTycUXWue0Thq9StjUM0uJ8PqWn3WlxFlxxVvV8j/kHrXFNz2xKy";
const hashToCompare = user?.passwordHash ?? dummyHash;
const passwordValid = await bcrypt.compare(password, hashToCompare);
if (!user || !user.passwordHash || !passwordValid) {
return NextResponse.json({ error: GENERIC_AUTH_ERROR }, { status: 401 });
}
// Create a DB session matching PrismaAdapter.createSession format
const sessionToken = randomUUID();
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await prisma.session.create({
data: { sessionToken, userId: user.id, expires },
});
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Auth.js-compatible cookie so auth() reads it unchanged
const forwarded = request.headers.get("x-forwarded-proto");
const isSecure =
forwarded === "https" || request.url.startsWith("https://");
const cookieName = isSecure
? "__Secure-authjs.session-token"
: "authjs.session-token";
const cookieStore = await cookies();
cookieStore.set(cookieName, sessionToken, {
httpOnly: true,
secure: isSecure,
sameSite: "lax",
path: "/",
expires,
});
return NextResponse.json({
ok: true,
mustChangePassword: user.mustChangePassword,
});
}

View file

@ -0,0 +1,75 @@
import { type NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
/**
* POST /api/auth/reset-password
*
* Body: { token: string, newPassword: string }
*
* Validates the reset token, bcrypt-hashes the new password, stores it,
* clears the token and flips mustChangePassword=false. Also revokes all
* existing sessions for the user so anyone with a stolen cookie is
* logged out.
*
* Responses:
* 200 { ok: true }
* 400 { error } missing fields, weak password, invalid/expired token
*/
const MIN_PASSWORD_LEN = 10;
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
const token = body?.token as string | undefined;
const newPassword = body?.newPassword as string | undefined;
if (!token || !newPassword) {
return NextResponse.json(
{ error: "Token and new password are required" },
{ status: 400 }
);
}
if (newPassword.length < MIN_PASSWORD_LEN) {
return NextResponse.json(
{ error: `Password must be at least ${MIN_PASSWORD_LEN} characters` },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({
where: { passwordResetToken: token },
select: { id: true, passwordResetExpires: true },
});
if (
!user ||
!user.passwordResetExpires ||
user.passwordResetExpires < new Date()
) {
return NextResponse.json(
{ error: "Reset link is invalid or expired" },
{ status: 400 }
);
}
const passwordHash = await bcrypt.hash(newPassword, 10);
await prisma.$transaction([
prisma.user.update({
where: { id: user.id },
data: {
passwordHash,
mustChangePassword: false,
passwordResetToken: null,
passwordResetExpires: null,
},
}),
// Nuke any existing sessions so a leaked cookie can't linger after
// a password reset.
prisma.session.deleteMany({ where: { userId: user.id } }),
]);
return NextResponse.json({ ok: true });
}

View file

@ -33,7 +33,10 @@ export function middleware(request: NextRequest) {
}
}
const isAuthPage = pathname.startsWith("/login");
const isAuthPage =
pathname.startsWith("/login") ||
pathname.startsWith("/forgot-password") ||
pathname.startsWith("/reset-password");
const isPendingPage = pathname.startsWith("/pending");
const isApiAuth = pathname.startsWith("/api/auth");
const isHealthCheck = pathname === "/api/health";