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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
|
||||
<CardHeader className="text-center pb-6">
|
||||
@@ -56,6 +97,39 @@ export function LoginForm() {
|
||||
</CardHeader>
|
||||
|
||||
<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">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t('login.email')}</Label>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
|
||||
<CardHeader className="text-center pb-6">
|
||||
@@ -136,6 +179,39 @@ export function RegisterForm() {
|
||||
</CardHeader>
|
||||
|
||||
<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 && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
@@ -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({
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}>
|
||||
<I18nProvider>
|
||||
<QueryProvider>
|
||||
<NotificationProvider>
|
||||
{children}
|
||||
</NotificationProvider>
|
||||
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""}>
|
||||
<NotificationProvider>
|
||||
{children}
|
||||
</NotificationProvider>
|
||||
</GoogleOAuthProvider>
|
||||
</QueryProvider>
|
||||
</I18nProvider>
|
||||
{process.env.NODE_ENV === "development" && <Agentation />}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user