feat(auth): sign in with Google behind NEXT_PUBLIC_GOOGLE_AUTH_ENABLED (#1441)
Adds a 'Continue with Google' button to login and register, gated by NEXT_PUBLIC_GOOGLE_AUTH_ENABLED so it ships dark until the Google provider is configured in Supabase. The OAuth round-trip reuses the existing /auth/callback PKCE exchange, which already owns MFA routing, invite acceptance and silent-team creation. A flow=oauth marker on the redirect lets the callback tag failures (including provider consent denials) so the login page shows Google-specific error copy instead of the email-confirmation framing. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
0bb0b89353
commit
28b58aedc4
@@ -37,6 +37,12 @@ CRON_SECRET=generate-a-random-secret
|
||||
# Hosted keeps this unset: public signup stays open there.
|
||||
# AUTH_SIGNUPS_DISABLED=false
|
||||
|
||||
# Sign in with Google. Requires the Google provider to be configured in
|
||||
# Supabase/GoTrue first (Google Cloud OAuth client + redirect URI):
|
||||
# https://supabase.com/docs/guides/auth/social-login/auth-google
|
||||
# The button stays hidden until this is true.
|
||||
# NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=true
|
||||
|
||||
# ── Optional: extension features (core runs without these) ─
|
||||
# AI features: Claude via AWS Bedrock (document extraction + AI assistant).
|
||||
# Needs an AWS account with Bedrock model access to Claude. Plain
|
||||
|
||||
@@ -84,6 +84,34 @@ describe('GET /auth/callback: recovery flow', () => {
|
||||
'http://localhost:3000/login?error=auth_error&flow=signup'
|
||||
)
|
||||
})
|
||||
|
||||
it('tags a failed OAuth code exchange (flow=oauth marker) with flow=oauth', async () => {
|
||||
exchangeCodeForSession.mockResolvedValue({
|
||||
error: { message: 'code verifier missing' },
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/auth/callback?code=xyz&flow=oauth')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'http://localhost:3000/login?error=auth_error&flow=oauth'
|
||||
)
|
||||
})
|
||||
|
||||
it('tags a provider denial (no code, only ?error from the provider) with flow=oauth', async () => {
|
||||
const request = new NextRequest(
|
||||
'http://localhost:3000/auth/callback?flow=oauth&error=access_denied'
|
||||
)
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'http://localhost:3000/login?error=auth_error&flow=oauth'
|
||||
)
|
||||
expect(exchangeCodeForSession).not.toHaveBeenCalled()
|
||||
expect(verifyOtp).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /auth/callback: admin invite flow (type=invite)', () => {
|
||||
|
||||
@@ -239,10 +239,16 @@ export async function GET(request: NextRequest) {
|
||||
// flow hint so the login page can show the right copy: a failed signup
|
||||
// confirmation must not be framed as a failed password reset. On the PKCE
|
||||
// (?code=) path there is no `type`, so recovery is identified by the
|
||||
// next=/reset-password marker that resetPasswordForEmail sets; everything
|
||||
// next=/reset-password marker that resetPasswordForEmail sets, and OAuth
|
||||
// by the flow=oauth marker that GoogleAuthButton puts in redirectTo
|
||||
// (provider denials arrive here with ?error and no code); everything
|
||||
// else defaults to the signup/confirmation framing.
|
||||
const failedFlow =
|
||||
type === 'recovery' || next === '/reset-password' ? 'recovery' : 'signup'
|
||||
searchParams.get('flow') === 'oauth'
|
||||
? 'oauth'
|
||||
: type === 'recovery' || next === '/reset-password'
|
||||
? 'recovery'
|
||||
: 'signup'
|
||||
const loginUrl = new URL('/login', origin)
|
||||
loginUrl.searchParams.set('error', 'auth_error')
|
||||
loginUrl.searchParams.set('flow', failedFlow)
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from '@/lib/auth/consume-invite-cookie'
|
||||
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
|
||||
import { AuthFormError } from '@/components/auth/AuthFormError'
|
||||
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
|
||||
import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth'
|
||||
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
|
||||
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
|
||||
import {
|
||||
@@ -61,7 +63,7 @@ function LoginPageContent() {
|
||||
const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null)
|
||||
// Auth failures render inline next to the form (see AuthFormError), never
|
||||
// as a toast: `kind` drives field highlighting and the recovery action.
|
||||
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid'; message: string } | null>(null)
|
||||
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid' | 'oauth'; message: string } | null>(null)
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
@@ -79,6 +81,7 @@ function LoginPageContent() {
|
||||
const nextPath = safeReturnTo(searchParams.get('next'), '/')
|
||||
const supabase = createClient()
|
||||
const bankIdEnabled = isBankIdEnabled()
|
||||
const googleAuthEnabled = isGoogleAuthEnabled()
|
||||
const tAuth = useTranslations('auth')
|
||||
const tCommon = useTranslations('common')
|
||||
const tInvite = useTranslations('invite')
|
||||
@@ -470,7 +473,16 @@ function LoginPageContent() {
|
||||
)}
|
||||
{callbackError === 'auth_error' && (
|
||||
<div className="mb-5 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
{callbackFlow === 'recovery' ? (
|
||||
{callbackFlow === 'oauth' ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{tAuth('callback_error_title_oauth')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-destructive/90">
|
||||
{tAuth('callback_error_body_oauth')}
|
||||
</p>
|
||||
</>
|
||||
) : callbackFlow === 'recovery' ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{tAuth('callback_error_title')}
|
||||
@@ -499,9 +511,9 @@ function LoginPageContent() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{bankIdEnabled && (
|
||||
{(bankIdEnabled || googleAuthEnabled) && (
|
||||
<>
|
||||
{bankIdNoAccount ? (
|
||||
{bankIdEnabled && (bankIdNoAccount ? (
|
||||
<div className="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{tAuth('bankid_no_account_greeting', { name: bankIdNoAccount.givenName ?? '' })}
|
||||
@@ -522,6 +534,13 @@ function LoginPageContent() {
|
||||
<div className="mb-5">
|
||||
<BankIdAuth mode="login" onComplete={handleBankIdComplete} />
|
||||
</div>
|
||||
))}
|
||||
{googleAuthEnabled && !(isBankIdReauth && !showPasswordLogin) && (
|
||||
<div className="mb-5">
|
||||
<GoogleAuthButton
|
||||
onError={(message) => setFormError({ kind: 'oauth', message })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isBankIdReauth && !showPasswordLogin ? (
|
||||
<Button
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from '@/lib/auth/consume-invite-cookie'
|
||||
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
|
||||
import { AuthFormError } from '@/components/auth/AuthFormError'
|
||||
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
|
||||
import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth'
|
||||
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -67,7 +69,7 @@ function RegisterPageContent() {
|
||||
// Signup failures render inline next to the form (see AuthFormError), never
|
||||
// as a toast. Field-level problems attach to their field; everything else
|
||||
// goes to the form-level alert above the form.
|
||||
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid'; message: string } | null>(null)
|
||||
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid' | 'oauth'; message: string } | null>(null)
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null)
|
||||
const [confirmError, setConfirmError] = useState<string | null>(null)
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -76,6 +78,7 @@ function RegisterPageContent() {
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const bankIdEnabled = isBankIdEnabled()
|
||||
const googleAuthEnabled = isGoogleAuthEnabled()
|
||||
const t = useTranslations('register')
|
||||
const tInvite = useTranslations('invite')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
@@ -459,11 +462,20 @@ function RegisterPageContent() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
{bankIdEnabled && !bankIdUser && (
|
||||
{(bankIdEnabled || googleAuthEnabled) && !bankIdUser && (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<BankIdAuth mode="signup" onComplete={handleBankIdComplete} />
|
||||
</div>
|
||||
{bankIdEnabled && (
|
||||
<div className="mb-5">
|
||||
<BankIdAuth mode="signup" onComplete={handleBankIdComplete} />
|
||||
</div>
|
||||
)}
|
||||
{googleAuthEnabled && (
|
||||
<div className="mb-5">
|
||||
<GoogleAuthButton
|
||||
onError={(message) => setFormError({ kind: 'oauth', message })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative mb-5">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t" />
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
|
||||
/** Official Google "G" mark, drawn inline (no external assets on auth pages). */
|
||||
function GoogleMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 48 48" aria-hidden className="h-4 w-4">
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
|
||||
/>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Continue with Google" for the login and register pages.
|
||||
*
|
||||
* Kicks off the Supabase OAuth redirect; the round-trip lands in
|
||||
* /auth/callback (PKCE code exchange), which owns MFA routing, invite
|
||||
* acceptance and silent-team creation for OAuth sign-ins and sign-ups alike.
|
||||
* The flow=oauth marker lets the callback tag failures so the login page
|
||||
* shows Google-specific copy instead of the email-confirmation framing.
|
||||
*/
|
||||
export function GoogleAuthButton({ onError }: { onError: (message: string) => void }) {
|
||||
const [isRedirecting, setIsRedirecting] = useState(false)
|
||||
const supabase = createClient()
|
||||
const tAuth = useTranslations('auth')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsRedirecting(true)
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback?flow=oauth`,
|
||||
},
|
||||
})
|
||||
if (error) {
|
||||
onError(getErrorMessage(error, { context: 'auth', locale: errorLocale }))
|
||||
setIsRedirecting(false)
|
||||
}
|
||||
// On success the browser navigates away; keep the spinner until then.
|
||||
} catch (error) {
|
||||
onError(getErrorMessage(error, { context: 'auth', locale: errorLocale }))
|
||||
setIsRedirecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11"
|
||||
onClick={handleClick}
|
||||
disabled={isRedirecting}
|
||||
>
|
||||
{isRedirecting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<span className="mr-2 flex items-center"><GoogleMark /></span>
|
||||
)}
|
||||
{tAuth('continue_with_google')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Google OAuth feature flag.
|
||||
*
|
||||
* Signing in with Google requires the Google provider to be configured in
|
||||
* Supabase (GoTrue) with a Google Cloud OAuth client; the flag ships the UI
|
||||
* dark until that is done:
|
||||
* https://supabase.com/docs/guides/auth/social-login/auth-google
|
||||
* Unlike BankID this is not hosted-only: self-hosted installations can
|
||||
* configure their own Google OAuth client.
|
||||
*/
|
||||
export function isGoogleAuthEnabled(): boolean {
|
||||
return process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === 'true'
|
||||
}
|
||||
@@ -274,6 +274,9 @@
|
||||
"login_error_rate_limited": "Too many sign-in attempts. Wait a moment and try again.",
|
||||
"login_error_user_banned": "This account has been suspended. Contact support if you think this is a mistake.",
|
||||
"login_error_reset_link": "Reset your password",
|
||||
"continue_with_google": "Continue with Google",
|
||||
"callback_error_title_oauth": "Google sign-in didn't work",
|
||||
"callback_error_body_oauth": "The Google sign-in could not be completed. Try again, or sign in with email and password.",
|
||||
"session_idle": "You were inactive. Sign in again.",
|
||||
"session_absolute": "Your session expired for security reasons.",
|
||||
"use_password_instead": "Sign in with email instead",
|
||||
|
||||
@@ -274,6 +274,9 @@
|
||||
"login_error_rate_limited": "För många inloggningsförsök. Vänta en stund och försök igen.",
|
||||
"login_error_user_banned": "Kontot är avstängt. Kontakta supporten om du tror att det är fel.",
|
||||
"login_error_reset_link": "Återställ lösenordet",
|
||||
"continue_with_google": "Fortsätt med Google",
|
||||
"callback_error_title_oauth": "Google-inloggningen fungerade inte",
|
||||
"callback_error_body_oauth": "Inloggningen med Google kunde inte slutföras. Försök igen, eller logga in med e-post och lösenord.",
|
||||
"session_idle": "Du har varit inaktiv. Logga in igen.",
|
||||
"session_absolute": "Sessionen har upphört av säkerhetsskäl.",
|
||||
"use_password_instead": "Logga in med e-post i stället",
|
||||
|
||||
Reference in New Issue
Block a user