diff --git a/.env.example b/.env.example index fb1c90de..b33c2557 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +BASE_URL=http://localhost:3000 # # Database # @@ -14,8 +15,19 @@ CDN_API_KEY=api-key CDN_BASE_UPLOAD_URL=https://sg.storage.bunnycdn.com/job-board/assets CDN_BASE_ACCESS_URL=https://job-board.b-cdn.net/assets - NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=maps-api-key +# +# Email SMTP credentials +# +EMAIL_USER=user@gmail.com +EMAIL_PASSWORD= + +# +# Google OAuth credentials +# +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + # To run the application in production environment / check the envs # SKIP_ENV_CHECK=true npm run [replace with your script name] diff --git a/README.md b/README.md index a13d9175..d64fa79f 100644 --- a/README.md +++ b/README.md @@ -48,15 +48,27 @@ Follow these steps to set up the repository locally and run it. # NEXTAUTH_SECRET= NEXTAUTH_URL="http://localhost:3000" - + # # Bunny CDN # CDN_API_KEY= CDN_BASE_UPLOAD_URL= CDN_BASE_ACCESS_URL= - + NEXT_PUBLIC_GOOGLE_MAPS_API_KEY= + + # + # Email SMTP credentials + # + EMAIL_USER=user@gmail.com + EMAIL_PASSWORD= + + # + # Google OAuth credentials + # + GOOGLE_CLIENT_ID= + GOOGLE_CLIENT_SECRET= ``` 2. To generate AUTH_SECRET, @@ -148,40 +160,41 @@ Which is https://[your-pull-zone-hostname]/[any folder name you might have added <img src="https://utfs.io/f/CUistsOk9f0IyM9047Pa7YvK8qbtnUAPO9jwxdskhzc2JNoR" alt=" CDN_BASE_ACCESS_URL" width="600" /> - # Steps to Set Up Google Maps Platform API Key To use the Google Maps API in your applications, follow the steps below to create and set up your API key. ### Step 1: Go to Google Cloud Console + 1. Navigate to the [Google Cloud Console](https://console.cloud.google.com/). 2. If you don’t have a Google account, create one and sign in. ### Step 2: Create a New Project + 1. In the Cloud Console, click on the **Select a project** dropdown at the top. 2. Click **New Project** to create a new project. 3. Give your project a name, select the organization (optional), and choose the billing account. 4. Click **Create**. -### Step 3: Google Maps Platform +### Step 3: Google Maps Platform + 1. Search Google Maps Platform in the Console search bar -<img width="1438" alt="Screenshot 2024-09-22 at 10 15 15 AM" src="https://github.com/user-attachments/assets/a5f93c1e-d7b6-4a5b-847b-868b1133643d"> + <img width="1438" alt="Screenshot 2024-09-22 at 10 15 15 AM" src="https://github.com/user-attachments/assets/a5f93c1e-d7b6-4a5b-847b-868b1133643d"> 2. If your account is not setup yet , finish your account setup -<img width="930" alt="Screenshot 2024-09-22 at 10 02 59 AM" src="https://github.com/user-attachments/assets/c8ee7aa3-7610-4836-86f6-c28e8604c2b9"> + <img width="930" alt="Screenshot 2024-09-22 at 10 02 59 AM" src="https://github.com/user-attachments/assets/c8ee7aa3-7610-4836-86f6-c28e8604c2b9"> -3.After Completeing account setup , select the "Keys and Credentails" Section. +3.After Completeing account setup , select the "Keys and Credentails" Section. 4.Then select the Create Credentials option , under which you can select the "API Key Option" <img width="1440" alt="Screenshot 2024-09-22 at 10 05 36 AM" src="https://github.com/user-attachments/assets/9e897c91-3282-4e28-8fdf-d920d6c4bc15"> 5. You will receive a API Key , add the key to the NEXT_PUBLIC_GOOGLE_MAPS_API_KEY in the .env -<img width="660" alt="Screenshot 2024-09-22 at 10 19 33 AM" src="https://github.com/user-attachments/assets/adcb5a49-892e-43a1-b318-56b296280611"> - - + <img width="660" alt="Screenshot 2024-09-22 at 10 19 33 AM" src="https://github.com/user-attachments/assets/adcb5a49-892e-43a1-b318-56b296280611"> ### Step 4: Changes required to make it work on localhost + 1. Although the documentation mentions that without restriction , the API key will work everywhere, that is not the case for http requests. -2. Add a restriction and mention your localhost along with your port for it to start working on local , and save and continue -<img width="694" alt="Screenshot 2024-09-22 at 10 06 44 AM" src="https://github.com/user-attachments/assets/3acfdf47-4b1d-480f-8172-0fbfa1c39f02"> +2. Add a restriction and mention your localhost along with your port for it to start working on local , and save and continue + <img width="694" alt="Screenshot 2024-09-22 at 10 06 44 AM" src="https://github.com/user-attachments/assets/3acfdf47-4b1d-480f-8172-0fbfa1c39f02"> 3. to test navigate to the http://localhost:3000/create , and test the "Where is the job located" input. diff --git a/package.json b/package.json index c4a08bcd..cc9844c7 100644 --- a/package.json +++ b/package.json @@ -57,14 +57,15 @@ "clsx": "^2.1.1", "dayjs": "^1.11.13", "framer-motion": "^11.5.4", - "linkify-react": "^4.1.3", "jiti": "^1.21.6", + "linkify-react": "^4.1.3", "lodash": "^4.17.21", "lucide-react": "^0.426.0", "next": "^14.2.12", "next-auth": "^4.24.7", "next-themes": "^0.3.0", "nextjs-toploader": "^1.6.12", + "nodemailer": "^6.9.15", "react": "^18", "react-dom": "^18", "react-hook-form": "^7.52.2", @@ -80,6 +81,7 @@ "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/node": "^20", + "@types/nodemailer": "^6.4.16", "@types/react": "^18", "@types/react-dom": "^18", "@typescript-eslint/eslint-plugin": "^8.1.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0995ab0..35e0af8c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,12 +10,40 @@ datasource db { model User { id String @id @default(cuid()) name String - email String @unique - password String + + password String? avatar String? isVerified Boolean @default(false) role Role @default(USER) jobs Job[] + + email String @unique + emailVerified DateTime? + + oauthProvider OauthProvider? // Tracks OAuth provider (e.g., 'google') + oauthId String? + + blockedByAdmin DateTime? + +} + +enum OauthProvider { + GOOGLE +} + + +model VerificationToken { + token String + identifier String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + type TokenType + @@unique([token,identifier]) +} + +enum TokenType { + EMAIL_VERIFICATION + RESET_PASSWORD } model Job { diff --git a/prisma/seed.ts b/prisma/seed.ts index 71caed80..214ae561 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -298,6 +298,7 @@ async function seedUsers() { name: u.name, password: hashedPassword, role: u.role || Role.USER, + emailVerified: new Date(), }, update: {}, }) diff --git a/src/actions/auth.actions.ts b/src/actions/auth.actions.ts new file mode 100644 index 00000000..6e6601d8 --- /dev/null +++ b/src/actions/auth.actions.ts @@ -0,0 +1,315 @@ +'use server'; +import { + EMAIL_VERIFICATION_LINK_RESENT_TIME, + PASSWORD_HASH_SALT_ROUNDS, + PENDING_EMAIL_VERIFICATION_USER_ID, +} from '@/config/auth.config'; +import APP_PATHS from '@/config/path.config'; +import prisma from '@/config/prisma.config'; +import { serverEnv } from '@/env/server'; +import { withServerActionAsyncCatcher } from '@/lib/async-catch'; +import { ErrorHandler } from '@/lib/error'; +import { + SignupSchema, + SignupSchemaType, +} from '@/lib/validators/auth.validator'; +import { ServerActionReturnType } from '@/types/api.types'; +import bcryptjs from 'bcryptjs'; +import { v4 as uuidv4 } from 'uuid'; +import { sendConfirmationEmail } from '@/lib/sendConfirmationEmail'; +import { cookies } from 'next/headers'; +import { SuccessResponse } from '@/lib/success'; +import { isTokenExpiredUtil } from '@/lib/utils'; +import { TokenType } from '@prisma/client'; +export const signUp = withServerActionAsyncCatcher< + SignupSchemaType, + ServerActionReturnType +>(async (_data) => { + const data = SignupSchema.parse(_data); + + const userExist = await prisma.user.findFirst({ + where: { email: data.email }, + }); + + if (userExist) + throw new ErrorHandler('User with this email already exist', 'BAD_REQUEST'); + + const hashedPassword = await bcryptjs.hash( + data.password, + PASSWORD_HASH_SALT_ROUNDS + ); + + try { + await prisma.$transaction(async (txn) => { + const user = await txn.user.create({ + data: { ...data, password: hashedPassword }, + }); + + const verificationToken = await txn.verificationToken.create({ + data: { + identifier: user.id, + token: uuidv4(), + type: 'EMAIL_VERIFICATION', + }, + }); + + const confirmationLink = `${serverEnv.BASE_URL}/${APP_PATHS.VERIFY_EMAIL}/${verificationToken.token}`; + + await sendConfirmationEmail( + data.email, + confirmationLink, + 'EMAIL_VERIFICATION' + ); + + cookies().set(PENDING_EMAIL_VERIFICATION_USER_ID, user.id, { + maxAge: 5 * 60, // 5 minutes + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + }); + + return user; + }); + + return new SuccessResponse( + 'User registered successfully. A verification link has been sent to your email.', + 201 + ).serialize(); + } catch { + throw new ErrorHandler( + 'Registration Failed, please try again!', + 'INTERNAL_SERVER_ERROR' + ); + } +}); + +export const resendVerificationEmail = withServerActionAsyncCatcher< + null, + ServerActionReturnType +>(async () => { + const unverifiedUserId = cookies().get( + PENDING_EMAIL_VERIFICATION_USER_ID + )?.value; + + if (!unverifiedUserId) + throw new ErrorHandler('Resource not found!', 'BAD_REQUEST'); + + const unverifiedUser = await prisma.user.findFirst({ + where: { id: unverifiedUserId, emailVerified: null }, + }); + + if (!unverifiedUser) + throw new ErrorHandler('Resource not found!', 'BAD_REQUEST'); + + const verificationToken = await prisma.verificationToken.findFirst({ + where: { identifier: unverifiedUserId, type: 'EMAIL_VERIFICATION' }, + }); + + if (!verificationToken) + throw new ErrorHandler('Resource not found!', 'BAD_REQUEST'); + + if (isTokenExpiredUtil(verificationToken.createdAt)) + throw new ErrorHandler('Link expired!', 'BAD_REQUEST', { + linkExpired: true, + }); + + const now = new Date().getTime(); + + // last time update i.e. token was updated + const updatedAt = new Date(verificationToken.updatedAt).getTime(); + const isTooEarlyToResend = + now - updatedAt < EMAIL_VERIFICATION_LINK_RESENT_TIME * 1000; + if (isTooEarlyToResend) + throw new ErrorHandler('Too much request', 'BAD_REQUEST'); + + // TODO: should we not delete the user record from db + await resendVerificationLinkUtil({ + userId: unverifiedUser.id, + email: unverifiedUser.email, + prevToken: verificationToken.token, + type: 'EMAIL_VERIFICATION', + }); + + return new SuccessResponse( + 'Verfication link resent successfully!', + 201 + ).serialize(); +}); + +const resendVerificationLinkUtil = async ({ + userId, + email, + prevToken, + reIssue = false, + type, +}: { + userId: string; + email: string; + prevToken: string; + reIssue?: boolean; + type: TokenType; +}) => { + const newToken = uuidv4(); + await prisma.verificationToken.update({ + where: { + token_identifier: { + identifier: userId, + token: prevToken, + }, + type, + }, + data: { token: newToken, ...(reIssue ? { createdAt: new Date() } : {}) }, + }); + + const confirmationLink = `${serverEnv.BASE_URL}/${APP_PATHS.VERIFY_EMAIL}/${newToken}`; + await sendConfirmationEmail(email, confirmationLink, type); +}; + +// It is serving two purpose/ +// 1. Email verification +// 2. In case link got expired, by passing resend:true will be re-usable to re-issue the verfication link +export const verifyEmail = withServerActionAsyncCatcher< + { token: string; resend?: boolean }, + ServerActionReturnType +>(async ({ token, resend = false }) => { + let verificationToken = await prisma.verificationToken.findFirst({ + where: { token, type: 'EMAIL_VERIFICATION' }, + }); + + if (!verificationToken) + throw new ErrorHandler('Resource not found!', 'BAD_REQUEST', { + notFound: true, + }); + + if (!isTokenExpiredUtil(verificationToken.createdAt)) { + await prisma.$transaction(async (txn) => { + await txn.user.update({ + where: { id: verificationToken.identifier }, + data: { emailVerified: new Date() }, + }); + + await txn.verificationToken.delete({ + where: { + token_identifier: { + token: verificationToken.token, + identifier: verificationToken.identifier, + }, + }, + }); + + return true; + }); + cookies().delete(PENDING_EMAIL_VERIFICATION_USER_ID); + return new SuccessResponse('Email verified successfully!', 201).serialize(); + } + + if (!resend) { + throw new ErrorHandler('Link expired!', 'BAD_REQUEST', { + linkExpired: true, + }); + } + + const unverifiedUser = await prisma.user.findFirst({ + where: { id: verificationToken.identifier }, + }); + + await resendVerificationLinkUtil({ + email: unverifiedUser!.email, + prevToken: verificationToken.token, + userId: unverifiedUser!.id, + reIssue: true, + type: 'EMAIL_VERIFICATION', + }); + + return new SuccessResponse( + 'Verfication link resent successfully!', + 201 + ).serialize(); +}); + +export const forgetPassword = withServerActionAsyncCatcher< + { email: string }, + ServerActionReturnType +>(async ({ email }) => { + const user = await prisma.user.findFirst({ where: { email } }); + + if (!user) + throw new ErrorHandler( + 'No account associated with this email address.', + 'BAD_REQUEST' + ); + + const verificationToken = await prisma.verificationToken.create({ + data: { + type: 'RESET_PASSWORD', + token: uuidv4(), + identifier: user.id, + }, + }); + + const resetPasswordLink = `${serverEnv.BASE_URL}/${APP_PATHS.RESET_PASSWORD}/${verificationToken.token}`; + + await sendConfirmationEmail(email, resetPasswordLink, 'RESET_PASSWORD'); + + return new SuccessResponse( + 'A password reset link has been sent to your email. Please check your inbox.', + 201 + ).serialize(); +}); + +export const resetPassword = withServerActionAsyncCatcher< + { + token: string; + password: string; + confirmPassword: string; + }, + ServerActionReturnType +>(async ({ token, password, confirmPassword }) => { + if (password !== confirmPassword) + throw new ErrorHandler('Password does not match.', 'BAD_REQUEST'); + + const verificationToken = await prisma.verificationToken.findFirst({ + where: { token }, + }); + + if (!verificationToken) + throw new ErrorHandler('Invalid or expired reset link.', 'BAD_REQUEST'); + + if (isTokenExpiredUtil(verificationToken.createdAt)) + throw new ErrorHandler( + 'The reset link has expired. Please request a new one.', + 'BAD_REQUEST' + ); + + const user = await prisma.user.findFirst({ + where: { id: verificationToken.identifier }, + }); + + if (!user) + throw new ErrorHandler( + 'Unauthorized access. User not found.', + 'AUTHENTICATION_FAILED' + ); + + await prisma.$transaction(async (txn) => { + await txn.user.update({ + where: { id: verificationToken.identifier }, + data: { + password: await bcryptjs.hash(password, PASSWORD_HASH_SALT_ROUNDS), + }, + }); + + await txn.verificationToken.delete({ + where: { + token_identifier: { + token: verificationToken.token, + identifier: verificationToken.identifier, + }, + }, + }); + }); + + return new SuccessResponse( + 'Your password has been successfully updated.', + 201 + ).serialize(); +}); diff --git a/src/actions/job.action.ts b/src/actions/job.action.ts index 2d104754..f0a9c066 100644 --- a/src/actions/job.action.ts +++ b/src/actions/job.action.ts @@ -15,11 +15,16 @@ import { } from '@/lib/validators/jobs.validator'; import { getJobFilters } from '@/services/jobs.services'; import { ServerActionReturnType } from '@/types/api.types'; + +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/authOptions'; + import { getAllJobsAdditonalType, getAllRecommendedJobs, getJobType, } from '@/types/jobs.types'; + type additional = { isVerifiedJob: boolean; @@ -28,6 +33,10 @@ export const createJob = withServerActionAsyncCatcher< JobPostSchemaType, ServerActionReturnType<additional> >(async (data) => { + const auth = await getServerSession(authOptions); + if (!auth || !auth?.user?.id) + throw new ErrorHandler('Not Authrised', 'UNAUTHORIZED'); + const result = JobPostSchema.parse(data); const { companyName, @@ -48,7 +57,7 @@ export const createJob = withServerActionAsyncCatcher< } = result; await prisma.job.create({ data: { - userId: '1', // Default to 1 since there's no session to check for user id + userId: auth.user.id, title, description, companyName, @@ -293,3 +302,38 @@ export const getRecentJobs = async () => { return new ErrorHandler('Internal server error', 'DATABASE_ERROR'); } }; + +export const updateJob = withServerActionAsyncCatcher< + JobPostSchemaType & { jobId: string }, + ServerActionReturnType<additional> +>(async (data) => { + const auth = await getServerSession(authOptions); + if (!auth || !auth?.user?.id) + throw new ErrorHandler('Not Authorized', 'UNAUTHORIZED'); + + const { jobId, ...updateData } = data; + const parsedId = JobByIdSchema.parse({ id: jobId }); + + const result = JobPostSchema.parse(updateData); + + let job = await prisma.job.findFirst({ + where: { id: parsedId.id, userId: auth.user.id }, + }); + + if (!job) + throw new ErrorHandler('Job not found or not authorized', 'NOT_FOUND'); + + // Update the job + job = await prisma.job.update({ + where: { id: parsedId.id }, + data: { ...result, isVerifiedJob: false }, + }); + + const additonal = { isVerifiedJob: false, jobId: job.id }; + + return new SuccessResponse( + 'Job updated successfully', + 200, + additonal + ).serialize(); +}); diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 00000000..342cc9b5 --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,18 @@ +import { ForgotPassword } from '@/components/auth/forgot-password'; +import { FormContainer } from '@/layouts/form-container'; +import React from 'react'; + +const ForgootPasswordPage = async () => { + return ( + <div className="my-20"> + <FormContainer + heading={'Welcome back!'} + description={'Enter your details below to continue with your sign-in.'} + > + <ForgotPassword /> + </FormContainer> + </div> + ); +}; + +export default ForgootPasswordPage; diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 00000000..da5ec1ec --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { authOptions } from '@/lib/authOptions'; +import { getServerSession } from 'next-auth'; +import { redirect } from 'next/navigation'; + +export default async function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + const auth = await getServerSession(authOptions); + + if (auth) redirect(`/`); + + return children; +} diff --git a/src/app/(auth)/reset-password/[token]/page.tsx b/src/app/(auth)/reset-password/[token]/page.tsx new file mode 100644 index 00000000..b137f9fc --- /dev/null +++ b/src/app/(auth)/reset-password/[token]/page.tsx @@ -0,0 +1,40 @@ +import { ResetPassword } from '@/components/auth/reset-password'; +import prisma from '@/config/prisma.config'; +import { FormContainer } from '@/layouts/form-container'; +import { isTokenExpiredUtil } from '@/lib/utils'; +import { notFound } from 'next/navigation'; +import React from 'react'; + +const ResetPasswordPage = async ({ + params: { token }, +}: { + params: { token: string }; +}) => { + const verificatinToken = await prisma.verificationToken.findFirst({ + where: { token }, + }); + + if (!verificatinToken) notFound(); + + if (isTokenExpiredUtil(verificatinToken.createdAt)) + return <h1>link expried</h1>; + + const user = await prisma.user.findFirst({ + where: { id: verificatinToken.identifier }, + }); + + if (!user) notFound(); + + return ( + <div className="my-20"> + <FormContainer + heading={'Welcome back!'} + description={'Enter your details below to continue with your sign-in.'} + > + <ResetPassword /> + </FormContainer> + </div> + ); +}; + +export default ResetPasswordPage; diff --git a/src/app/(auth)/signin/page.tsx b/src/app/(auth)/signin/page.tsx new file mode 100644 index 00000000..c81f3395 --- /dev/null +++ b/src/app/(auth)/signin/page.tsx @@ -0,0 +1,17 @@ +import { Signin } from '@/components/auth/signin'; +import { FormContainer } from '@/layouts/form-container'; + +const LoginPage = () => { + return ( + <div className="my-20"> + <FormContainer + heading={'Welcome back'} + description={'Please enter your details to sign in.'} + > + <Signin /> + </FormContainer> + </div> + ); +}; + +export default LoginPage; diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx new file mode 100644 index 00000000..f3e6ef8c --- /dev/null +++ b/src/app/(auth)/signup/page.tsx @@ -0,0 +1,17 @@ +import { Signup } from '@/components/auth/signup'; +import { FormContainer } from '@/layouts/form-container'; + +const SignupPage = () => { + return ( + <div className="my-20"> + <FormContainer + heading={'Welcome to 100xJobs'} + description={'Please enter your details to sign up.'} + > + <Signup /> + </FormContainer> + </div> + ); +}; + +export default SignupPage; diff --git a/src/app/(auth)/verify-email/[token]/EmailVerificationLinkExpired.tsx b/src/app/(auth)/verify-email/[token]/EmailVerificationLinkExpired.tsx new file mode 100644 index 00000000..a2bf0bc9 --- /dev/null +++ b/src/app/(auth)/verify-email/[token]/EmailVerificationLinkExpired.tsx @@ -0,0 +1,54 @@ +'use client'; +import { useState } from 'react'; +import { FormContainer } from '@/layouts/form-container'; +import Link from 'next/link'; +import APP_PATHS from '@/config/path.config'; +import { Button } from '@/components/ui/button'; +import { verifyEmail } from '@/actions/auth.actions'; +import { useToast } from '@/components/ui/use-toast'; + +export const EmailVerificationLinkExpired = ({ token }: { token: string }) => { + const [isEmailSent, setIsEmailSent] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + const handleResendClick = async () => { + setIsLoading(true); + try { + await verifyEmail({ token, resend: true }); + setIsEmailSent(true); + } catch { + toast({ + variant: 'destructive', + title: 'Something went wrong, please try again!', + }); + } finally { + setIsLoading(!true); + } + }; + + return ( + <div className="my-20"> + <FormContainer + heading={'Link Expired!'} + description={ + isEmailSent + ? 'We’ve sent a confirmation email to your inbox. Please confirm your email address to activate your account.' + : 'The verification link has expired or is invalid. Please request a new verification link.' + } + > + {!isEmailSent ? ( + <Link href={APP_PATHS.SIGNIN}> + <Button className="w-full" disabled={isLoading}> + Go to Login + </Button> + </Link> + ) : ( + <Link href={APP_PATHS.SIGNIN} onClick={handleResendClick}> + <Button className="w-full">Resend Verification Email</Button> + </Link> + )} + </FormContainer> + </div> + ); +}; diff --git a/src/app/(auth)/verify-email/[token]/page.tsx b/src/app/(auth)/verify-email/[token]/page.tsx new file mode 100644 index 00000000..0a64b44a --- /dev/null +++ b/src/app/(auth)/verify-email/[token]/page.tsx @@ -0,0 +1,52 @@ +import { verifyEmail } from '@/actions/auth.actions'; +import { Button } from '@/components/ui/button'; +import APP_PATHS from '@/config/path.config'; +import { FormContainer } from '@/layouts/form-container'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import React from 'react'; +import { EmailVerificationLinkExpired } from './EmailVerificationLinkExpired'; + +const Page = async ({ params: { token } }: { params: { token: string } }) => { + const res = await verifyEmail({ token }); + + if (res.status) return <EmailVerifiedSuccess />; + else if (res?.error?.notFound) return <EmailVerificationLinkNotFound />; + else if (res?.error?.linkExpired) + return <EmailVerificationLinkExpired token={token} />; + return redirect(APP_PATHS.SIGNIN); +}; + +export default Page; + +const EmailVerifiedSuccess = () => { + return ( + <div className="my-20"> + <FormContainer + heading={'Email Verified!'} + description={ + 'Your email has been successfully verified. You can now access your account.' + } + > + <Link href={APP_PATHS.SIGNIN}> + <Button className="w-full">Go to Login</Button> + </Link> + </FormContainer> + </div> + ); +}; + +const EmailVerificationLinkNotFound = () => { + return ( + <div className="my-20"> + <FormContainer + heading={'Link Not Found'} + description={'The verification link you used is invalid or not found.'} + > + <Link href={APP_PATHS.SIGNUP}> + <Button className="w-full">Go to Signup</Button> + </Link> + </FormContainer> + </div> + ); +}; diff --git a/src/app/(auth)/welcome/page.tsx b/src/app/(auth)/welcome/page.tsx new file mode 100644 index 00000000..ec06b53d --- /dev/null +++ b/src/app/(auth)/welcome/page.tsx @@ -0,0 +1,27 @@ +import { Welcome } from '@/components/auth/welcome'; +import APP_PATHS from '@/config/path.config'; +import { FormContainer } from '@/layouts/form-container'; + +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { PENDING_EMAIL_VERIFICATION_USER_ID } from '@/config/auth.config'; + +const WelcomePage = () => { + const unverifiedUserId = cookies().get(PENDING_EMAIL_VERIFICATION_USER_ID); + if (!unverifiedUserId) redirect(APP_PATHS.SIGNIN); + + return ( + <div className="my-20"> + <FormContainer + heading={'Check Your Email!'} + description={ + 'We’ve sent a confirmation email to your inbox. Please confirm your email address to activate your account.' + } + > + <Welcome /> + </FormContainer> + </div> + ); +}; + +export default WelcomePage; diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..47c6692d --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import { authOptions } from '@/lib/authOptions'; +import NextAuth from 'next-auth/next'; + +const handler = NextAuth(authOptions); +// export default handler; +export { handler as GET, handler as POST }; diff --git a/src/components/auth/forgot-password.tsx b/src/components/auth/forgot-password.tsx new file mode 100644 index 00000000..ad4b8f8b --- /dev/null +++ b/src/components/auth/forgot-password.tsx @@ -0,0 +1,60 @@ +'use client'; +import { Label } from '../ui/label'; +import { Input } from '../ui/input'; + +import { Button } from '../ui/button'; +import { FormEvent, useState } from 'react'; +import { useToast } from '../ui/use-toast'; +import { forgetPassword } from '@/actions/auth.actions'; + +export const ForgotPassword = () => { + const [isLoading, setIsLoading] = useState(false); + const [email, setEmail] = useState(''); + const { toast } = useToast(); + + const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { + e.preventDefault(); + setIsLoading(true); + + try { + const res = await forgetPassword({ email }); + + toast({ + title: res.message, + variant: res.status ? 'success' : 'destructive', + }); + } catch { + toast({ + title: + "We're sorry for the inconvenience. Please report this issue to our support team", + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + return ( + <form + onSubmit={handleSubmit} + className="grid w-full max-w-sm items-center gap-4" + > + <Label htmlFor="email">Email</Label> + + <Input + type="email" + id="email" + placeholder="Email" + required={true} + value={email} + onChange={(e) => { + setEmail(e.target.value); + }} + /> + + <Button type="submit" className="mt-4" disabled={isLoading}> + Submit + </Button> + </form> + ); +}; diff --git a/src/components/auth/reset-password.tsx b/src/components/auth/reset-password.tsx new file mode 100644 index 00000000..7ffa25f0 --- /dev/null +++ b/src/components/auth/reset-password.tsx @@ -0,0 +1,122 @@ +'use client'; +import { useParams } from 'next/navigation'; +import React, { FormEvent, useState } from 'react'; +import { Label } from '../ui/label'; +import { Input } from '../ui/input'; +import { Button } from '../ui/button'; +import { resetPassword } from '@/actions/auth.actions'; +import { useToast } from '../ui/use-toast'; +import { useRouter } from 'next/navigation'; +import APP_PATHS from '@/config/path.config'; +import { EyeIcon, EyeOffIcon } from 'lucide-react'; + +export const ResetPassword = () => { + const params = useParams(); + + const token = params.token; + + const [data, setData] = useState({ + password: '', + confirmPassword: '', + }); + + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const { toast } = useToast(); + const router = useRouter(); + + const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { + e.preventDefault(); + if (data.confirmPassword !== data.password) { + setErrorMessage('Password must be identical!'); + return; + } + setIsLoading(true); + try { + const res = await resetPassword({ + ...data, + token: token as string, + }); + toast({ + variant: res.status ? 'default' : 'destructive', + title: res.message, + }); + + router.replace(APP_PATHS.SIGNIN); + } catch { + } finally { + setIsLoading(false); + } + }; + + return ( + <form + onSubmit={handleSubmit} + className="grid w-full max-w-sm items-center gap-4" + > + <div className="space-y-2"> + <Label htmlFor="email">Password</Label> + + <PasswordInput + placeholder="Enter your password" + value={data.password} + onChange={(e) => + setData((prev) => ({ ...prev, password: e.target.value })) + } + /> + </div> + <div className="space-y-2"> + <Label htmlFor="email">Confirm Password</Label> + <PasswordInput + placeholder="Confirm your password" + value={data.confirmPassword} + onChange={(e) => + setData((prev) => ({ ...prev, confirmPassword: e.target.value })) + } + /> + </div> + {errorMessage ? ( + <p className="text-sm font-medium text-destructive">{errorMessage}</p> + ) : null} + <Button type="submit" disabled={isLoading}> + Submit + </Button> + </form> + ); +}; + +interface PasswordInputProps { + placeholder?: string; + value: string; + onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; +} + +export const PasswordInput = ({ + placeholder, + value, + onChange, +}: PasswordInputProps) => { + const [showPassword, setShowPassword] = useState(false); + + const togglePasswordVisibility = () => { + setShowPassword(!showPassword); + }; + + return ( + <div className="relative"> + <Input + type={showPassword ? 'text' : 'password'} + value={value} + onChange={onChange} + placeholder={placeholder || '••••••••'} + /> + <button + type="button" + onClick={togglePasswordVisibility} + className="absolute right-2 top-1/2 transform -translate-y-1/2 focus:outline-none" + > + {showPassword ? <EyeOffIcon /> : <EyeIcon />} + </button> + </div> + ); +}; diff --git a/src/components/auth/signin.tsx b/src/components/auth/signin.tsx new file mode 100644 index 00000000..4c15d239 --- /dev/null +++ b/src/components/auth/signin.tsx @@ -0,0 +1,129 @@ +'use client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import APP_PATHS from '@/config/path.config'; +import { + SigninSchema, + SigninSchemaType, +} from '@/lib/validators/auth.validator'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { signIn } from 'next-auth/react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../ui/form'; +import { useToast } from '../ui/use-toast'; +import { DemarcationLine, GoogleOauthButton } from './social-auth'; +import { PasswordInput } from '../password-input'; + +export const Signin = () => { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm<SigninSchemaType>({ + resolver: zodResolver(SigninSchema), + defaultValues: { + email: '', + password: '', + }, + }); + + async function signinHandler(data: SigninSchemaType) { + try { + const response = await signIn('signin', { ...data, redirect: false }); + if (!response?.ok) { + return toast({ + title: response?.error || 'Internal server error', + variant: 'destructive', + }); + } + toast({ + title: 'Login successful! Welcome back!', + variant: 'success', + }); + // const redirect = searchParams.get('next') || APP_PATHS.HOME; + const searchParams = new URLSearchParams(window.location.search); + const redirect = searchParams.get('next') || APP_PATHS.HOME; + router.push(redirect); + } catch (_error) { + return toast({ + title: 'Internal server error', + variant: 'destructive', + }); + } + } + + return ( + <div className=""> + <Form {...form}> + <form + onSubmit={form.handleSubmit(signinHandler)} + className="w-full space-y-6" + > + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email address</FormLabel> + <FormControl> + <Input {...field} placeholder="name@gmail.com" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <PasswordInput field={field} placeholder="Password" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <div className="flex justify-end"> + <Link + href={APP_PATHS.FORGOT_PASSWORD} + className="text-xs text-muted-foreground font-medium hover:underline" + > + Forget your password? + </Link> + </div> + <Button + type="submit" + disabled={form.formState.isSubmitting} + className="w-full h-10" + > + {form.formState.isSubmitting ? 'Please wait...' : 'Sign In'} + </Button> + <DemarcationLine /> + <GoogleOauthButton label="Sign in with Google" /> + </form> + </Form> + <div className="flex items-center justify-center mt-6"> + <span className="text-muted-foreground"> + Don't have an account yet?{' '} + <Link + href={APP_PATHS.SIGNUP} + className="text-muted-foreground font-semibold hover:underline" + > + Sign Up + </Link> + </span> + </div> + </div> + ); +}; diff --git a/src/components/auth/signup.tsx b/src/components/auth/signup.tsx new file mode 100644 index 00000000..70aac2fb --- /dev/null +++ b/src/components/auth/signup.tsx @@ -0,0 +1,143 @@ +'use client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import APP_PATHS from '@/config/path.config'; +import { + SignupSchema, + SignupSchemaType, +} from '@/lib/validators/auth.validator'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../ui/form'; +import { useToast } from '../ui/use-toast'; +import { signUp } from '@/actions/auth.actions'; +import { DemarcationLine, GoogleOauthButton } from './social-auth'; +import { PasswordInput } from '../password-input'; + +export const Signup = () => { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm<SignupSchemaType>({ + resolver: zodResolver(SignupSchema), + defaultValues: { + name: '', + email: '', + password: '', + }, + }); + + async function signupHandler(data: SignupSchemaType) { + try { + const response = await signUp(data); + + if (!response.status) { + toast({ + title: response.message || 'Something went wrong', + variant: 'destructive', + }); + } else { + toast({ + title: response.message || 'Signup successful! Welcome to 100xJobs!', + variant: 'success', + }); + + router.push(APP_PATHS.WELCOME); + } + } catch { + toast({ + title: 'something went wrong', + variant: 'destructive', + }); + } + } + + return ( + <> + <Form {...form}> + <form + onSubmit={form.handleSubmit(signupHandler)} + className="w-full space-y-6" + > + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Name</FormLabel> + <FormControl> + <Input {...field} placeholder="Jhon Doe" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email address</FormLabel> + <FormControl> + <Input {...field} placeholder="name@gmail.com" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <PasswordInput field={field} placeholder="Password" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <div className="flex justify-end"> + <Link + href={APP_PATHS.FORGOT_PASSWORD} + className="text-xs text-muted-foreground font-medium hover:underline" + > + Forget your password? + </Link> + </div> + <Button + type="submit" + disabled={form.formState.isSubmitting} + className="w-full h-10" + > + {form.formState.isSubmitting ? 'Please wait...' : 'Create Account'} + </Button> + <DemarcationLine /> + <GoogleOauthButton label="Sign un with Google" /> + </form> + </Form> + <div className="flex items-center justify-center mt-6"> + <span className="text-muted-foreground"> + Already have an account?{' '} + <Link + href={APP_PATHS.SIGNIN} + className="text-muted-foreground font-semibold hover:underline" + > + Sign In + </Link> + </span> + </div> + </> + ); +}; diff --git a/src/components/auth/social-auth.tsx b/src/components/auth/social-auth.tsx new file mode 100644 index 00000000..7b7a8b09 --- /dev/null +++ b/src/components/auth/social-auth.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { Button } from '@/components/ui/button'; + +import { signIn } from 'next-auth/react'; +export const DemarcationLine = () => ( + <div className="flex items-center my-4"> + <div className="flex-grow h-px bg-gray-300" /> + <span className="px-4 text-sm text-gray-500">or continue with</span> + <div className="flex-grow h-px bg-gray-300" /> + </div> +); + +export const GoogleOauthButton = ({ label }: { label: string }) => ( + <Button + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + signIn('google'); + }} + className="w-full h-10 bg-white border border-gray-300 text-gray-700 font-medium hover:bg-gray-50" + > + <svg + className="w-4 h-4 mr-2" + aria-hidden="true" + focusable="false" + data-prefix="fab" + data-icon="google" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 488 512" + > + <path + fill="currentColor" + d="M488 261.8c0-17.8-1.6-35.6-4.9-52.9H249.2v99.4h135.3c-5.8 30-23.1 55.3-49.2 72.1v59.8h79.4c46.5-42.9 73.3-106 73.3-178.4zM249.2 480c65.7 0 120.7-21.8 160.9-59.2l-79.4-59.8c-22.2 14.9-50.5 23.7-81.5 23.7-62.7 0-115.8-42.3-134.7-99.2H33.8v62.1C74 428.7 157.5 480 249.2 480zM114.5 303.7c-7.8-22.8-7.8-47.5 0-70.3V171.3H33.8c-35.1 69.8-35.1 151.8 0 221.6l80.7-62.1zM249.2 97.4c35.8-.6 70.1 12.7 96.2 36.2l72.3-69.2C370.2 28.3 310.4 0 249.2 0 157.5 0 74 51.3 33.8 130.4l80.7 62.1c18.9-56.9 72-99.2 134.7-95.1z" + /> + </svg> + {label} + </Button> +); diff --git a/src/components/auth/welcome.tsx b/src/components/auth/welcome.tsx new file mode 100644 index 00000000..6967564a --- /dev/null +++ b/src/components/auth/welcome.tsx @@ -0,0 +1,65 @@ +'use client'; +import React, { useEffect, useState } from 'react'; +import { Button } from '../ui/button'; +import APP_PATHS from '@/config/path.config'; +import { useRouter } from 'next/navigation'; +import { resendVerificationEmail } from '@/actions/auth.actions'; +import { EMAIL_VERIFICATION_LINK_RESENT_TIME } from '@/config/auth.config'; + +export const Welcome = () => { + const router = useRouter(); + return ( + <div className="text-center p-4"> + <p className="text-muted-foreground mb-4"> + Didn’t receive the email? Click the button below to resend it. + </p> + <CountdownButton /> + <Button + variant="link" + className="mt-4 text-primary underline" + onClick={() => router.push(APP_PATHS.SIGNIN)} + > + Go to Login + </Button> + </div> + ); +}; + +const CountdownButton = () => { + const [isDisabled, setIsDisabled] = useState(true); + const resentTime = EMAIL_VERIFICATION_LINK_RESENT_TIME; + const [secondsRemaining, setSecondsRemaining] = useState(resentTime); + + // Handler when button is clicked + const handleClick = async () => { + setIsDisabled(true); + setSecondsRemaining(resentTime); + await resendVerificationEmail(null); + }; + + // useEffect to handle the countdown logic + useEffect(() => { + if (secondsRemaining === 0 && isDisabled) { + setIsDisabled(false); // Enable the button once the countdown is over + } + + let timer: NodeJS.Timeout; + + if (isDisabled && secondsRemaining > 0) { + timer = setTimeout(() => { + setSecondsRemaining((prev) => prev - 1); + }, 1000); // Decrease the time every second + } + + // Cleanup the timer + return () => clearTimeout(timer); + }, [isDisabled, secondsRemaining]); + + return ( + <Button onClick={handleClick} disabled={isDisabled} className="w-full"> + {isDisabled + ? `Wait ${secondsRemaining} second${secondsRemaining !== 1 ? 's' : ''}...` + : 'Click Me'} + </Button> + ); +}; diff --git a/src/components/job-form.tsx b/src/components/job-form.tsx index d9661b6f..06da8fb8 100644 --- a/src/components/job-form.tsx +++ b/src/components/job-form.tsx @@ -16,7 +16,7 @@ import { SelectValue, } from '@/components/ui/select'; import { zodResolver } from '@hookform/resolvers/zod'; -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { JobPostSchema, @@ -39,8 +39,18 @@ const DynamicGmapsAutoSuggest = dynamic(() => import('./gmaps-autosuggest'), { }); import { EmployementType } from '@prisma/client'; import _ from 'lodash'; +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import APP_PATHS from '@/config/path.config'; const PostJobForm = () => { + const session = useSession(); + const router = useRouter(); + useEffect(() => { + if (session.status !== 'loading' && session.status === 'unauthenticated') + router.push(`${APP_PATHS.SIGNIN}?redirectTo=/create`); + }, [session.status]); + const { toast } = useToast(); const companyLogoImg = useRef<HTMLImageElement>(null); const form = useForm<JobPostSchemaType>({ @@ -157,6 +167,9 @@ const PostJobForm = () => { } form.setValue('companyLogo', 'https://wwww.example.com'); }, [watchHasSalaryRange, form]); + + if (session.status === 'loading') return null; + return ( <div className="flex flex-col items-center gap-y-10 justify-center"> <div className="w-full md:justify-center mt-4 flex flex-col md:flex-row gap-2"> diff --git a/src/components/password-input.tsx b/src/components/password-input.tsx new file mode 100644 index 00000000..2099ef12 --- /dev/null +++ b/src/components/password-input.tsx @@ -0,0 +1,33 @@ +import { useState } from 'react'; +import { Input } from './ui/input'; +import { EyeIcon, EyeOffIcon } from 'lucide-react'; + +interface PasswordInputProps { + placeholder?: string; + field: any; +} + +export const PasswordInput = ({ placeholder, field }: PasswordInputProps) => { + const [showPassword, setShowPassword] = useState(false); + + const togglePasswordVisibility = () => { + setShowPassword(!showPassword); + }; + + return ( + <div className="relative"> + <Input + type={showPassword ? 'text' : 'password'} + {...field} + placeholder={placeholder || '••••••••'} + /> + <button + type="button" + onClick={togglePasswordVisibility} + className="absolute right-2 top-1/2 transform -translate-y-1/2" + > + {showPassword ? <EyeOffIcon /> : <EyeIcon />} + </button> + </div> + ); +}; diff --git a/src/config/auth.config.ts b/src/config/auth.config.ts index ce443428..f6d7040d 100644 --- a/src/config/auth.config.ts +++ b/src/config/auth.config.ts @@ -1,2 +1,7 @@ export const AUTH_TOKEN_EXPIRATION_TIME = 30 * 24 * 60 * 60; export const PASSWORD_HASH_SALT_ROUNDS = 10; + +export const PENDING_EMAIL_VERIFICATION_USER_ID = + 'PENDING_EMAIL_VERIFICATION_USER_ID'; +export const EMAIL_VERIFICATION_LINK_EXPIRATION_TIME = 24 * 60 * 60; +export const EMAIL_VERIFICATION_LINK_RESENT_TIME = 0.5 * 60; diff --git a/src/config/path.config.ts b/src/config/path.config.ts index b5877d8f..266fb467 100644 --- a/src/config/path.config.ts +++ b/src/config/path.config.ts @@ -9,5 +9,8 @@ const APP_PATHS = { CONTACT_US: '', TESTIMONIALS: '#testimonials', FAQS: '#faq', + VERIFY_EMAIL: '/verify-email', + FORGOT_PASSWORD: '/forgot-password', + WELCOME: '/welcome', }; export default APP_PATHS; diff --git a/src/env/server.ts b/src/env/server.ts index 3fa1d7dc..49ca3ca6 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -1,22 +1,32 @@ // // server-env.ts -// import { createEnv } from '@t3-oss/env-nextjs'; -// import { z } from 'zod'; +import { createEnv } from '@t3-oss/env-nextjs'; +import { z } from 'zod'; -// export const serverEnv = createEnv({ -// server: { -// DATABASE_URL: z.string().url(), -// NEXTAUTH_SECRET: z.string().min(1), -// NEXTAUTH_URL: z.string().url(), -// CDN_API_KEY: z.string().min(1), -// CDN_BASE_UPLOAD_URL: z.string().url(), -// CDN_BASE_ACCESS_URL: z.string().url(), -// }, -// runtimeEnv: { -// DATABASE_URL: process.env.DATABASE_URL, -// NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, -// NEXTAUTH_URL: process.env.NEXTAUTH_URL, -// CDN_API_KEY: process.env.CDN_API_KEY, -// CDN_BASE_UPLOAD_URL: process.env.CDN_BASE_UPLOAD_URL, -// CDN_BASE_ACCESS_URL: process.env.CDN_BASE_ACCESS_URL, -// }, -// }); +export const serverEnv = createEnv({ + server: { + DATABASE_URL: z.string().url(), + NEXTAUTH_SECRET: z.string().min(1), + NEXTAUTH_URL: z.string().url(), + CDN_API_KEY: z.string().min(1), + CDN_BASE_UPLOAD_URL: z.string().url(), + CDN_BASE_ACCESS_URL: z.string().url(), + BASE_URL: z.string().url(), + EMAIL_USER: z.string().min(1), + EMAIL_PASSWORD: z.string().min(1), + GOOGLE_CLIENT_ID: z.string().min(1), + GOOGLE_CLIENT_SECRET: z.string().min(1), + }, + runtimeEnv: { + DATABASE_URL: process.env.DATABASE_URL, + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, + NEXTAUTH_URL: process.env.NEXTAUTH_URL, + CDN_API_KEY: process.env.CDN_API_KEY, + CDN_BASE_UPLOAD_URL: process.env.CDN_BASE_UPLOAD_URL, + CDN_BASE_ACCESS_URL: process.env.CDN_BASE_ACCESS_URL, + BASE_URL: process.env.BASE_URL, + EMAIL_USER: process.env.EMAIL_USER, + EMAIL_PASSWORD: process.env.EMAIL_PASSWORD, + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, + }, +}); diff --git a/src/layouts/header.tsx b/src/layouts/header.tsx index 0121fc55..f4c5efb1 100644 --- a/src/layouts/header.tsx +++ b/src/layouts/header.tsx @@ -5,15 +5,16 @@ import { nonUserNavbar, userNavbar, } from '@/lib/constant/app.constant'; -import { useSession } from 'next-auth/react'; +import { signOut, useSession } from 'next-auth/react'; import Link from 'next/link'; import { NavItem } from '@/components/navitem'; import Image from 'next/image'; import { Skeleton } from '@/components/ui/skeleton'; -import { Moon, Sun } from 'lucide-react'; +import { LogOutIcon, Moon, Sun } from 'lucide-react'; import { useTheme } from 'next-themes'; import { ADMIN_ROLE } from '@/config/app.config'; import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; export const CompanyLogo = () => { return ( <div className="flex items-center gap-2"> @@ -68,6 +69,22 @@ const Header = () => { : nonUserNavbar.map((item) => ( <NavItem {...item} key={item.id} /> ))} + {session.status === 'authenticated' ? ( + <li> + <Button + className="rounded-lg" + size="sm" + variant="destructive" + > + <LogOutIcon + className="w-4 h-4" + onClick={() => { + signOut(); + }} + /> + </Button> + </li> + ) : null} </ul> <div className="flex items-center"> {mounted && ( diff --git a/src/lib/async-catch.ts b/src/lib/async-catch.ts index cc9f0341..4efc0628 100644 --- a/src/lib/async-catch.ts +++ b/src/lib/async-catch.ts @@ -1,11 +1,11 @@ import { standardizeApiError } from './error'; -type withServerActionAsyncCatcherType<T, R> = (args?: T) => Promise<R>; +type withServerActionAsyncCatcherType<T, R> = (args: T) => Promise<R>; -function withServerActionAsyncCatcher<T, R>( +export function withServerActionAsyncCatcher<T, R>( serverAction: withServerActionAsyncCatcherType<T, R> ): withServerActionAsyncCatcherType<T, R> { - return async (args?: T): Promise<R> => { + return async (args: T): Promise<R> => { try { return await serverAction(args); } catch (error) { @@ -14,4 +14,20 @@ function withServerActionAsyncCatcher<T, R>( }; } -export { withServerActionAsyncCatcher }; +/** + * Usage example for empty args: + * + * export const serverAction = withServerActionAsyncCatcher<null, ServerActionReturnType>( + * async () => { + * return new SuccessResponse('message', 201, 'additional').serialize(); + * } + * ); + * serverAction(null) + * Usage example for args with a defined type: + * + * export const serverActionWithArgs = withServerActionAsyncCatcher<{ name: string }, ServerActionReturnType>( + * async (data) => { + * return new SuccessResponse('message', 200).serialize(); + * } + * ); + */ diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d4ad07d9..746faac5 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -42,7 +42,7 @@ export const options = { }, }); - if (!user) + if (!user || !user.password) throw new ErrorHandler( 'Email or password is incorrect', 'AUTHENTICATION_FAILED' diff --git a/src/lib/authOptions.ts b/src/lib/authOptions.ts new file mode 100644 index 00000000..15596431 --- /dev/null +++ b/src/lib/authOptions.ts @@ -0,0 +1,154 @@ +import { AUTH_TOKEN_EXPIRATION_TIME } from '@/config/auth.config'; +import prisma from '@/config/prisma.config'; +import bcrypt from 'bcryptjs'; +import { NextAuthOptions } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { ErrorHandler } from './error'; +import { SigninSchema } from './validators/auth.validator'; +import GoogleProvider from 'next-auth/providers/google'; +import { serverEnv } from '@/env/server'; + +export const authOptions = { + providers: [ + GoogleProvider({ + clientId: serverEnv.GOOGLE_CLIENT_ID as string, + clientSecret: serverEnv.GOOGLE_CLIENT_SECRET as string, + }), + CredentialsProvider({ + name: 'signin', + id: 'signin', + credentials: { + email: { label: 'Email', type: 'email', placeholder: 'email' }, + password: { label: 'password', type: 'password' }, + }, + async authorize(credentials): Promise<any> { + const result = SigninSchema.safeParse(credentials); + + if (!result.success) { + throw new ErrorHandler( + 'Input Validation failed', + 'VALIDATION_ERROR', + { + fieldErrors: result.error.flatten().fieldErrors, + } + ); + } + + const { email, password } = result.data; + const user = await prisma.user.findUnique({ + where: { + email: email, + emailVerified: { not: null }, + blockedByAdmin: null, + }, + select: { + id: true, + name: true, + password: true, + role: true, + emailVerified: true, + }, + }); + + if (!user || !user.password) + throw new ErrorHandler( + 'Email or password is incorrect', + 'AUTHENTICATION_FAILED' + ); + + const isPasswordMatched = await bcrypt.compare(password, user.password); + + if (!isPasswordMatched) { + throw new ErrorHandler( + 'Email or password is incorrect', + 'AUTHENTICATION_FAILED' + ); + } + + return { + id: user.id, + name: user.name, + email: email, + isVerified: !!user.emailVerified, + role: user.role, + }; + }, + }), + ], + callbacks: { + async signIn(signInProps) { + let { user, account, profile } = signInProps; + + if (account?.provider === 'google' && profile) { + const { id: oauthId, email, name, image: avatar } = user; + + let existingUser = await prisma.user.findFirst({ + where: { + OR: [{ email: email! }, { oauthId: oauthId! }], + }, + }); + if (existingUser?.blockedByAdmin) return false; + + if (!existingUser) { + existingUser = await prisma.user.create({ + data: { + oauthId, + oauthProvider: 'GOOGLE', + email: email as string, + name: name as string, + avatar, + emailVerified: new Date(), + }, + }); + } + } + + return true; + }, + + async jwt(jwtProps) { + const { token, user, trigger, session } = jwtProps; + if (trigger === 'update') { + return { + ...token, + ...session.user, + }; + } + if (user) { + const loggedInUser = await prisma.user.findFirst({ + where: { + OR: [{ oauthId: { equals: user.id } }, { id: { equals: user.id } }], + blockedByAdmin: null, + emailVerified: { not: null }, + }, + }); + if (!loggedInUser) return null; + + token.id = loggedInUser.id; + token.name = user.name; + token.isVerified = user.isVerified; + token.role = user.role; + } + return token; + }, + + session({ session, token }) { + if (token && session && session.user) { + session.user.id = token.id; + session.user.isVerified = token.isVerified; + session.user.role = token.role; + } + return session; + }, + }, + session: { + strategy: 'jwt', + maxAge: AUTH_TOKEN_EXPIRATION_TIME, + }, + jwt: { + maxAge: AUTH_TOKEN_EXPIRATION_TIME, + }, + pages: { + signIn: '/signin', + }, +} satisfies NextAuthOptions; diff --git a/src/lib/error.ts b/src/lib/error.ts index 6940333b..c6005a51 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -6,6 +6,7 @@ export type ErrorResponseType = { message: string; code: number; status: false; + error?: any; }; class ErrorHandler extends Error { status: false; @@ -27,6 +28,7 @@ function standardizeApiError(error: unknown): ErrorResponseType { message: error.message, code: error.code, status: false, + error: error.error, }; } if (error instanceof ZodError) { diff --git a/src/lib/sendConfirmationEmail.ts b/src/lib/sendConfirmationEmail.ts new file mode 100644 index 00000000..9819b39f --- /dev/null +++ b/src/lib/sendConfirmationEmail.ts @@ -0,0 +1,43 @@ +import { TokenType } from '@prisma/client'; +import nodemailer from 'nodemailer'; +import { serverEnv } from '../env/server'; + +export async function sendConfirmationEmail( + email: string, + confirmationLink: string, + type: TokenType +) { + try { + const transporter = nodemailer.createTransport({ + service: 'Gmail', + auth: { + user: serverEnv.EMAIL_USER, + pass: serverEnv.EMAIL_PASSWORD, + }, + }); + + if (type === 'EMAIL_VERIFICATION') { + const mailOptions = { + from: serverEnv.BASE_URL, + to: email, + subject: 'Confirm your Email', + text: `Click the following link to confirm your email: ${confirmationLink}`, + html: `<p>Click the following link to confirm your email:</p><a href="${confirmationLink}">Confirm Email</a>`, + }; + + await transporter.sendMail(mailOptions); + } else if (type === 'RESET_PASSWORD') { + const mailOptions = { + from: serverEnv.BASE_URL, + to: email, + subject: 'Reset your password', + text: `Click the following link to reset your password: ${confirmationLink}`, + html: `<p>Click the following link to reset your password:</p><a href="${confirmationLink}">Reset Password</a>`, + }; + + await transporter.sendMail(mailOptions); + } + } catch (error) { + console.error('Error sending email:', error); + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 01b0d83e..27e10b3d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,3 +1,4 @@ +import { EMAIL_VERIFICATION_LINK_EXPIRATION_TIME } from '@/config/auth.config'; import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; @@ -19,3 +20,11 @@ export const formatSalary = (salary: number) => { } return salary; }; + +export const isTokenExpiredUtil = (createdAt: Date) => { + const now = new Date().getTime(); + const tokenCreationTime = new Date(createdAt).getTime(); + return ( + now - tokenCreationTime > EMAIL_VERIFICATION_LINK_EXPIRATION_TIME * 1000 + ); +};