Add Google sign in and two-step auth flow (#193)
This commit is contained in:
parent
a98407176b
commit
701ace3dff
@ -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
|
||||
|
||||
@ -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
2
src/env.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,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 (
|
||||
<AuthPageCard
|
||||
title="Sign in"
|
||||
footer={
|
||||
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
|
||||
to="/forgot-password"
|
||||
search={getSignInSearch(redirectTo)}
|
||||
@ -145,6 +181,7 @@ function SignInPage() {
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
to="/sign-up"
|
||||
search={getSignInSearch(redirectTo)}
|
||||
@ -156,6 +193,25 @@ function SignInPage() {
|
||||
) : 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
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
@ -258,6 +314,7 @@ function SignInPage() {
|
||||
}}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
)}
|
||||
</AuthPageCard>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,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 (
|
||||
<AuthPageCard
|
||||
title="Create your account"
|
||||
footer={
|
||||
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">
|
||||
<p className="text-sm leading-relaxed text-base-content/60">
|
||||
By signing up, you agree to our{" "}
|
||||
@ -143,9 +188,29 @@ function SignUpPage() {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : 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
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
@ -276,6 +341,7 @@ function SignUpPage() {
|
||||
}}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
)}
|
||||
</AuthPageCard>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user