Cleanup
This commit is contained in:
47
packages/features/auth/package.json
Normal file
47
packages/features/auth/package.json
Normal 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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
46
packages/features/auth/src/components/auth-error-alert.tsx
Normal file
46
packages/features/auth/src/components/auth-error-alert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
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 space-x-2 text-center'}
|
||||
data-provider={providerId}
|
||||
data-test={'auth-provider-button'}
|
||||
variant={'outline'}
|
||||
onClick={onClick}
|
||||
>
|
||||
<OauthProviderLogoImage providerId={providerId} />
|
||||
|
||||
<span>{children}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
137
packages/features/auth/src/components/email-otp-container.tsx
Normal file
137
packages/features/auth/src/components/email-otp-container.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
113
packages/features/auth/src/components/oauth-providers.tsx
Normal file
113
packages/features/auth/src/components/oauth-providers.tsx
Normal 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);
|
||||
}
|
||||
154
packages/features/auth/src/components/password-reset-form.tsx
Normal file
154
packages/features/auth/src/components/password-reset-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
120
packages/features/auth/src/components/password-sign-in-form.tsx
Normal file
120
packages/features/auth/src/components/password-sign-in-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
140
packages/features/auth/src/components/password-sign-up-form.tsx
Normal file
140
packages/features/auth/src/components/password-sign-up-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
packages/features/auth/src/mfa.ts
Normal file
1
packages/features/auth/src/mfa.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components/multi-factor-challenge-container';
|
||||
1
packages/features/auth/src/password-reset.ts
Normal file
1
packages/features/auth/src/password-reset.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components/password-reset-request-container';
|
||||
11
packages/features/auth/src/schemas/password-reset.schema.ts
Normal file
11
packages/features/auth/src/schemas/password-reset.schema.ts
Normal 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'],
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PasswordSignInSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(99),
|
||||
});
|
||||
@@ -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'],
|
||||
},
|
||||
);
|
||||
1
packages/features/auth/src/shared.ts
Normal file
1
packages/features/auth/src/shared.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components/auth-layout';
|
||||
2
packages/features/auth/src/sign-in.ts
Normal file
2
packages/features/auth/src/sign-in.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './components/sign-in-methods-container';
|
||||
export * from './schemas/password-sign-in.schema';
|
||||
2
packages/features/auth/src/sign-up.ts
Normal file
2
packages/features/auth/src/sign-up.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './components/sign-up-methods-container';
|
||||
export * from './schemas/password-sign-up.schema';
|
||||
8
packages/features/auth/tsconfig.json
Normal file
8
packages/features/auth/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user