From 701ace3dff89ae36d88d7c12a0c89be78b56225a Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 13 May 2026 20:55:20 -0400 Subject: [PATCH] Add Google sign in and two-step auth flow (#193) --- .env.example | 2 + src/client/features/auth/AuthPage.tsx | 62 ++++ src/env.d.ts | 2 + src/lib/auth.ts | 20 ++ src/routes/_auth.sign-in.tsx | 267 +++++++++++------- src/routes/_auth.sign-up.tsx | 388 +++++++++++++++----------- 6 files changed, 475 insertions(+), 266 deletions(-) diff --git a/.env.example b/.env.example index 72c9edc..7abd1d4 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,8 @@ # Required when AUTH_MODE=hosted # BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters # BETTER_AUTH_URL=http://localhost:3001 +# GOOGLE_CLIENT_ID=replace-with-your-google-oauth-client-id +# GOOGLE_CLIENT_SECRET=replace-with-your-google-oauth-client-secret # POSTHOG_PUBLIC_KEY=replace-with-your-posthog-project-api-key # POSTHOG_HOST=https://us.i.posthog.com # LOOPS_API_KEY=replace-with-your-loops-api-key diff --git a/src/client/features/auth/AuthPage.tsx b/src/client/features/auth/AuthPage.tsx index d59d6dd..808c3bc 100644 --- a/src/client/features/auth/AuthPage.tsx +++ b/src/client/features/auth/AuthPage.tsx @@ -36,6 +36,68 @@ export function getFormError(error: unknown) { return getSharedFormError(error); } +export function AuthMethodChooser({ + googleLabel, + emailLabel = "Continue with email", + isBusy, + disabled, + onContinueWithGoogle, + onContinueWithEmail, +}: { + googleLabel: string; + emailLabel?: string; + isBusy?: boolean; + disabled?: boolean; + onContinueWithGoogle: () => void; + onContinueWithEmail: () => void; +}) { + return ( +
+ + + +
+ ); +} + +function GoogleLogo() { + return ( + + ); +} + export function AuthPageCard({ title, helperText, diff --git a/src/env.d.ts b/src/env.d.ts index 83b78b8..55c1a24 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -13,6 +13,8 @@ declare namespace Cloudflare { POSTHOG_HOST?: string; BETTER_AUTH_SECRET?: string; BETTER_AUTH_URL?: string; + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; LOOPS_API_KEY?: string; LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID?: string; LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b28b756..59b4800 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -74,6 +74,7 @@ function createAuth() { } }, }, + socialProviders: getSocialProviders(), trustedOrigins: getTrustedOrigins(baseUrl), database: drizzleAdapter(db, { provider: "sqlite", @@ -146,6 +147,25 @@ function getHostedSecret() { return secret; } +function getSocialProviders() { + const googleClientId = env.GOOGLE_CLIENT_ID?.trim(); + const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim(); + + if (!googleClientId || !googleClientSecret) { + return undefined; + } + + return { + google: { + clientId: googleClientId, + clientSecret: googleClientSecret, + mapProfileToUser: (profile: { name?: string }) => ({ + name: profile.name, + }), + }, + }; +} + function hasHostedAuthEmailConfig() { const loopsVars = [ "LOOPS_API_KEY", diff --git a/src/routes/_auth.sign-in.tsx b/src/routes/_auth.sign-in.tsx index 585ddac..4640c12 100644 --- a/src/routes/_auth.sign-in.tsx +++ b/src/routes/_auth.sign-in.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { toast } from "sonner"; import { AuthPageCard, + AuthMethodChooser, authRedirectSearchSchema, getFieldError, getFormError, @@ -34,6 +35,9 @@ function SignInPage() { null, ); const [isSendingVerification, setIsSendingVerification] = useState(false); + const [showEmailForm, setShowEmailForm] = useState(false); + const [isStartingGoogle, setIsStartingGoogle] = useState(false); + const [socialError, setSocialError] = useState(null); const form = useForm({ defaultValues: { @@ -132,19 +136,52 @@ function SignInPage() { } } + async function handleContinueWithGoogle() { + setSocialError(null); + setIsStartingGoogle(true); + + try { + captureClientEvent("auth:sign_in_google_start", { + redirect_to: redirectTo, + }); + const result = await authClient.signIn.social({ + provider: "google", + callbackURL: authCallbackURL, + }); + + if (result.error) { + setSocialError( + result.error.message || "Google sign in is not available right now.", + ); + setIsStartingGoogle(false); + } + } catch { + setSocialError("Google sign in is not available right now."); + setIsStartingGoogle(false); + } + } + return ( - - Forgot password? - +
+ {showEmailForm ? ( + + Forgot password? + + ) : null} -
{ - event.preventDefault(); - void form.handleSubmit(); - }} - > - - {(field) => { - const error = getFieldError(field.state.meta.errors); - - return ( -
- field.handleChange(event.target.value)} - autoComplete="email" - disabled={!isHostedMode} - required - /> - {error ? ( -

{error}

- ) : null} -
- ); + {!showEmailForm ? ( + <> + { + void handleContinueWithGoogle(); + }} + onContinueWithEmail={() => { + setShowEmailForm(true); + setSocialError(null); + }} + /> + {socialError ? ( +

{socialError}

+ ) : null} + + ) : ( + { + event.preventDefault(); + void form.handleSubmit(); }} -
- - - {(field) => { - const error = getFieldError(field.state.meta.errors); - - return ( -
- field.handleChange(event.target.value)} - autoComplete="current-password" - disabled={!isHostedMode} - required - /> - {error ? ( -

{error}

- ) : null} -
- ); - }} -
- - {verificationEmail ? ( -
-
-

- Please check {verificationEmail} for a link to confirm your - email. -

- -
-
- ) : null} - - ({ - submitError: state.errorMap.onSubmit, - isSubmitting: state.isSubmitting, - })} > - {({ submitError, isSubmitting }) => { - const errorMessage = getFormError(submitError); - return ( - <> - {errorMessage ? ( -

{errorMessage}

- ) : null} + + {(field) => { + const error = getFieldError(field.state.meta.errors); + + return ( +
+ field.handleChange(event.target.value)} + autoComplete="email" + disabled={!isHostedMode} + required + /> + {error ? ( +

{error}

+ ) : null} +
+ ); + }} +
+ + + {(field) => { + const error = getFieldError(field.state.meta.errors); + + return ( +
+ field.handleChange(event.target.value)} + autoComplete="current-password" + disabled={!isHostedMode} + required + /> + {error ? ( +

{error}

+ ) : null} +
+ ); + }} +
+ + {verificationEmail ? ( +
+
+

+ Please check {verificationEmail} for a link to confirm your + email. +

- - ); - }} - - +
+
+ ) : null} + + ({ + submitError: state.errorMap.onSubmit, + isSubmitting: state.isSubmitting, + })} + > + {({ submitError, isSubmitting }) => { + const errorMessage = getFormError(submitError); + return ( + <> + {errorMessage ? ( +

{errorMessage}

+ ) : null} + + + ); + }} +
+ + )} ); } diff --git a/src/routes/_auth.sign-up.tsx b/src/routes/_auth.sign-up.tsx index 51613b5..a9fbbe7 100644 --- a/src/routes/_auth.sign-up.tsx +++ b/src/routes/_auth.sign-up.tsx @@ -1,7 +1,9 @@ import { useForm } from "@tanstack/react-form"; import { Link, createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; import { AuthPageCard, + AuthMethodChooser, authRedirectSearchSchema, getFieldError, getFormError, @@ -46,6 +48,9 @@ function SignUpPage() { const search = Route.useSearch(); const navigate = useNavigate(); const { redirectTo, isHostedMode } = useAuthPageState(search.redirect); + const [showEmailForm, setShowEmailForm] = useState(false); + const [isStartingGoogle, setIsStartingGoogle] = useState(false); + const [socialError, setSocialError] = useState(null); const form = useForm({ defaultValues: { @@ -104,178 +109,239 @@ function SignUpPage() { }, }); + async function handleContinueWithGoogle() { + const callbackURL = redirectTo === "/" ? "/subscribe" : redirectTo; + setSocialError(null); + setIsStartingGoogle(true); + + try { + captureClientEvent("auth:sign_up_google_start", { + redirect_to: callbackURL, + }); + const result = await authClient.signIn.social({ + provider: "google", + callbackURL, + newUserCallbackURL: callbackURL, + requestSignUp: true, + }); + + if (result.error) { + setSocialError( + result.error.message || "Google sign up is not available right now.", + ); + setIsStartingGoogle(false); + } + } catch { + setSocialError("Google sign up is not available right now."); + setIsStartingGoogle(false); + } + } + return ( -

- By signing up, you agree to our{" "} - - Terms - {" "} - and{" "} - - Privacy Policy - - . -

+ showEmailForm ? ( + + ) : ( +
+

+ By signing up, you agree to our{" "} + + Terms + {" "} + and{" "} + + Privacy Policy + + . +

-

- Already have an account?{" "} - - Sign in - -

-
+

+ Already have an account?{" "} + + Sign in + +

+
+ ) ) : null } > -
{ - event.preventDefault(); - void form.handleSubmit(); - }} - > - - {(field) => { - const error = getFieldError(field.state.meta.errors); - - return ( -
- field.handleChange(event.target.value)} - autoComplete="name" - disabled={!isHostedMode} - /> - {error ? ( -

{error}

- ) : null} -
- ); + {!showEmailForm ? ( + <> + { + void handleContinueWithGoogle(); + }} + onContinueWithEmail={() => { + setShowEmailForm(true); + setSocialError(null); + }} + /> + {socialError ? ( +

{socialError}

+ ) : null} + + ) : ( + { + event.preventDefault(); + void form.handleSubmit(); }} -
- - - {(field) => { - const error = getFieldError(field.state.meta.errors); - - return ( -
- field.handleChange(event.target.value)} - autoComplete="email" - disabled={!isHostedMode} - required - /> - {error ? ( -

{error}

- ) : null} -
- ); - }} -
- - - {(field) => { - const error = getFieldError(field.state.meta.errors); - - return ( -
- field.handleChange(event.target.value)} - autoComplete="new-password" - disabled={!isHostedMode} - required - minLength={HOSTED_PASSWORD_MIN_LENGTH} - maxLength={HOSTED_PASSWORD_MAX_LENGTH} - /> - {error ? ( -

{error}

- ) : null} -
- ); - }} -
- - - {(field) => { - const error = getFieldError(field.state.meta.errors); - - return ( -
- field.handleChange(event.target.value)} - autoComplete="new-password" - disabled={!isHostedMode} - required - minLength={HOSTED_PASSWORD_MIN_LENGTH} - maxLength={HOSTED_PASSWORD_MAX_LENGTH} - /> - {error ? ( -

{error}

- ) : null} -
- ); - }} -
- - ({ - submitError: state.errorMap.onSubmit, - isSubmitting: state.isSubmitting, - })} > - {({ submitError, isSubmitting }) => { - const errorMessage = getFormError(submitError); - return ( - <> - {errorMessage ? ( -

{errorMessage}

- ) : null} - - - ); - }} -
-
+ + {(field) => { + const error = getFieldError(field.state.meta.errors); + + return ( +
+ field.handleChange(event.target.value)} + autoComplete="name" + disabled={!isHostedMode} + /> + {error ? ( +

{error}

+ ) : null} +
+ ); + }} +
+ + + {(field) => { + const error = getFieldError(field.state.meta.errors); + + return ( +
+ field.handleChange(event.target.value)} + autoComplete="email" + disabled={!isHostedMode} + required + /> + {error ? ( +

{error}

+ ) : null} +
+ ); + }} +
+ + + {(field) => { + const error = getFieldError(field.state.meta.errors); + + return ( +
+ field.handleChange(event.target.value)} + autoComplete="new-password" + disabled={!isHostedMode} + required + minLength={HOSTED_PASSWORD_MIN_LENGTH} + maxLength={HOSTED_PASSWORD_MAX_LENGTH} + /> + {error ? ( +

{error}

+ ) : null} +
+ ); + }} +
+ + + {(field) => { + const error = getFieldError(field.state.meta.errors); + + return ( +
+ field.handleChange(event.target.value)} + autoComplete="new-password" + disabled={!isHostedMode} + required + minLength={HOSTED_PASSWORD_MIN_LENGTH} + maxLength={HOSTED_PASSWORD_MAX_LENGTH} + /> + {error ? ( +

{error}

+ ) : null} +
+ ); + }} +
+ + ({ + submitError: state.errorMap.onSubmit, + isSubmitting: state.isSubmitting, + })} + > + {({ submitError, isSubmitting }) => { + const errorMessage = getFormError(submitError); + return ( + <> + {errorMessage ? ( +

{errorMessage}

+ ) : null} + + + ); + }} +
+ + )}
); }