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
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user