326 lines
11 KiB
TypeScript
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>
|
|
);
|
|
} |