From a76442b382a56d8a34324b5793f997988c5e8a50 Mon Sep 17 00:00:00 2001 From: sepehr Date: Sun, 10 May 2026 19:47:08 +0200 Subject: [PATCH] feat: add Google Sign-In to login and register pages - Add GoogleOAuthProvider wrapper in layout.tsx - Add Google login button to LoginForm with "or continue with email" separator - Add Google signup button to RegisterForm - Add Google auth types and API client integration - Add GOOGLE_CLIENT_ID and NEXT_PUBLIC_GOOGLE_CLIENT_ID to env config - Add translations (EN/FR) for Google OAuth UI - Backend already has /api/v1/auth/google endpoint, no changes needed Co-Authored-By: Claude Opus 4.7 --- .env.production | 4 + docker-compose.yml | 5 +- frontend/src/app/auth/login/LoginForm.tsx | 76 +++++++++++++++++- frontend/src/app/auth/login/types.ts | 6 ++ .../src/app/auth/register/RegisterForm.tsx | 78 ++++++++++++++++++- frontend/src/app/layout.tsx | 9 ++- frontend/src/messages/en.json | 6 +- frontend/src/messages/fr.json | 6 +- 8 files changed, 179 insertions(+), 11 deletions(-) diff --git a/.env.production b/.env.production index 941ad9b..fae5c35 100644 --- a/.env.production +++ b/.env.production @@ -25,6 +25,10 @@ TRANSLATION_SERVICE=google # ---- Google (gratuit, toujours actif) ---- GOOGLE_TRANSLATE_ENABLED=true +# ---- Google OAuth (connexion avec Google) ---- +GOOGLE_CLIENT_ID= +NEXT_PUBLIC_GOOGLE_CLIENT_ID= + # ---- Ollama (local, gratuit) ---- OLLAMA_ENABLED=false OLLAMA_BASE_URL=http://ollama:11434 diff --git a/docker-compose.yml b/docker-compose.yml index d5d2816..65d2d8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,7 +71,7 @@ services: container_name: wordly-backend restart: unless-stopped ports: - - "8000:8000" + - "8001:8000" env_file: - .env environment: @@ -104,6 +104,7 @@ services: - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} - STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-} - STRIPE_BUSINESS_PRICE_ID=${STRIPE_BUSINESS_PRICE_ID:-} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} volumes: - uploads_data:/app/uploads - outputs_data:/app/outputs @@ -146,7 +147,7 @@ services: - .env environment: - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://wordly.art} - networks: + - NEXT_PUBLIC_GOOGLE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-} - wordly-network depends_on: backend: diff --git a/frontend/src/app/auth/login/LoginForm.tsx b/frontend/src/app/auth/login/LoginForm.tsx index 13e2f63..d5dd751 100644 --- a/frontend/src/app/auth/login/LoginForm.tsx +++ b/frontend/src/app/auth/login/LoginForm.tsx @@ -1,24 +1,34 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Languages } from 'lucide-react'; +import { GoogleLogin } from '@react-oauth/google'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { useNotification } from '@/components/ui/notification'; import { useI18n } from '@/lib/i18n'; +import { apiClient } from '@/lib/apiClient'; import { useLogin } from './useLogin'; +import type { GoogleAuthResponse } from './types'; export function LoginForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); + const [googleLoading, setGoogleLoading] = useState(false); const loginMutation = useLogin(); const { notify } = useNotification(); const { t } = useI18n(); + const router = useRouter(); + const searchParams = useSearchParams(); + const redirect = searchParams.get('redirect') || '/dashboard'; + + const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''; useEffect(() => { if (loginMutation.isError && loginMutation.error) { @@ -35,6 +45,37 @@ export function LoginForm() { loginMutation.mutate({ email, password }); }; + const handleGoogleSuccess = useCallback(async (credentialResponse: { credential?: string }) => { + if (!credentialResponse.credential) return; + setGoogleLoading(true); + try { + const response = await apiClient.post<{ data: GoogleAuthResponse }>( + '/api/v1/auth/google', + { credential: credentialResponse.credential }, + ); + const { access_token, refresh_token } = response.data; + localStorage.setItem('token', access_token); + localStorage.setItem('refresh_token', refresh_token); + router.push(redirect); + } catch { + notify({ + title: t('login.google.errorGeneric'), + description: t('login.google.errorFailed'), + variant: 'destructive', + }); + } finally { + setGoogleLoading(false); + } + }, [redirect, router, notify, t]); + + const handleGoogleError = useCallback(() => { + notify({ + title: t('login.google.errorGeneric'), + description: t('login.google.errorFailed'), + variant: 'destructive', + }); + }, [notify, t]); + return ( @@ -56,6 +97,39 @@ export function LoginForm() { + {googleClientId && ( + <> +
+ {googleLoading ? ( + + ) : ( + + )} +
+ +
+
+ +
+
+ + {t('login.orContinueWith')} + +
+
+ + )} +
diff --git a/frontend/src/app/auth/login/types.ts b/frontend/src/app/auth/login/types.ts index 288b516..a2aaefc 100644 --- a/frontend/src/app/auth/login/types.ts +++ b/frontend/src/app/auth/login/types.ts @@ -14,3 +14,9 @@ export interface LoginResponse { refresh_token: string; token_type: string; } + +export interface GoogleAuthResponse { + access_token: string; + refresh_token: string; + token_type: string; +} diff --git a/frontend/src/app/auth/register/RegisterForm.tsx b/frontend/src/app/auth/register/RegisterForm.tsx index 0c1d081..5474faa 100644 --- a/frontend/src/app/auth/register/RegisterForm.tsx +++ b/frontend/src/app/auth/register/RegisterForm.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; import { useI18n } from '@/lib/i18n'; import { Eye, @@ -16,12 +17,16 @@ import { Languages, User, } from 'lucide-react'; +import { GoogleLogin } from '@react-oauth/google'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useNotification } from '@/components/ui/notification'; +import { apiClient } from '@/lib/apiClient'; import { useRegister } from './useRegister'; import { cn } from '@/lib/utils'; +import type { GoogleAuthResponse } from '../login/types'; function validateEmail(email: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); @@ -70,10 +75,17 @@ export function RegisterForm() { const [confirmPassword, setConfirmPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); const [showConfirm, setShowConfirm] = useState(false); + const [googleLoading, setGoogleLoading] = useState(false); const [touched, setTouched] = useState({ name: false, email: false, password: false, confirmPassword: false }); const registerMutation = useRegister(); + const { notify } = useNotification(); const { t } = useI18n(); + const router = useRouter(); + const searchParams = useSearchParams(); + const redirect = searchParams.get('redirect') || '/dashboard'; + + const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''; const nameError = touched.name && name.length > 0 && name.length < 2 ? t('register.name.error') @@ -119,6 +131,37 @@ export function RegisterForm() { ); }; + const handleGoogleSuccess = useCallback(async (credentialResponse: { credential?: string }) => { + if (!credentialResponse.credential) return; + setGoogleLoading(true); + try { + const response = await apiClient.post<{ data: GoogleAuthResponse }>( + '/api/v1/auth/google', + { credential: credentialResponse.credential }, + ); + const { access_token, refresh_token } = response.data; + localStorage.setItem('token', access_token); + localStorage.setItem('refresh_token', refresh_token); + router.push(redirect); + } catch { + notify({ + title: t('login.google.errorGeneric'), + description: t('login.google.errorFailed'), + variant: 'destructive', + }); + } finally { + setGoogleLoading(false); + } + }, [redirect, router, notify, t]); + + const handleGoogleError = useCallback(() => { + notify({ + title: t('login.google.errorGeneric'), + description: t('login.google.errorFailed'), + variant: 'destructive', + }); + }, [notify, t]); + return ( @@ -136,6 +179,39 @@ export function RegisterForm() { + {googleClientId && ( + <> +
+ {googleLoading ? ( + + ) : ( + + )} +
+ +
+
+ +
+
+ + {t('login.orContinueWith')} + +
+
+ + )} + {registerMutation.isError && (
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 7da3049..178ac4e 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -6,6 +6,7 @@ import { ThemeProvider } from "@/providers/ThemeProvider"; import { NotificationProvider } from "@/components/ui/notification"; import { I18nProvider } from "@/lib/i18n"; import { Agentation } from "agentation"; +import { GoogleOAuthProvider } from "@react-oauth/google"; export const dynamic = 'force-dynamic'; @@ -29,9 +30,11 @@ export default function RootLayout({ - - {children} - + + + {children} + + {process.env.NODE_ENV === "development" && } diff --git a/frontend/src/messages/en.json b/frontend/src/messages/en.json index 462922d..866f449 100644 --- a/frontend/src/messages/en.json +++ b/frontend/src/messages/en.json @@ -212,7 +212,8 @@ }, "login": { "title": "Welcome back", - "subtitle": "Sign in to keep translating", + "welcomeBack": "Welcome back", + "signInToContinue": "Sign in to keep translating", "email": "Email", "password": "Password", "emailPlaceholder": "you@example.com", @@ -221,7 +222,8 @@ "signIn": "Sign in", "errorTitle": "Sign-in error", "noAccount": "Don't have an account?", - "signUpFree": "Sign up for free" + "signUpFree": "Sign up for free", + "orContinueWith": "or continue with email" }, "register": { "title": "Create an account", diff --git a/frontend/src/messages/fr.json b/frontend/src/messages/fr.json index 6f63422..bd9c7e0 100644 --- a/frontend/src/messages/fr.json +++ b/frontend/src/messages/fr.json @@ -212,7 +212,8 @@ }, "login": { "title": "Bon retour", - "subtitle": "Connectez-vous pour continuer à traduire", + "welcomeBack": "Bon retour", + "signInToContinue": "Connectez-vous pour continuer à traduire", "email": "E-mail", "password": "Mot de passe", "emailPlaceholder": "vous@exemple.com", @@ -221,7 +222,8 @@ "signIn": "Se connecter", "errorTitle": "Erreur de connexion", "noAccount": "Pas encore de compte ?", - "signUpFree": "Créer un compte gratuitement" + "signUpFree": "Créer un compte gratuitement", + "orContinueWith": "ou continuer avec email" }, "register": { "title": "Créer un compte",