fix: resolve Google login hydration mismatch and dynamic env load
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m42s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m42s
This commit is contained in:
@@ -14,6 +14,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { apiClient } from '@/lib/apiClient';
|
import { apiClient } from '@/lib/apiClient';
|
||||||
import { useLogin } from './useLogin';
|
import { useLogin } from './useLogin';
|
||||||
import type { GoogleAuthResponse } from './types';
|
import type { GoogleAuthResponse } from './types';
|
||||||
|
import { useGoogleConfig } from '@/providers/ClientGoogleProvider';
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -28,7 +29,7 @@ export function LoginForm() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const redirect = searchParams.get('redirect') || '/dashboard';
|
const redirect = searchParams.get('redirect') || '/dashboard';
|
||||||
|
|
||||||
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '';
|
const { clientId: googleClientId, enabled: googleEnabled } = useGoogleConfig();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loginMutation.isError && loginMutation.error) {
|
if (loginMutation.isError && loginMutation.error) {
|
||||||
@@ -97,7 +98,7 @@ export function LoginForm() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{googleClientId && (
|
{googleEnabled && googleClientId && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
{googleLoading ? (
|
{googleLoading ? (
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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';
|
import type { GoogleAuthResponse } from '../login/types';
|
||||||
|
import { useGoogleConfig } from '@/providers/ClientGoogleProvider';
|
||||||
|
|
||||||
function validateEmail(email: string) {
|
function validateEmail(email: string) {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
@@ -85,7 +86,7 @@ export function RegisterForm() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const redirect = searchParams.get('redirect') || '/dashboard';
|
const redirect = searchParams.get('redirect') || '/dashboard';
|
||||||
|
|
||||||
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '';
|
const { clientId: googleClientId, enabled: googleEnabled } = useGoogleConfig();
|
||||||
|
|
||||||
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')
|
||||||
@@ -179,7 +180,7 @@ export function RegisterForm() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="space-y-5">
|
||||||
{googleClientId && (
|
{googleEnabled && googleClientId && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
{googleLoading ? (
|
{googleLoading ? (
|
||||||
|
|||||||
@@ -6,7 +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";
|
import { ClientGoogleProvider } from "@/providers/ClientGoogleProvider";
|
||||||
import { CookieConsent } from "@/components/ui/cookie-consent";
|
import { CookieConsent } from "@/components/ui/cookie-consent";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -37,12 +37,12 @@ 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>
|
||||||
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""}>
|
<ClientGoogleProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
{children}
|
{children}
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</GoogleOAuthProvider>
|
</ClientGoogleProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
{process.env.NODE_ENV === "development" && <Agentation />}
|
{process.env.NODE_ENV === "development" && <Agentation />}
|
||||||
|
|||||||
79
frontend/src/providers/ClientGoogleProvider.tsx
Normal file
79
frontend/src/providers/ClientGoogleProvider.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
|
import { apiClient } from '@/lib/apiClient';
|
||||||
|
|
||||||
|
interface GoogleConfig {
|
||||||
|
clientId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GoogleConfigContext = createContext<GoogleConfig>({
|
||||||
|
clientId: '',
|
||||||
|
enabled: false,
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useGoogleConfig() {
|
||||||
|
return useContext(GoogleConfigContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientGoogleProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [config, setConfig] = useState<GoogleConfig>({
|
||||||
|
clientId: '',
|
||||||
|
enabled: false,
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
apiClient
|
||||||
|
.get<{ data: { google_client_id: string; google_auth_enabled: boolean } }>('/api/v1/auth/config')
|
||||||
|
.then((res) => {
|
||||||
|
if (!active) return;
|
||||||
|
setConfig({
|
||||||
|
clientId: res.data.google_client_id,
|
||||||
|
enabled: res.data.google_auth_enabled && !!res.data.google_client_id,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to load Google client configuration:', err);
|
||||||
|
if (!active) return;
|
||||||
|
setConfig({
|
||||||
|
clientId: '',
|
||||||
|
enabled: false,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (config.loading) {
|
||||||
|
return (
|
||||||
|
<GoogleConfigContext.Provider value={config}>
|
||||||
|
{children}
|
||||||
|
</GoogleConfigContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.enabled && config.clientId) {
|
||||||
|
return (
|
||||||
|
<GoogleConfigContext.Provider value={config}>
|
||||||
|
<GoogleOAuthProvider clientId={config.clientId}>
|
||||||
|
{children}
|
||||||
|
</GoogleOAuthProvider>
|
||||||
|
</GoogleConfigContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GoogleConfigContext.Provider value={config}>
|
||||||
|
{children}
|
||||||
|
</GoogleConfigContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -620,6 +620,7 @@ async def google_auth_v1(body: GoogleAuthRequest):
|
|||||||
try:
|
try:
|
||||||
user = get_or_create_google_user(email=email, name=name, avatar_url=avatar_url)
|
user = get_or_create_google_user(email=email, name=name, avatar_url=avatar_url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.exception("Google authentication failed in get_or_create_google_user")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={"error": "USER_CREATE_FAILED", "message": str(exc)},
|
content={"error": "USER_CREATE_FAILED", "message": str(exc)},
|
||||||
@@ -641,6 +642,21 @@ async def google_auth_v1(body: GoogleAuthRequest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router_v1.get("/config")
|
||||||
|
async def get_auth_config():
|
||||||
|
"""Retrieve public configuration settings, such as Google Client ID."""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200,
|
||||||
|
content={
|
||||||
|
"data": {
|
||||||
|
"google_client_id": os.getenv("GOOGLE_CLIENT_ID", ""),
|
||||||
|
"google_auth_enabled": bool(os.getenv("GOOGLE_CLIENT_ID", "").strip()),
|
||||||
|
},
|
||||||
|
"meta": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router_v1.post("/refresh")
|
@router_v1.post("/refresh")
|
||||||
async def refresh_v1(request: Request):
|
async def refresh_v1(request: Request):
|
||||||
"""Refresh tokens (API v1) — accepte refresh_token en corps, retourne nouvel access_token et refresh_token."""
|
"""Refresh tokens (API v1) — accepte refresh_token en corps, retourne nouvel access_token et refresh_token."""
|
||||||
|
|||||||
23
tests/test_google_auth.py
Normal file
23
tests/test_google_auth.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import pytest
|
||||||
|
import services.auth_service as auth_svc
|
||||||
|
from services.auth_service import get_or_create_google_user
|
||||||
|
|
||||||
|
def test_google_user_creation(monkeypatch):
|
||||||
|
# Enable database for testing
|
||||||
|
monkeypatch.setattr(auth_svc, "USE_DATABASE", True)
|
||||||
|
monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", True)
|
||||||
|
|
||||||
|
email = "google_test_user@example.com"
|
||||||
|
name = "Google User"
|
||||||
|
avatar_url = "https://example.com/avatar.png"
|
||||||
|
|
||||||
|
# Create the user
|
||||||
|
user = get_or_create_google_user(email=email, name=name, avatar_url=avatar_url)
|
||||||
|
assert user is not None
|
||||||
|
assert user.email == email
|
||||||
|
assert user.name == name
|
||||||
|
assert user.avatar_url == avatar_url
|
||||||
|
|
||||||
|
# Retrieve again
|
||||||
|
user_again = get_or_create_google_user(email=email, name=name, avatar_url="https://new-avatar.com")
|
||||||
|
assert user_again.id == user.id
|
||||||
Reference in New Issue
Block a user