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:
165
app/forgot-password/page.tsx
Normal file
165
app/forgot-password/page.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user