Add Google sign in and two-step auth flow (#193)

This commit is contained in:
Ben Senescu 2026-05-13 20:55:20 -04:00 committed by GitHub
parent a98407176b
commit 701ace3dff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 475 additions and 266 deletions

View File

@ -30,6 +30,8 @@
# Required when AUTH_MODE=hosted # Required when AUTH_MODE=hosted
# BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters # BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters
# BETTER_AUTH_URL=http://localhost:3001 # 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_PUBLIC_KEY=replace-with-your-posthog-project-api-key
# POSTHOG_HOST=https://us.i.posthog.com # POSTHOG_HOST=https://us.i.posthog.com
# LOOPS_API_KEY=replace-with-your-loops-api-key # LOOPS_API_KEY=replace-with-your-loops-api-key

View File

@ -36,6 +36,68 @@ export function getFormError(error: unknown) {
return getSharedFormError(error); 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 (
<div className="space-y-3">
<button
type="button"
className="btn w-full bg-white text-neutral border border-base-content/15 hover:bg-base-100 hover:border-base-content/25 disabled:bg-base-300"
onClick={onContinueWithGoogle}
disabled={disabled || isBusy}
>
<GoogleLogo />
{isBusy ? "Opening Google..." : googleLabel}
</button>
<button
type="button"
className="btn btn-soft w-full"
onClick={onContinueWithEmail}
disabled={disabled || isBusy}
>
{emailLabel}
</button>
</div>
);
}
function GoogleLogo() {
return (
<svg aria-hidden="true" viewBox="0 0 18 18" className="size-4 shrink-0">
<path
fill="#4285F4"
d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62Z"
/>
<path
fill="#34A853"
d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.33-1.58-5.04-3.72H.94v2.34A9 9 0 0 0 9 18Z"
/>
<path
fill="#FBBC05"
d="M3.96 10.7A5.4 5.4 0 0 1 3.68 9c0-.59.1-1.16.28-1.7V4.96H.94A9 9 0 0 0 0 9c0 1.45.34 2.82.94 4.04l3.02-2.34Z"
/>
<path
fill="#EA4335"
d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A8.64 8.64 0 0 0 9 0 9 9 0 0 0 .94 4.96L3.96 7.3C4.67 5.16 6.66 3.58 9 3.58Z"
/>
</svg>
);
}
export function AuthPageCard({ export function AuthPageCard({
title, title,
helperText, helperText,

2
src/env.d.ts vendored
View File

@ -13,6 +13,8 @@ declare namespace Cloudflare {
POSTHOG_HOST?: string; POSTHOG_HOST?: string;
BETTER_AUTH_SECRET?: string; BETTER_AUTH_SECRET?: string;
BETTER_AUTH_URL?: string; BETTER_AUTH_URL?: string;
GOOGLE_CLIENT_ID?: string;
GOOGLE_CLIENT_SECRET?: string;
LOOPS_API_KEY?: string; LOOPS_API_KEY?: string;
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID?: string; LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID?: string;
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string; LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string;

View File

@ -74,6 +74,7 @@ function createAuth() {
} }
}, },
}, },
socialProviders: getSocialProviders(),
trustedOrigins: getTrustedOrigins(baseUrl), trustedOrigins: getTrustedOrigins(baseUrl),
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "sqlite", provider: "sqlite",
@ -146,6 +147,25 @@ function getHostedSecret() {
return secret; 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() { function hasHostedAuthEmailConfig() {
const loopsVars = [ const loopsVars = [
"LOOPS_API_KEY", "LOOPS_API_KEY",

View File

@ -4,6 +4,7 @@ import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
AuthPageCard, AuthPageCard,
AuthMethodChooser,
authRedirectSearchSchema, authRedirectSearchSchema,
getFieldError, getFieldError,
getFormError, getFormError,
@ -34,6 +35,9 @@ function SignInPage() {
null, null,
); );
const [isSendingVerification, setIsSendingVerification] = useState(false); const [isSendingVerification, setIsSendingVerification] = useState(false);
const [showEmailForm, setShowEmailForm] = useState(false);
const [isStartingGoogle, setIsStartingGoogle] = useState(false);
const [socialError, setSocialError] = useState<string | null>(null);
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
@ -132,12 +136,44 @@ 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 ( return (
<AuthPageCard <AuthPageCard
title="Sign in" title="Sign in"
footer={ footer={
isHostedMode ? ( isHostedMode ? (
<div className="flex justify-between text-sm text-base-content/50"> <div
className={
showEmailForm
? "flex justify-between text-sm text-base-content/50"
: "text-sm text-base-content/50"
}
>
{showEmailForm ? (
<Link <Link
to="/forgot-password" to="/forgot-password"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
@ -145,6 +181,7 @@ function SignInPage() {
> >
Forgot password? Forgot password?
</Link> </Link>
) : null}
<Link <Link
to="/sign-up" to="/sign-up"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
@ -156,6 +193,25 @@ function SignInPage() {
) : null ) : null
} }
> >
{!showEmailForm ? (
<>
<AuthMethodChooser
googleLabel="Continue with Google"
disabled={!isHostedMode}
isBusy={isStartingGoogle}
onContinueWithGoogle={() => {
void handleContinueWithGoogle();
}}
onContinueWithEmail={() => {
setShowEmailForm(true);
setSocialError(null);
}}
/>
{socialError ? (
<p className="text-sm text-error">{socialError}</p>
) : null}
</>
) : (
<form <form
className="space-y-4" className="space-y-4"
onSubmit={(event) => { onSubmit={(event) => {
@ -258,6 +314,7 @@ function SignInPage() {
}} }}
</form.Subscribe> </form.Subscribe>
</form> </form>
)}
</AuthPageCard> </AuthPageCard>
); );
} }

View File

@ -1,7 +1,9 @@
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router"; import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { import {
AuthPageCard, AuthPageCard,
AuthMethodChooser,
authRedirectSearchSchema, authRedirectSearchSchema,
getFieldError, getFieldError,
getFormError, getFormError,
@ -46,6 +48,9 @@ function SignUpPage() {
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect); const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
const [showEmailForm, setShowEmailForm] = useState(false);
const [isStartingGoogle, setIsStartingGoogle] = useState(false);
const [socialError, setSocialError] = useState<string | null>(null);
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
@ -104,11 +109,51 @@ 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 ( return (
<AuthPageCard <AuthPageCard
title="Create your account" title="Create your account"
footer={ footer={
isHostedMode ? ( isHostedMode ? (
showEmailForm ? (
<button
type="button"
className="text-sm text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
onClick={() => {
setShowEmailForm(false);
setSocialError(null);
}}
>
Back to signup
</button>
) : (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm leading-relaxed text-base-content/60"> <p className="text-sm leading-relaxed text-base-content/60">
By signing up, you agree to our{" "} By signing up, you agree to our{" "}
@ -143,9 +188,29 @@ function SignUpPage() {
</Link> </Link>
</p> </p>
</div> </div>
)
) : null ) : null
} }
> >
{!showEmailForm ? (
<>
<AuthMethodChooser
googleLabel="Continue with Google"
disabled={!isHostedMode}
isBusy={isStartingGoogle}
onContinueWithGoogle={() => {
void handleContinueWithGoogle();
}}
onContinueWithEmail={() => {
setShowEmailForm(true);
setSocialError(null);
}}
/>
{socialError ? (
<p className="text-sm text-error">{socialError}</p>
) : null}
</>
) : (
<form <form
className="space-y-4" className="space-y-4"
onSubmit={(event) => { onSubmit={(event) => {
@ -276,6 +341,7 @@ function SignUpPage() {
}} }}
</form.Subscribe> </form.Subscribe>
</form> </form>
)}
</AuthPageCard> </AuthPageCard>
); );
} }