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 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 19:47:08 +02:00
parent d2d0b2c53c
commit a76442b382
8 changed files with 179 additions and 11 deletions

View File

@@ -25,6 +25,10 @@ TRANSLATION_SERVICE=google
# ---- Google (gratuit, toujours actif) ---- # ---- Google (gratuit, toujours actif) ----
GOOGLE_TRANSLATE_ENABLED=true GOOGLE_TRANSLATE_ENABLED=true
# ---- Google OAuth (connexion avec Google) ----
GOOGLE_CLIENT_ID=
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# ---- Ollama (local, gratuit) ---- # ---- Ollama (local, gratuit) ----
OLLAMA_ENABLED=false OLLAMA_ENABLED=false
OLLAMA_BASE_URL=http://ollama:11434 OLLAMA_BASE_URL=http://ollama:11434

View File

@@ -71,7 +71,7 @@ services:
container_name: wordly-backend container_name: wordly-backend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8000:8000" - "8001:8000"
env_file: env_file:
- .env - .env
environment: environment:
@@ -104,6 +104,7 @@ services:
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-} - STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-}
- STRIPE_BUSINESS_PRICE_ID=${STRIPE_BUSINESS_PRICE_ID:-} - STRIPE_BUSINESS_PRICE_ID=${STRIPE_BUSINESS_PRICE_ID:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
volumes: volumes:
- uploads_data:/app/uploads - uploads_data:/app/uploads
- outputs_data:/app/outputs - outputs_data:/app/outputs
@@ -146,7 +147,7 @@ services:
- .env - .env
environment: environment:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://wordly.art} - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://wordly.art}
networks: - NEXT_PUBLIC_GOOGLE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
- wordly-network - wordly-network
depends_on: depends_on:
backend: backend:

View File

@@ -1,24 +1,34 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Languages } from 'lucide-react'; import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Languages } from 'lucide-react';
import { GoogleLogin } from '@react-oauth/google';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useNotification } from '@/components/ui/notification'; import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { apiClient } from '@/lib/apiClient';
import { useLogin } from './useLogin'; import { useLogin } from './useLogin';
import type { GoogleAuthResponse } from './types';
export function LoginForm() { export function LoginForm() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const loginMutation = useLogin(); const loginMutation = useLogin();
const { notify } = useNotification(); const { notify } = useNotification();
const { t } = useI18n(); 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(() => { useEffect(() => {
if (loginMutation.isError && loginMutation.error) { if (loginMutation.isError && loginMutation.error) {
@@ -35,6 +45,37 @@ export function LoginForm() {
loginMutation.mutate({ email, password }); 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 ( return (
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}> <Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
<CardHeader className="text-center pb-6"> <CardHeader className="text-center pb-6">
@@ -56,6 +97,39 @@ export function LoginForm() {
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{googleClientId && (
<>
<div className="flex justify-center">
{googleLoading ? (
<Button variant="outline" className="w-full" disabled>
<Loader2 className="me-2 h-4 w-4 animate-spin" />
{t('login.google.connecting')}
</Button>
) : (
<GoogleLogin
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
text="continue_with"
shape="rectangular"
size="large"
width={380}
/>
)}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">
{t('login.orContinueWith')}
</span>
</div>
</div>
</>
)}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">{t('login.email')}</Label> <Label htmlFor="email">{t('login.email')}</Label>

View File

@@ -14,3 +14,9 @@ export interface LoginResponse {
refresh_token: string; refresh_token: string;
token_type: string; token_type: string;
} }
export interface GoogleAuthResponse {
access_token: string;
refresh_token: string;
token_type: string;
}

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { import {
Eye, Eye,
@@ -16,12 +17,16 @@ import {
Languages, Languages,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { GoogleLogin } from '@react-oauth/google';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 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 { useRegister } from './useRegister';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { GoogleAuthResponse } from '../login/types';
function validateEmail(email: string) { function validateEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
@@ -70,10 +75,17 @@ export function RegisterForm() {
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = 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 [touched, setTouched] = useState({ name: false, email: false, password: false, confirmPassword: false });
const registerMutation = useRegister(); const registerMutation = useRegister();
const { notify } = useNotification();
const { t } = useI18n(); 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 const nameError = touched.name && name.length > 0 && name.length < 2
? t('register.name.error') ? 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 ( return (
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}> <Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
<CardHeader className="text-center pb-6"> <CardHeader className="text-center pb-6">
@@ -136,6 +179,39 @@ export function RegisterForm() {
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
{googleClientId && (
<>
<div className="flex justify-center">
{googleLoading ? (
<Button variant="outline" className="w-full" disabled>
<Loader2 className="me-2 h-4 w-4 animate-spin" />
{t('login.google.connecting')}
</Button>
) : (
<GoogleLogin
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
text="signup_with"
shape="rectangular"
size="large"
width={380}
/>
)}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">
{t('login.orContinueWith')}
</span>
</div>
</div>
</>
)}
{registerMutation.isError && ( {registerMutation.isError && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4"> <div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">

View File

@@ -6,6 +6,7 @@ import { ThemeProvider } from "@/providers/ThemeProvider";
import { NotificationProvider } from "@/components/ui/notification"; import { NotificationProvider } from "@/components/ui/notification";
import { I18nProvider } from "@/lib/i18n"; import { I18nProvider } from "@/lib/i18n";
import { Agentation } from "agentation"; import { Agentation } from "agentation";
import { GoogleOAuthProvider } from "@react-oauth/google";
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -29,9 +30,11 @@ export default function RootLayout({
<ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}> <ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}>
<I18nProvider> <I18nProvider>
<QueryProvider> <QueryProvider>
<NotificationProvider> <GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""}>
{children} <NotificationProvider>
</NotificationProvider> {children}
</NotificationProvider>
</GoogleOAuthProvider>
</QueryProvider> </QueryProvider>
</I18nProvider> </I18nProvider>
{process.env.NODE_ENV === "development" && <Agentation />} {process.env.NODE_ENV === "development" && <Agentation />}

View File

@@ -212,7 +212,8 @@
}, },
"login": { "login": {
"title": "Welcome back", "title": "Welcome back",
"subtitle": "Sign in to keep translating", "welcomeBack": "Welcome back",
"signInToContinue": "Sign in to keep translating",
"email": "Email", "email": "Email",
"password": "Password", "password": "Password",
"emailPlaceholder": "you@example.com", "emailPlaceholder": "you@example.com",
@@ -221,7 +222,8 @@
"signIn": "Sign in", "signIn": "Sign in",
"errorTitle": "Sign-in error", "errorTitle": "Sign-in error",
"noAccount": "Don't have an account?", "noAccount": "Don't have an account?",
"signUpFree": "Sign up for free" "signUpFree": "Sign up for free",
"orContinueWith": "or continue with email"
}, },
"register": { "register": {
"title": "Create an account", "title": "Create an account",

View File

@@ -212,7 +212,8 @@
}, },
"login": { "login": {
"title": "Bon retour", "title": "Bon retour",
"subtitle": "Connectez-vous pour continuer à traduire", "welcomeBack": "Bon retour",
"signInToContinue": "Connectez-vous pour continuer à traduire",
"email": "E-mail", "email": "E-mail",
"password": "Mot de passe", "password": "Mot de passe",
"emailPlaceholder": "vous@exemple.com", "emailPlaceholder": "vous@exemple.com",
@@ -221,7 +222,8 @@
"signIn": "Se connecter", "signIn": "Se connecter",
"errorTitle": "Erreur de connexion", "errorTitle": "Erreur de connexion",
"noAccount": "Pas encore de compte ?", "noAccount": "Pas encore de compte ?",
"signUpFree": "Créer un compte gratuitement" "signUpFree": "Créer un compte gratuitement",
"orContinueWith": "ou continuer avec email"
}, },
"register": { "register": {
"title": "Créer un compte", "title": "Créer un compte",