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
# 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

View File

@ -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 (
<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({
title,
helperText,

2
src/env.d.ts vendored
View File

@ -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;

View File

@ -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",

View File

@ -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<string | null>(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 (
<AuthPageCard
title="Sign in"
footer={
isHostedMode ? (
<div className="flex justify-between text-sm text-base-content/50">
<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>
<div
className={
showEmailForm
? "flex justify-between text-sm text-base-content/50"
: "text-sm text-base-content/50"
}
>
{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
to="/sign-up"
search={getSignInSearch(redirectTo)}
@ -156,108 +193,128 @@ function SignInPage() {
) : null
}
>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
<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>
);
{!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
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
</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 }) => {
const errorMessage = getFormError(submitError);
return (
<>
{errorMessage ? (
<p className="text-sm text-error">{errorMessage}</p>
) : null}
<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="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
className="btn btn-soft w-full"
disabled={!isHostedMode || isSubmitting}
type="button"
className="btn btn-sm btn-outline"
onClick={() => {
void handleResendVerification();
}}
disabled={isSendingVerification}
>
{isSubmitting ? "Signing in..." : "Sign in"}
{isSendingVerification
? "Sending email..."
: "Send another email"}
</button>
</>
);
}}
</form.Subscribe>
</form>
</div>
</div>
) : null}
<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>
);
}

View File

@ -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<string | null>(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 (
<AuthPageCard
title="Create your account"
footer={
isHostedMode ? (
<div className="space-y-4">
<p className="text-sm leading-relaxed text-base-content/60">
By signing up, you agree to our{" "}
<a
href="https://openseo.so/terms-and-conditions"
target="_blank"
rel="noreferrer"
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
>
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>
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">
<p className="text-sm leading-relaxed text-base-content/60">
By signing up, you agree to our{" "}
<a
href="https://openseo.so/terms-and-conditions"
target="_blank"
rel="noreferrer"
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
>
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">
Already have an account?{" "}
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
>
Sign in
</Link>
</p>
</div>
<p className="text-sm text-base-content/50">
Already have an account?{" "}
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
>
Sign in
</Link>
</p>
</div>
)
) : null
}
>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
<form.Field name="name">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="text"
className="input input-bordered w-full"
placeholder="Name (optional)..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="name"
disabled={!isHostedMode}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
{!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
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
</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>
<form.Field name="name">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="text"
className="input input-bordered w-full"
placeholder="Name (optional)..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="name"
disabled={!isHostedMode}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : 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>
);
}