Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ feat: implement password recovery flow #47

Merged
merged 18 commits into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4f256b7
feat(reset-password): setup new reset password page and translations
marvin-splitt Mar 17, 2024
f3564f0
🚧 fix(reset-password): fix page setup & redirect
marvin-splitt Mar 18, 2024
5029d93
🚧 feat(reset-password): add first reset flow draft
marvin-splitt Mar 24, 2024
e0c1420
🚧 fix(reset-password): fix translation error
marvin-splitt Mar 24, 2024
77dd658
🚧 fix(reset-password): fix wrong type
marvin-splitt Mar 24, 2024
d7fe7c1
🚧 wip(reset-password): adjust redirect url
marvin-splitt Mar 30, 2024
92d6965
🚧 wip(reset-password): fix redirect base url
marvin-splitt Mar 30, 2024
7fdcea1
🚧 wip(reset-password): fix getBaseUrl env
marvin-splitt Mar 30, 2024
b0939f6
🚧 wip(reset-password): fix redirect base url
marvin-splitt Mar 30, 2024
31ed150
🚧 wip(reset-password): add seperate reset page
marvin-splitt Apr 22, 2024
7503680
🚧 wip(reset-password): remove url query param
marvin-splitt Apr 22, 2024
2777123
🚧 wip(reset-password): add reset password as public page
marvin-splitt Apr 22, 2024
6da9ca4
🚧 wip(reset-password): minor improvements; seperate translations
marvin-splitt Apr 24, 2024
2aea67b
🚧 wip(reset-password): get user email from reset session
marvin-splitt Apr 24, 2024
0114632
🚧 wip(reset-password): add translations for reset password page
marvin-splitt Apr 24, 2024
9f5274e
🚧 wip(reset-password): add translations for forgot password page
marvin-splitt Apr 24, 2024
e915785
Merge branch 'main' into 43-implement-password-recovery-flow
marvin-splitt Apr 24, 2024
31e2940
fix(reset-password): apply pr suggestions
marvin-splitt May 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,162 @@
'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();
marvin-splitt marked this conversation as resolved.
Show resolved Hide resolved
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);
}
};

useEffect(() => {
console.log('formState:', form.formState);
marvin-splitt marked this conversation as resolved.
Show resolved Hide resolved
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
Loading