diff --git a/.env.example b/.env.example index ca0bf1f3..ff354b05 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/(auth)/auth/callback/__tests__/route.test.ts b/app/(auth)/auth/callback/__tests__/route.test.ts index 2324fb52..9f252324 100644 --- a/app/(auth)/auth/callback/__tests__/route.test.ts +++ b/app/(auth)/auth/callback/__tests__/route.test.ts @@ -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)', () => { diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts index a0771e9f..d34cb74a 100644 --- a/app/(auth)/auth/callback/route.ts +++ b/app/(auth)/auth/callback/route.ts @@ -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) diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 6a0476b0..21da9636 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -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(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' && (
- {callbackFlow === 'recovery' ? ( + {callbackFlow === 'oauth' ? ( + <> +

+ {tAuth('callback_error_title_oauth')} +

+

+ {tAuth('callback_error_body_oauth')} +

+ + ) : callbackFlow === 'recovery' ? ( <>

{tAuth('callback_error_title')} @@ -499,9 +511,9 @@ function LoginPageContent() { )}

)} - {bankIdEnabled && ( + {(bankIdEnabled || googleAuthEnabled) && ( <> - {bankIdNoAccount ? ( + {bankIdEnabled && (bankIdNoAccount ? (

{tAuth('bankid_no_account_greeting', { name: bankIdNoAccount.givenName ?? '' })} @@ -522,6 +534,13 @@ function LoginPageContent() {

+ ))} + {googleAuthEnabled && !(isBankIdReauth && !showPasswordLogin) && ( +
+ setFormError({ kind: 'oauth', message })} + /> +
)} {isBankIdReauth && !showPasswordLogin ? (
- {bankIdEnabled && !bankIdUser && ( + {(bankIdEnabled || googleAuthEnabled) && !bankIdUser && ( <> -
- -
+ {bankIdEnabled && ( +
+ +
+ )} + {googleAuthEnabled && ( +
+ setFormError({ kind: 'oauth', message })} + /> +
+ )}
diff --git a/components/auth/GoogleAuthButton.tsx b/components/auth/GoogleAuthButton.tsx new file mode 100644 index 00000000..1e64be1f --- /dev/null +++ b/components/auth/GoogleAuthButton.tsx @@ -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 ( + + + + + + + ) +} + +/** + * "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 ( + + ) +} diff --git a/lib/auth/google-oauth.ts b/lib/auth/google-oauth.ts new file mode 100644 index 00000000..02edc5d6 --- /dev/null +++ b/lib/auth/google-oauth.ts @@ -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' +} diff --git a/messages/en.json b/messages/en.json index f794de0a..d535aeda 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 396d4912..eefc882e 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",