diff --git a/packages/shared/src/components/auth/AuthDefault.tsx b/packages/shared/src/components/auth/AuthDefault.tsx index 2197fdca12b..b3cde349945 100644 --- a/packages/shared/src/components/auth/AuthDefault.tsx +++ b/packages/shared/src/components/auth/AuthDefault.tsx @@ -3,6 +3,8 @@ import React, { useEffect, useRef, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import dynamic from 'next/dynamic'; import { checkKratosEmail } from '../../lib/kratos'; +import { checkBetterAuthEmail } from '../../lib/betterAuth'; +import { useIsBetterAuth } from '../../hooks/useIsBetterAuth'; import type { AuthFormProps, Provider } from './common'; import { getFormEmail } from './common'; import EmailSignupForm from './EmailSignupForm'; @@ -62,13 +64,17 @@ const AuthDefault = ({ simplified, }: AuthDefaultProps): ReactElement => { const { logEvent } = useLogContext(); + const isBetterAuth = useIsBetterAuth(); const [shouldLogin, setShouldLogin] = useState(isLoginFlow); const title = shouldLogin ? logInTitle : signUpTitle; const { displayToast } = useToastNotification(); const [registerEmail, setRegisterEmail] = useState(null); const socialLoginListRef = useRef(null); const { mutateAsync: checkEmail } = useMutation({ - mutationFn: (emailParam: string) => checkKratosEmail(emailParam), + mutationFn: (emailParam: string) => + isBetterAuth + ? checkBetterAuthEmail(emailParam) + : checkKratosEmail(emailParam), }); const focusFirstSocialLink = () => { diff --git a/packages/shared/src/components/auth/AuthOptionsInner.tsx b/packages/shared/src/components/auth/AuthOptionsInner.tsx index 1d8f1ab8bc9..8bdb74cdca3 100644 --- a/packages/shared/src/components/auth/AuthOptionsInner.tsx +++ b/packages/shared/src/components/auth/AuthOptionsInner.tsx @@ -13,6 +13,13 @@ import { AuthTriggers, getNodeValue, } from '../../lib/auth'; +import { + getBetterAuthSocialUrl, + betterAuthVerifySignupEmail, + betterAuthSendSignupVerification, +} from '../../lib/betterAuth'; +import { useIsBetterAuth } from '../../hooks/useIsBetterAuth'; +import { webappUrl, broadcastChannel, isTesting } from '../../lib/constants'; import { generateNameFromEmail } from '../../lib/strings'; import { generateUsername, claimClaimableItem } from '../../graphql/users'; import useRegistration from '../../hooks/useRegistration'; @@ -36,7 +43,6 @@ import { useEventListener, usePersistentState, } from '../../hooks'; -import { broadcastChannel, isTesting } from '../../lib/constants'; import type { SignBackProvider } from '../../hooks/auth/useSignBack'; import { SIGNIN_METHOD_KEY, useSignBack } from '../../hooks/auth/useSignBack'; import type { LoggedUser } from '../../lib/user'; @@ -129,6 +135,7 @@ function AuthOptionsInner({ const { syncSettings } = useSettingsContext(); const { trackSignup } = usePixelsContext(); const { logEvent } = useLogContext(); + const isBetterAuth = useIsBetterAuth(); const [isConnected, setIsConnected] = useState(false); const [registrationHints, setRegistrationHints] = useState( {}, @@ -354,6 +361,23 @@ function AuthOptionsInner({ target_id: provider, extra: JSON.stringify({ trigger }), }); + + if (isBetterAuth) { + const callbackURL = login + ? `${webappUrl}callback?login=true` + : `${webappUrl}callback`; + if (!isNativeAuthSupported(provider)) { + windowPopup.current = window.open(); + } + await setChosenProvider(provider); + windowPopup.current.location.href = getBetterAuthSocialUrl( + provider.toLowerCase(), + callbackURL, + ); + onAuthStateUpdate?.({ isLoading: true }); + return; + } + // Only web auth requires a popup if (!isNativeAuthSupported(provider)) { windowPopup.current = window.open(); @@ -675,6 +699,23 @@ function AuthOptionsInner({ { + const res = await betterAuthVerifySignupEmail(code); + if (res.error) { + throw new Error(res.error); + } + } + : undefined + } + onResendCode={ + isBetterAuth + ? async () => { + await betterAuthSendSignupVerification(); + } + : undefined + } /> diff --git a/packages/shared/src/components/auth/EmailCodeVerification.tsx b/packages/shared/src/components/auth/EmailCodeVerification.tsx index bd63becf11e..1beeb64eb38 100644 --- a/packages/shared/src/components/auth/EmailCodeVerification.tsx +++ b/packages/shared/src/components/auth/EmailCodeVerification.tsx @@ -17,12 +17,15 @@ import { } from '../typography/Typography'; import { CodeField } from '../fields/CodeField'; import { useAuthData } from '../../contexts/AuthDataContext'; +import useTimer from '../../hooks/useTimer'; interface EmailCodeVerificationProps extends AuthFormProps { code?: string; flowId: string; onSubmit?: () => void; className?: string; + onVerifyCode?: (code: string) => Promise; + onResendCode?: () => Promise; } function EmailCodeVerification({ @@ -30,16 +33,24 @@ function EmailCodeVerification({ flowId, onSubmit, className, + onVerifyCode, + onResendCode, }: EmailCodeVerificationProps): ReactElement { const { email } = useAuthData(); const { logEvent } = useLogContext(); const [hint, setHint] = useState(''); const [alert, setAlert] = useState({ firstAlert: true, alert: false }); const [code, setCode] = useState(codeProp); + const [isVerifyingCustom, setIsVerifyingCustom] = useState(false); + const { + timer: customResendTimer, + setTimer: setCustomResendTimer, + runTimer: runCustomTimer, + } = useTimer(() => {}, 60); const { sendEmail, verifyCode, resendTimer, autoResend, isVerifyingCode } = useAccountEmailFlow({ flow: AuthFlow.Verification, - flowId, + flowId: onVerifyCode ? 'skip' : flowId, timerOnLoad: 60, onError: setHint, onVerifyCodeSuccess: () => { @@ -50,11 +61,34 @@ function EmailCodeVerification({ }, }); + const activeResendTimer = onVerifyCode ? customResendTimer : resendTimer; + const activeIsVerifying = onVerifyCode ? isVerifyingCustom : isVerifyingCode; + useEffect(() => { - if (autoResend && !alert.alert && alert.firstAlert === true) { + if ( + !onVerifyCode && + autoResend && + !alert.alert && + alert.firstAlert === true + ) { setAlert({ firstAlert: false, alert: true }); } - }, [autoResend, alert]); + }, [autoResend, alert, onVerifyCode]); + + const handleCustomVerify = async (verifyCodeValue: string) => { + setIsVerifyingCustom(true); + try { + await onVerifyCode(verifyCodeValue); + logEvent({ + event_name: AuthEventNames.VerifiedSuccessfully, + }); + onSubmit(); + } catch (err) { + setHint(err instanceof Error ? err.message : 'Verification failed'); + } finally { + setIsVerifyingCustom(false); + } + }; const onCodeVerification = async (e: React.FormEvent) => { e.preventDefault(); @@ -64,22 +98,36 @@ function EmailCodeVerification({ }); setHint(''); setAlert({ firstAlert: false, alert: false }); - await verifyCode({ code }); + if (onVerifyCode) { + await handleCustomVerify(code); + } else { + await verifyCode({ code }); + } }; - const onSendCode = () => { + const onSendCode = async () => { logEvent({ event_name: LogEvent.Click, target_type: TargetType.ResendVerificationCode, }); setAlert({ firstAlert: false, alert: false }); - sendEmail(email); + if (onResendCode) { + await onResendCode(); + setCustomResendTimer(60); + runCustomTimer(); + } else { + sendEmail(email); + } }; const onCodeSubmit = async (newCode: string) => { if (newCode.length === 6) { setCode(newCode); - await verifyCode({ code: newCode }); + if (onVerifyCode) { + await handleCustomVerify(newCode); + } else { + await verifyCode({ code: newCode }); + } } }; @@ -120,7 +168,7 @@ function EmailCodeVerification({ {hint && ( 0} + disabled={activeResendTimer > 0} onClick={onSendCode} className={ - resendTimer === 0 ? 'text-text-link' : 'text-text-disabled' + activeResendTimer === 0 ? 'text-text-link' : 'text-text-disabled' } > Resend code - {resendTimer > 0 && ` ${resendTimer}s`} + {activeResendTimer > 0 && ` ${activeResendTimer}s`} @@ -150,12 +198,12 @@ function EmailCodeVerification({ className="w-full" type="submit" variant={ButtonVariant.Primary} - loading={isVerifyingCode} - disabled={autoResend} + loading={activeIsVerifying} + disabled={onVerifyCode ? false : autoResend} > Verify - {alert.alert && ( + {!onVerifyCode && alert.alert && ( Your session expired, please click the resend button above to get a diff --git a/packages/shared/src/components/auth/RegistrationForm.spec.tsx b/packages/shared/src/components/auth/RegistrationForm.spec.tsx index 29be58a8694..5c1010bbfb5 100644 --- a/packages/shared/src/components/auth/RegistrationForm.spec.tsx +++ b/packages/shared/src/components/auth/RegistrationForm.spec.tsx @@ -15,6 +15,7 @@ import SettingsContext from '../../contexts/SettingsContext'; import { mockGraphQL } from '../../../__tests__/helpers/graphql'; import { GET_USERNAME_SUGGESTION } from '../../graphql/users'; import { AuthTriggers } from '../../lib/auth'; +import * as betterAuthHook from '../../hooks/useIsBetterAuth'; import type { AuthOptionsProps } from './common'; const user = null; @@ -24,6 +25,7 @@ beforeEach(() => { jest.clearAllMocks(); nock.cleanAll(); jest.clearAllMocks(); + jest.spyOn(betterAuthHook, 'useIsBetterAuth').mockReturnValue(false); }); const onSuccessfulLogin = jest.fn(); @@ -129,6 +131,55 @@ const renderLogin = async (email: string) => { }); }; +const renderBetterAuthRegistration = async ( + email = 'sshanzel@yahoo.com', + name = 'Lee Solevilla', + username = 'leesolevilla', +) => { + jest.spyOn(betterAuthHook, 'useIsBetterAuth').mockReturnValue(true); + renderComponent(); + await waitForNock(); + + nock(process.env.NEXT_PUBLIC_API_URL as string) + .get('/a/auth/check-email') + .query({ email }) + .reply(200, { result: false }); + + fireEvent.input(screen.getByPlaceholderText('Email'), { + target: { value: email }, + }); + fireEvent.click(await screen.findByTestId('email_signup_submit')); + await waitForNock(); + + let queryCalled = false; + mockGraphQL({ + request: { + query: GET_USERNAME_SUGGESTION, + variables: { name }, + }, + result: () => { + queryCalled = true; + return { data: { generateUniqueUsername: username } }; + }, + }); + + await screen.findByTestId('registration_form'); + const nameInput = screen.getByPlaceholderText('Name'); + fireEvent.input(screen.getByPlaceholderText('Enter a username'), { + target: { value: username }, + }); + fireEvent.input(screen.getByPlaceholderText('Name'), { + target: { value: name }, + }); + simulateTextboxInput(nameInput as HTMLTextAreaElement, name); + fireEvent.input(screen.getByPlaceholderText('Create a password'), { + target: { value: '#123xAbc' }, + }); + + await waitForNock(); + await waitFor(() => expect(queryCalled).toBeTruthy()); +}; + // NOTE: Chris turned this off needs a good re-look at // it('should post registration', async () => { // const email = 'sshanzel@yahoo.com'; @@ -171,6 +222,39 @@ it('should show login if email exists', async () => { expect(text).toBeInTheDocument(); }); +it('should show a generic sign up error when Better Auth sign up is privacy-protected', async () => { + const email = 'sshanzel@yahoo.com'; + await renderBetterAuthRegistration(email); + + nock(process.env.NEXT_PUBLIC_API_URL as string) + .post('/a/auth/sign-up/email', { + name: 'Lee Solevilla', + email, + password: '#123xAbc', + }) + .reply(200, { status: true }); + nock(process.env.NEXT_PUBLIC_API_URL as string) + .post('/a/auth/sign-in/email', { + email, + password: '#123xAbc', + }) + .reply(401, { + code: 'INVALID_CREDENTIALS', + message: 'Invalid credentials', + }); + + fireEvent.submit(await screen.findByTestId('registration_form')); + await waitForNock(); + + await waitFor(() => { + expect( + screen.getByText( + "We couldn't complete sign up. If you already have an account, try signing in instead.", + ), + ).toBeInTheDocument(); + }); +}); + describe('testing username auto generation', () => { it('should suggest a valid option', async () => { const email = 'sshanzel@yahoo.com'; diff --git a/packages/shared/src/hooks/onboarding/useCheckExistingEmail.ts b/packages/shared/src/hooks/onboarding/useCheckExistingEmail.ts index 3ab5db5bd61..bfb462fe453 100644 --- a/packages/shared/src/hooks/onboarding/useCheckExistingEmail.ts +++ b/packages/shared/src/hooks/onboarding/useCheckExistingEmail.ts @@ -2,6 +2,8 @@ import type React from 'react'; import { useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { checkKratosEmail } from '../../lib/kratos'; +import { checkBetterAuthEmail } from '../../lib/betterAuth'; +import { useIsBetterAuth } from '../useIsBetterAuth'; import { getFormEmail } from '../../components/auth/common'; interface UseCheckExistingEmailProps { @@ -27,10 +29,14 @@ export const useCheckExistingEmail = ({ onBeforeEmailCheck, onValidEmail, }: UseCheckExistingEmailProps): UseCheckExistingEmail => { + const isBetterAuth = useIsBetterAuth(); const [emailToCheck, setEmailToCheck] = useState(''); const [emailAlreadyExists, setEmailAlreadyExists] = useState(false); const { mutateAsync: checkEmail, isPending: isCheckPending } = useMutation({ - mutationFn: (emailParam: string) => checkKratosEmail(emailParam), + mutationFn: (emailParam: string) => + isBetterAuth + ? checkBetterAuthEmail(emailParam) + : checkKratosEmail(emailParam), onSuccess: (res, emailValue) => { const emailExists = !!res?.result; diff --git a/packages/shared/src/hooks/useIsBetterAuth.ts b/packages/shared/src/hooks/useIsBetterAuth.ts new file mode 100644 index 00000000000..57c83d6de82 --- /dev/null +++ b/packages/shared/src/hooks/useIsBetterAuth.ts @@ -0,0 +1,7 @@ +import { useFeature } from '../components/GrowthBookProvider'; +import { featureAuthStrategy } from '../lib/featureManagement'; + +export const useIsBetterAuth = (): boolean => { + const authStrategy = useFeature(featureAuthStrategy); + return authStrategy === 'betterauth'; +}; diff --git a/packages/shared/src/hooks/useLogin.ts b/packages/shared/src/hooks/useLogin.ts index a148f95acff..b400a9308c0 100644 --- a/packages/shared/src/hooks/useLogin.ts +++ b/packages/shared/src/hooks/useLogin.ts @@ -20,6 +20,8 @@ import { initializeKratosFlow, submitKratosFlow, } from '../lib/kratos'; +import { betterAuthSignIn, getBetterAuthSocialUrl } from '../lib/betterAuth'; +import { useIsBetterAuth } from './useIsBetterAuth'; import { useLogContext } from '../contexts/LogContext'; import { useToastNotification } from './useToastNotification'; import type { SignBackProvider } from './auth/useSignBack'; @@ -27,7 +29,7 @@ import { useSignBack } from './auth/useSignBack'; import type { LoggedUser } from '../lib/user'; import { labels } from '../lib'; import { useEventListener } from './useEventListener'; -import { broadcastChannel } from '../lib/constants'; +import { broadcastChannel, webappUrl } from '../lib/constants'; const LOGIN_FLOW_NOT_AVAILABLE_TOAST = 'An error occurred, please refresh the page.'; @@ -61,6 +63,7 @@ const useLogin = ({ session, onLoginError, }: UseLoginProps = {}): UseLogin => { + const isBetterAuth = useIsBetterAuth(); const { onUpdateSignBack } = useSignBack(); const { displayToast } = useToastNotification(); const { logEvent } = useLogContext(); @@ -70,9 +73,48 @@ const useLogin = ({ const { data: login } = useQuery({ queryKey: [AuthEvent.Login, { ...queryParams }], queryFn: () => initializeKratosFlow(AuthFlow.Login, queryParams), - enabled: queryEnabled, + enabled: queryEnabled && !isBetterAuth, refetchOnWindowFocus: false, }); + + const { + mutateAsync: onBetterAuthPasswordLogin, + isPending: isBetterAuthPasswordLoading, + } = useMutation({ + mutationFn: async (form: LoginFormParams) => { + logEvent({ + event_name: 'click', + target_type: AuthEventNames.LoginProvider, + target_id: 'email', + extra: JSON.stringify({ trigger }), + }); + return betterAuthSignIn({ + email: form.identifier, + password: form.password, + }); + }, + onSuccess: async (res) => { + if (res.error) { + logEvent({ + event_name: AuthEventNames.LoginError, + extra: JSON.stringify({ + error: labels.auth.error.invalidEmailOrPassword, + }), + }); + setHint(labels.auth.error.invalidEmailOrPassword); + return; + } + + const { data: boot } = await refetchBoot(); + + if (boot.user && !boot.user.shouldVerify) { + onUpdateSignBack(boot.user as LoggedUser, 'password'); + } + + onSuccessfulLogin?.(boot?.user?.shouldVerify); + }, + }); + const { mutateAsync: onPasswordLogin, isPending: isLoading } = useMutation({ mutationFn: (params: ValidateLoginParams) => { logEvent({ @@ -131,6 +173,12 @@ const useLogin = ({ const onSubmitSocialLogin = useCallback( (provider: string) => { + if (isBetterAuth) { + const callbackURL = `${webappUrl}callback?login=true`; + window.open(getBetterAuthSocialUrl(provider, callbackURL)); + return; + } + if (!login?.ui) { displayToast(LOGIN_FLOW_NOT_AVAILABLE_TOAST); return; @@ -144,11 +192,16 @@ const useLogin = ({ }; onSocialLogin({ action, params }); }, - [displayToast, login?.ui, onSocialLogin], + [isBetterAuth, displayToast, login?.ui, onSocialLogin], ); const onSubmitPasswordLogin = useCallback( (form: LoginFormParams) => { + if (isBetterAuth) { + onBetterAuthPasswordLogin(form); + return; + } + if (!login?.ui) { displayToast(LOGIN_FLOW_NOT_AVAILABLE_TOAST); return; @@ -162,7 +215,13 @@ const useLogin = ({ }; onPasswordLogin({ action, params }); }, - [displayToast, login?.ui, onPasswordLogin], + [ + isBetterAuth, + onBetterAuthPasswordLogin, + displayToast, + login?.ui, + onPasswordLogin, + ], ); const onLoginMessage = async (e: MessageEvent) => { @@ -213,8 +272,10 @@ const useLogin = ({ return { loginHint: hintState, - isPasswordLoginLoading: isLoading, - isReady: !!login?.ui, + isPasswordLoginLoading: isBetterAuth + ? isBetterAuthPasswordLoading + : isLoading, + isReady: isBetterAuth ? true : !!login?.ui, onSocialLogin: onSubmitSocialLogin, onPasswordLogin: onSubmitPasswordLogin, }; diff --git a/packages/shared/src/hooks/useRegistration.ts b/packages/shared/src/hooks/useRegistration.ts index 42d37f82c17..89ff535f07a 100644 --- a/packages/shared/src/hooks/useRegistration.ts +++ b/packages/shared/src/hooks/useRegistration.ts @@ -28,6 +28,14 @@ import { KRATOS_ERROR_MESSAGE, submitKratosFlow, } from '../lib/kratos'; +import { + type BetterAuthResponse, + betterAuthSignIn, + betterAuthSignUp, + betterAuthSendSignupVerification, + getBetterAuthSocialUrl, +} from '../lib/betterAuth'; +import { useIsBetterAuth } from './useIsBetterAuth'; import { useToastNotification } from './useToastNotification'; import { getUserDefaultTimezone } from '../lib/timezones'; import { useLogContext } from '../contexts/LogContext'; @@ -35,6 +43,7 @@ import { Origin } from '../lib/log'; import { LogoutReason } from '../lib/user'; import { AFTER_AUTH_PARAM } from '../components/auth/common'; import { disabledRefetch } from '../lib/func'; +import { webappUrl } from '../lib/constants'; type ParamKeys = keyof RegistrationParameters; @@ -62,6 +71,28 @@ interface UseRegistration { type FormParams = Omit; const EMAIL_EXISTS_ERROR_ID = KRATOS_ERROR.EXISTING_USER; +const BETTER_AUTH_SIGNUP_FALLBACK_ERROR = + "We couldn't complete sign up. If you already have an account, try signing in instead."; + +const isBetterAuthVerificationRequired = ( + response: BetterAuthResponse, +): boolean => { + if (response.code === '403') { + return true; + } + + const error = response.error?.toLowerCase(); + + if (!error) { + return false; + } + + return ( + error.includes('verify') || + error.includes('verification') || + error.includes('unverified') + ); +}; const useRegistration = ({ key, @@ -73,10 +104,11 @@ const useRegistration = ({ onInitializeVerification, keepSession = false, }: UseRegistrationProps): UseRegistration => { + const isBetterAuth = useIsBetterAuth(); const { logEvent } = useLogContext(); const { displayToast } = useToastNotification(); const [verificationId, setVerificationId] = useState(); - const { trackingId, referral, referralOrigin, logout, geo } = + const { trackingId, referral, referralOrigin, logout, geo, refetchBoot } = useContext(AuthContext); const timezone = getUserDefaultTimezone(); const { @@ -87,7 +119,7 @@ const useRegistration = ({ queryKey: key, queryFn: () => initializeKratosFlow(AuthFlow.Registration, _params), ...disabledRefetch, - enabled, + enabled: enabled && !isBetterAuth, }); if (registration?.error) { @@ -215,7 +247,71 @@ const useRegistration = ({ }, }); + const { + mutateAsync: betterAuthRegister, + isPending: isBetterAuthMutationLoading, + } = useMutation({ + mutationFn: async (params: { + name: string; + email: string; + password: string; + }) => { + logEvent({ + event_name: 'click', + target_type: AuthEventNames.SignUpProvider, + target_id: 'email', + extra: JSON.stringify({ trigger: 'registration' }), + }); + return betterAuthSignUp(params); + }, + onSuccess: async (res, params) => { + if (res.error) { + onInvalidRegistration?.({ + 'traits.email': res.error, + }); + return; + } + + const signInRes = await betterAuthSignIn({ + email: params.email, + password: params.password, + }); + + if (!signInRes.error) { + await refetchBoot(); + return; + } + + if (isBetterAuthVerificationRequired(signInRes)) { + await betterAuthSendSignupVerification(); + onInitializeVerification?.(); + return; + } + + const normalizedSignInError = signInRes.error.toLowerCase(); + const signupError = + normalizedSignInError.includes('invalid') || + normalizedSignInError.includes('credentials') || + normalizedSignInError.includes('password') + ? BETTER_AUTH_SIGNUP_FALLBACK_ERROR + : signInRes.error; + + onInvalidRegistration?.({ + 'traits.email': signupError, + }); + }, + }); + const onValidateRegistration = async (values: RegistrationParameters) => { + if (isBetterAuth) { + await betterAuthRegister({ + name: values['traits.name'] as string, + email: values['traits.email'] as string, + password: values.password as string, + }); + return; + } + const { nodes, action } = registration.ui; const postData: RegistrationParameters = { ...values, @@ -231,6 +327,15 @@ const useRegistration = ({ }; const onSocialRegistration = async (provider: string) => { + if (isBetterAuth) { + const callbackURL = `${webappUrl}callback`; + const url = getBetterAuthSocialUrl(provider.toLowerCase(), callbackURL); + if (onRedirect) { + onRedirect(url); + } + return; + } + if (!registration?.ui) { logEvent({ event_name: AuthEventNames.RegistrationError, @@ -277,8 +382,10 @@ const useRegistration = ({ return useMemo( () => ({ - isReady: !!registration?.ui, - isLoading: isQueryLoading || isMutationLoading, + isReady: isBetterAuth ? true : !!registration?.ui, + isLoading: isBetterAuth + ? isBetterAuthMutationLoading + : isQueryLoading || isMutationLoading, registration, onSocialRegistration, validateRegistration: onValidateRegistration, @@ -287,7 +394,15 @@ const useRegistration = ({ }), // @NOTE see https://dailydotdev.atlassian.net/l/cp/dK9h1zoM // eslint-disable-next-line react-hooks/exhaustive-deps - [registration, status, isQueryLoading, isMutationLoading, verificationId], + [ + registration, + status, + isQueryLoading, + isMutationLoading, + isBetterAuth, + isBetterAuthMutationLoading, + verificationId, + ], ); }; diff --git a/packages/shared/src/lib/betterAuth.ts b/packages/shared/src/lib/betterAuth.ts new file mode 100644 index 00000000000..a80e79b4ee0 --- /dev/null +++ b/packages/shared/src/lib/betterAuth.ts @@ -0,0 +1,196 @@ +import { apiUrl } from './config'; + +export type BetterAuthResponse = { + error?: string; + code?: string; + message?: string; + status?: boolean; + user?: { + id: string; + name: string; + email: string; + }; +}; + +type BetterAuthResult> = T & + Pick; + +const betterAuthPost = async >( + path: string, + body?: Record, + fallbackError = 'Request failed', +): Promise> => { + const res = await fetch(`${apiUrl}/a/auth/${path}`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + ...(body && { body: JSON.stringify(body) }), + }); + + if (!res.ok) { + try { + const data = (await res.json()) as BetterAuthResult; + return { + ...data, + error: data?.message || data?.error || data?.code || fallbackError, + } as BetterAuthResult; + } catch { + return { error: fallbackError } as BetterAuthResult; + } + } + + return res.json(); +}; + +export const betterAuthSignIn = async ({ + email, + password, +}: { + email: string; + password: string; +}): Promise => { + return betterAuthPost('sign-in/email', { email, password }, 'Sign in failed'); +}; + +export const betterAuthSignUp = async ({ + name, + email, + password, +}: { + name: string; + email: string; + password: string; +}): Promise => { + return betterAuthPost( + 'sign-up/email', + { name, email, password }, + 'Sign up failed', + ); +}; + +const buildSocialRedirectUrl = ( + path: string, + provider: string, + callbackURL: string, +): string => { + const absoluteCallbackURL = callbackURL.startsWith('http') + ? callbackURL + : `${window.location.origin}${ + callbackURL.startsWith('/') ? '' : '/' + }${callbackURL}`; + return `${apiUrl}/a/auth/${path}?provider=${encodeURIComponent( + provider, + )}&callbackURL=${encodeURIComponent(absoluteCallbackURL)}`; +}; + +export const getBetterAuthSocialUrl = ( + provider: string, + callbackURL: string, +): string => buildSocialRedirectUrl('sign-in/social', provider, callbackURL); + +export const getBetterAuthLinkSocialUrl = ( + provider: string, + callbackURL: string, +): string => buildSocialRedirectUrl('link-social', provider, callbackURL); + +export const checkBetterAuthEmail = async ( + email: string, +): Promise<{ result: boolean }> => { + const url = new URL(`${apiUrl}/a/auth/check-email`, window.location.origin); + url.searchParams.set('email', email); + const res = await fetch(url.toString(), { + credentials: 'include', + }); + return res.json(); +}; + +export const getBetterAuthProviders = async (): Promise<{ + ok: boolean; + result: string[]; +}> => { + const res = await fetch(`${apiUrl}/a/auth/list-accounts`, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) { + return { ok: false, result: [] }; + } + const accounts: { providerId: string }[] = await res.json(); + const providers = accounts.map((a) => + a.providerId === 'credential' ? 'password' : a.providerId, + ); + return { ok: true, result: providers }; +}; + +export const unlinkBetterAuthAccount = async ( + providerId: string, +): Promise<{ status: boolean }> => { + const res = await fetch(`${apiUrl}/a/auth/unlink-account`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ providerId }), + }); + if (!res.ok) { + return { status: false }; + } + return res.json(); +}; + +export const betterAuthSetPassword = async ( + newPassword: string, +): Promise<{ status?: boolean; error?: string; code?: string }> => { + return betterAuthPost( + 'set-password', + { newPassword }, + 'Failed to set password', + ); +}; + +export const betterAuthChangeEmail = async ( + newEmail: string, +): Promise<{ status?: boolean; error?: string }> => { + return betterAuthPost('change-email', { newEmail }, 'Failed to change email'); +}; + +export const betterAuthVerifyChangeEmail = async ( + code: string, +): Promise<{ status?: boolean; error?: string }> => { + return betterAuthPost( + 'verify-change-email', + { code }, + 'Failed to verify email change', + ); +}; + +export const betterAuthSendSignupVerification = async (): Promise<{ + status?: boolean; + error?: string; +}> => { + return betterAuthPost( + 'send-signup-verification', + undefined, + 'Failed to send verification code', + ); +}; + +export const betterAuthVerifySignupEmail = async ( + code: string, +): Promise<{ status?: boolean; error?: string }> => { + return betterAuthPost( + 'verify-signup-email', + { code }, + 'Failed to verify email', + ); +}; + +export const betterAuthChangePassword = async ( + currentPassword: string, + newPassword: string, +): Promise<{ error?: string; code?: string }> => { + return betterAuthPost( + 'change-password', + { currentPassword, newPassword }, + 'Failed to change password', + ); +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index e1503d8f262..e7e33f40b71 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -154,3 +154,8 @@ export const agentsLeaderboardEntrypointFeature = new Feature( 'agents_leaderboard_entrypoint', ); + +export const featureAuthStrategy = new Feature<'kratos' | 'betterauth'>( + 'auth_strategy', + 'betterauth', +); diff --git a/packages/webapp/__tests__/AccountSecurityPage.tsx b/packages/webapp/__tests__/AccountSecurityPage.tsx index a4c631fc52e..9d59427d3bf 100644 --- a/packages/webapp/__tests__/AccountSecurityPage.tsx +++ b/packages/webapp/__tests__/AccountSecurityPage.tsx @@ -22,6 +22,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { waitForNock } from '@dailydotdev/shared/__tests__/helpers/utilities'; import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext'; import { getNodeValue } from '@dailydotdev/shared/src/lib/auth'; +import * as betterAuthHook from '@dailydotdev/shared/src/hooks/useIsBetterAuth'; +import * as toastNotificationHook from '@dailydotdev/shared/src/hooks/useToastNotification'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { LazyModalElement } from '@dailydotdev/shared/src/components/modals/LazyModalElement'; import nock from 'nock'; @@ -52,6 +54,10 @@ beforeEach(() => { jest.clearAllMocks(); nock.cleanAll(); matchMedia('1020'); + jest.spyOn(betterAuthHook, 'useIsBetterAuth').mockReturnValue(false); + jest + .spyOn(toastNotificationHook, 'useToastNotification') + .mockReturnValue({ displayToast } as never); }); const defaultLoggedUser: LoggedUser = { @@ -65,6 +71,7 @@ const defaultLoggedUser: LoggedUser = { const updateUser = jest.fn(); const refetchBoot = jest.fn(); +const displayToast = jest.fn(); const waitAllRenderMocks = async () => { await waitForNock(); @@ -233,6 +240,42 @@ it('should allow changing of email but require verification', async () => { expect(sent).toBeInTheDocument(); }); +it('should show generic change email confirmation for Better Auth', async () => { + jest.spyOn(betterAuthHook, 'useIsBetterAuth').mockReturnValue(true); + const email = 'sample@email.com'; + nock(process.env.NEXT_PUBLIC_API_URL as string) + .get('/a/auth/list-accounts') + .reply(200, [{ providerId: 'credential' }]); + const changeEmailScope = nock(process.env.NEXT_PUBLIC_API_URL as string) + .post('/a/auth/change-email', { newEmail: email }) + .reply(200, { status: true }); + + renderComponent(); + await waitForNock(); + + fireEvent.click(await screen.findByText('Change email')); + fireEvent.input(screen.getByPlaceholderText('Email'), { + target: { value: email }, + }); + + const sendCodeButton = await screen.findByText('Send code'); + const submitEvent = new Event('submit', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(submitEvent, 'submitter', { + value: sendCodeButton, + }); + + await act(() => sendCodeButton.dispatchEvent(submitEvent)); + await waitForNock(); + + expect(changeEmailScope.isDone()).toBeTruthy(); + expect(displayToast).toHaveBeenCalledWith( + 'If that email is available, we sent a verification code.', + ); +}); + it('should allow setting new password', async () => { renderComponent(); await waitAllRenderMocks(); diff --git a/packages/webapp/components/layouts/SettingsLayout/EmailForm.tsx b/packages/webapp/components/layouts/SettingsLayout/EmailForm.tsx index c97a3917666..11f90289359 100644 --- a/packages/webapp/components/layouts/SettingsLayout/EmailForm.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/EmailForm.tsx @@ -20,6 +20,7 @@ import { CommonTextField } from './common'; export interface EmailFormProps { onSubmit: (email: string) => void; onVerifySuccess: () => Promise; + onVerifyCode?: (code: string) => Promise; className?: string; verificationId?: string; emailProps?: Partial; @@ -31,6 +32,7 @@ export interface EmailFormProps { function EmailForm({ onSubmit, onVerifySuccess, + onVerifyCode, className, hint, setHint, @@ -41,11 +43,12 @@ function EmailForm({ const { logEvent } = useLogContext(); const [code, setCode] = useState(); const [email, setEmail] = useState(); + const [codeHint, setCodeHint] = useState(); const { timer, setTimer, runTimer } = useTimer(null, 0); const { verifyCode } = useAccountEmailFlow({ flow: AuthFlow.Verification, flowId: verificationId, - onError: setHint, + onError: setCodeHint, onVerifyCodeSuccess: () => { logEvent({ event_name: AuthEventNames.VerifiedSuccessfully, @@ -59,8 +62,18 @@ function EmailForm({ event_name: LogEvent.Click, target_type: TargetType.VerifyEmail, }); - setHint(''); - await verifyCode({ code, altFlowId: verificationId }); + setCodeHint(''); + if (onVerifyCode) { + try { + await onVerifyCode(code); + } catch (error) { + setCodeHint( + error instanceof Error ? error.message : 'Verification failed', + ); + } + } else { + await verifyCode({ code, altFlowId: verificationId }); + } }; const onSubmitEmail = () => { @@ -116,11 +129,11 @@ function EmailForm({ inputId="code" label="Enter 6-digit code" placeholder="Enter 6-digit code" - hint={hint} + hint={codeHint} defaultValue={code} - valid={!hint} + valid={!codeHint} valueChanged={setCode} - onChange={() => hint && setHint('')} + onChange={() => codeHint && setCodeHint('')} actionButton={