From 780dac21089cf633deec553c670ed61533336a7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Musid=C5=82owski?= <38129890+musidlo@users.noreply.github.com> Date: Mon, 18 Nov 2024 22:26:42 +0100 Subject: [PATCH] Handle existing user trying to use OTP --- packages/api/src/routers/_app.ts | 2 +- packages/api/src/routers/auth.ts | 57 --- packages/api/src/routers/auth/router.ts | 88 ++++ packages/api/src/routers/auth/types.ts | 4 + .../features/auth/sign-up/sign-up-form.tsx | 386 +++++++++++------- 5 files changed, 330 insertions(+), 207 deletions(-) delete mode 100644 packages/api/src/routers/auth.ts create mode 100644 packages/api/src/routers/auth/router.ts create mode 100644 packages/api/src/routers/auth/types.ts diff --git a/packages/api/src/routers/_app.ts b/packages/api/src/routers/_app.ts index 2dd287a15..b7f8b326f 100644 --- a/packages/api/src/routers/_app.ts +++ b/packages/api/src/routers/_app.ts @@ -1,6 +1,6 @@ import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server' import { createTRPCRouter } from '../trpc' -import { authRouter } from './auth' +import { authRouter } from './auth/router' import { chainAddressRouter } from './chainAddress' import { distributionRouter } from './distribution' import { tagRouter } from './tag' diff --git a/packages/api/src/routers/auth.ts b/packages/api/src/routers/auth.ts deleted file mode 100644 index 858ad4e89..000000000 --- a/packages/api/src/routers/auth.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { TRPCError } from '@trpc/server' -import { supabaseAdmin } from 'app/utils/supabase/admin' -import debug from 'debug' -import { z } from 'zod' -import { createTRPCRouter, publicProcedure } from '../trpc' - -const log = debug('api:auth') - -export const authRouter = createTRPCRouter({ - signInWithOtp: publicProcedure - .input( - z.object({ phone: z.string(), countrycode: z.string(), captchaToken: z.string().optional() }) - ) - .mutation(async ({ input }) => { - const { phone, countrycode, captchaToken } = input - if (!phone) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'Phone number is required', - }) - } - if (!countrycode) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'Country Code is required', - }) - } - if (!!process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY && !captchaToken) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'Captcha token is required', - }) - } - try { - const result = await supabaseAdmin.auth - .signInWithOtp({ phone: `${countrycode}${phone}`, options: { captchaToken } }) - .then(async (r) => { - // TODO: potentially add a fake numbers list for app store reviewers - if (__DEV__ || process.env.CI) { - log('fake_otp_credentials', { phone: `${countrycode}${phone}` }) - return await supabaseAdmin.rpc('fake_otp_credentials', { - phone: `${countrycode}${phone}`, - }) - } - const errMessage = r.error?.message.toLowerCase() - log('signInWithOtp', { errMessage }) - return r - }) - return { error: result.error } - } catch (error) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: error instanceof Error ? error.message : 'Unknown error', - }) - } - }), -}) diff --git a/packages/api/src/routers/auth/router.ts b/packages/api/src/routers/auth/router.ts new file mode 100644 index 000000000..6f6a7e67c --- /dev/null +++ b/packages/api/src/routers/auth/router.ts @@ -0,0 +1,88 @@ +import { TRPCError } from '@trpc/server' +import { supabaseAdmin } from 'app/utils/supabase/admin' +import debug from 'debug' +import { z } from 'zod' +import { createTRPCRouter, publicProcedure } from '../../trpc' +import { AuthStatus } from './types' + +const log = debug('api:auth') + +export const authRouter = createTRPCRouter({ + signInWithOtp: publicProcedure + .input( + z.object({ + phone: z.string().trim(), + countrycode: z.string(), + captchaToken: z.string().optional(), + bypassOnboardedCheck: z.boolean().optional().default(false), + }) + ) + .mutation(async ({ input }) => { + const { phone, countrycode, captchaToken, bypassOnboardedCheck } = input + + if (!phone) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Phone number is required', + }) + } + + if (!countrycode) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Country Code is required', + }) + } + + if (!!process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY && !captchaToken) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Captcha token is required', + }) + } + + if (!bypassOnboardedCheck) { + log('checking if phone is already used', { phone }) + + const { data } = await supabaseAdmin + .rpc('profile_lookup', { lookup_type: 'phone', identifier: `${countrycode}${phone}` }) + .maybeSingle() + + if (data) { + log('phone is already used', { phone }) + + return { + status: AuthStatus.PhoneAlreadyUsed, + } + } + } + + const { error } = await supabaseAdmin.auth + .signInWithOtp({ phone: `${countrycode}${phone}`, options: { captchaToken } }) + .then(async (r) => { + // TODO: potentially add a fake numbers list for app store reviewers + if (__DEV__ || process.env.CI) { + log('fake_otp_credentials', { phone: `${countrycode}${phone}` }) + return await supabaseAdmin.rpc('fake_otp_credentials', { + phone: `${countrycode}${phone}`, + }) + } + const errMessage = r.error?.message.toLowerCase() + log('signInWithOtp', { errMessage, phone }) + return r + }) + + if (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: error.message, + }) + } + + log('successfully signed up with otp', { phone }) + + return { + status: AuthStatus.SignedIn, + } + }), +}) diff --git a/packages/api/src/routers/auth/types.ts b/packages/api/src/routers/auth/types.ts new file mode 100644 index 000000000..92d12d56b --- /dev/null +++ b/packages/api/src/routers/auth/types.ts @@ -0,0 +1,4 @@ +export enum AuthStatus { + SignedIn = 'SignedIn', + PhoneAlreadyUsed = 'PhoneAlreadyUsed', +} diff --git a/packages/app/features/auth/sign-up/sign-up-form.tsx b/packages/app/features/auth/sign-up/sign-up-form.tsx index ff4dff944..8a3859ac9 100644 --- a/packages/app/features/auth/sign-up/sign-up-form.tsx +++ b/packages/app/features/auth/sign-up/sign-up-form.tsx @@ -9,13 +9,14 @@ import { XStack, YStack, useToastController, + Button, } from '@my/ui' -import { TRPCClientError } from '@trpc/client' import { bytesToHex, hexToBytes } from 'viem' import { useRouter } from 'solito/router' import { z } from 'zod' import { FormProvider, useForm } from 'react-hook-form' import { RecoveryOptions } from '@my/api/src/routers/account-recovery/types' +import { AuthStatus } from '@my/api/src/routers/auth/types' import { VerifyCode } from 'app/features/auth/components/VerifyCode' import { SchemaForm, formFields } from 'app/utils/SchemaForm' import { api } from 'app/utils/api' @@ -28,25 +29,39 @@ const SignUpSchema = z.object({ phone: formFields.text.min(1).max(20), }) +enum PageState { + SignupForm = 'SignUpForm', + VerifyCode = 'VerifyCode', + BackUpPrompt = 'BackUpPrompt', +} + export const SignUpForm = () => { const form = useForm>() - const signInWithOtp = api.auth.signInWithOtp.useMutation() const router = useRouter() const [queryParams] = useAuthScreenParams() const { redirectUri } = queryParams const toast = useToastController() const [captchaToken, setCaptchaToken] = useState() const [isSigningIn, setIsSigningIn] = useState(false) + const [pageState, setPageState] = useState(PageState.SignupForm) + + const { mutateAsync: signInWithOtpMutateAsync } = api.auth.signInWithOtp.useMutation({ + retry: false, + }) const { mutateAsync: getChallengeMutateAsync } = api.challenge.getChallenge.useMutation({ retry: false, }) + const { mutateAsync: validateSignatureMutateAsync } = api.challenge.validateSignature.useMutation( { retry: false } ) - const handleSignIn = async () => { + const handleSignIn = async (options: { isPhoneAlreadyUsed?: boolean } = {}) => { + const { isPhoneAlreadyUsed = false } = options + setIsSigningIn(true) + try { const challengeData = await getChallengeMutateAsync() @@ -70,6 +85,11 @@ export const SignUpForm = () => { router.push(redirectUri ?? '/') } catch (error) { + if (isPhoneAlreadyUsed && error instanceof DOMException && error.name === 'NotAllowedError') { + setPageState(PageState.BackUpPrompt) + return + } + toast.show(formatErrorMessage(error), { preset: 'error', isUrgent: true, @@ -80,166 +100,234 @@ export const SignUpForm = () => { } } - async function signUpWithPhone({ phone, countrycode }: z.infer) { - const { error } = await signInWithOtp - .mutateAsync({ + async function signUpWithPhone( + formData: z.infer, + options: { bypassOnboardedCheck?: boolean } = {} + ) { + const { phone, countrycode } = formData + const { bypassOnboardedCheck = false } = options + + try { + const { status } = await signInWithOtpMutateAsync({ phone, countrycode, captchaToken, - }) - .catch((e) => { - console.error("Couldn't send OTP", e) - if (e instanceof TRPCClientError) { - return { error: { message: e.message } } - } - return { error: { message: e.message } } + bypassOnboardedCheck, }) - if (error) { + if (status === AuthStatus.PhoneAlreadyUsed) { + await handleSignIn({ isPhoneAlreadyUsed: true }) + return + } + + setPageState(PageState.VerifyCode) + } catch (error) { + console.error("Couldn't send OTP", error) const errorMessage = error.message.toLowerCase() form.setError('phone', { type: 'custom', message: errorMessage }) } } + function handleBackUpConfirm() { + const formData = form.getValues() + void signUpWithPhone(formData, { bypassOnboardedCheck: true }) + } + + function handleBackUpDenial() { + void handleSignIn({ isPhoneAlreadyUsed: true }) + } + + function handleGoBackFromBackUpPrompt() { + setPageState(PageState.SignupForm) + } + useEffect(() => () => toast.hide(), [toast]) + const verifyCode = ( + { + router.push('/') + }} + /> + ) + + const signUpForm = ( + ( + + + submit()} + br="$3" + bc={'$green9Light'} + $sm={{ w: '100%' }} + $gtMd={{ + mt: '0', + als: 'flex-end', + mx: 0, + ml: 'auto', + w: '$10', + h: '$3.5', + }} + > + + {'/SIGN UP'} + + + + + + + Already have an account? + + handleSignIn()} disabled={isSigningIn} unstyled> + + {isSigningIn ? 'Signing in...' : 'Sign in'} + + + + + + {!!process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY && ( + { + setCaptchaToken(token) + }} + /> + )} + + + )} + > + {({ countrycode: CountryCode, phone: Phone }) => ( + + CREATE YOUR ACCOUNT +

+ Sign up with your phone number. +

+ + + + Your Phone + + + {CountryCode} + {Phone} + + +
+ )} +
+ ) + + const backUpForm = ( + + ARE YOU BACKING UP? +

+ This number is already associated with an account. +

+ + + YES + + + + + + + Go back + + + + to sign up form + + +
+ ) + return ( - {form.formState.isSubmitSuccessful ? ( - { - router.push('/') - }} - /> - ) : ( - ( - - - submit()} - br="$3" - bc={'$green9Light'} - $sm={{ w: '100%' }} - $gtMd={{ - mt: '0', - als: 'flex-end', - mx: 0, - ml: 'auto', - w: '$10', - h: '$3.5', - }} - > - - {'/SIGN UP'} - - - - - - - Already have an account? - - - - {isSigningIn ? 'Signing in...' : 'Sign in'} - - - - - - {!!process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY && ( - { - setCaptchaToken(token) - }} - /> - )} - - - )} - > - {({ countrycode: CountryCode, phone: Phone }) => ( - - CREATE YOUR ACCOUNT -

- Sign up with your phone number. -

- - - - Your Phone - - - {CountryCode} - {Phone} - - -
- )} -
- )} + {(() => { + switch (pageState) { + case PageState.SignupForm: + return signUpForm + case PageState.VerifyCode: + return verifyCode + case PageState.BackUpPrompt: + return backUpForm + } + })()}
) }