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,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 ( 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
<Link className={
to="/forgot-password" showEmailForm
search={getSignInSearch(redirectTo)} ? "flex justify-between text-sm text-base-content/50"
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors" : "text-sm text-base-content/50"
> }
Forgot password? >
</Link> {showEmailForm ? (
<Link
to="/forgot-password"
search={getSignInSearch(redirectTo)}
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
>
Forgot password?
</Link>
) : null}
<Link <Link
to="/sign-up" to="/sign-up"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
@ -156,108 +193,128 @@ function SignInPage() {
) : null ) : null
} }
> >
<form {!showEmailForm ? (
className="space-y-4" <>
onSubmit={(event) => { <AuthMethodChooser
event.preventDefault(); googleLabel="Continue with Google"
void form.handleSubmit(); disabled={!isHostedMode}
}} isBusy={isStartingGoogle}
> onContinueWithGoogle={() => {
<form.Field name="email"> void handleContinueWithGoogle();
{(field) => { }}
const error = getFieldError(field.state.meta.errors); onContinueWithEmail={() => {
setShowEmailForm(true);
return ( setSocialError(null);
<div> }}
<input />
type="email" {socialError ? (
className="input input-bordered w-full" <p className="text-sm text-error">{socialError}</p>
placeholder="Email address..." ) : null}
value={field.state.value} </>
onChange={(event) => field.handleChange(event.target.value)} ) : (
autoComplete="email" <form
disabled={!isHostedMode} className="space-y-4"
required onSubmit={(event) => {
/> event.preventDefault();
{error ? ( void form.handleSubmit();
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}} }}
</form.Field>
<form.Field name="password">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="password"
className="input input-bordered w-full"
placeholder="Password..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="current-password"
disabled={!isHostedMode}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
{verificationEmail ? (
<div className="alert alert-warning items-start">
<div className="space-y-3">
<p className="text-sm">
Please check {verificationEmail} for a link to confirm your
email.
</p>
<button
type="button"
className="btn btn-sm btn-outline"
onClick={() => {
void handleResendVerification();
}}
disabled={isSendingVerification}
>
{isSendingVerification
? "Sending email..."
: "Send another email"}
</button>
</div>
</div>
) : null}
<form.Subscribe
selector={(state) => ({
submitError: state.errorMap.onSubmit,
isSubmitting: state.isSubmitting,
})}
> >
{({ submitError, isSubmitting }) => { <form.Field name="email">
const errorMessage = getFormError(submitError); {(field) => {
return ( const error = getFieldError(field.state.meta.errors);
<>
{errorMessage ? ( return (
<p className="text-sm text-error">{errorMessage}</p> <div>
) : null} <input
type="email"
className="input input-bordered w-full"
placeholder="Email address..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
disabled={!isHostedMode}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
<form.Field name="password">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="password"
className="input input-bordered w-full"
placeholder="Password..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="current-password"
disabled={!isHostedMode}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
{verificationEmail ? (
<div className="alert alert-warning items-start">
<div className="space-y-3">
<p className="text-sm">
Please check {verificationEmail} for a link to confirm your
email.
</p>
<button <button
className="btn btn-soft w-full" type="button"
disabled={!isHostedMode || isSubmitting} className="btn btn-sm btn-outline"
onClick={() => {
void handleResendVerification();
}}
disabled={isSendingVerification}
> >
{isSubmitting ? "Signing in..." : "Sign in"} {isSendingVerification
? "Sending email..."
: "Send another email"}
</button> </button>
</> </div>
); </div>
}} ) : null}
</form.Subscribe>
</form> <form.Subscribe
selector={(state) => ({
submitError: state.errorMap.onSubmit,
isSubmitting: state.isSubmitting,
})}
>
{({ submitError, isSubmitting }) => {
const errorMessage = getFormError(submitError);
return (
<>
{errorMessage ? (
<p className="text-sm text-error">{errorMessage}</p>
) : null}
<button
className="btn btn-soft w-full"
disabled={!isHostedMode || isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</button>
</>
);
}}
</form.Subscribe>
</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,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 ( return (
<AuthPageCard <AuthPageCard
title="Create your account" title="Create your account"
footer={ footer={
isHostedMode ? ( isHostedMode ? (
<div className="space-y-4"> showEmailForm ? (
<p className="text-sm leading-relaxed text-base-content/60"> <button
By signing up, you agree to our{" "} type="button"
<a className="text-sm text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
href="https://openseo.so/terms-and-conditions" onClick={() => {
target="_blank" setShowEmailForm(false);
rel="noreferrer" setSocialError(null);
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors" }}
> >
Terms Back to signup
</a>{" "} </button>
and{" "} ) : (
<a <div className="space-y-4">
href="https://openseo.so/privacy" <p className="text-sm leading-relaxed text-base-content/60">
target="_blank" By signing up, you agree to our{" "}
rel="noreferrer" <a
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors" href="https://openseo.so/terms-and-conditions"
> target="_blank"
Privacy Policy rel="noreferrer"
</a> className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
. >
</p> Terms
</a>{" "}
and{" "}
<a
href="https://openseo.so/privacy"
target="_blank"
rel="noreferrer"
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
>
Privacy Policy
</a>
.
</p>
<p className="text-sm text-base-content/50"> <p className="text-sm text-base-content/50">
Already have an account?{" "} Already have an account?{" "}
<Link <Link
to="/sign-in" to="/sign-in"
search={getSignInSearch(redirectTo)} search={getSignInSearch(redirectTo)}
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors" className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
> >
Sign in Sign in
</Link> </Link>
</p> </p>
</div> </div>
)
) : null ) : null
} }
> >
<form {!showEmailForm ? (
className="space-y-4" <>
onSubmit={(event) => { <AuthMethodChooser
event.preventDefault(); googleLabel="Continue with Google"
void form.handleSubmit(); disabled={!isHostedMode}
}} isBusy={isStartingGoogle}
> onContinueWithGoogle={() => {
<form.Field name="name"> void handleContinueWithGoogle();
{(field) => { }}
const error = getFieldError(field.state.meta.errors); onContinueWithEmail={() => {
setShowEmailForm(true);
return ( setSocialError(null);
<div> }}
<input />
type="text" {socialError ? (
className="input input-bordered w-full" <p className="text-sm text-error">{socialError}</p>
placeholder="Name (optional)..." ) : null}
value={field.state.value} </>
onChange={(event) => field.handleChange(event.target.value)} ) : (
autoComplete="name" <form
disabled={!isHostedMode} className="space-y-4"
/> onSubmit={(event) => {
{error ? ( event.preventDefault();
<p className="mt-1 text-sm text-error">{error}</p> void form.handleSubmit();
) : null}
</div>
);
}} }}
</form.Field>
<form.Field name="email">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="email"
className="input input-bordered w-full"
placeholder="Email address..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
disabled={!isHostedMode}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
<form.Field name="password">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="password"
className="input input-bordered w-full"
placeholder="Password..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
<form.Field name="confirmPassword">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="password"
className="input input-bordered w-full"
placeholder="Confirm password..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
<form.Subscribe
selector={(state) => ({
submitError: state.errorMap.onSubmit,
isSubmitting: state.isSubmitting,
})}
> >
{({ submitError, isSubmitting }) => { <form.Field name="name">
const errorMessage = getFormError(submitError); {(field) => {
return ( const error = getFieldError(field.state.meta.errors);
<>
{errorMessage ? ( return (
<p className="text-sm text-error">{errorMessage}</p> <div>
) : null} <input
<button type="text"
className="btn btn-soft w-full" className="input input-bordered w-full"
disabled={!isHostedMode || isSubmitting} placeholder="Name (optional)..."
> value={field.state.value}
{isSubmitting ? "Creating account..." : "Create account"} onChange={(event) => field.handleChange(event.target.value)}
</button> autoComplete="name"
</> disabled={!isHostedMode}
); />
}} {error ? (
</form.Subscribe> <p className="mt-1 text-sm text-error">{error}</p>
</form> ) : null}
</div>
);
}}
</form.Field>
<form.Field name="email">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="email"
className="input input-bordered w-full"
placeholder="Email address..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
disabled={!isHostedMode}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
<form.Field name="password">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="password"
className="input input-bordered w-full"
placeholder="Password..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
<form.Field name="confirmPassword">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="password"
className="input input-bordered w-full"
placeholder="Confirm password..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
<form.Subscribe
selector={(state) => ({
submitError: state.errorMap.onSubmit,
isSubmitting: state.isSubmitting,
})}
>
{({ submitError, isSubmitting }) => {
const errorMessage = getFormError(submitError);
return (
<>
{errorMessage ? (
<p className="text-sm text-error">{errorMessage}</p>
) : null}
<button
className="btn btn-soft w-full"
disabled={!isHostedMode || isSubmitting}
>
{isSubmitting ? "Creating account..." : "Create account"}
</button>
</>
);
}}
</form.Subscribe>
</form>
)}
</AuthPageCard> </AuthPageCard>
); );
} }