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:
committed by
GitHub
parent
856e9612c4
commit
9033155fcd
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
249
packages/features/auth/src/components/otp-sign-in-container.tsx
Normal file
249
packages/features/auth/src/components/otp-sign-in-container.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
78
packages/features/auth/src/hooks/use-last-auth-method.ts
Normal file
78
packages/features/auth/src/hooks/use-last-auth-method.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import type { AuthMethod, LastAuthMethod } from '../utils/last-auth-method';
|
||||
import {
|
||||
clearLastAuthMethod,
|
||||
getLastAuthMethod,
|
||||
saveLastAuthMethod,
|
||||
} from '../utils/last-auth-method';
|
||||
|
||||
export function useLastAuthMethod() {
|
||||
const [lastAuthMethod, setLastAuthMethod] = useState<LastAuthMethod | null>(
|
||||
getLastAuthMethod(),
|
||||
);
|
||||
|
||||
// Save a new auth method - memoized to prevent unnecessary re-renders
|
||||
const recordAuthMethod = useCallback(
|
||||
(
|
||||
method: AuthMethod,
|
||||
options?: {
|
||||
provider?: string;
|
||||
email?: string;
|
||||
},
|
||||
) => {
|
||||
const authMethod: LastAuthMethod = {
|
||||
method,
|
||||
provider: options?.provider,
|
||||
email: options?.email,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
saveLastAuthMethod(authMethod);
|
||||
setLastAuthMethod(authMethod);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Clear the stored auth method - memoized to prevent unnecessary re-renders
|
||||
const clearAuthMethod = useCallback(() => {
|
||||
clearLastAuthMethod();
|
||||
setLastAuthMethod(null);
|
||||
}, []);
|
||||
|
||||
// Compute derived values using useMemo for performance
|
||||
const derivedData = useMemo(() => {
|
||||
if (!lastAuthMethod) {
|
||||
return {
|
||||
hasLastMethod: false,
|
||||
methodType: null,
|
||||
providerName: null,
|
||||
isOAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isOAuth = lastAuthMethod.method === 'oauth';
|
||||
|
||||
const providerName =
|
||||
isOAuth && lastAuthMethod.provider
|
||||
? lastAuthMethod.provider.charAt(0).toUpperCase() +
|
||||
lastAuthMethod.provider.slice(1)
|
||||
: null;
|
||||
|
||||
return {
|
||||
hasLastMethod: true,
|
||||
methodType: lastAuthMethod.method,
|
||||
providerName,
|
||||
isOAuth,
|
||||
};
|
||||
}, [lastAuthMethod]);
|
||||
|
||||
return {
|
||||
lastAuthMethod,
|
||||
recordAuthMethod,
|
||||
clearAuthMethod,
|
||||
...derivedData,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { useRouter } from 'next/navigation';
|
||||
import { useAppEvents } from '@kit/shared/events';
|
||||
import { useSignUpWithEmailAndPassword } from '@kit/supabase/hooks/use-sign-up-with-email-password';
|
||||
|
||||
import { useLastAuthMethod } from './use-last-auth-method';
|
||||
|
||||
type SignUpCredentials = {
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -33,6 +35,7 @@ export function usePasswordSignUpFlow({
|
||||
const router = useRouter();
|
||||
const signUpMutation = useSignUpWithEmailAndPassword();
|
||||
const appEvents = useAppEvents();
|
||||
const { recordAuthMethod } = useLastAuthMethod();
|
||||
|
||||
const signUp = useCallback(
|
||||
async (credentials: SignUpCredentials) => {
|
||||
@@ -47,6 +50,9 @@ export function usePasswordSignUpFlow({
|
||||
captchaToken,
|
||||
});
|
||||
|
||||
// Record last auth method
|
||||
recordAuthMethod('password', { email: credentials.email });
|
||||
|
||||
// emit event to track sign up
|
||||
appEvents.emit({
|
||||
type: 'user.signedUp',
|
||||
@@ -58,6 +64,7 @@ export function usePasswordSignUpFlow({
|
||||
// Update URL with success status. This is useful for password managers
|
||||
// to understand that the form was submitted successfully.
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
url.searchParams.set('status', 'success');
|
||||
router.replace(url.pathname + url.search);
|
||||
|
||||
@@ -66,6 +73,7 @@ export function usePasswordSignUpFlow({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
resetCaptchaToken?.();
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './components/sign-in-methods-container';
|
||||
export * from './components/otp-sign-in-container';
|
||||
export * from './schemas/password-sign-in.schema';
|
||||
|
||||
71
packages/features/auth/src/utils/last-auth-method.ts
Normal file
71
packages/features/auth/src/utils/last-auth-method.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { isBrowser } from '@kit/shared/utils';
|
||||
|
||||
// Key for localStorage
|
||||
const LAST_AUTH_METHOD_KEY = 'auth_last_method';
|
||||
|
||||
// Types of authentication methods
|
||||
export type AuthMethod = 'password' | 'otp' | 'magic_link' | 'oauth';
|
||||
|
||||
export interface LastAuthMethod {
|
||||
method: AuthMethod;
|
||||
provider?: string; // For OAuth providers (e.g., 'google', 'github')
|
||||
email?: string; // Store email for method-specific hints
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the last used authentication method to localStorage
|
||||
*/
|
||||
export function saveLastAuthMethod(authMethod: LastAuthMethod): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_AUTH_METHOD_KEY, JSON.stringify(authMethod));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save last auth method:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last used authentication method from localStorage
|
||||
*/
|
||||
export function getLastAuthMethod() {
|
||||
if (!isBrowser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(LAST_AUTH_METHOD_KEY);
|
||||
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored) as LastAuthMethod;
|
||||
|
||||
// Check if the stored method is older than 30 days
|
||||
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (parsed.timestamp < thirtyDaysAgo) {
|
||||
localStorage.removeItem(LAST_AUTH_METHOD_KEY);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.warn('Failed to get last auth method:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the last used authentication method from localStorage
|
||||
*/
|
||||
export function clearLastAuthMethod() {
|
||||
try {
|
||||
localStorage.removeItem(LAST_AUTH_METHOD_KEY);
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear last auth method:', error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user