B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1,39 @@
'use client';
import { createContext, useCallback, useRef, useState } from 'react';
import { TurnstileInstance } from '@marsidev/react-turnstile';
export const Captcha = createContext<{
token: string;
setToken: (token: string) => void;
instance: TurnstileInstance | null;
setInstance: (ref: TurnstileInstance) => void;
}>({
token: '',
instance: null,
setToken: (_: string) => {
// do nothing
return '';
},
setInstance: () => {
// do nothing
},
});
export function CaptchaProvider(props: { children: React.ReactNode }) {
const [token, setToken] = useState<string>('');
const instanceRef = useRef<TurnstileInstance | null>(null);
const setInstance = useCallback((ref: TurnstileInstance) => {
instanceRef.current = ref;
}, []);
return (
<Captcha.Provider
value={{ token, setToken, instance: instanceRef.current, setInstance }}
>
{props.children}
</Captcha.Provider>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { useContext } from 'react';
import { Turnstile, TurnstileProps } from '@marsidev/react-turnstile';
import { Captcha } from './captcha-provider';
export function CaptchaTokenSetter(props: {
siteKey: string | undefined;
options?: TurnstileProps;
}) {
const { setToken, setInstance } = useContext(Captcha);
if (!props.siteKey) {
return null;
}
const options = props.options ?? {
options: {
size: 'invisible',
},
};
return (
<Turnstile
ref={(instance) => {
if (instance) {
setInstance(instance);
}
}}
siteKey={props.siteKey}
onSuccess={setToken}
{...options}
/>
);
}

View File

@@ -0,0 +1,3 @@
export * from './captcha-token-setter';
export * from './use-captcha-token';
export * from './captcha-provider';

View File

@@ -0,0 +1,23 @@
import { useContext, useMemo } from 'react';
import { Captcha } from './captcha-provider';
/**
* @name useCaptchaToken
* @description A hook to get the captcha token and reset function
* @returns The captcha token and reset function
*/
export function useCaptchaToken() {
const context = useContext(Captcha);
if (!context) {
throw new Error(`useCaptchaToken must be used within a CaptchaProvider`);
}
return useMemo(() => {
return {
captchaToken: context.token,
resetCaptchaToken: () => context.instance?.reset(),
};
}, [context]);
}

View File

@@ -0,0 +1 @@
export * from './verify-captcha';

View File

@@ -0,0 +1,39 @@
import 'server-only';
const verifyEndpoint =
'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const CAPTCHA_SECRET_TOKEN = process.env.CAPTCHA_SECRET_TOKEN;
/**
* @name verifyCaptchaToken
* @description Verify the CAPTCHA token with the CAPTCHA service
* @param token - The CAPTCHA token to verify
*/
export async function verifyCaptchaToken(token: string) {
if (!CAPTCHA_SECRET_TOKEN) {
throw new Error('CAPTCHA_SECRET_TOKEN is not set');
}
const formData = new FormData();
formData.append('secret', CAPTCHA_SECRET_TOKEN);
formData.append('response', token);
const res = await fetch(verifyEndpoint, {
method: 'POST',
body: formData,
});
if (!res.ok) {
console.error(`Captcha failed:`, res.statusText);
throw new Error('Failed to verify CAPTCHA token');
}
const data = await res.json();
if (!data.success) {
throw new Error('Invalid CAPTCHA token');
}
}

View 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>
);
}

View 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>
);
}

View 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]);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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);
}

View File

@@ -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>
</>
);
}

View File

@@ -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}
/>
</>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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,
});
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}}
/>
);
}

View 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>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useAppEvents } from '@kit/shared/events';
import { useSignUpWithEmailAndPassword } from '@kit/supabase/hooks/use-sign-up-with-email-password';
type SignUpCredentials = {
email: string;
password: string;
};
type UseSignUpFlowProps = {
emailRedirectTo: string;
onSignUp?: (userId?: string) => unknown;
captchaToken?: string;
resetCaptchaToken?: () => void;
};
/**
* @name usePasswordSignUpFlow
* @description
* This hook is used to handle the sign up flow using the email and password method.
*/
export function usePasswordSignUpFlow({
emailRedirectTo,
onSignUp,
captchaToken,
resetCaptchaToken,
}: UseSignUpFlowProps) {
const router = useRouter();
const signUpMutation = useSignUpWithEmailAndPassword();
const appEvents = useAppEvents();
const signUp = useCallback(
async (credentials: SignUpCredentials) => {
if (signUpMutation.isPending) {
return;
}
try {
const data = await signUpMutation.mutateAsync({
...credentials,
emailRedirectTo,
captchaToken,
});
// emit event to track sign up
appEvents.emit({
type: 'user.signedUp',
payload: {
method: 'password',
},
});
// Update URL with success status. This is useful for password managers
// to understand that the form was submitted successfully.
const url = new URL(window.location.href);
url.searchParams.set('status', 'success');
router.replace(url.pathname + url.search);
if (onSignUp) {
onSignUp(data.user?.id);
}
} catch (error) {
console.error(error);
throw error;
} finally {
resetCaptchaToken?.();
}
},
[
signUpMutation,
emailRedirectTo,
captchaToken,
appEvents,
router,
onSignUp,
resetCaptchaToken,
],
);
return {
signUp,
loading: signUpMutation.isPending,
error: signUpMutation.error,
showVerifyEmailAlert: signUpMutation.isSuccess,
};
}

View File

@@ -0,0 +1 @@
export * from './components/multi-factor-challenge-container';

View File

@@ -0,0 +1,2 @@
export * from './components/password-reset-request-container';
export * from './components/update-password-form';

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema';
export const PasswordResetSchema = z
.object({
password: RefinedPasswordSchema,
repeatPassword: RefinedPasswordSchema,
})
.superRefine(refineRepeatPassword);

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
import { PasswordSchema } from './password.schema';
export const PasswordSignInSchema = z.object({
email: z.string().email(),
password: PasswordSchema,
});

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema';
export const PasswordSignUpSchema = z
.object({
email: z.string().email(),
password: RefinedPasswordSchema,
repeatPassword: RefinedPasswordSchema,
})
.superRefine(refineRepeatPassword);

View File

@@ -0,0 +1,82 @@
import { z } from 'zod';
/**
* Password requirements
* These are the requirements for the password when signing up or changing the password
*/
const requirements = {
minLength: 6,
maxLength: 99,
specialChars:
process.env.NEXT_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS === 'true',
numbers: process.env.NEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERS === 'true',
uppercase: process.env.NEXT_PUBLIC_PASSWORD_REQUIRE_UPPERCASE === 'true',
};
/**
* Password schema
* This is used to validate the password on sign in (for existing users when requirements are not enforced)
*/
export const PasswordSchema = z
.string()
.min(requirements.minLength)
.max(requirements.maxLength);
/**
* Refined password schema with additional requirements
* This is required to validate the password requirements on sign up and password change
*/
export const RefinedPasswordSchema = PasswordSchema.superRefine((val, ctx) =>
validatePassword(val, ctx),
);
export function refineRepeatPassword(
data: { password: string; repeatPassword: string },
ctx: z.RefinementCtx,
) {
if (data.password !== data.repeatPassword) {
ctx.addIssue({
message: 'auth:errors.passwordsDoNotMatch',
path: ['repeatPassword'],
code: 'custom',
});
}
return true;
}
function validatePassword(password: string, ctx: z.RefinementCtx) {
if (requirements.specialChars) {
const specialCharsCount =
password.match(/[!@#$%^&*(),.?":{}|<>]/g)?.length ?? 0;
if (specialCharsCount < 1) {
ctx.addIssue({
message: 'auth:errors.minPasswordSpecialChars',
code: 'custom',
});
}
}
if (requirements.numbers) {
const numbersCount = password.match(/\d/g)?.length ?? 0;
if (numbersCount < 1) {
ctx.addIssue({
message: 'auth:errors.minPasswordNumbers',
code: 'custom',
});
}
}
if (requirements.uppercase) {
if (!/[A-Z]/.test(password)) {
ctx.addIssue({
message: 'auth:errors.uppercasePassword',
code: 'custom',
});
}
}
return true;
}

View File

@@ -0,0 +1 @@
export * from './components/auth-layout';

View File

@@ -0,0 +1,2 @@
export * from './components/sign-in-methods-container';
export * from './schemas/password-sign-in.schema';

View File

@@ -0,0 +1,2 @@
export * from './components/sign-up-methods-container';
export * from './schemas/password-sign-up.schema';