chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
163
app/reset-password/page.tsx
Normal file
163
app/reset-password/page.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { Button, PageHeader, SectionCard } from "@/components/ui";
|
||||
import { getLocale, getTranslator } from "@/lib/i18n";
|
||||
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
||||
import { AuthTokenType, UserStatus } from "@prisma/client";
|
||||
import { consumeAuthToken } from "@/lib/auth-tokens";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
async function resetPassword(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const requestContext = await getRequestAuditContext();
|
||||
|
||||
const tokenRaw = formData.get("token");
|
||||
const passwordRaw = formData.get("password");
|
||||
const confirmRaw = formData.get("confirmPassword");
|
||||
const token = typeof tokenRaw === "string" ? tokenRaw : "";
|
||||
const password = typeof passwordRaw === "string" ? passwordRaw : "";
|
||||
const confirmPassword = typeof confirmRaw === "string" ? confirmRaw : "";
|
||||
|
||||
if (!token || !password || !confirmPassword) {
|
||||
redirect(`/reset-password?token=${encodeURIComponent(token)}&error=missing_fields`);
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
redirect(`/reset-password?token=${encodeURIComponent(token)}&error=password_mismatch`);
|
||||
}
|
||||
|
||||
const resolvedToken = await consumeAuthToken(token, AuthTokenType.PASSWORD_RESET);
|
||||
if (!resolvedToken.valid) {
|
||||
const reason = resolvedToken.reason === "expired" ? "expired_token" : "invalid_token";
|
||||
redirect(`/reset-password?token=${encodeURIComponent(token)}&error=${reason}`);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: resolvedToken.token.userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
await writeAuditTrail({
|
||||
tenantId: resolvedToken.token.tenantId,
|
||||
actorUserId: null,
|
||||
entityType: "user",
|
||||
entityId: resolvedToken.token.userId,
|
||||
action: "password_reset_token_used_no_user",
|
||||
metadata: { reason: "user_not_found", tokenType: AuthTokenType.PASSWORD_RESET },
|
||||
ipAddress: requestContext.ipAddress,
|
||||
userAgent: requestContext.userAgent
|
||||
});
|
||||
redirect("/reset-password?error=invalid_token");
|
||||
}
|
||||
|
||||
if (user.status !== UserStatus.ACTIVE) {
|
||||
await writeAuditTrail({
|
||||
tenantId: user.tenantId,
|
||||
actorUserId: user.id,
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
action: "password_reset_denied",
|
||||
metadata: { reason: "invalid_status", status: user.status },
|
||||
ipAddress: requestContext.ipAddress,
|
||||
userAgent: requestContext.userAgent
|
||||
});
|
||||
redirect("/reset-password?error=invalid_token");
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash: await hashPassword(password)
|
||||
}
|
||||
}),
|
||||
prisma.authToken.update({
|
||||
where: { id: resolvedToken.token.id },
|
||||
data: { consumedAt: new Date() }
|
||||
})
|
||||
]);
|
||||
|
||||
await writeAuditTrail({
|
||||
tenantId: user.tenantId,
|
||||
actorUserId: user.id,
|
||||
entityType: "user",
|
||||
entityId: user.id,
|
||||
action: "password_reset_completed",
|
||||
metadata: { source: "web" },
|
||||
ipAddress: requestContext.ipAddress,
|
||||
userAgent: requestContext.userAgent
|
||||
});
|
||||
|
||||
revalidatePath("/login");
|
||||
redirect("/login?success=password_reset_done");
|
||||
}
|
||||
|
||||
export default async function ResetPasswordPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: Promise<{ token?: string; error?: string }>;
|
||||
}) {
|
||||
const t = getTranslator(await getLocale());
|
||||
const params = await (
|
||||
searchParams ??
|
||||
Promise.resolve({
|
||||
token: undefined,
|
||||
error: undefined
|
||||
})
|
||||
);
|
||||
const token = typeof params.token === "string" ? params.token : "";
|
||||
|
||||
const tokenState =
|
||||
params?.error === "invalid_token"
|
||||
? t("pages", "invalid_token")
|
||||
: params?.error === "expired_token"
|
||||
? t("pages", "reset_token_expired")
|
||||
: params?.error === "missing_fields"
|
||||
? t("login", "error_credentials_required")
|
||||
: params?.error === "password_mismatch"
|
||||
? t("pages", "password_mismatch")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background px-6 py-16">
|
||||
<div className="mx-auto w-full max-w-2xl rounded-[1.5rem] bg-surface-container-lowest p-4 shadow-card md:p-8">
|
||||
<PageHeader title={t("pages", "reset_password")} description={t("pages", "reset_desc")} />
|
||||
<div className="mt-8">
|
||||
<SectionCard title={t("login", "password_label")}>
|
||||
{tokenState ? (
|
||||
<p className="mb-4 rounded-xl border border-error-container bg-error-container p-3 text-sm text-on-error-container">
|
||||
{tokenState}
|
||||
</p>
|
||||
) : null}
|
||||
<form action={resetPassword} className="grid gap-4">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<div className="grid gap-4">
|
||||
<label className="text-sm font-medium text-on-surface-variant">
|
||||
{t("login", "password_label")}
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
className="mt-2 w-full rounded-full border border-line bg-surface-container-high px-4 py-3 text-sm outline-none ring-1 ring-transparent transition focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm font-medium text-on-surface-variant">
|
||||
{t("pages", "reset_password")}
|
||||
<input
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
className="mt-2 w-full rounded-full border border-line bg-surface-container-high px-4 py-3 text-sm outline-none ring-1 ring-transparent transition focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-4">
|
||||
<Button type="submit">{t("pages", "reset_password")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user