phosphat-report-app/app/routes/reset-password.tsx
2025-07-24 12:39:15 +03:00

326 lines
11 KiB
TypeScript

import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
import bcrypt from "bcryptjs";
import crypto from "crypto";
import { sendNotificationEmail } from "~/utils/mail.server";
import { prisma } from "~/utils/db.server";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
if (!token) {
return json({ showEmailForm: true, token: null, error: null });
}
// Check if token is valid
const resetToken = await prisma.passwordResetToken.findUnique({
where: { token },
include: { employee: true }
});
if (!resetToken) {
return json({
showEmailForm: false,
token: null,
error: "Invalid reset token. Please request a new password reset."
});
}
if (resetToken.used) {
return json({
showEmailForm: false,
token: null,
error: "This reset token has already been used. Please request a new password reset."
});
}
if (new Date() > resetToken.expiresAt) {
return json({
showEmailForm: false,
token: null,
error: "This reset token has expired. Please request a new password reset."
});
}
return json({
showEmailForm: false,
token,
error: null,
employeeName: resetToken.employee.name
});
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "send-reset-email") {
const email = formData.get("email")?.toString();
if (!email) {
return json({ error: "Email is required" }, { status: 400 });
}
// Find employee by email
const employee = await prisma.employee.findUnique({
where: { email }
});
if (!employee) {
// Don't reveal if email exists or not for security
return json({
success: true,
message: "If an account with this email exists, you will receive a password reset link."
});
}
// Generate reset token
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Save token to database
await prisma.passwordResetToken.create({
data: {
token,
employeeId: employee.id,
expiresAt
}
});
// Send reset email
const resetUrl = `${new URL(request.url).origin}/reset-password?token=${token}`;
const emailResult = await sendNotificationEmail(
employee.email,
"Password Reset Request",
`Hello ${employee.name},\n\nYou requested a password reset. Click the link below to reset your password:\n\n${resetUrl}\n\nThis link will expire in 1 hour.\n\nIf you didn't request this, please ignore this email.`,
false
);
if (!emailResult.success) {
return json({ error: "Failed to send reset email. Please try again." }, { status: 500 });
}
return json({
success: true,
message: "If an account with this email exists, you will receive a password reset link."
});
}
if (intent === "reset-password") {
const token = formData.get("token")?.toString();
const password = formData.get("password")?.toString();
const confirmPassword = formData.get("confirmPassword")?.toString();
if (!token || !password || !confirmPassword) {
return json({ error: "All fields are required" }, { status: 400 });
}
if (password !== confirmPassword) {
return json({ error: "Passwords do not match" }, { status: 400 });
}
if (password.length < 6) {
return json({ error: "Password must be at least 6 characters long" }, { status: 400 });
}
// Verify token again
const resetToken = await prisma.passwordResetToken.findUnique({
where: { token },
include: { employee: true }
});
if (!resetToken || resetToken.used || new Date() > resetToken.expiresAt) {
return json({ error: "Invalid or expired token" }, { status: 400 });
}
// Hash new password
const hashedPassword = await bcrypt.hash(password, 10);
// Update password and mark token as used
await prisma.$transaction([
prisma.employee.update({
where: { id: resetToken.employeeId },
data: { password: hashedPassword }
}),
prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { used: true }
})
]);
return redirect("/signin?message=Password reset successful. Please sign in with your new password.");
}
return json({ error: "Invalid request" }, { status: 400 });
}
export default function ResetPassword() {
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (loaderData.error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Password Reset Error
</h2>
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-800">{loaderData.error}</p>
</div>
<div className="mt-4">
<a
href="/reset-password"
className="text-indigo-600 hover:text-indigo-500 font-medium"
>
Request a new password reset
</a>
</div>
</div>
</div>
</div>
);
}
if (loaderData.showEmailForm) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Enter your email address and we'll send you a link to reset your password.
</p>
</div>
{actionData?.error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-800">{actionData.error}</p>
</div>
)}
{actionData?.success && (
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
<p className="text-green-800">{actionData.message}</p>
</div>
)}
<Form method="post" className="mt-8 space-y-6">
<input type="hidden" name="intent" value="send-reset-email" />
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div>
<div>
<button
type="submit"
disabled={isSubmitting}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{isSubmitting ? "Sending..." : "Send reset link"}
</button>
</div>
<div className="text-center">
<a
href="/signin"
className="text-indigo-600 hover:text-indigo-500 font-medium"
>
Back to sign in
</a>
</div>
</Form>
</div>
</div>
);
}
// Show password reset form
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Set new password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
{loaderData.employeeName && `Hello ${loaderData.employeeName}, `}
Enter your new password below.
</p>
</div>
{actionData?.error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-800">{actionData.error}</p>
</div>
)}
<Form method="post" className="mt-8 space-y-6">
<input type="hidden" name="intent" value="reset-password" />
<input type="hidden" name="token" value={loaderData.token} />
<div className="space-y-4">
<div>
<label htmlFor="password" className="sr-only">
New Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={6}
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="New password (min 6 characters)"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="sr-only">
Confirm Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
minLength={6}
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Confirm new password"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isSubmitting}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{isSubmitting ? "Updating..." : "Update password"}
</button>
</div>
</Form>
</div>
</div>
);
}