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:
Jakob Wennberg
2026-08-06 21:26:09 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 0bb0b89353
commit 28b58aedc4
9 changed files with 186 additions and 11 deletions
@@ -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)', () => {
+8 -2
View File
@@ -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 -4
View File
@@ -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
+17 -5
View File
@@ -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" />