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}
-
- {(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}
+ >
+ ) : (
+
-
-
- {(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
}
>
-
- {(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}
+ >
+ ) : (
+
-
-
- {(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}
+
+ >
+ );
+ }}
+
+
+ )}
);
}