Files
whatsapp-inbox-platform/app/forgot-password/page.tsx
Wira Basalamah adde003fba
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
chore: initial project import
2026-04-21 09:29:29 +07:00

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>
);
}