166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
import { redirect } from "next/navigation";
|
|
|
|
import { AuthTokenType, UserStatus } from "@prisma/client";
|
|
|
|
import { Button, PageHeader, SectionCard } from "@/components/ui";
|
|
import { createAuthToken, makeResetUrl } from "@/lib/auth-tokens";
|
|
import { getLocale, getTranslator } from "@/lib/i18n";
|
|
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
|
import { consumeRateLimit } from "@/lib/rate-limit";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { sendTransactionalNotification } from "@/lib/notification";
|
|
|
|
async function requestPasswordReset(formData: FormData) {
|
|
"use server";
|
|
|
|
const requestContext = await getRequestAuditContext();
|
|
const rawEmail = formData.get("email");
|
|
const email = typeof rawEmail === "string" ? rawEmail.trim().toLowerCase() : "";
|
|
|
|
const rateLimit = consumeRateLimit(requestContext.ipAddress || "unknown", {
|
|
scope: "password_reset_request",
|
|
limit: 6,
|
|
windowMs: 15 * 60 * 1000
|
|
});
|
|
if (!rateLimit.allowed) {
|
|
redirect("/forgot-password?error=rate_limited");
|
|
}
|
|
|
|
if (!email) {
|
|
redirect("/forgot-password?error=missing_email");
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { email }
|
|
});
|
|
|
|
if (user && user.status === UserStatus.ACTIVE) {
|
|
const created = await createAuthToken({
|
|
userId: user.id,
|
|
tenantId: user.tenantId,
|
|
tokenType: AuthTokenType.PASSWORD_RESET
|
|
});
|
|
const resetUrl = makeResetUrl(created.rawToken);
|
|
const notificationResult = await sendTransactionalNotification({
|
|
to: user.email,
|
|
subject: "Reset password to continue your account access",
|
|
text: `Gunakan tautan ini untuk mengatur ulang password: ${resetUrl}`,
|
|
html: `<p>Gunakan tautan berikut untuk mengatur ulang password: <a href="${resetUrl}">${resetUrl}</a></p>`
|
|
});
|
|
|
|
if (!notificationResult.ok) {
|
|
await writeAuditTrail({
|
|
tenantId: user.tenantId,
|
|
actorUserId: user.id,
|
|
entityType: "user",
|
|
entityId: user.id,
|
|
action: "password_reset_notified_failed",
|
|
metadata: {
|
|
email,
|
|
reason: notificationResult.error,
|
|
source: "web",
|
|
provider: notificationResult.provider ?? null
|
|
},
|
|
ipAddress: requestContext.ipAddress,
|
|
userAgent: requestContext.userAgent
|
|
});
|
|
}
|
|
|
|
await writeAuditTrail({
|
|
tenantId: user.tenantId,
|
|
actorUserId: user.id,
|
|
entityType: "user",
|
|
entityId: user.id,
|
|
action: "password_reset_requested",
|
|
metadata: {
|
|
email,
|
|
expiresAt: created.expiresAt.toISOString(),
|
|
source: "web",
|
|
notifyProvider: notificationResult?.provider ?? null,
|
|
notifyQueued: notificationResult?.ok ?? false
|
|
},
|
|
ipAddress: requestContext.ipAddress,
|
|
userAgent: requestContext.userAgent
|
|
});
|
|
if (notificationResult.ok === true && notificationResult.provider === "console") {
|
|
console.log(`RESET_PASSWORD_LINK=${resetUrl}`);
|
|
}
|
|
} else if (user) {
|
|
await writeAuditTrail({
|
|
tenantId: user.tenantId,
|
|
actorUserId: user.id,
|
|
entityType: "user",
|
|
entityId: user.id,
|
|
action: "password_reset_denied",
|
|
metadata: {
|
|
email,
|
|
status: user.status,
|
|
source: "web"
|
|
},
|
|
ipAddress: requestContext.ipAddress,
|
|
userAgent: requestContext.userAgent
|
|
});
|
|
}
|
|
|
|
// Always respond with generic success to avoid user enumeration.
|
|
redirect("/forgot-password?success=sent");
|
|
}
|
|
|
|
export default async function ForgotPasswordPage({
|
|
searchParams
|
|
}: {
|
|
searchParams?: Promise<{ error?: string; success?: string }>;
|
|
}) {
|
|
const t = getTranslator(await getLocale());
|
|
const params = await (searchParams ?? Promise.resolve({ error: undefined, success: undefined }));
|
|
const error =
|
|
params?.error === "missing_email"
|
|
? t("login", "missing_email")
|
|
: params?.error === "rate_limited"
|
|
? t("login", "error_rate_limited")
|
|
: null;
|
|
|
|
const success = params?.success === "sent";
|
|
|
|
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", "forgot_password")}
|
|
description={t("pages", "reset_password")}
|
|
actions={<Button href="/login" variant="secondary">{t("common", "back_to_login")}</Button>}
|
|
/>
|
|
<div className="mt-8">
|
|
<SectionCard title={t("pages", "reset_password")}>
|
|
{error ? (
|
|
<p className="mb-4 rounded-xl border border-error-container bg-error-container p-3 text-sm text-on-error-container">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
{success ? (
|
|
<p className="mb-4 rounded-xl border border-success/30 bg-success/10 p-3 text-sm text-success">
|
|
{t("login", "forgot_success")}
|
|
</p>
|
|
) : null}
|
|
<form action={requestPasswordReset} className="grid gap-4">
|
|
<label className="block text-sm font-medium text-on-surface-variant">
|
|
{t("login", "email_label")}
|
|
<input
|
|
name="email"
|
|
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"
|
|
placeholder={t("login", "work_email_placeholder")}
|
|
required
|
|
/>
|
|
</label>
|
|
<div />
|
|
<div className="mt-4">
|
|
<Button type="submit">{t("login", "forgot_action")}</Button>
|
|
</div>
|
|
</form>
|
|
</SectionCard>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|