Add OTP sign-in option + Account Linking (#276)

* feat(accounts): allow linking email password
* feat(auth): add OTP sign-in
* refactor(accounts): remove 'sonner' dependency and update toast imports
* feat(supabase): enable analytics and configure database seeding
* feat(auth): update email templates and add OTP template
* feat(auth): add last sign in method hints
* feat(config): add devIndicators position to bottom-right
* feat(auth): implement comprehensive last authentication method tracking tests
This commit is contained in:
Giancarlo Buomprisco
2025-06-13 16:47:35 +07:00
committed by GitHub
parent 856e9612c4
commit 9033155fcd
87 changed files with 2580 additions and 1172 deletions

View File

@@ -15,7 +15,7 @@ export function AuthLayoutShell({
{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`}
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:py-8`}
>
{children}
</div>

View File

@@ -1,34 +0,0 @@
'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

@@ -1,6 +1,5 @@
import { Button } from '@kit/ui/button';
import { OauthProviderLogoImage } from './oauth-provider-logo-image';
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
export function AuthProviderButton({
providerId,

View File

@@ -0,0 +1,87 @@
'use client';
import { useMemo } from 'react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { UserCheck } from 'lucide-react';
import { Alert, AlertDescription } from '@kit/ui/alert';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
interface ExistingAccountHintProps {
signInPath?: string;
className?: string;
}
// we force dynamic import to avoid hydration errors
export const ExistingAccountHint = dynamic(
async () => ({ default: ExistingAccountHintImpl }),
{
ssr: false,
},
);
export function ExistingAccountHintImpl({
signInPath = '/auth/sign-in',
className,
}: ExistingAccountHintProps) {
const { hasLastMethod, methodType, providerName, isOAuth } =
useLastAuthMethod();
// Get the appropriate method description for the hint
// This must be called before any conditional returns to follow Rules of Hooks
const methodDescription = useMemo(() => {
if (isOAuth && providerName) {
return providerName;
}
switch (methodType) {
case 'password':
return 'email and password';
case 'otp':
return 'email verification';
case 'magic_link':
return 'email link';
default:
return 'another method';
}
}, [methodType, isOAuth, providerName]);
// Don't show anything until loaded or if no last method
if (!hasLastMethod) {
return null;
}
return (
<If condition={Boolean(methodDescription)}>
<Alert
data-test={'existing-account-hint'}
variant="info"
className={className}
>
<UserCheck className="h-4 w-4" />
<AlertDescription>
<Trans
i18nKey="auth:existingAccountHint"
values={{ method: methodDescription }}
components={{
method: <span className="font-medium" />,
signInLink: (
<Link
href={signInPath}
className="font-medium underline hover:no-underline"
/>
),
}}
/>
</AlertDescription>
</Alert>
</If>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import { useMemo } from 'react';
import dynamic from 'next/dynamic';
import { Lightbulb } from 'lucide-react';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
interface LastAuthMethodHintProps {
className?: string;
}
// we force dynamic import to avoid hydration errors
export const LastAuthMethodHint = dynamic(
async () => ({ default: LastAuthMethodHintImpl }),
{
ssr: false,
},
);
function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
const { hasLastMethod, methodType, providerName, isOAuth } =
useLastAuthMethod();
// Get the appropriate translation key based on the method - memoized
// This must be called before any conditional returns to follow Rules of Hooks
const methodKey = useMemo(() => {
switch (methodType) {
case 'password':
return 'auth:methodPassword';
case 'otp':
return 'auth:methodOtp';
case 'magic_link':
return 'auth:methodMagicLink';
case 'oauth':
return 'auth:methodOauth';
default:
return null;
}
}, [methodType]);
// Don't show anything until loaded or if no last method
if (!hasLastMethod) {
return null;
}
if (!methodKey) {
return null; // If method is not recognized, don't render anything
}
return (
<div
data-test="last-auth-method-hint"
className={`text-muted-foreground/80 flex items-center justify-center gap-2 text-xs ${className || ''}`}
>
<Lightbulb className="h-3 w-3" />
<span>
<Trans i18nKey="auth:lastUsedMethodPrefix" />{' '}
<If condition={isOAuth && Boolean(providerName)}>
<Trans
i18nKey="auth:methodOauthWithProvider"
values={{ provider: providerName }}
components={{
provider: <span className="text-muted-foreground font-medium" />,
}}
/>
</If>
<If condition={!isOAuth || !providerName}>
<span className="text-muted-foreground font-medium">
<Trans i18nKey={methodKey} />
</span>
</If>
</span>
</div>
);
}

View File

@@ -4,7 +4,6 @@ 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';
@@ -21,9 +20,11 @@ import {
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from '../captcha/client';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import { TermsAndConditionsFormField } from './terms-and-conditions-form-field';
export function MagicLinkAuthContainer({
@@ -46,6 +47,7 @@ export function MagicLinkAuthContainer({
const { t } = useTranslation();
const signInWithOtpMutation = useSignInWithOtp();
const appEvents = useAppEvents();
const { recordAuthMethod } = useLastAuthMethod();
const form = useForm({
resolver: zodResolver(
@@ -77,6 +79,8 @@ export function MagicLinkAuthContainer({
},
});
recordAuthMethod('magic_link', { email });
if (shouldCreateUser) {
appEvents.emit({
type: 'user.signedUp',
@@ -90,7 +94,7 @@ export function MagicLinkAuthContainer({
toast.promise(promise, {
loading: t('auth:sendingEmailLink'),
success: t(`auth:sendLinkSuccessToast`),
error: t(`auth:errors.link`),
error: t(`auth:errors.linkTitle`),
});
resetCaptchaToken();
@@ -103,11 +107,11 @@ export function MagicLinkAuthContainer({
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'}>
<If condition={signInWithOtpMutation.error}>
<ErrorAlert />
</If>
<FormField
render={({ field }) => (
<FormItem>
@@ -171,11 +175,11 @@ function ErrorAlert() {
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'auth:errors.generic'} />
<Trans i18nKey={'auth:errors.linkTitle'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:errors.link'} />
<Trans i18nKey={'auth:errors.linkDescription'} />
</AlertDescription>
</Alert>
);

View File

@@ -1,63 +0,0 @@
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

@@ -12,6 +12,7 @@ import { If } from '@kit/ui/if';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Trans } from '@kit/ui/trans';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import { AuthErrorAlert } from './auth-error-alert';
import { AuthProviderButton } from './auth-provider-button';
@@ -42,6 +43,7 @@ export const OauthProviders: React.FC<{
};
}> = (props) => {
const signInWithProviderMutation = useSignInWithProvider();
const { recordAuthMethod } = useLastAuthMethod();
// we make the UI "busy" until the next page is fully loaded
const loading = signInWithProviderMutation.isPending;
@@ -105,9 +107,15 @@ export const OauthProviders: React.FC<{
},
} satisfies SignInWithOAuthCredentials;
return onSignInWithProvider(() =>
signInWithProviderMutation.mutateAsync(credentials),
);
return onSignInWithProvider(async () => {
const result =
await signInWithProviderMutation.mutateAsync(credentials);
// Record successful OAuth sign-in
recordAuthMethod('oauth', { provider });
return result;
});
}}
>
<Trans

View File

@@ -0,0 +1,249 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@kit/ui/input-otp';
import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from '../captcha/client';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import { AuthErrorAlert } from './auth-error-alert';
const EmailSchema = z.object({ email: z.string().email() });
const OtpSchema = z.object({ token: z.string().min(6).max(6) });
export function OtpSignInContainer({
onSignIn,
shouldCreateUser,
}: {
onSignIn?: (userId?: string) => void;
shouldCreateUser: boolean;
}) {
const verifyMutation = useVerifyOtp();
const router = useRouter();
const params = useSearchParams();
const { recordAuthMethod } = useLastAuthMethod();
const otpForm = useForm({
resolver: zodResolver(OtpSchema.merge(EmailSchema)),
defaultValues: {
token: '',
email: '',
},
});
const email = useWatch({
control: otpForm.control,
name: 'email',
});
const isEmailStep = !email;
const handleVerifyOtp = async ({
token,
email,
}: {
token: string;
email: string;
}) => {
const result = await verifyMutation.mutateAsync({
type: 'email',
email,
token,
});
// Record successful OTP sign-in
recordAuthMethod('otp', { email });
if (onSignIn) {
return onSignIn(result?.user?.id);
}
// on sign ups we redirect to the app home
if (shouldCreateUser) {
const next = params.get('next') ?? '/home';
router.replace(next);
}
};
if (isEmailStep) {
return (
<OtpEmailForm
shouldCreateUser={shouldCreateUser}
onSendOtp={(email) => {
otpForm.setValue('email', email, {
shouldValidate: true,
});
}}
/>
);
}
return (
<Form {...otpForm}>
<form
className="flex w-full flex-col items-center space-y-8"
onSubmit={otpForm.handleSubmit(handleVerifyOtp)}
>
<AuthErrorAlert error={verifyMutation.error} />
<FormField
name="token"
render={({ field }) => (
<FormItem>
<FormControl>
<InputOTP
maxLength={6}
{...field}
disabled={verifyMutation.isPending}
>
<InputOTPGroup>
<InputOTPSlot index={0} data-slot="0" />
<InputOTPSlot index={1} data-slot="1" />
<InputOTPSlot index={2} data-slot="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} data-slot="3" />
<InputOTPSlot index={4} data-slot="4" />
<InputOTPSlot index={5} data-slot="5" />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
<Trans i18nKey="common:otp.enterCodeFromEmail" />
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full flex-col gap-y-2">
<Button
type="submit"
disabled={verifyMutation.isPending}
data-test="otp-verify-button"
>
{verifyMutation.isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
<Trans i18nKey="common:otp.verifying" />
</>
) : (
<Trans i18nKey="common:otp.verifyCode" />
)}
</Button>
<Button
type="button"
variant="ghost"
disabled={verifyMutation.isPending}
onClick={() => {
otpForm.setValue('email', '', {
shouldValidate: true,
});
}}
>
<Trans i18nKey="common:otp.requestNewCode" />
</Button>
</div>
</form>
</Form>
);
}
function OtpEmailForm({
shouldCreateUser,
onSendOtp,
}: {
shouldCreateUser: boolean;
onSendOtp: (email: string) => void;
}) {
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
const signInMutation = useSignInWithOtp();
const emailForm = useForm({
resolver: zodResolver(EmailSchema),
defaultValues: { email: '' },
});
const handleSendOtp = async ({ email }: z.infer<typeof EmailSchema>) => {
await signInMutation.mutateAsync({
email,
options: { captchaToken, shouldCreateUser },
});
resetCaptchaToken();
onSendOtp(email);
};
return (
<Form {...emailForm}>
<form
className="flex flex-col gap-y-4"
onSubmit={emailForm.handleSubmit(handleSendOtp)}
>
<AuthErrorAlert error={signInMutation.error} />
<FormField
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
required
type="email"
placeholder="email@example.com"
data-test="otp-email-input"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={signInMutation.isPending}
data-test="otp-send-button"
>
{signInMutation.isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
<Trans i18nKey="common:otp.sendingCode" />
</>
) : (
<Trans i18nKey="common:otp.sendVerificationCode" />
)}
</Button>
</form>
</Form>
);
}

View File

@@ -7,6 +7,7 @@ import type { z } from 'zod';
import { useSignInWithEmailPassword } from '@kit/supabase/hooks/use-sign-in-with-email-password';
import { useCaptchaToken } from '../captcha/client';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import type { PasswordSignInSchema } from '../schemas/password-sign-in.schema';
import { AuthErrorAlert } from './auth-error-alert';
import { PasswordSignInForm } from './password-sign-in-form';
@@ -18,6 +19,7 @@ export function PasswordSignInContainer({
}) {
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
const signInMutation = useSignInWithEmailPassword();
const { recordAuthMethod } = useLastAuthMethod();
const isLoading = signInMutation.isPending;
const isRedirecting = signInMutation.isSuccess;
@@ -29,6 +31,9 @@ export function PasswordSignInContainer({
options: { captchaToken },
});
// Record successful password sign-in
recordAuthMethod('password', { email: credentials.email });
if (onSignIn) {
const userId = data?.user?.id;
@@ -40,7 +45,13 @@ export function PasswordSignInContainer({
resetCaptchaToken();
}
},
[captchaToken, onSignIn, resetCaptchaToken, signInMutation],
[
captchaToken,
onSignIn,
resetCaptchaToken,
signInMutation,
recordAuthMethod,
],
);
return (

View File

@@ -1,5 +1,7 @@
'use client';
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
import type { Provider } from '@supabase/supabase-js';
@@ -9,8 +11,10 @@ import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans';
import { LastAuthMethodHint } from './last-auth-method-hint';
import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers';
import { OtpSignInContainer } from './otp-sign-in-container';
import { PasswordSignInContainer } from './password-sign-in-container';
export function SignInMethodsContainer(props: {
@@ -25,6 +29,7 @@ export function SignInMethodsContainer(props: {
providers: {
password: boolean;
magicLink: boolean;
otp: boolean;
oAuth: Provider[];
};
}) {
@@ -34,7 +39,7 @@ export function SignInMethodsContainer(props: {
? new URL(props.paths.callback, window?.location.origin).toString()
: '';
const onSignIn = () => {
const onSignIn = useCallback(() => {
// if the user has an invite token, we should join the team
if (props.inviteToken) {
const searchParams = new URLSearchParams({
@@ -50,10 +55,12 @@ export function SignInMethodsContainer(props: {
// otherwise, we should redirect to the return path
router.replace(returnPath);
}
};
}, [props.inviteToken, props.paths.joinTeam, props.paths.returnPath, router]);
return (
<>
<LastAuthMethodHint />
<If condition={props.providers.password}>
<PasswordSignInContainer onSignIn={onSignIn} />
</If>
@@ -66,6 +73,10 @@ export function SignInMethodsContainer(props: {
/>
</If>
<If condition={props.providers.otp}>
<OtpSignInContainer shouldCreateUser={false} onSignIn={onSignIn} />
</If>
<If condition={props.providers.oAuth.length}>
<div className="relative">
<div className="absolute inset-0 flex items-center">

View File

@@ -8,8 +8,10 @@ import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans';
import { ExistingAccountHint } from './existing-account-hint';
import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers';
import { OtpSignInContainer } from './otp-sign-in-container';
import { EmailPasswordSignUpContainer } from './password-sign-up-container';
export function SignUpMethodsContainer(props: {
@@ -21,6 +23,7 @@ export function SignUpMethodsContainer(props: {
providers: {
password: boolean;
magicLink: boolean;
otp: boolean;
oAuth: Provider[];
};
@@ -32,6 +35,9 @@ export function SignUpMethodsContainer(props: {
return (
<>
{/* Show hint if user might already have an account */}
<ExistingAccountHint />
<If condition={props.inviteToken}>
<InviteAlert />
</If>
@@ -44,6 +50,10 @@ export function SignUpMethodsContainer(props: {
/>
</If>
<If condition={props.providers.otp}>
<OtpSignInContainer shouldCreateUser={true} />
</If>
<If condition={props.providers.magicLink}>
<MagicLinkAuthContainer
inviteToken={props.inviteToken}