Add 'Sign in with Google' button to /login and /wholesale/login
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
Wires up Better Auth's signIn.social({ provider: 'google' }) so users
can authenticate via Google OAuth. The flow is:
1. User clicks the Google button.
2. Client calls the signInWithGoogleAction server action.
3. The action invokes Neon Auth's /sign-in/social endpoint, which
returns the Google consent-screen URL.
4. Client navigates the browser to that URL.
5. Google redirects back through Neon Auth to the callbackURL.
Files:
- src/actions/auth-actions.ts: new signInWithGoogleAction server
action that wraps signIn.social and returns the redirect URL
to the client. Structured { url, error } return — never throws.
- src/app/login/LoginClient.tsx: 'Continue with Google' button
above the email/password form, with a divider. Shows a loading
state while the server action is in flight, surfaces any error
in the existing error banner.
- src/app/wholesale/login/page.tsx: same button for wholesale
buyers. Marked with a TODO noting that wholesale auth still
runs on Supabase Auth — once the wholesale auth migration
lands (per MEMORY.md 'What's left'), the button will start
working for them. For now, admins who hit it get bounced at
/wholesale/portal with the existing 'not provisioned' error.
- tests/unit/sign-in-with-google.test.ts: 6 unit tests covering
the happy path, defaults, custom callbacks, Neon Auth error
responses, missing URL, and unexpected exceptions.
Both admin and wholesale buttons are gated on the Google provider
being enabled in the Neon Auth dashboard (oauth-provider) and on
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET being set in the runtime
env — both are already wired through .gitea/workflows/deploy.yml.
This commit is contained in:
@@ -17,6 +17,7 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [googleLoading, setGoogleLoading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
async function handleSignIn() {
|
||||
@@ -50,6 +51,27 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleSignIn() {
|
||||
setGoogleLoading(true);
|
||||
setLocalError(null);
|
||||
try {
|
||||
const { signInWithGoogleAction } = await import("@/actions/auth-actions");
|
||||
const result = await signInWithGoogleAction({ callbackURL: REDIRECT_URL });
|
||||
if (result.error || !result.url) {
|
||||
setLocalError(
|
||||
result.error ??
|
||||
"Google sign-in is not available. Contact your administrator or sign in with email + password.",
|
||||
);
|
||||
setGoogleLoading(false);
|
||||
return;
|
||||
}
|
||||
window.location.href = result.url;
|
||||
} catch {
|
||||
setLocalError("Google sign-in failed. Please try again.");
|
||||
setGoogleLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDevLogin(role: string) {
|
||||
// Set the dev_session cookie for local development bypass
|
||||
document.cookie = `dev_session=${role}; path=/; max-age=86400`;
|
||||
@@ -119,6 +141,44 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
className="space-y-5"
|
||||
noValidate
|
||||
>
|
||||
{/* Google sign-in (primary path — works for both new and existing users) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={googleLoading || loading}
|
||||
className="atelier-cta atelier-cta-secondary w-full"
|
||||
style={{ background: "white", color: "#1c1917", border: "1px solid #e7e5e4", boxShadow: "0 1px 2px rgba(0,0,0,0.04)" }}
|
||||
>
|
||||
{googleLoading ? (
|
||||
<span className="relative z-10 inline-flex items-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Redirecting to Google…
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative z-10 inline-flex items-center gap-2.5">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path fill="#FBBC05" d="M5.84 14.1c-.22-.66-.35-1.36-.35-2.1s.13-1.44.35-2.1V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.83z" />
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
Continue with Google
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3" aria-hidden="true">
|
||||
<div className="h-px flex-1 bg-stone-200" />
|
||||
<span className="text-[10px] font-semibold tracking-[0.15em] uppercase text-stone-400" style={{ fontFamily: "var(--font-fragment-mono)" }}>
|
||||
or
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-stone-200" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={emailId} className="atelier-section-label">
|
||||
Email
|
||||
|
||||
Reference in New Issue
Block a user