This commit is contained in:
giancarlo
2024-03-24 02:23:22 +08:00
parent 648d77b430
commit bce3479368
589 changed files with 37067 additions and 9596 deletions

View File

@@ -0,0 +1,47 @@
{
"name": "@kit/auth",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"exports": {
"./sign-in": "./src/sign-in.ts",
"./sign-up": "./src/sign-up.ts",
"./password-reset": "./src/password-reset.ts",
"./shared": "./src/shared.ts",
"./mfa": "./src/mfa.ts"
},
"dependencies": {
"@kit/ui": "0.1.0",
"@kit/supabase": "0.1.0",
"@radix-ui/react-icons": "^1.3.0",
"react-i18next": "14.1.0",
"sonner": "^1.4.41",
"@tanstack/react-query": "5.28.6"
},
"devDependencies": {
"@kit/prettier-config": "0.1.0",
"@kit/eslint-config": "0.2.0",
"@kit/tailwind-config": "0.1.0",
"@kit/tsconfig": "0.1.0"
},
"prettier": "@kit/prettier-config",
"eslintConfig": {
"root": true,
"extends": [
"@kit/eslint-config/base",
"@kit/eslint-config/react"
]
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -0,0 +1,46 @@
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
className={'text-sm font-medium'}
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 space-y-4' +
' dark:lg:bg-background md:space-y-8 lg:space-y-12 lg:bg-gray-50' +
' animate-in fade-in slide-in-from-top-8 duration-1000'
}
>
{Logo && <Logo />}
<div
className={`bg-background dark:border-border flex w-full max-w-sm flex-col items-center space-y-4 rounded-lg border-transparent md:w-8/12 md:border md:px-8 md:py-6 md:shadow lg:w-5/12 lg:px-6 xl:w-4/12 2xl:w-3/12`}
>
{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 space-x-2 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,137 @@
'use client';
import { useState } from 'react';
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
import { useVerifyOtp } from '@kit/supabase/hooks/use-verify-otp';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { OtpInput } from '@kit/ui/otp-input';
import { Trans } from '@kit/ui/trans';
export function EmailOtpContainer({
shouldCreateUser,
onSignIn,
inviteCode,
redirectUrl,
}: React.PropsWithChildren<{
inviteCode?: string;
redirectUrl: string;
shouldCreateUser: boolean;
onSignIn?: () => void;
}>) {
const [email, setEmail] = useState('');
if (email) {
return (
<VerifyOtpForm
redirectUrl={redirectUrl}
inviteCode={inviteCode}
onSuccess={onSignIn}
email={email}
/>
);
}
return (
<EmailOtpForm onSuccess={setEmail} shouldCreateUser={shouldCreateUser} />
);
}
function VerifyOtpForm({
email,
inviteCode,
onSuccess,
redirectUrl,
}: {
email: string;
redirectUrl: string;
onSuccess?: () => void;
inviteCode?: string;
}) {
const verifyOtpMutation = useVerifyOtp();
const [verifyCode, setVerifyCode] = useState('');
return (
<form
className={'w-full'}
onSubmit={async (event) => {
event.preventDefault();
const queryParams = inviteCode ? `?inviteCode=${inviteCode}` : '';
const redirectTo = [redirectUrl, queryParams].join('');
await verifyOtpMutation.mutateAsync({
email,
token: verifyCode,
type: 'email',
options: {
redirectTo,
},
});
onSuccess && onSuccess();
}}
>
<div className={'flex flex-col space-y-4'}>
<OtpInput onValid={setVerifyCode} onInvalid={() => setVerifyCode('')} />
<Button disabled={verifyOtpMutation.isPending || !verifyCode}>
{verifyOtpMutation.isPending ? (
<Trans i18nKey={'profile:verifyingCode'} />
) : (
<Trans i18nKey={'profile:submitVerificationCode'} />
)}
</Button>
</div>
</form>
);
}
function EmailOtpForm({
shouldCreateUser,
onSuccess,
}: React.PropsWithChildren<{
shouldCreateUser: boolean;
onSuccess: (email: string) => void;
}>) {
const signInWithOtpMutation = useSignInWithOtp();
return (
<form
className={'w-full'}
onSubmit={async (event) => {
event.preventDefault();
const email = event.currentTarget.email.value;
await signInWithOtpMutation.mutateAsync({
email,
options: {
shouldCreateUser,
},
});
onSuccess(email);
}}
>
<div className={'flex flex-col space-y-4'}>
<Label>
<Trans i18nKey={'auth:emailAddress'} />
<Input name={'email'} type={'email'} placeholder={''} />
</Label>
<Button disabled={signInWithOtpMutation.isPending}>
<If
condition={signInWithOtpMutation.isPending}
fallback={<Trans i18nKey={'auth:sendEmailCode'} />}
>
<Trans i18nKey={'auth:sendingEmailCode'} />
</If>
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import type { FormEventHandler } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
import { Alert, AlertDescription } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Trans } from '@kit/ui/trans';
export function MagicLinkAuthContainer({
inviteCode,
redirectUrl,
}: {
inviteCode?: string;
redirectUrl: string;
}) {
const { t } = useTranslation();
const signInWithOtpMutation = useSignInWithOtp();
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
(event) => {
event.preventDefault();
const target = event.currentTarget;
const data = new FormData(target);
const email = data.get('email') as string;
const queryParams = inviteCode ? `?inviteCode=${inviteCode}` : '';
const emailRedirectTo = [redirectUrl, queryParams].join('');
const promise = signInWithOtpMutation.mutateAsync({
email,
options: {
emailRedirectTo,
},
});
toast.promise(promise, {
loading: t('auth:sendingEmailLink'),
success: t(`auth:sendLinkSuccessToast`),
error: t(`auth:errors.link`),
});
},
[inviteCode, redirectUrl, signInWithOtpMutation, t],
);
if (signInWithOtpMutation.data) {
return (
<Alert variant={'success'}>
<AlertDescription>
<Trans i18nKey={'auth:sendLinkSuccess'} />
</AlertDescription>
</Alert>
);
}
return (
<form className={'w-full'} onSubmit={onSubmit}>
<If condition={signInWithOtpMutation.error}>
<Alert variant={'destructive'}>
<AlertDescription>
<Trans i18nKey={'auth:errors.link'} />
</AlertDescription>
</Alert>
</If>
<div className={'flex flex-col space-y-4'}>
<Label>
<Trans i18nKey={'common:emailAddress'} />
<Input
data-test={'email-input'}
required
type="email"
placeholder={t('auth:emailPlaceholder')}
name={'email'}
/>
</Label>
<Button disabled={signInWithOtpMutation.isPending}>
<If
condition={signInWithOtpMutation.isPending}
fallback={<Trans i18nKey={'auth:sendEmailLink'} />}
>
<Trans i18nKey={'auth:sendingEmailLink'} />
</If>
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,196 @@
import type { FormEventHandler } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
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 } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { OtpInput } from '@kit/ui/otp-input';
import Spinner from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
export function MultiFactorChallengeContainer({
onSuccess,
}: React.PropsWithChildren<{
onSuccess: () => void;
}>) {
const [factorId, setFactorId] = useState('');
const [verifyCode, setVerifyCode] = useState('');
const verifyMFAChallenge = useVerifyMFAChallenge();
const onSubmitClicked: FormEventHandler<HTMLFormElement> = useCallback(
(event) => {
void (async () => {
event.preventDefault();
if (!factorId || !verifyCode) {
return;
}
await verifyMFAChallenge.mutateAsync({
factorId,
verifyCode,
});
onSuccess();
})();
},
[factorId, verifyMFAChallenge, onSuccess, verifyCode],
);
if (!factorId) {
return (
<FactorsListContainer onSelect={setFactorId} onSuccess={onSuccess} />
);
}
return (
<form onSubmit={onSubmitClicked}>
<div className={'flex flex-col space-y-4'}>
<span className={'text-sm'}>
<Trans i18nKey={'profile:verifyActivationCodeDescription'} />
</span>
<div className={'flex w-full flex-col space-y-2.5'}>
<OtpInput
onInvalid={() => setVerifyCode('')}
onValid={setVerifyCode}
/>
<If condition={verifyMFAChallenge.error}>
<Alert variant={'destructive'}>
<AlertDescription>
<Trans i18nKey={'profile:invalidVerificationCode'} />
</AlertDescription>
</Alert>
</If>
</div>
<Button disabled={verifyMFAChallenge.isPending || !verifyCode}>
{verifyMFAChallenge.isPending ? (
<Trans i18nKey={'profile:verifyingCode'} />
) : (
<Trans i18nKey={'profile:submitVerificationCode'} />
)}
</Button>
</div>
</form>
);
}
function useVerifyMFAChallenge() {
const client = useSupabase();
const mutationKey = ['mfa-verify-challenge'];
const mutationFn = async (params: {
factorId: string;
verifyCode: string;
}) => {
const { factorId, verifyCode: code } = params;
const response = await client.auth.mfa.challengeAndVerify({
factorId,
code,
});
if (response.error) {
throw response.error;
}
return response.data;
};
return useMutation({ mutationKey, mutationFn });
}
function FactorsListContainer({
onSuccess,
onSelect,
}: React.PropsWithChildren<{
onSuccess: () => void;
onSelect: (factor: string) => void;
}>) {
const signOut = useSignOut();
const { data: factors, isLoading, error } = useFetchAuthFactors();
const isSuccess = factors && !isLoading && !error;
useEffect(() => {
// If there are no factors, continue
if (isSuccess && !factors.totp.length) {
onSuccess();
}
}, [factors?.totp.length, isSuccess, onSuccess]);
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>
<Trans i18nKey={'profile:loadingFactors'} />
</div>
</div>
);
}
if (error) {
return (
<div className={'w-full'}>
<Alert variant={'destructive'}>
<AlertDescription>
<Trans i18nKey={'profile:factorsListError'} />
</AlertDescription>
</Alert>
</div>
);
}
const verifiedFactors = factors?.totp ?? [];
return (
<div className={'flex flex-col space-y-4'}>
<div>
<Heading level={6}>
<Trans i18nKey={'profile:selectFactor'} />
</Heading>
</div>
{verifiedFactors.map((factor) => (
<div key={factor.id}>
<Button
variant={'outline'}
className={'w-full border-gray-50'}
onClick={() => onSelect(factor.id)}
>
{factor.friendly_name}
</Button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import Image from 'next/image';
import { AtSignIcon, PhoneIcon } from 'lucide-react';
const DEFAULT_IMAGE_SIZE = 18;
export const OauthProviderLogoImage: React.FC<{
providerId: string;
width?: number;
height?: number;
}> = ({ providerId, width, height }) => {
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: <AtSignIcon className={'s-[18px]'} />,
phone: <PhoneIcon className={'s-[18px]'} />,
google: '/assets/images/google.webp',
facebook: '/assets/images/facebook.webp',
twitter: '/assets/images/twitter.webp',
github: '/assets/images/github.webp',
microsoft: '/assets/images/microsoft.webp',
apple: '/assets/images/apple.webp',
};
}

View File

@@ -0,0 +1,113 @@
'use client';
import { useCallback } from 'react';
import type { Provider } 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';
export const OauthProviders: React.FC<{
returnUrl?: string;
inviteCode?: string;
enabledProviders: Provider[];
redirectUrl: 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();
}
},
[],
);
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.returnUrl) {
queryParams.set('next', props.returnUrl);
}
if (props.inviteCode) {
queryParams.set('inviteCode', props.inviteCode);
}
const redirectPath = [
props.redirectUrl,
queryParams.toString(),
].join('?');
const redirectTo = [origin, redirectPath].join('');
const credentials = {
provider,
options: {
redirectTo,
},
};
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,154 @@
'use client';
import Link from 'next/link';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
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';
function PasswordResetForm(params: { redirectTo: string }) {
const updateUser = useUpdateUser();
const form = useForm<z.infer<typeof PasswordResetSchema>>({
resolver: zodResolver(PasswordResetSchema),
defaultValues: {
password: '',
repeatPassword: '',
},
});
if (updateUser.error) {
return <ErrorState onRetry={() => updateUser.reset()} />;
}
if (updateUser.data && !updateUser.isPending) {
return <SuccessState />;
}
return (
<div className={'flex w-full flex-col space-y-6'}>
<div className={'flex justify-center'}>
<Heading level={5}>
<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>
);
}
export default PasswordResetForm;
function SuccessState() {
return (
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'profile:updatePasswordSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'profile:updatePasswordSuccessMessage'} />
</AlertDescription>
</Alert>
<Link href={'/'}>
<Button variant={'outline'}>
<Trans i18nKey={'common:backToHomePage'} />
</Button>
</Link>
</div>
);
}
function ErrorState(props: { onRetry: () => void }) {
return (
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'auth:resetPasswordError'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
<Button onClick={props.onRetry} variant={'outline'}>
<Trans i18nKey={'common:retry'} />
</Button>
</div>
);
}

View File

@@ -0,0 +1,103 @@
'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 { AuthErrorAlert } from './auth-error-alert';
const PasswordResetSchema = z.object({
email: z.string().email(),
});
export function PasswordResetRequestContainer(params: { redirectTo: string }) {
const { t } = useTranslation('auth');
const resetPasswordMutation = useRequestResetPassword();
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 }) => {
return resetPasswordMutation.mutateAsync({
email,
redirectTo: params.redirectTo,
});
})}
className={'w-full'}
>
<div className={'flex flex-col space-y-4'}>
<div>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth:passwordResetSubheading'} />
</p>
</div>
<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,42 @@
'use client';
import { useCallback } from 'react';
import type { z } from 'zod';
import { useSignInWithEmailPassword } from '@kit/supabase/hooks/use-sign-in-with-email-password';
import type { PasswordSignInSchema } from '../schemas/password-sign-in.schema';
import { AuthErrorAlert } from './auth-error-alert';
import { PasswordSignInForm } from './password-sign-in-form';
export const PasswordSignInContainer: React.FC<{
onSignIn?: (userId?: string) => unknown;
}> = ({ onSignIn }) => {
const signInMutation = useSignInWithEmailPassword();
const isLoading = signInMutation.isPending;
const onSubmit = useCallback(
async (credentials: z.infer<typeof PasswordSignInSchema>) => {
try {
const data = await signInMutation.mutateAsync(credentials);
const userId = data?.user?.id;
if (onSignIn) {
onSignIn(userId);
}
} catch (e) {
// wrong credentials, do nothing
}
},
[onSignIn, signInMutation],
);
return (
<>
<AuthErrorAlert error={signInMutation.error} />
<PasswordSignInForm onSubmit={onSubmit} loading={isLoading} />
</>
);
};

View File

@@ -0,0 +1,120 @@
'use client';
import Link from 'next/link';
import { zodResolver } from '@hookform/resolvers/zod';
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 const PasswordSignInForm: React.FC<{
onSubmit: (params: z.infer<typeof PasswordSignInSchema>) => unknown;
loading: boolean;
}> = ({ onSubmit, loading }) => {
const { t } = useTranslation('auth');
const form = useForm<z.infer<typeof PasswordSignInSchema>>({
resolver: zodResolver(PasswordSignInSchema),
defaultValues: {
email: '',
password: '',
},
});
return (
<Form {...form}>
<form
className={'w-full space-y-2.5'}
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 />
<Link href={'/auth/password-reset'}>
<Button
type={'button'}
size={'sm'}
variant={'link'}
className={'text-xs'}
>
<Trans i18nKey={'auth:passwordForgottenQuestion'} />
</Button>
</Link>
</FormItem>
)}
/>
<Button
data-test="auth-submit-button"
className={'w-full'}
type="submit"
disabled={loading}
>
<If
condition={loading}
fallback={<Trans i18nKey={'auth:signInWithEmail'} />}
>
<Trans i18nKey={'auth:signingIn'} />
</If>
</Button>
</form>
</Form>
);
};

View File

@@ -0,0 +1,88 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { CheckIcon } from 'lucide-react';
import { useSignUpWithEmailAndPassword } from '@kit/supabase/hooks/use-sign-up-with-email-password';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { AuthErrorAlert } from './auth-error-alert';
import { PasswordSignUpForm } from './password-sign-up-form';
export function EmailPasswordSignUpContainer({
onSignUp,
onError,
emailRedirectTo,
}: React.PropsWithChildren<{
onSignUp?: (userId?: string) => unknown;
onError?: (error?: unknown) => unknown;
emailRedirectTo: string;
}>) {
const signUpMutation = useSignUpWithEmailAndPassword();
const redirecting = useRef(false);
const loading = signUpMutation.isPending || redirecting.current;
const [showVerifyEmailAlert, setShowVerifyEmailAlert] = useState(false);
const callOnErrorCallback = useCallback(() => {
if (signUpMutation.error && onError) {
onError(signUpMutation.error);
}
}, [signUpMutation.error, onError]);
useEffect(() => {
callOnErrorCallback();
}, [callOnErrorCallback]);
const onSignupRequested = useCallback(
async (credentials: { email: string; password: string }) => {
if (loading) {
return;
}
try {
const data = await signUpMutation.mutateAsync({
...credentials,
emailRedirectTo,
});
setShowVerifyEmailAlert(true);
if (onSignUp) {
onSignUp(data.user?.id);
}
} catch (error) {
if (onError) {
onError(error);
}
}
},
[emailRedirectTo, loading, onError, onSignUp, signUpMutation],
);
return (
<>
<If condition={showVerifyEmailAlert}>
<Alert variant={'success'}>
<CheckIcon className={'w-4'} />
<AlertTitle>
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
</AlertTitle>
<AlertDescription data-test={'email-confirmation-alert'}>
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
</AlertDescription>
</Alert>
</If>
<If condition={!showVerifyEmailAlert}>
<AuthErrorAlert error={signUpMutation.error} />
<PasswordSignUpForm onSubmit={onSignupRequested} loading={loading} />
</If>
</>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
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';
export const PasswordSignUpForm: React.FC<{
onSubmit: (params: {
email: string;
password: string;
repeatPassword: string;
}) => unknown;
loading: boolean;
}> = ({ onSubmit, loading }) => {
const { t } = useTranslation();
const form = useForm({
resolver: zodResolver(PasswordSignUpSchema),
defaultValues: {
email: '',
password: '',
repeatPassword: '',
},
});
return (
<Form {...form}>
<form
className={'w-full space-y-2.5'}
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 />
</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>
)}
/>
<Button
data-test={'auth-submit-button'}
className={'w-full'}
type="submit"
disabled={loading}
>
<If
condition={loading}
fallback={<Trans i18nKey={'auth:signUpWithEmail'} />}
>
<Trans i18nKey={'auth:signingUp'} />
</If>
</Button>
</form>
</Form>
);
};

View File

@@ -0,0 +1,71 @@
'use client';
import { useMutation } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { Alert, AlertDescription } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Trans } from '@kit/ui/trans';
function ResendAuthLinkForm() {
const resendLink = useResendLink();
if (resendLink.data && !resendLink.isPending) {
return (
<Alert variant={'success'}>
<AlertDescription>
<Trans i18nKey={'auth:resendLinkSuccess'} defaults={'Success!'} />
</AlertDescription>
</Alert>
);
}
return (
<form
className={'flex flex-col space-y-2'}
onSubmit={(data) => {
data.preventDefault();
const email = new FormData(data.currentTarget).get('email') as string;
return resendLink.mutateAsync(email);
}}
>
<Label>
<Trans i18nKey={'common:emailAddress'} />
<Input name={'email'} required placeholder={''} />
</Label>
<Button disabled={resendLink.isPending}>
<Trans i18nKey={'auth:resendLink'} defaults={'Resend Link'} />
</Button>
</form>
);
}
export default ResendAuthLinkForm;
function useResendLink() {
const supabase = useSupabase();
const mutationKey = ['resend-link'];
const mutationFn = async (email: string) => {
const response = await supabase.auth.resend({
email,
type: 'signup',
});
if (response.error) {
throw response.error;
}
return response.data;
};
return useMutation({
mutationKey,
mutationFn,
});
}

View File

@@ -0,0 +1,66 @@
'use client';
import { useRouter } from 'next/navigation';
import type { Provider } from '@supabase/supabase-js';
import { isBrowser } from '@supabase/ssr';
import { Divider } from '@kit/ui/divider';
import { If } from '@kit/ui/if';
import { EmailOtpContainer } from './email-otp-container';
import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers';
import { PasswordSignInContainer } from './password-sign-in-container';
export function SignInMethodsContainer(props: {
paths: {
callback: string;
home: string;
};
providers: {
password: boolean;
magicLink: boolean;
otp: boolean;
oAuth: Provider[];
};
}) {
const redirectUrl = new URL(
props.paths.callback,
isBrowser() ? window?.location.origin : '',
).toString();
const router = useRouter();
const onSignIn = () => router.replace(props.paths.home);
return (
<>
<If condition={props.providers.password}>
<PasswordSignInContainer onSignIn={onSignIn} />
</If>
<If condition={props.providers.magicLink}>
<MagicLinkAuthContainer redirectUrl={redirectUrl} />
</If>
<If condition={props.providers.otp}>
<EmailOtpContainer
onSignIn={onSignIn}
redirectUrl={redirectUrl}
shouldCreateUser={false}
/>
</If>
<If condition={props.providers.oAuth.length}>
<Divider />
<OauthProviders
enabledProviders={props.providers.oAuth}
redirectUrl={redirectUrl}
/>
</If>
</>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import type { Provider } from '@supabase/supabase-js';
import { isBrowser } from '@supabase/ssr';
import { Divider } from '@kit/ui/divider';
import { If } from '@kit/ui/if';
import { EmailOtpContainer } from './email-otp-container';
import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers';
import { EmailPasswordSignUpContainer } from './password-sign-up-container';
export function SignUpMethodsContainer(props: {
callbackPath: string;
providers: {
password: boolean;
magicLink: boolean;
otp: boolean;
oAuth: Provider[];
};
inviteCode?: string;
}) {
const redirectUrl = new URL(
props.callbackPath,
isBrowser() ? window?.location.origin : '',
).toString();
return (
<>
<If condition={props.providers.password}>
<EmailPasswordSignUpContainer emailRedirectTo={redirectUrl} />
</If>
<If condition={props.providers.magicLink}>
<MagicLinkAuthContainer
inviteCode={props.inviteCode}
redirectUrl={redirectUrl}
/>
</If>
<If condition={props.providers.otp}>
<EmailOtpContainer
redirectUrl={redirectUrl}
shouldCreateUser={true}
inviteCode={props.inviteCode}
/>
</If>
<If condition={props.providers.oAuth.length}>
<Divider />
<OauthProviders
enabledProviders={props.providers.oAuth}
redirectUrl={redirectUrl}
inviteCode={props.inviteCode}
/>
</If>
</>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const PasswordResetSchema = z
.object({
password: z.string().min(8).max(99),
repeatPassword: z.string().min(8).max(99),
})
.refine((data) => data.password === data.repeatPassword, {
message: 'Passwords do not match',
path: ['repeatPassword'],
});

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const PasswordSignInSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(99),
});

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const PasswordSignUpSchema = z
.object({
email: z.string().email(),
password: z.string().min(8).max(99),
repeatPassword: z.string().min(8).max(99),
})
.refine(
(schema) => {
return schema.password === schema.repeatPassword;
},
{
message: 'Passwords do not match',
path: ['repeatPassword'],
},
);

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';

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}