diff --git a/.env.local.example b/.env.local.example index 400206e7..5fa4c60e 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,8 +1,13 @@ # Clerk. See https://clerk.com NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= CLERK_SECRET_KEY= +# DO NOT modify +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/signin +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/join +NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ +NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ -# Database. Do not modify in development. +# Database (DO NOT modify in development) DATABASE_URL=file:dev.sqlite DATABASE_AUTH_TOKEN= diff --git a/src/app/(account)/forgot-password/ForgotPassword.tsx b/src/app/(account)/forgot-password/ForgotPassword.tsx new file mode 100644 index 00000000..8e4c0800 --- /dev/null +++ b/src/app/(account)/forgot-password/ForgotPassword.tsx @@ -0,0 +1,186 @@ +'use client'; + +import Button from '@/components/Button'; +import ControlledField from '@/components/ControlledField'; +import FancyRectangle from '@/components/FancyRectangle'; +import { useSignIn } from '@clerk/nextjs'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { handleClerkErrors } from '../helpers'; +import { codeSchema, emailSchema, passwordSchema } from '../schemas'; + +const sendCodeSchema = z.object({ + email: emailSchema, +}); +const resetPasswordSchema = z + .object({ + code: codeSchema, + password: passwordSchema, + confirmPassword: z.string().min(1, { message: 'Please confirm password' }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }); + +const STEP_INSTRUCTIONS = [ + '', // Step start from 1 + 'Enter your email to receive a reset code.', + 'Enter your new password and the code received in your email.', + 'Password reset complete!', +] as const; + +export default function ForgotPassword() { + const [step, setStep] = useState(1); + const { isLoaded, signIn, setActive } = useSignIn(); + + const sendCodeForm = useForm>({ + defaultValues: { email: '' }, + resolver: zodResolver(sendCodeSchema), + }); + const resetPasswordForm = useForm>({ + defaultValues: { code: '', password: '' }, + resolver: zodResolver(resetPasswordSchema), + }); + + const [sendCodeLoading, setSendCodeLoading] = useState(false); + const [resetPasswordLoading, setResetPasswordLoading] = useState(false); + + const handleSendCode = sendCodeForm.handleSubmit(async ({ email }) => { + if (!isLoaded) return; + + setSendCodeLoading(true); + + try { + const result = await signIn.create({ + strategy: 'reset_password_email_code', + identifier: email, + }); + if (result) { + setStep(2); + } + } catch (error) { + handleClerkErrors(error, sendCodeForm, [ + { + code: 'form_identifier_not_found', + field: 'email', + message: + "Couldn't find account with given email. Please create an account first.", + }, + { + code: 'form_conditional_param_value_disallowed', + field: 'email', + message: 'Account was created through Google. Please sign in using Google.', + }, + ]); + } + + setSendCodeLoading(false); + }); + + const handleResetPassword = resetPasswordForm.handleSubmit(async ({ code, password }) => { + if (!isLoaded) return; + + setResetPasswordLoading(true); + + try { + const resetResult = await signIn.attemptFirstFactor({ + strategy: 'reset_password_email_code', + code, + password, + }); + if (resetResult.status === 'complete') { + setActive({ session: resetResult.createdSessionId }); + setStep(3); + } + } catch (error) { + handleClerkErrors(error, resetPasswordForm, [ + { + code: 'form_password_not_strong_enough', + field: 'password', + message: + 'Given password is not strong enough. For account safety, please use a different password.', + }, + { + code: 'form_password_not_strong_enough', + field: 'password', + message: + 'Password has been found in an online data breach. For account safety, please use a different password.', + }, + { + code: 'form_code_incorrect', + field: 'code', + message: 'Incorrect Code. Please enter the code from your email.', + }, + ]); + } + + setResetPasswordLoading(false); + }); + + return ( +
+ +
+

Forgot Your Password?

+

{STEP_INSTRUCTIONS[step]}

+ {step === 1 && ( +
+ + + + )} + + {step === 2 && ( +
+ + + + + + )} + + {step === 3 && ( + + )} +
+
+
+ ); +} diff --git a/src/app/(account)/forgot-password/page.tsx b/src/app/(account)/forgot-password/page.tsx index 17e3f347..1a52a001 100644 --- a/src/app/(account)/forgot-password/page.tsx +++ b/src/app/(account)/forgot-password/page.tsx @@ -1,191 +1,10 @@ -'use client'; - -import Button from '@/components/Button'; -import ControlledField from '@/components/ControlledField'; -import FancyRectangle from '@/components/FancyRectangle'; -import { useSignIn } from '@clerk/nextjs'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { handleClerkErrors } from '../helpers'; -import { codeSchema, emailSchema, passwordSchema } from '../schemas'; - -// export const metadata: Metadata = { -// title: 'Forgot Password', -// robots: { index: false, follow: false }, -// }; - -const sendCodeSchema = z.object({ - email: emailSchema, -}); -const resetPasswordSchema = z - .object({ - code: codeSchema, - password: passwordSchema, - confirmPassword: z.string().min(1, { message: 'Please confirm password' }), - }) - .refine((data) => data.password === data.confirmPassword, { - message: 'Passwords do not match', - path: ['confirmPassword'], - }); - -const STEP_INSTRUCTIONS = [ - '', // Step start from 1 - 'Enter your email to receive a reset code.', - 'Enter your new password and the code received in your email.', - 'Password reset complete!', -] as const; +import type { Metadata } from 'next'; +import ForgotPassword from './ForgotPassword'; +export const metadata: Metadata = { + title: 'Forgot Password', + robots: { index: false, follow: false }, +}; export default function ForgotPasswordPage() { - const [step, setStep] = useState(1); - const { isLoaded, signIn, setActive } = useSignIn(); - - const sendCodeForm = useForm>({ - defaultValues: { email: '' }, - resolver: zodResolver(sendCodeSchema), - }); - const resetPasswordForm = useForm>({ - defaultValues: { code: '', password: '' }, - resolver: zodResolver(resetPasswordSchema), - }); - - const [sendCodeLoading, setSendCodeLoading] = useState(false); - const [resetPasswordLoading, setResetPasswordLoading] = useState(false); - - const handleSendCode = sendCodeForm.handleSubmit(async ({ email }) => { - if (!isLoaded) return; - - setSendCodeLoading(true); - - try { - const result = await signIn.create({ - strategy: 'reset_password_email_code', - identifier: email, - }); - if (result) { - setStep(2); - } - } catch (error) { - handleClerkErrors(error, sendCodeForm, [ - { - code: 'form_identifier_not_found', - field: 'email', - message: - "Couldn't find account with given email. Please create an account first.", - }, - { - code: 'form_conditional_param_value_disallowed', - field: 'email', - message: 'Account was created through Google. Please sign in using Google.', - }, - ]); - } - - setSendCodeLoading(false); - }); - - const handleResetPassword = resetPasswordForm.handleSubmit(async ({ code, password }) => { - if (!isLoaded) return; - - setResetPasswordLoading(true); - - try { - const resetResult = await signIn.attemptFirstFactor({ - strategy: 'reset_password_email_code', - code, - password, - }); - if (resetResult.status === 'complete') { - setActive({ session: resetResult.createdSessionId }); - setStep(3); - } - } catch (error) { - handleClerkErrors(error, resetPasswordForm, [ - { - code: 'form_password_not_strong_enough', - field: 'password', - message: - 'Given password is not strong enough. For account safety, please use a different password.', - }, - { - code: 'form_password_not_strong_enough', - field: 'password', - message: - 'Password has been found in an online data breach. For account safety, please use a different password.', - }, - { - code: 'form_code_incorrect', - field: 'code', - message: 'Incorrect Code. Please enter the code from your email.', - }, - ]); - } - - setResetPasswordLoading(false); - }); - - return ( -
- -
-

Forgot Your Password?

-

{STEP_INSTRUCTIONS[step]}

- {step === 1 && ( -
- - - - )} - - {step === 2 && ( -
- - - - - - )} - - {step === 3 && ( - - )} -
-
-
- ); + return ; } diff --git a/src/app/(account)/join/Join.tsx b/src/app/(account)/join/Join.tsx new file mode 100644 index 00000000..e1114c03 --- /dev/null +++ b/src/app/(account)/join/Join.tsx @@ -0,0 +1,73 @@ +'use client'; + +import FancyRectangle from '@/components/FancyRectangle'; +import Title from '@/components/Title'; +import { SignedIn, SignedOut, useUser } from '@clerk/nextjs'; +import Link from 'next/link'; +import { useEffect } from 'react'; +import ProgressBar from './ProgressBar'; +import StepFour from './steps/StepFour'; +import StepOne from './steps/StepOne'; +import StepThree from './steps/StepThree'; +import StepTwo from './steps/StepTwo'; +import { useJoinUsHeading, useJoinUsStep } from './store'; + +export default function Join() { + const { step, setStep } = useJoinUsStep(); + const { heading } = useJoinUsHeading(); + + const { isSignedIn } = useUser(); + useEffect(() => { + if (isSignedIn) { + setStep(2); + } + }, [isSignedIn]); + + return ( +
+ Join Us +
+
+

New Members are

+
+

Always Welcome

+
+
+
+

+ Membership costs $10 for the full year. + You can pay for membership online here at our website. Alternatively, you + can pay at a club event or contact one of the{' '} + + committee members + + . +

+

+ Create an account below to start the + registration process. +

+
+
+
+ +
+

{heading.title}

+

{heading.description}

+ + + + + + + { + // eslint-disable-next-line react/jsx-key + [, , ][step - 2] + } + +
+
+
+
+ ); +} diff --git a/src/app/(account)/join/page.tsx b/src/app/(account)/join/page.tsx index dce7418b..cfeb0c00 100644 --- a/src/app/(account)/join/page.tsx +++ b/src/app/(account)/join/page.tsx @@ -1,77 +1,20 @@ -'use client'; +import { checkUserExists } from '@/server/check-user-exists'; +import { currentUser } from '@clerk/nextjs'; +import type { Metadata } from 'next'; +import { redirect } from 'next/navigation'; +import Join from './Join'; -import FancyRectangle from '@/components/FancyRectangle'; -import Title from '@/components/Title'; -import { SignedIn, SignedOut, useUser } from '@clerk/nextjs'; -import Link from 'next/link'; -import { useEffect } from 'react'; -import ProgressBar from './ProgressBar'; -import StepFour from './steps/StepFour'; -import StepOne from './steps/StepOne'; -import StepThree from './steps/StepThree'; -import StepTwo from './steps/StepTwo'; -import { useJoinUsHeading, useJoinUsStep } from './store'; +export const metadata: Metadata = { + title: 'Join', +}; -// export const metadata: Metadata = { -// title: 'Join', -// }; - -export default function JoinPage() { - const { step, setStep } = useJoinUsStep(); - const { heading } = useJoinUsHeading(); - - const { isSignedIn } = useUser(); - useEffect(() => { - if (isSignedIn) { - setStep(2); +export default async function JoinPage() { + const user = await currentUser(); + if (user) { + const userExists = await checkUserExists(user.id); + if (userExists) { + redirect('/settings'); } - }, [isSignedIn]); - - return ( -
- Join Us -
-
-

New Members are

-
-

Always Welcome

-
-
-
-

- Membership costs $10 for the full year. - You can pay for membership online here at our website. Alternatively, you - can pay at a club event or contact one of the{' '} - - committee members - - . -

-

- Create an account below to start the - registration process. -

-
-
-
- -
-

{heading.title}

-

{heading.description}

- - - - - - - { - // eslint-disable-next-line react/jsx-key - [, , ][step - 2] - } - -
-
-
-
- ); + } + return ; } diff --git a/src/app/(account)/signin/SignIn.tsx b/src/app/(account)/signin/SignIn.tsx new file mode 100644 index 00000000..c218171d --- /dev/null +++ b/src/app/(account)/signin/SignIn.tsx @@ -0,0 +1,151 @@ +'use client'; + +import Button from '@/components/Button'; +import ControlledField from '@/components/ControlledField'; +import FancyRectangle from '@/components/FancyRectangle'; +import { useSignIn } from '@clerk/clerk-react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { FcGoogle } from 'react-icons/fc'; +import { z } from 'zod'; +import { handleClerkErrors } from '../helpers'; +import { emailSchema } from '../schemas'; + +const signInSchema = z.object({ + email: emailSchema, + password: z.string().min(1, { message: 'Please enter your password' }), +}); + +export default function SignIn() { + const { isLoaded, signIn, setActive } = useSignIn(); + + const form = useForm>({ + defaultValues: { email: '', password: '' }, + resolver: zodResolver(signInSchema), + }); + + const [signInLoading, setSignInLoading] = useState(false); + + const router = useRouter(); + const handleSignIn = form.handleSubmit(async ({ email, password }) => { + if (!isLoaded) return; + + setSignInLoading(true); + + try { + const result = await signIn.create({ + identifier: email, + password, + }); + + if (result.status === 'complete') { + await setActive({ session: result.createdSessionId }); + router.push('/'); + router.refresh(); + } else { + console.log(result); + } + } catch (error) { + handleClerkErrors(error, form, [ + { + code: 'form_identifier_not_found', + field: 'email', + message: "Can't find your account.", + }, + { + code: 'form_password_incorrect', + field: 'password', + message: 'Password is incorrect. Try again, or use another method.', + }, + { + code: 'strategy_for_user_invalid', + field: 'password', + message: + 'Account is not set up for password sign-in. Please sign in with Google.', + }, + ]); + } + + setSignInLoading(false); + }); + + const handleGoogleSignIn = async () => { + if (!isLoaded) return; + try { + await signIn.authenticateWithRedirect({ + strategy: 'oauth_google', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + }); + } catch (error) { + // Handle any errors that might occur during the sign-in process + console.error('Google Sign-In Error:', error); + } + }; + + return ( +
+
+ +
+ {/* Heading */} +

Sign In

+

Sign into your account

+ + + +
+
+

or

+
+
+
+ + + + Forgot password? + + + + + {/* Sign-up option */} +
+

+ Don't have an account yet?{' '} + + Join Us + +

+
+
+ +
+
+ ); +} diff --git a/src/app/(account)/signin/page.tsx b/src/app/(account)/signin/page.tsx index 9d7f2c37..7db5f167 100644 --- a/src/app/(account)/signin/page.tsx +++ b/src/app/(account)/signin/page.tsx @@ -1,155 +1,14 @@ -'use client'; - -import Button from '@/components/Button'; -import ControlledField from '@/components/ControlledField'; -import FancyRectangle from '@/components/FancyRectangle'; -import { useSignIn } from '@clerk/clerk-react'; -import { zodResolver } from '@hookform/resolvers/zod'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { FcGoogle } from 'react-icons/fc'; -import { z } from 'zod'; -import { handleClerkErrors } from '../helpers'; -import { emailSchema } from '../schemas'; - -// export const metadata: Metadata = { -// title: 'Sign In', -// }; - -const signInSchema = z.object({ - email: emailSchema, - password: z.string().min(1, { message: 'Please enter your password' }), -}); - -export default function SignInPage() { - const { isLoaded, signIn, setActive } = useSignIn(); - - const form = useForm>({ - defaultValues: { email: '', password: '' }, - resolver: zodResolver(signInSchema), - }); - - const [signInLoading, setSignInLoading] = useState(false); - - const router = useRouter(); - const handleSignIn = form.handleSubmit(async ({ email, password }) => { - if (!isLoaded) return; - - setSignInLoading(true); - - try { - const result = await signIn.create({ - identifier: email, - password, - }); - - if (result.status === 'complete') { - await setActive({ session: result.createdSessionId }); - router.push('/'); - router.refresh(); - } else { - console.log(result); - } - } catch (error) { - handleClerkErrors(error, form, [ - { - code: 'form_identifier_not_found', - field: 'email', - message: "Can't find your account.", - }, - { - code: 'form_password_incorrect', - field: 'password', - message: 'Password is incorrect. Try again, or use another method.', - }, - { - code: 'strategy_for_user_invalid', - field: 'password', - message: - 'Account is not set up for password sign-in. Please sign in with Google.', - }, - ]); - } - - setSignInLoading(false); - }); - - const handleGoogleSignIn = async () => { - if (!isLoaded) return; - try { - await signIn.authenticateWithRedirect({ - strategy: 'oauth_google', - redirectUrl: '/sso-callback', - redirectUrlComplete: '/', - }); - } catch (error) { - // Handle any errors that might occur during the sign-in process - console.error('Google Sign-In Error:', error); - } - }; - - return ( -
-
- -
- {/* Heading */} -

Sign In

-

Sign into your account

- - - -
-
-

or

-
-
-
- - - - Forgot password? - - - - - {/* Sign-up option */} -
-

- Don't have an account yet?{' '} - - Join Us - -

-
-
- -
-
- ); +import { currentUser } from '@clerk/nextjs'; +import type { Metadata } from 'next'; +import { redirect } from 'next/navigation'; +import SignIn from './SignIn'; + +export const metadata: Metadata = { + title: 'Sign In', +}; + +export default async function SignInPage() { + const user = await currentUser(); + if (user) redirect('/settings'); + return ; } diff --git a/src/env.mjs b/src/env.mjs index 8695e18a..f6494e30 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -15,10 +15,20 @@ export const env = createEnv({ client: { NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1), NEXT_PUBLIC_DRIVE_LINK: z.string().url().min(1), + // Clerk URLs. Redundant, but Clerk does not provide any other method for redirecting + // when user is/isn't signed in or has/hasn't signed up. + NEXT_PUBLIC_CLERK_SIGN_IN_URL: z.literal('/signin'), + NEXT_PUBLIC_CLERK_SIGN_UP_URL: z.literal('/join'), + NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL: z.literal('/'), + NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL: z.literal('/'), }, experimental__runtimeEnv: { NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, NEXT_PUBLIC_DRIVE_LINK: process.env.NEXT_PUBLIC_DRIVE_LINK, + NEXT_PUBLIC_CLERK_SIGN_IN_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL, + NEXT_PUBLIC_CLERK_SIGN_UP_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL, + NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL: process.env.NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL, + NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL: process.env.NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL, }, skipValidation: process.env.SKIP_ENV_VALIDATION, }); diff --git a/src/middleware.ts b/src/middleware.ts index 9d9bdc5d..95373370 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,11 +1,9 @@ import { authMiddleware } from '@clerk/nextjs'; -const authRoutes = ['/account', '/dashboard', '/settings', '/admin']; +const authRoutes = ['/settings', '/admin']; export default authMiddleware({ - publicRoutes: (req) => { - return !authRoutes.includes(req.nextUrl.pathname); - }, + publicRoutes: (req) => !authRoutes.includes(req.url), }); export const config = {