From 31b2180726d942a1f104b47ddd6aeb34c9a1c3a0 Mon Sep 17 00:00:00 2001 From: ysandler Date: Sat, 3 May 2025 20:28:19 -0500 Subject: [PATCH] feat: reset/forgot password flow --- .env.example | 4 + .../(frontend)/forgotPassword/page.client.tsx | 0 src/app/(frontend)/forgotPassword/page.tsx | 41 +++++ src/collections/Users.ts | 55 ++++++- src/components/ForgotPasswordForm.tsx | 145 ++++++++++++++++++ src/payload-types.ts | 4 +- src/serverActions/ForgotPassword.ts | 28 ++++ src/serverActions/ResetPassword.ts | 37 +++++ 8 files changed, 305 insertions(+), 9 deletions(-) create mode 100644 src/app/(frontend)/forgotPassword/page.client.tsx create mode 100644 src/app/(frontend)/forgotPassword/page.tsx create mode 100644 src/components/ForgotPasswordForm.tsx create mode 100644 src/serverActions/ForgotPassword.ts create mode 100644 src/serverActions/ResetPassword.ts diff --git a/.env.example b/.env.example index ecc73e0..2c2d8c5 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ DATABASE_URI=postgres://postgres:@127.0.0.1:5432/your-database-name PAYLOAD_SECRET=YOUR_SECRET_HERE + +DOMIAN_NAME= + SMTP_HOST= SMTP_USER= SMTP_PASS= SMTP_PORT=587 +PASSWORD_RESET_EXPIRATION_IN_MINUTES= diff --git a/src/app/(frontend)/forgotPassword/page.client.tsx b/src/app/(frontend)/forgotPassword/page.client.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(frontend)/forgotPassword/page.tsx b/src/app/(frontend)/forgotPassword/page.tsx new file mode 100644 index 0000000..41f8c2f --- /dev/null +++ b/src/app/(frontend)/forgotPassword/page.tsx @@ -0,0 +1,41 @@ +import { headers as nextHeaders } from 'next/headers' +import { getPayload } from 'payload' +import configPromise from '@payload-config' +import { redirect } from 'next/navigation' +import Image from 'next/image' +import { ForgotPasswordForm } from '@/components/ForgotPasswordForm' + +const LoginPage = async () => { + const payload = await getPayload({ config: configPromise }) + const headers = await nextHeaders() + const userResult = await payload.auth({ headers }) + if (Boolean(userResult.user)) redirect('/profile') + + return ( +
+
+ +
+ beitzah logo +
+
+ Developed with 💜 + by Beitzah.tech +
+
+ +
+
+ ) +} + +export default LoginPage diff --git a/src/collections/Users.ts b/src/collections/Users.ts index f8e55c2..2bb211a 100644 --- a/src/collections/Users.ts +++ b/src/collections/Users.ts @@ -1,12 +1,59 @@ import { defaultAccess } from '@/lib/utils' import type { CollectionConfig } from 'payload' +const expirationInMinutes = parseInt(process.env.PASSWORD_RESET_EXPIRATION_IN_MINUTES || '30') +const domain = process.env.DOMAIN_NAME || 'localhost:3000' + export const Users: CollectionConfig = { slug: 'users', admin: { useAsTitle: 'email', }, - auth: true, + auth: { + // verify: { + // generateEmailSubject: () => { + // return `Verify Account for ${domain}` + // }, + // generateEmailHTML: ({ req, token, user }) => { + // const url = `https://${domain}/verify?token=${token}` + // return ` + // + // + // + //

Verify Account for ${domain}

+ //

Hey ${user.email}, verify your email by clicking here: ${url}

+ //

If you have not recently been signed up for ${domain} then please ignore this email.

+ // + // + // ` + // + // }, + // }, + forgotPassword: { + expiration: (60000 * expirationInMinutes), + generateEmailSubject: () => { + return `Reset password request for ${domain}` + }, + generateEmailHTML: (props) => { + const resetPasswordURL = `https://${domain}/forgotPassword?token=${props?.token}` + + return ` + + + +

Reset Password for ${domain}

+

Hello, ${props?.user.firstName}!

+

There has been a request for the account account for ${props?.user.email}. If this is not the case then please ignore this email.

+

If you intend to reset your password then you can do so with the link below.

+

+ ${resetPasswordURL} +

+ + + ` + }, + } + }, access: { ...defaultAccess, update: ({ req, data }) => { @@ -21,7 +68,7 @@ export const Users: CollectionConfig = { { name: 'role', type: 'select', - options: ['admin', 'user', 'unclaimed'], + options: ['admin', 'user'], saveToJWT: true }, { @@ -32,10 +79,6 @@ export const Users: CollectionConfig = { name: 'lastName', type: 'text', }, - { - name: 'isOwnershipClaimed', - type: 'checkbox', - }, { name: 'repositories', type: 'join', diff --git a/src/components/ForgotPasswordForm.tsx b/src/components/ForgotPasswordForm.tsx new file mode 100644 index 0000000..3e7b901 --- /dev/null +++ b/src/components/ForgotPasswordForm.tsx @@ -0,0 +1,145 @@ +'use client' + +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useRouter, useSearchParams } from 'next/navigation' +import { useState } from 'react' +import resetPassword from '@/serverActions/ResetPassword' +import { toast } from 'sonner' +import forgotPassword from '@/serverActions/ForgotPassword' + +export function ForgotPasswordForm({ className, ...props }: React.ComponentProps<'div'>) { + const router = useRouter() + const [isLoading, setIsLoading] = useState(false) + const [didSendForgetRequest, setDidSendForgetRequest] = useState(false) + const searchParams = useSearchParams() + + const token = searchParams.get('token') + + const handleResetPasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (isLoading) return + setIsLoading(true) + + const formData = new FormData(e.currentTarget) + const password = String(formData.get('password')) + const confirmPassword = String(formData.get('confirmPassword')) + + if (!password || !confirmPassword || password !== confirmPassword) { + toast('Finish the form to continue') + setIsLoading(false) + return + } + + if (!token) { + toast('Password reset token is missing') + setIsLoading(false) + return + } + + const didReset = await resetPassword({ + token, + password, + confirmPassword, + }) + + console.log('didReset', didReset) + + if (!didReset) { + toast('Issue resetting your password') + setIsLoading(false) + } else router.push('/login') + } + + const handleForgotPasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (isLoading) return + setIsLoading(true) + + const formData = new FormData(e.currentTarget) + const email = String(formData.get('email')) + + if (!email) return + + const didForget = await forgotPassword({ email }) + + if (didForget) { + toast('A password change email was sent') + setDidSendForgetRequest(true) + } else toast('There was an issue with your forget password request') + + setIsLoading(false) + } + + return ( +
+ + + Welcome back + + {!token + ? 'An email to reset your password will be sent if we find it in our system' + : 'Please enter your new password'} + + + +
+
+
+ {!token ? ( +
+ {didSendForgetRequest ? ( +

+ Your request has been sent. Check you emails inbox and span folders +

+ ) : ( + <> + + + + )} +
+ ) : ( +
+
+ +
+ + +
+ +
+ +
+ )} + {!didSendForgetRequest && ( + + )} +
+
+
+
+
+
+ By clicking Rest or Forgot Password, you agree to our{' '} + Terms of Service and Privacy Policy. +
+
+ ) +} diff --git a/src/payload-types.ts b/src/payload-types.ts index ef3c2a1..f7e3900 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -158,10 +158,9 @@ export interface UserAuthOperations { */ export interface User { id: number; - role?: ('admin' | 'user' | 'unclaimed') | null; + role?: ('admin' | 'user') | null; firstName?: string | null; lastName?: string | null; - isOwnershipClaimed?: boolean | null; repositories?: { docs?: (number | Repository)[]; hasNextPage?: boolean; @@ -479,7 +478,6 @@ export interface UsersSelect { role?: T; firstName?: T; lastName?: T; - isOwnershipClaimed?: T; repositories?: T; profilePicture?: T; updatedAt?: T; diff --git a/src/serverActions/ForgotPassword.ts b/src/serverActions/ForgotPassword.ts new file mode 100644 index 0000000..a56e446 --- /dev/null +++ b/src/serverActions/ForgotPassword.ts @@ -0,0 +1,28 @@ +'use server' + +import { getPayload } from 'payload' +import config from '@/payload.config' + +type Props = { + email: string, +} +export const forgotPassword = async (props: Props): Promise => { + const { email } = props + + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + + try { + await payload.forgotPassword({ + collection: 'users', + data: { + email + } + }) + return true + } catch (_) { + return false + } +} + +export default forgotPassword diff --git a/src/serverActions/ResetPassword.ts b/src/serverActions/ResetPassword.ts new file mode 100644 index 0000000..7fd0d60 --- /dev/null +++ b/src/serverActions/ResetPassword.ts @@ -0,0 +1,37 @@ +'use server' + +import { getPayload } from 'payload' +import config from '@/payload.config' + +type Props = { + password: string, + confirmPassword: string, + token: string +} +export const resetPassword = async (props: Props): Promise => { + const { password, confirmPassword, token } = props + + if (password !== confirmPassword) return false + + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + + try { + const result = await payload.resetPassword({ + collection: 'users', + overrideAccess: false, + data: { + password: password, + token: token, + } + }) + console.log('result') + console.log(result) + return true + } catch (err) { + console.log(err) + return false + } +} + +export default resetPassword