Skip to content

Commit

Permalink
Merge pull request #47 from keeep-app/43-implement-password-recovery-…
Browse files Browse the repository at this point in the history
…flow

✨ feat: implement password recovery flow
  • Loading branch information
marvin-splitt authored May 3, 2024
2 parents b14dbdd + 31e2940 commit 9a0b71a
Show file tree
Hide file tree
Showing 8 changed files with 492 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use client';

import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { cn, getBaseUrl } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { useToast } from '@/components/ui/use-toast';
import Spinner from '@/components/spinner';
import { useSupabase } from '@/lib/provider/supabase';
import { useState } from 'react';

interface ForgotPasswordFormProps {
className?: string;
}

export const ForgotPasswordForm: React.FC<ForgotPasswordFormProps> = ({
className,
}) => {
const { supabase } = useSupabase();
const formSchema = z.object({
email: z.string().email(),
});
type FormValues = z.infer<typeof formSchema>;

const { toast } = useToast();
const t = useTranslations('ForgotPasswordForm');

const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
});

const [loading, setLoading] = useState(false);

async function onSubmit(values: FormValues) {
if (!supabase) return;

setLoading(true);
const redirectTo = `${getBaseUrl()}/reset-password`;
const { error } = await supabase.auth.resetPasswordForEmail(values.email, {
redirectTo,
});

if (error) {
toast({
title: t('toast.title.error'),
description: error.message,
});
return;
}

setLoading(false);
toast({
title: t('toast.title.success'),
description: t('toast.description.success'),
});
}

return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn(className, '')}
>
<div className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.label')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
disabled={loading || !form.watch('email')?.length}
type="submit"
className="relative mt-6 w-full"
>
<span
className={cn({
'opacity-0': loading,
'opacity-100': !loading,
})}
>
{t('submit')}
</span>
{loading && <Spinner className="absolute inset-0" />}
</Button>
</form>
</Form>
);
};
56 changes: 56 additions & 0 deletions apps/web/app/[locale]/(app)/(auth)/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Link from 'next/link';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
import {
NextIntlClientProvider,
useMessages,
useTranslations,
} from 'next-intl';
import { IntlMessages, LocalePageProps } from '@/lib/types/global';
import pick from 'lodash/pick';
import { unstable_setRequestLocale } from 'next-intl/server';
import { ForgotPasswordForm } from './ForgotPasswordForm';

export default function ForgotPasswordPage({
params: { locale },
searchParams,
}: LocalePageProps) {
unstable_setRequestLocale(locale);

const t = useTranslations('ForgotPasswordPage');
const messages = useMessages() as IntlMessages;
const email = searchParams.email;

if (typeof email !== 'string' && typeof email !== 'undefined') {
throw new Error('Invalid nonce');
}

return (
<>
<Link
href="/login"
className={cn(
buttonVariants({ variant: 'ghost' }),
'absolute right-4 top-4 md:right-8 md:top-8'
)}
>
{t('loginButton')}
</Link>
<div className="lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{t('title')}
</h1>
<p className="text-sm text-muted-foreground">{t('description')}</p>
</div>
<NextIntlClientProvider
messages={pick(messages, 'ForgotPasswordForm')}
>
<ForgotPasswordForm />
</NextIntlClientProvider>
</div>
</div>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
'use client';

import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useRouter } from 'next/navigation';
import { cn, getBaseUrl } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useToast } from '@/components/ui/use-toast';
import Spinner from '@/components/spinner';
import { useSupabase } from '@/lib/provider/supabase';

interface ResetPasswordFormProps {
className?: string;
}

export const ResetPasswordForm: React.FC<ResetPasswordFormProps> = ({
className,
}) => {
const router = useRouter();
const { supabase } = useSupabase();
const t = useTranslations('ResetPasswordForm');
const formSchema = z
.object({
email: z.string().email().optional(),
password: z.string().min(8),
confirmPassword: z.string().min(8),
})
.refine(
data => data.confirmPassword === data.password,
t('confirmPassword.error')
);
type FormValues = z.infer<typeof formSchema>;

const { toast } = useToast();

const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
});

const [loading, setLoading] = useState(false);

const setEmail = async () => {
if (!supabase) return;
const {
data: { user },
} = await supabase.auth.getUser();
if (user && user.email) {
form.setValue('email', user.email);
return;
}
toast({
title: t('toast.title.expired'),
description: t('toast.description.expired'),
});
router.push(`/forgot-password`);
};

useEffect(() => {
if (!supabase) return;

setEmail();
}, [supabase]);

async function resetPassword(values: FormValues) {
if (!supabase) return;
setLoading(true);

const { error } = await supabase.auth.updateUser({
password: values.password,
});

if (error) {
toast({
title: t('toast.title.error'),
description: error.message,
});
return;
}

setLoading(false);
toast({
title: t('toast.title.success'),
description: t('toast.description.success'),
});
router.push(`${getBaseUrl()}/dashboard`);
}

return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(resetPassword)}
className={cn(className, '')}
>
<div className="space-y-4">
<FormField
control={form.control}
disabled
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.label')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('password.label')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('confirmPassword.label')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
disabled={loading || !form.watch('password')?.length}
type="submit"
className="relative mt-6 w-full"
>
<span
className={cn({
'opacity-0': loading,
'opacity-100': !loading,
})}
>
{t('submit')}
</span>
{loading && <Spinner className="absolute inset-0" />}
</Button>
</form>
</Form>
);
};
50 changes: 50 additions & 0 deletions apps/web/app/[locale]/(app)/(auth)/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Link from 'next/link';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
import {
NextIntlClientProvider,
useMessages,
useTranslations,
} from 'next-intl';
import { IntlMessages, LocalePageProps } from '@/lib/types/global';
import pick from 'lodash/pick';
import { unstable_setRequestLocale } from 'next-intl/server';
import { ResetPasswordForm } from './ResetPasswordForm';

export default function ForgotPasswordPage({
params: { locale },
}: LocalePageProps) {
unstable_setRequestLocale(locale);

const t = useTranslations('ResetPasswordPage');
const messages = useMessages() as IntlMessages;

return (
<>
<Link
href="/login"
className={cn(
buttonVariants({ variant: 'ghost' }),
'absolute right-4 top-4 md:right-8 md:top-8'
)}
>
{t('loginButton')}
</Link>
<div className="lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
{t('title')}
</h1>
<p className="text-sm text-muted-foreground">{t('description')}</p>
</div>
<NextIntlClientProvider
messages={pick(messages, 'ResetPasswordForm')}
>
<ResetPasswordForm />
</NextIntlClientProvider>
</div>
</div>
</>
);
}
4 changes: 2 additions & 2 deletions apps/web/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function cn(...inputs: ClassValue[]) {
}

export function getBaseUrl() {
return process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
return process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: 'http://localhost:3000';
}
Loading

1 comment on commit 9a0b71a

@vercel
Copy link

@vercel vercel bot commented on 9a0b71a May 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

keeep – ./

keeep-keeep.vercel.app
keeep-git-main-keeep.vercel.app
keeep-chi.vercel.app
keeep.app

Please sign in to comment.