Skip to content
22 changes: 22 additions & 0 deletions app/forgot-password/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { redirect } from 'next/navigation';
import { getSupabaseServerClient } from '@/lib/supabase';

export default async function ForgotPasswordLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await getSupabaseServerClient();

// note: we use getUser() instead of getSession() because
// this sends a request to the Auth server to revalidate auth token,
// preventing potential spoofing of cookies
const {
data: { user },
} = await supabase.auth.getUser();

// redirect already logged-in users to reset password instead
if (user) return redirect('/reset-password');

return children;
}
20 changes: 20 additions & 0 deletions app/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Link from 'next/link';
import ForgotPasswordFlowDecider from '@/components/auth/forgot-password/ForgotPasswordFlowDecider';
import Logo from '@/components/Logo';

export default function ForgotPasswordPage() {
return (
<div className="flex size-full flex-col items-center gap-4">
<Link href="/">
<Logo />
</Link>

<div className="flex size-full flex-col items-center justify-center">
<ForgotPasswordFlowDecider />
</div>

{/* spacer */}
<div className="h-22" />
</div>
);
}
23 changes: 23 additions & 0 deletions app/reset-password/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { redirect } from 'next/navigation';
import { getSupabaseServerClient } from '@/lib/supabase';

export default async function ResetPasswordLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await getSupabaseServerClient();

// note: we use getUser() instead of getSession() because
// this sends a request to the Auth server to revalidate auth token,
// preventing potential spoofing of cookies
const {
data: { user },
error,
} = await supabase.auth.getUser();

// redirect non-authenticated users to forgot password instead
if (error || !user) return redirect('/forgot-password');

return children;
}
20 changes: 20 additions & 0 deletions app/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Link from 'next/link';
import ResetPasswordFlowDecider from '@/components/auth/reset-password/ResetPasswordFlowDecider';
import Logo from '@/components/Logo';

export default function ResetPasswordPage() {
return (
<div className="flex size-full flex-col items-center gap-4">
<Link href="/">
<Logo />
</Link>

<div className="flex size-full flex-col items-center justify-center">
<ResetPasswordFlowDecider />
</div>

{/* spacer */}
<div className="h-22" />
</div>
);
}
33 changes: 33 additions & 0 deletions components/AsyncButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client';

import { useState } from 'react';
import { Button } from './Button';
import LoadingSpinner from './LoadingSpinner';

export default function AsyncButton({
onClick,
disabled,
children,
...props
}: React.ComponentProps<typeof Button>) {
const [isProcessing, setIsProcessing] = useState(false);

const handleClick = (fn?: React.MouseEventHandler<HTMLButtonElement>) => {
return async (e: React.MouseEvent<HTMLButtonElement>) => {
setIsProcessing(true);
await fn?.(e);
setIsProcessing(false);
};
};

return (
<Button
onClick={handleClick(onClick)}
disabled={disabled || isProcessing}
{...props}
>
{children}
{isProcessing && <LoadingSpinner className="ml-2" />}
</Button>
);
}
38 changes: 23 additions & 15 deletions components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,29 @@ import { forwardRef } from 'react';
import Link from 'next/link';
import { cva } from 'class-variance-authority';

const buttonStyle = cva('cursor-pointer block text-center', {
variants: {
variant: {
default:
'border border-gray-700 rounded-lg hover:bg-gray-50 transition-colors px-2 py-1 cursor-pointer',
secondary:
'border border-gray-8 bg-gray-1 rounded-lg hover:bg-cyan-12 hover:text-gray-1 transition-colors px-2 py-1 cursor-pointer text-cyan-12',
primary:
'bg-cyan-12 hover:bg-cyan-10 rounded-lg transition-colors px-2.5 py-2.5 cursor-pointer text-gray-1',
const buttonStyle = cva(
'text-center disabled:pointer-events-none flex gap-2 items-center justify-center cursor-pointer',
{
variants: {
variant: {
default:
'border border-gray-700 rounded-lg hover:bg-gray-50 transition-colors px-2 py-1',
secondary:
'border border-gray-8 bg-gray-1 rounded-lg hover:bg-cyan-12 hover:text-gray-1 transition-colors px-2 py-1 text-cyan-12',
primary:
'bg-cyan-12 hover:bg-cyan-10 rounded-lg transition-colors px-2.5 py-2.5 text-gray-1',
},
disabled: {
true: 'opacity-50 cursor-not-allowed!',
false: '',
},
},
defaultVariants: {
variant: 'default',
disabled: false,
},
},
defaultVariants: {
variant: 'default',
},
});
);

type ButtonVariant = VariantProps<typeof buttonStyle>['variant'];

Expand All @@ -26,11 +34,11 @@ export const Button = forwardRef<
React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
}
>(({ children, className, variant = 'default', ...props }, ref) => {
>(({ children, className, variant = 'default', disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={buttonStyle({ variant, className })}
className={buttonStyle({ variant, className, disabled })}
{...props}
>
{children}
Expand Down
15 changes: 15 additions & 0 deletions components/LoadingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IconType } from 'react-icons';
import { LuLoaderCircle } from 'react-icons/lu';
import { cn } from '@/lib/utils';

export default function LoadingSpinner({
className,
...props
}: React.ComponentProps<IconType>) {
return (
<LuLoaderCircle
className={cn('animate-spin text-gray-11', className)}
{...props}
/>
);
}
108 changes: 108 additions & 0 deletions components/auth/forgot-password/ForgotPassword.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use client';

import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import Logger from '@/actions/logging';
import { Button } from '@/components/Button';
import LoadingSpinner from '@/components/LoadingSpinner';
import { Textbox } from '@/components/Textbox';
import { useTimer } from '@/hooks/useTimer';
import { getSupabaseBrowserClient } from '@/lib/supabase';
import { getSiteUrl } from '@/lib/utils';

interface ForgotPasswordForm {
email: string;
}

export default function ForgotPassword() {
const [isProcessing, setIsProcessing] = useState(false);
const { cooldownSeconds, startTimer } = useTimer({
cooldowns: [10],
onFinish: () => setIsProcessing(false),
});

const searchParams = useSearchParams();
const email = useMemo(() => searchParams.get('email') ?? '', [searchParams]);

const {
register,
handleSubmit,
formState: { errors },
setError,
} = useForm<ForgotPasswordForm>({ defaultValues: { email } });

const router = useRouter();

// send email
const sendResetEmail = async (email: string) => {
// brute force check
if (cooldownSeconds > 0) return;

// send email
const supabase = getSupabaseBrowserClient();
const siteUrl = getSiteUrl();
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${siteUrl}forgot-password?status=loading`,
});

// handle error cases
if (error) {
switch (error.code) {
case 'email_address_invalid':
setError('email', { message: 'Invalid email.' });
break;
default:
setError('email', {
message: 'An unexpected error occurred, please try again later.',
});
Logger.error(
`Unexpected error occurred while sending forgot password email: ${error.message}`,
);
break;
}

return;
}

router.push(`?status=check-email&email=${email}`);
};

const onSubmit = async ({ email }: ForgotPasswordForm) => {
// set is processing
setIsProcessing(true);

// send email
await sendResetEmail(email);

// set buffer timer
startTimer();
};

return (
<form
className="flex w-106 flex-col gap-7 rounded-2xl bg-gray-1 p-8"
onSubmit={handleSubmit(onSubmit)}
>
<h1 className="text-3xl font-medium">Forgot password?</h1>

<div className="flex flex-col gap-4">
<div className="flex flex-col">
<p className="text-base text-gray-9">Email</p>
<Textbox
type="email"
placeholder="[email protected]"
error={errors.email?.message}
{...register('email', { required: true })}
/>
</div>

<Button variant="primary" type="submit" disabled={isProcessing}>
Reset password
{cooldownSeconds > 0 && `(${cooldownSeconds} s)`}
{isProcessing && <LoadingSpinner className="text-gray-1" />}
</Button>
</div>
</form>
);
}
75 changes: 75 additions & 0 deletions components/auth/forgot-password/ForgotPasswordCheckEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import { useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import Logger from '@/actions/logging';
import { Button } from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import { useTimer } from '@/hooks/useTimer';
import { getSupabaseBrowserClient } from '@/lib/supabase';
import { getSiteUrl } from '@/lib/utils';

const incrementalCooldowns = [30, 45, 60, 90, 120];

export default function ForgotPasswordCheckEmail() {
const searchParams = useSearchParams();
const email = useMemo(() => searchParams.get('email') ?? '', [searchParams]);

const [error, setError] = useState('');
const { cooldownSeconds, startTimer } = useTimer({
initialCooldown: 25,
cooldowns: incrementalCooldowns,
});

const handleResendEmail = async () => {
// brute force check
if (cooldownSeconds > 0) return;

// start cooldown
startTimer();

// resend email
const supabase = getSupabaseBrowserClient();
const siteUrl = getSiteUrl();
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${siteUrl}forgot-password?status=loading`,
});

if (error) {
switch (error.code) {
case 'email_address_invalid':
setError('Invalid email');
break;
default:
setError('An unexpected error occurred, please try again later.');
Logger.error(
`Unexpected error occurred while sending forgot password email: ${error.message}`,
);
break;
}
} else {
setError('');
}
};

return (
<div className="flex w-106 flex-col gap-4 rounded-2xl bg-gray-1 p-8">
<p className="text-3xl font-medium">Check your email.</p>

<p className="text-gray-11">
If {email} exists, you will receive a link to proceed with resetting
your password.
</p>

<Button
variant="primary"
className="mt-2"
disabled={cooldownSeconds > 0}
onClick={handleResendEmail}
>
Resend email {cooldownSeconds > 0 && `(${cooldownSeconds} s)`}
</Button>
<ErrorMessage error={error} />
</div>
);
}
Loading