B2B-88: add starter kit structure and elements
This commit is contained in:
43
packages/features/auth/src/components/auth-error-alert.tsx
Normal file
43
packages/features/auth/src/components/auth-error-alert.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
/**
|
||||
* @name AuthErrorAlert
|
||||
* @param error This error comes from Supabase as the code returned on errors
|
||||
* This error is mapped from the translation auth:errors.{error}
|
||||
* To update the error messages, please update the translation file
|
||||
* https://github.com/supabase/gotrue-js/blob/master/src/lib/errors.ts
|
||||
* @constructor
|
||||
*/
|
||||
export function AuthErrorAlert({
|
||||
error,
|
||||
}: {
|
||||
error: Error | null | undefined | string;
|
||||
}) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DefaultError = <Trans i18nKey="auth:errors.default" />;
|
||||
const errorCode = error instanceof Error ? error.message : error;
|
||||
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={`auth:errorAlertHeading`} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription data-test={'auth-error-message'}>
|
||||
<Trans
|
||||
i18nKey={`auth:errors.${errorCode}`}
|
||||
defaults={'<DefaultError />'}
|
||||
components={{ DefaultError }}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
24
packages/features/auth/src/components/auth-layout.tsx
Normal file
24
packages/features/auth/src/components/auth-layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export function AuthLayoutShell({
|
||||
children,
|
||||
Logo,
|
||||
}: React.PropsWithChildren<{
|
||||
Logo?: React.ComponentType;
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex h-screen flex-col items-center justify-center' +
|
||||
' bg-background lg:bg-muted/30 gap-y-10 lg:gap-y-8' +
|
||||
' animate-in fade-in slide-in-from-top-16 zoom-in-95 duration-1000'
|
||||
}
|
||||
>
|
||||
{Logo ? <Logo /> : null}
|
||||
|
||||
<div
|
||||
className={`bg-background flex w-full max-w-[23rem] flex-col gap-y-6 rounded-lg px-6 md:w-8/12 md:px-8 md:py-6 lg:w-5/12 lg:px-8 xl:w-4/12 xl:gap-y-8 xl:py-8`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
packages/features/auth/src/components/auth-link-redirect.tsx
Normal file
34
packages/features/auth/src/components/auth-link-redirect.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
function AuthLinkRedirect(props: { redirectPath?: string }) {
|
||||
const params = useSearchParams();
|
||||
|
||||
const redirectPath = params?.get('redirectPath') ?? props.redirectPath ?? '/';
|
||||
|
||||
useRedirectOnSignIn(redirectPath);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default AuthLinkRedirect;
|
||||
|
||||
function useRedirectOnSignIn(redirectPath: string) {
|
||||
const supabase = useSupabase();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const { data } = supabase.auth.onAuthStateChange((_, session) => {
|
||||
if (session) {
|
||||
router.push(redirectPath);
|
||||
}
|
||||
});
|
||||
|
||||
return () => data.subscription.unsubscribe();
|
||||
}, [supabase, router, redirectPath]);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import { OauthProviderLogoImage } from './oauth-provider-logo-image';
|
||||
|
||||
export function AuthProviderButton({
|
||||
providerId,
|
||||
onClick,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
providerId: string;
|
||||
onClick: () => void;
|
||||
}>) {
|
||||
return (
|
||||
<Button
|
||||
className={'flex w-full gap-x-3 text-center'}
|
||||
data-provider={providerId}
|
||||
data-test={'auth-provider-button'}
|
||||
variant={'outline'}
|
||||
onClick={onClick}
|
||||
>
|
||||
<OauthProviderLogoImage providerId={providerId} />
|
||||
|
||||
<span>{children}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAppEvents } from '@kit/shared/events';
|
||||
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useCaptchaToken } from '../captcha/client';
|
||||
import { TermsAndConditionsFormField } from './terms-and-conditions-form-field';
|
||||
|
||||
export function MagicLinkAuthContainer({
|
||||
inviteToken,
|
||||
redirectUrl,
|
||||
shouldCreateUser,
|
||||
defaultValues,
|
||||
displayTermsCheckbox,
|
||||
}: {
|
||||
inviteToken?: string;
|
||||
redirectUrl: string;
|
||||
shouldCreateUser: boolean;
|
||||
displayTermsCheckbox?: boolean;
|
||||
|
||||
defaultValues?: {
|
||||
email: string;
|
||||
};
|
||||
}) {
|
||||
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
|
||||
const { t } = useTranslation();
|
||||
const signInWithOtpMutation = useSignInWithOtp();
|
||||
const appEvents = useAppEvents();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
email: defaultValues?.email ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = ({ email }: { email: string }) => {
|
||||
const url = new URL(redirectUrl);
|
||||
|
||||
if (inviteToken) {
|
||||
url.searchParams.set('invite_token', inviteToken);
|
||||
}
|
||||
|
||||
const emailRedirectTo = url.href;
|
||||
|
||||
const promise = async () => {
|
||||
await signInWithOtpMutation.mutateAsync({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo,
|
||||
captchaToken,
|
||||
shouldCreateUser,
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldCreateUser) {
|
||||
appEvents.emit({
|
||||
type: 'user.signedUp',
|
||||
payload: {
|
||||
method: 'magiclink',
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: t('auth:sendingEmailLink'),
|
||||
success: t(`auth:sendLinkSuccessToast`),
|
||||
error: t(`auth:errors.link`),
|
||||
});
|
||||
|
||||
resetCaptchaToken();
|
||||
};
|
||||
|
||||
if (signInWithOtpMutation.data) {
|
||||
return <SuccessAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className={'w-full'} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<If condition={signInWithOtpMutation.error}>
|
||||
<ErrorAlert />
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'email-input'}
|
||||
required
|
||||
type="email"
|
||||
placeholder={t('auth:emailPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'email'}
|
||||
/>
|
||||
|
||||
<If condition={displayTermsCheckbox}>
|
||||
<TermsAndConditionsFormField />
|
||||
</If>
|
||||
|
||||
<Button disabled={signInWithOtpMutation.isPending}>
|
||||
<If
|
||||
condition={signInWithOtpMutation.isPending}
|
||||
fallback={<Trans i18nKey={'auth:sendEmailLink'} />}
|
||||
>
|
||||
<Trans i18nKey={'auth:sendingEmailLink'} />
|
||||
</If>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessAlert() {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<CheckIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:sendLinkSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:sendLinkSuccessDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:errors.generic'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:errors.link'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from '@kit/ui/input-otp';
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function MultiFactorChallengeContainer({
|
||||
paths,
|
||||
userId,
|
||||
}: React.PropsWithChildren<{
|
||||
userId: string;
|
||||
paths: {
|
||||
redirectPath: string;
|
||||
};
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
|
||||
const verifyMFAChallenge = useVerifyMFAChallenge({
|
||||
onSuccess: () => {
|
||||
router.replace(paths.redirectPath);
|
||||
},
|
||||
});
|
||||
|
||||
const verificationCodeForm = useForm({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
factorId: z.string().min(1),
|
||||
verificationCode: z.string().min(6).max(6),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
factorId: '',
|
||||
verificationCode: '',
|
||||
},
|
||||
});
|
||||
|
||||
const factorId = useWatch({
|
||||
name: 'factorId',
|
||||
control: verificationCodeForm.control,
|
||||
});
|
||||
|
||||
if (!factorId) {
|
||||
return (
|
||||
<FactorsListContainer
|
||||
userId={userId}
|
||||
onSelect={(factorId) => {
|
||||
verificationCodeForm.setValue('factorId', factorId);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...verificationCodeForm}>
|
||||
<form
|
||||
className={'w-full'}
|
||||
onSubmit={verificationCodeForm.handleSubmit(async (data) => {
|
||||
await verifyMFAChallenge.mutateAsync({
|
||||
factorId,
|
||||
verificationCode: data.verificationCode,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<div className={'flex flex-col items-center gap-y-6'}>
|
||||
<div className="flex flex-col items-center gap-y-4">
|
||||
<Heading level={5}>
|
||||
<Trans i18nKey={'auth:verifyCodeHeading'} />
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div className={'flex w-full flex-col gap-y-2.5'}>
|
||||
<div className={'flex flex-col gap-y-4'}>
|
||||
<If condition={verifyMFAChallenge.error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'h-5'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account:invalidVerificationCodeHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey={'account:invalidVerificationCodeDescription'}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'verificationCode'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem
|
||||
className={
|
||||
'mx-auto flex flex-col items-center justify-center'
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<InputOTP {...field} maxLength={6} minLength={6}>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription className="text-center">
|
||||
<Trans
|
||||
i18nKey={'account:verifyActivationCodeDescription'}
|
||||
/>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
data-test={'submit-mfa-button'}
|
||||
disabled={
|
||||
verifyMFAChallenge.isPending ||
|
||||
verifyMFAChallenge.isSuccess ||
|
||||
!verificationCodeForm.formState.isValid
|
||||
}
|
||||
>
|
||||
<If condition={verifyMFAChallenge.isPending}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'account:verifyingCode'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
<If condition={verifyMFAChallenge.isSuccess}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'auth:redirecting'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
<If
|
||||
condition={
|
||||
!verifyMFAChallenge.isPending && !verifyMFAChallenge.isSuccess
|
||||
}
|
||||
>
|
||||
<Trans i18nKey={'account:submitVerificationCode'} />
|
||||
</If>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function useVerifyMFAChallenge({ onSuccess }: { onSuccess: () => void }) {
|
||||
const client = useSupabase();
|
||||
const mutationKey = ['mfa-verify-challenge'];
|
||||
|
||||
const mutationFn = async (params: {
|
||||
factorId: string;
|
||||
verificationCode: string;
|
||||
}) => {
|
||||
const { factorId, verificationCode: code } = params;
|
||||
|
||||
const response = await client.auth.mfa.challengeAndVerify({
|
||||
factorId,
|
||||
code,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({ mutationKey, mutationFn, onSuccess });
|
||||
}
|
||||
|
||||
function FactorsListContainer({
|
||||
onSelect,
|
||||
userId,
|
||||
}: React.PropsWithChildren<{
|
||||
userId: string;
|
||||
onSelect: (factor: string) => void;
|
||||
}>) {
|
||||
const signOut = useSignOut();
|
||||
const { data: factors, isLoading, error } = useFetchAuthFactors(userId);
|
||||
|
||||
const isSuccess = factors && !isLoading && !error;
|
||||
|
||||
useEffect(() => {
|
||||
// If there is an error, sign out
|
||||
if (error) {
|
||||
void signOut.mutateAsync();
|
||||
}
|
||||
}, [error, signOut]);
|
||||
|
||||
useEffect(() => {
|
||||
// If there is only one factor, select it automatically
|
||||
if (isSuccess && factors.totp.length === 1) {
|
||||
const factorId = factors.totp[0]?.id;
|
||||
|
||||
if (factorId) {
|
||||
onSelect(factorId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={'flex flex-col items-center space-y-4 py-8'}>
|
||||
<Spinner />
|
||||
|
||||
<div className={'text-sm'}>
|
||||
<Trans i18nKey={'account:loadingFactors'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={'w-full'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account:factorsListError'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account:factorsListErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const verifiedFactors = factors?.totp ?? [];
|
||||
|
||||
return (
|
||||
<div className={'animate-in fade-in flex flex-col space-y-4 duration-500'}>
|
||||
<div>
|
||||
<span className={'font-medium'}>
|
||||
<Trans i18nKey={'account:selectFactor'} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
{verifiedFactors.map((factor) => (
|
||||
<div key={factor.id}>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className={'w-full'}
|
||||
onClick={() => onSelect(factor.id)}
|
||||
>
|
||||
{factor.friendly_name}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
import { AtSign, Phone } from 'lucide-react';
|
||||
|
||||
const DEFAULT_IMAGE_SIZE = 18;
|
||||
|
||||
export function OauthProviderLogoImage({
|
||||
providerId,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
providerId: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
const image = getOAuthProviderLogos()[providerId];
|
||||
|
||||
if (typeof image === `string`) {
|
||||
return (
|
||||
<Image
|
||||
decoding={'async'}
|
||||
loading={'lazy'}
|
||||
src={image}
|
||||
alt={`${providerId} logo`}
|
||||
width={width ?? DEFAULT_IMAGE_SIZE}
|
||||
height={height ?? DEFAULT_IMAGE_SIZE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{image}</>;
|
||||
}
|
||||
|
||||
function getOAuthProviderLogos(): Record<string, string | React.ReactNode> {
|
||||
return {
|
||||
password: <AtSign className={'s-[18px]'} />,
|
||||
phone: <Phone className={'s-[18px]'} />,
|
||||
google: '/images/oauth/google.webp',
|
||||
facebook: '/images/oauth/facebook.webp',
|
||||
github: '/images/oauth/github.webp',
|
||||
microsoft: '/images/oauth/microsoft.webp',
|
||||
apple: '/images/oauth/apple.webp',
|
||||
twitter: <XLogo />,
|
||||
// add more logos here if needed
|
||||
};
|
||||
}
|
||||
|
||||
function XLogo() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 300 300"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
className={'fill-secondary-foreground'}
|
||||
d="M178.57 127.15 290.27 0h-26.46l-97.03 110.38L89.34 0H0l117.13 166.93L0 300.25h26.46l102.4-116.59 81.8 116.59h89.34M36.01 19.54H76.66l187.13 262.13h-40.66"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
139
packages/features/auth/src/components/oauth-providers.tsx
Normal file
139
packages/features/auth/src/components/oauth-providers.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type {
|
||||
Provider,
|
||||
SignInWithOAuthCredentials,
|
||||
} from '@supabase/supabase-js';
|
||||
|
||||
import { useSignInWithProvider } from '@kit/supabase/hooks/use-sign-in-with-provider';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { AuthProviderButton } from './auth-provider-button';
|
||||
|
||||
/**
|
||||
* @name OAUTH_SCOPES
|
||||
* @description
|
||||
* The OAuth scopes are used to specify the permissions that the application is requesting from the user.
|
||||
*
|
||||
* Please add your OAuth providers here and the scopes you want to use.
|
||||
*
|
||||
* @see https://supabase.com/docs/guides/auth/social-login
|
||||
*/
|
||||
const OAUTH_SCOPES: Partial<Record<Provider, string>> = {
|
||||
azure: 'email',
|
||||
keycloak: 'openid',
|
||||
// add your OAuth providers here
|
||||
};
|
||||
|
||||
export const OauthProviders: React.FC<{
|
||||
inviteToken?: string;
|
||||
shouldCreateUser: boolean;
|
||||
enabledProviders: Provider[];
|
||||
queryParams?: Record<string, string>;
|
||||
|
||||
paths: {
|
||||
callback: string;
|
||||
returnPath: string;
|
||||
};
|
||||
}> = (props) => {
|
||||
const signInWithProviderMutation = useSignInWithProvider();
|
||||
|
||||
// we make the UI "busy" until the next page is fully loaded
|
||||
const loading = signInWithProviderMutation.isPending;
|
||||
|
||||
const onSignInWithProvider = useCallback(
|
||||
async (signInRequest: () => Promise<unknown>) => {
|
||||
const credential = await signInRequest();
|
||||
|
||||
if (!credential) {
|
||||
return Promise.reject(new Error(`No credential returned`));
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const enabledProviders = props.enabledProviders;
|
||||
|
||||
if (!enabledProviders?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={loading}>
|
||||
<LoadingOverlay />
|
||||
</If>
|
||||
|
||||
<div className={'flex w-full flex-1 flex-col space-y-3'}>
|
||||
<div className={'flex-col space-y-2'}>
|
||||
{enabledProviders.map((provider) => {
|
||||
return (
|
||||
<AuthProviderButton
|
||||
key={provider}
|
||||
providerId={provider}
|
||||
onClick={() => {
|
||||
const origin = window.location.origin;
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (props.paths.returnPath) {
|
||||
queryParams.set('next', props.paths.returnPath);
|
||||
}
|
||||
|
||||
if (props.inviteToken) {
|
||||
queryParams.set('invite_token', props.inviteToken);
|
||||
}
|
||||
|
||||
const redirectPath = [
|
||||
props.paths.callback,
|
||||
queryParams.toString(),
|
||||
].join('?');
|
||||
|
||||
const redirectTo = [origin, redirectPath].join('');
|
||||
const scopes = OAUTH_SCOPES[provider] ?? undefined;
|
||||
|
||||
const credentials = {
|
||||
provider,
|
||||
options: {
|
||||
redirectTo,
|
||||
queryParams: props.queryParams,
|
||||
scopes,
|
||||
},
|
||||
} satisfies SignInWithOAuthCredentials;
|
||||
|
||||
return onSignInWithProvider(() =>
|
||||
signInWithProviderMutation.mutateAsync(credentials),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={'auth:signInWithProvider'}
|
||||
values={{
|
||||
provider: getProviderName(provider),
|
||||
}}
|
||||
/>
|
||||
</AuthProviderButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AuthErrorAlert error={signInWithProviderMutation.error} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function getProviderName(providerId: string) {
|
||||
const capitalize = (value: string) =>
|
||||
value.slice(0, 1).toUpperCase() + value.slice(1);
|
||||
|
||||
if (providerId.endsWith('.com')) {
|
||||
return capitalize(providerId.split('.com')[0]!);
|
||||
}
|
||||
|
||||
return capitalize(providerId);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useRequestResetPassword } from '@kit/supabase/hooks/use-request-reset-password';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useCaptchaToken } from '../captcha/client';
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
|
||||
const PasswordResetSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export function PasswordResetRequestContainer(params: {
|
||||
redirectPath: string;
|
||||
}) {
|
||||
const { t } = useTranslation('auth');
|
||||
const resetPasswordMutation = useRequestResetPassword();
|
||||
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
|
||||
|
||||
const error = resetPasswordMutation.error;
|
||||
const success = resetPasswordMutation.data;
|
||||
|
||||
const form = useForm<z.infer<typeof PasswordResetSchema>>({
|
||||
resolver: zodResolver(PasswordResetSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={success}>
|
||||
<Alert variant={'success'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:passwordResetSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={!resetPasswordMutation.data}>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(({ email }) => {
|
||||
const redirectTo = new URL(
|
||||
params.redirectPath,
|
||||
window.location.origin,
|
||||
).href;
|
||||
|
||||
return resetPasswordMutation
|
||||
.mutateAsync({
|
||||
email,
|
||||
redirectTo,
|
||||
captchaToken,
|
||||
})
|
||||
.catch(() => {
|
||||
resetCaptchaToken();
|
||||
});
|
||||
})}
|
||||
className={'w-full'}
|
||||
>
|
||||
<div className={'flex flex-col gap-y-4'}>
|
||||
<AuthErrorAlert error={error} />
|
||||
|
||||
<FormField
|
||||
name={'email'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
type="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button disabled={resetPasswordMutation.isPending} type="submit">
|
||||
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useSignInWithEmailPassword } from '@kit/supabase/hooks/use-sign-in-with-email-password';
|
||||
|
||||
import { useCaptchaToken } from '../captcha/client';
|
||||
import type { PasswordSignInSchema } from '../schemas/password-sign-in.schema';
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { PasswordSignInForm } from './password-sign-in-form';
|
||||
|
||||
export function PasswordSignInContainer({
|
||||
onSignIn,
|
||||
}: {
|
||||
onSignIn?: (userId?: string) => unknown;
|
||||
}) {
|
||||
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
|
||||
const signInMutation = useSignInWithEmailPassword();
|
||||
const isLoading = signInMutation.isPending;
|
||||
const isRedirecting = signInMutation.isSuccess;
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (credentials: z.infer<typeof PasswordSignInSchema>) => {
|
||||
try {
|
||||
const data = await signInMutation.mutateAsync({
|
||||
...credentials,
|
||||
options: { captchaToken },
|
||||
});
|
||||
|
||||
if (onSignIn) {
|
||||
const userId = data?.user?.id;
|
||||
|
||||
onSignIn(userId);
|
||||
}
|
||||
} catch {
|
||||
// wrong credentials, do nothing
|
||||
} finally {
|
||||
resetCaptchaToken();
|
||||
}
|
||||
},
|
||||
[captchaToken, onSignIn, resetCaptchaToken, signInMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthErrorAlert error={signInMutation.error} />
|
||||
|
||||
<PasswordSignInForm
|
||||
onSubmit={onSubmit}
|
||||
loading={isLoading}
|
||||
redirecting={isRedirecting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
146
packages/features/auth/src/components/password-sign-in-form.tsx
Normal file
146
packages/features/auth/src/components/password-sign-in-form.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { PasswordSignInSchema } from '../schemas/password-sign-in.schema';
|
||||
|
||||
export function PasswordSignInForm({
|
||||
onSubmit,
|
||||
loading = false,
|
||||
redirecting = false,
|
||||
}: {
|
||||
onSubmit: (params: z.infer<typeof PasswordSignInSchema>) => unknown;
|
||||
loading: boolean;
|
||||
redirecting: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
const form = useForm<z.infer<typeof PasswordSignInSchema>>({
|
||||
resolver: zodResolver(PasswordSignInSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex w-full flex-col gap-y-4'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'email'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'email-input'}
|
||||
required
|
||||
type="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'password'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:password'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
data-test={'password-input'}
|
||||
type="password"
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
<div>
|
||||
<Button
|
||||
asChild
|
||||
type={'button'}
|
||||
size={'sm'}
|
||||
variant={'link'}
|
||||
className={'text-xs'}
|
||||
>
|
||||
<Link href={'/auth/password-reset'}>
|
||||
<Trans i18nKey={'auth:passwordForgottenQuestion'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-test="auth-submit-button"
|
||||
className={'group w-full'}
|
||||
type="submit"
|
||||
disabled={loading || redirecting}
|
||||
>
|
||||
<If condition={redirecting}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'auth:redirecting'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
<If condition={loading}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'auth:signingIn'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
<If condition={!redirecting && !loading}>
|
||||
<span className={'animate-out fade-out flex items-center'}>
|
||||
<Trans i18nKey={'auth:signInWithEmail'} />
|
||||
|
||||
<ArrowRight
|
||||
className={
|
||||
'zoom-in animate-in slide-in-from-left-2 fill-mode-both h-4 delay-500 duration-500'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</If>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { CheckCircledIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useCaptchaToken } from '../captcha/client';
|
||||
import { usePasswordSignUpFlow } from '../hooks/use-sign-up-flow';
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { PasswordSignUpForm } from './password-sign-up-form';
|
||||
|
||||
interface EmailPasswordSignUpContainerProps {
|
||||
displayTermsCheckbox?: boolean;
|
||||
defaultValues?: {
|
||||
email: string;
|
||||
};
|
||||
onSignUp?: (userId?: string) => unknown;
|
||||
emailRedirectTo: string;
|
||||
}
|
||||
|
||||
export function EmailPasswordSignUpContainer({
|
||||
defaultValues,
|
||||
onSignUp,
|
||||
emailRedirectTo,
|
||||
displayTermsCheckbox,
|
||||
}: EmailPasswordSignUpContainerProps) {
|
||||
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
|
||||
|
||||
const {
|
||||
signUp: onSignupRequested,
|
||||
loading,
|
||||
error,
|
||||
showVerifyEmailAlert,
|
||||
} = usePasswordSignUpFlow({
|
||||
emailRedirectTo,
|
||||
onSignUp,
|
||||
captchaToken,
|
||||
resetCaptchaToken,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={showVerifyEmailAlert}>
|
||||
<SuccessAlert />
|
||||
</If>
|
||||
|
||||
<If condition={!showVerifyEmailAlert}>
|
||||
<AuthErrorAlert error={error} />
|
||||
|
||||
<PasswordSignUpForm
|
||||
onSubmit={onSignupRequested}
|
||||
loading={loading}
|
||||
defaultValues={defaultValues}
|
||||
displayTermsCheckbox={displayTermsCheckbox}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessAlert() {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<CheckCircledIcon className={'w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription data-test={'email-confirmation-alert'}>
|
||||
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
170
packages/features/auth/src/components/password-sign-up-form.tsx
Normal file
170
packages/features/auth/src/components/password-sign-up-form.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { PasswordSignUpSchema } from '../schemas/password-sign-up.schema';
|
||||
import { TermsAndConditionsFormField } from './terms-and-conditions-form-field';
|
||||
|
||||
interface PasswordSignUpFormProps {
|
||||
defaultValues?: {
|
||||
email: string;
|
||||
};
|
||||
|
||||
displayTermsCheckbox?: boolean;
|
||||
|
||||
onSubmit: (params: {
|
||||
email: string;
|
||||
password: string;
|
||||
repeatPassword: string;
|
||||
}) => unknown;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function PasswordSignUpForm({
|
||||
defaultValues,
|
||||
displayTermsCheckbox,
|
||||
onSubmit,
|
||||
loading,
|
||||
}: PasswordSignUpFormProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(PasswordSignUpSchema),
|
||||
defaultValues: {
|
||||
email: defaultValues?.email ?? '',
|
||||
password: '',
|
||||
repeatPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex w-full flex-col gap-y-4'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'email'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'email-input'}
|
||||
required
|
||||
type="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'password'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:password'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
data-test={'password-input'}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'auth:repeatPassword'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
data-test={'repeat-password-input'}
|
||||
type="password"
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
<FormDescription className={'pb-2 text-xs'}>
|
||||
<Trans i18nKey={'auth:repeatPasswordHint'} />
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<If condition={displayTermsCheckbox}>
|
||||
<TermsAndConditionsFormField />
|
||||
</If>
|
||||
|
||||
<Button
|
||||
data-test={'auth-submit-button'}
|
||||
className={'w-full'}
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
<If
|
||||
condition={loading}
|
||||
fallback={
|
||||
<>
|
||||
<Trans i18nKey={'auth:signUpWithEmail'} />
|
||||
|
||||
<ArrowRight
|
||||
className={
|
||||
'zoom-in animate-in slide-in-from-left-2 fill-mode-both h-4 delay-500 duration-500'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Trans i18nKey={'auth:signingUp'} />
|
||||
</If>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
113
packages/features/auth/src/components/resend-auth-link-form.tsx
Normal file
113
packages/features/auth/src/components/resend-auth-link-form.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useCaptchaToken } from '../captcha/client';
|
||||
|
||||
export function ResendAuthLinkForm(props: { redirectPath?: string }) {
|
||||
const resendLink = useResendLink();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(z.object({ email: z.string().email() })),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
if (resendLink.data && !resendLink.isPending) {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:resendLinkSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey={'auth:resendLinkSuccessDescription'}
|
||||
defaults={'Success!'}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex flex-col space-y-2'}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
return resendLink.mutate({
|
||||
email: data.email,
|
||||
redirectPath: props.redirectPath,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input type="email" required {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
name={'email'}
|
||||
/>
|
||||
|
||||
<Button disabled={resendLink.isPending}>
|
||||
<Trans i18nKey={'auth:resendLink'} defaults={'Resend Link'} />
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function useResendLink() {
|
||||
const supabase = useSupabase();
|
||||
const { captchaToken } = useCaptchaToken();
|
||||
|
||||
const mutationFn = async (props: {
|
||||
email: string;
|
||||
redirectPath?: string;
|
||||
}) => {
|
||||
const response = await supabase.auth.resend({
|
||||
email: props.email,
|
||||
type: 'signup',
|
||||
options: {
|
||||
emailRedirectTo: props.redirectPath,
|
||||
captchaToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { isBrowser } from '@kit/shared/utils';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { MagicLinkAuthContainer } from './magic-link-auth-container';
|
||||
import { OauthProviders } from './oauth-providers';
|
||||
import { PasswordSignInContainer } from './password-sign-in-container';
|
||||
|
||||
export function SignInMethodsContainer(props: {
|
||||
inviteToken?: string;
|
||||
|
||||
paths: {
|
||||
callback: string;
|
||||
joinTeam: string;
|
||||
returnPath: string;
|
||||
};
|
||||
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const redirectUrl = isBrowser()
|
||||
? new URL(props.paths.callback, window?.location.origin).toString()
|
||||
: '';
|
||||
|
||||
const onSignIn = () => {
|
||||
// if the user has an invite token, we should join the team
|
||||
if (props.inviteToken) {
|
||||
const searchParams = new URLSearchParams({
|
||||
invite_token: props.inviteToken,
|
||||
});
|
||||
|
||||
const joinTeamPath = props.paths.joinTeam + '?' + searchParams.toString();
|
||||
|
||||
router.replace(joinTeamPath);
|
||||
} else {
|
||||
// otherwise, we should redirect to the return path
|
||||
router.replace(props.paths.returnPath);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={props.providers.password}>
|
||||
<PasswordSignInContainer onSignIn={onSignIn} />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.magicLink}>
|
||||
<MagicLinkAuthContainer
|
||||
inviteToken={props.inviteToken}
|
||||
redirectUrl={redirectUrl}
|
||||
shouldCreateUser={false}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.oAuth.length}>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background text-muted-foreground px-2">
|
||||
<Trans i18nKey="auth:orContinueWith" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OauthProviders
|
||||
enabledProviders={props.providers.oAuth}
|
||||
inviteToken={props.inviteToken}
|
||||
shouldCreateUser={false}
|
||||
paths={{
|
||||
callback: props.paths.callback,
|
||||
returnPath: props.paths.returnPath,
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { isBrowser } from '@kit/shared/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { MagicLinkAuthContainer } from './magic-link-auth-container';
|
||||
import { OauthProviders } from './oauth-providers';
|
||||
import { EmailPasswordSignUpContainer } from './password-sign-up-container';
|
||||
|
||||
export function SignUpMethodsContainer(props: {
|
||||
paths: {
|
||||
callback: string;
|
||||
appHome: string;
|
||||
};
|
||||
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
|
||||
displayTermsCheckbox?: boolean;
|
||||
inviteToken?: string;
|
||||
}) {
|
||||
const redirectUrl = getCallbackUrl(props);
|
||||
const defaultValues = getDefaultValues();
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={props.inviteToken}>
|
||||
<InviteAlert />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.password}>
|
||||
<EmailPasswordSignUpContainer
|
||||
emailRedirectTo={redirectUrl}
|
||||
defaultValues={defaultValues}
|
||||
displayTermsCheckbox={props.displayTermsCheckbox}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.magicLink}>
|
||||
<MagicLinkAuthContainer
|
||||
inviteToken={props.inviteToken}
|
||||
redirectUrl={redirectUrl}
|
||||
shouldCreateUser={true}
|
||||
defaultValues={defaultValues}
|
||||
displayTermsCheckbox={props.displayTermsCheckbox}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.oAuth.length}>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background text-muted-foreground px-2">
|
||||
<Trans i18nKey="auth:orContinueWith" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OauthProviders
|
||||
enabledProviders={props.providers.oAuth}
|
||||
inviteToken={props.inviteToken}
|
||||
shouldCreateUser={true}
|
||||
paths={{
|
||||
callback: props.paths.callback,
|
||||
returnPath: props.paths.appHome,
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getCallbackUrl(props: {
|
||||
paths: {
|
||||
callback: string;
|
||||
appHome: string;
|
||||
};
|
||||
|
||||
inviteToken?: string;
|
||||
}) {
|
||||
if (!isBrowser()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const redirectPath = props.paths.callback;
|
||||
const origin = window.location.origin;
|
||||
const url = new URL(redirectPath, origin);
|
||||
|
||||
if (props.inviteToken) {
|
||||
url.searchParams.set('invite_token', props.inviteToken);
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const next = searchParams.get('next');
|
||||
|
||||
if (next) {
|
||||
url.searchParams.set('next', next);
|
||||
}
|
||||
|
||||
return url.href;
|
||||
}
|
||||
|
||||
function getDefaultValues() {
|
||||
if (!isBrowser()) {
|
||||
return { email: '' };
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const inviteToken = searchParams.get('invite_token');
|
||||
|
||||
if (!inviteToken) {
|
||||
return { email: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
email: searchParams.get('email') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function InviteAlert() {
|
||||
return (
|
||||
<Alert variant={'info'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:inviteAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:inviteAlertBody'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Checkbox } from '@kit/ui/checkbox';
|
||||
import { FormControl, FormField, FormItem, FormMessage } from '@kit/ui/form';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function TermsAndConditionsFormField(
|
||||
props: {
|
||||
name?: string;
|
||||
} = {},
|
||||
) {
|
||||
return (
|
||||
<FormField
|
||||
name={props.name ?? 'termsAccepted'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<label className={'flex items-start gap-x-3 py-2'}>
|
||||
<Checkbox required name={field.name} />
|
||||
|
||||
<div className={'text-xs'}>
|
||||
<Trans
|
||||
i18nKey={'auth:acceptTermsAndConditions'}
|
||||
components={{
|
||||
TermsOfServiceLink: (
|
||||
<Link
|
||||
target={'_blank'}
|
||||
className={'underline'}
|
||||
href={'/terms-of-service'}
|
||||
>
|
||||
<Trans i18nKey={'auth:termsOfService'} />
|
||||
</Link>
|
||||
),
|
||||
PrivacyPolicyLink: (
|
||||
<Link
|
||||
target={'_blank'}
|
||||
className={'underline'}
|
||||
href={'/privacy-policy'}
|
||||
>
|
||||
<Trans i18nKey={'auth:privacyPolicy'} />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
174
packages/features/auth/src/components/update-password-form.tsx
Normal file
174
packages/features/auth/src/components/update-password-form.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { PasswordResetSchema } from '../schemas/password-reset.schema';
|
||||
|
||||
export function UpdatePasswordForm(params: { redirectTo: string }) {
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
const form = useForm<z.infer<typeof PasswordResetSchema>>({
|
||||
resolver: zodResolver(PasswordResetSchema),
|
||||
defaultValues: {
|
||||
password: '',
|
||||
repeatPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
if (updateUser.error) {
|
||||
const error = updateUser.error as unknown as { code: string };
|
||||
|
||||
return <ErrorState error={error} onRetry={() => updateUser.reset()} />;
|
||||
}
|
||||
|
||||
if (updateUser.data && !updateUser.isPending) {
|
||||
return <SuccessState redirectTo={params.redirectTo} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-6'}>
|
||||
<div className={'flex justify-center'}>
|
||||
<Heading level={5} className={'tracking-tight'}>
|
||||
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex w-full flex-1 flex-col'}
|
||||
onSubmit={form.handleSubmit(({ password }) => {
|
||||
return updateUser.mutateAsync({
|
||||
password,
|
||||
redirectTo: params.redirectTo,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<div className={'flex-col space-y-4'}>
|
||||
<FormField
|
||||
name={'password'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:password'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input required type="password" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:repeatPassword'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input required type="password" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={updateUser.isPending}
|
||||
type="submit"
|
||||
className={'w-full'}
|
||||
>
|
||||
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessState(props: { redirectTo: string }) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'success'}>
|
||||
<CheckIcon className={'s-6'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account:updatePasswordSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account:updatePasswordSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Link href={props.redirectTo}>
|
||||
<Button variant={'outline'} className={'w-full'}>
|
||||
<span>
|
||||
<Trans i18nKey={'common:backToHomePage'} />
|
||||
</span>
|
||||
|
||||
<ArrowRightIcon className={'ml-2 h-4'} />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState(props: {
|
||||
onRetry: () => void;
|
||||
error: {
|
||||
code: string;
|
||||
};
|
||||
}) {
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
const errorMessage = t(`errors.${props.error.code}`, {
|
||||
defaultValue: t('errors.resetPasswordError'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'s-6'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'common:genericError'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={props.onRetry} variant={'outline'}>
|
||||
<Trans i18nKey={'common:retry'} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user