From bfdd2f7cfa3f30388082a6f51b66c8d1674a9cce Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 02:21:48 +0700 Subject: [PATCH] feat(web): add OAuth callback pages and auth flow for Google/Zalo - Add /auth/callback/google and /auth/callback/zalo pages that extract tokens from query params and persist them via the auth store - Add handleOAuthCallback method to Zustand auth store - Update middleware to allow /auth/callback/* as public routes - Show OAuth error messages on login page when redirected back Co-Authored-By: Paperclip --- apps/web/app/(auth)/login/page.tsx | 16 ++++++- apps/web/app/auth/callback/google/page.tsx | 55 ++++++++++++++++++++++ apps/web/app/auth/callback/zalo/page.tsx | 55 ++++++++++++++++++++++ apps/web/lib/auth-store.ts | 16 +++++++ apps/web/middleware.ts | 2 +- 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/auth/callback/google/page.tsx create mode 100644 apps/web/app/auth/callback/zalo/page.tsx diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 2b31e4f..b36c01e 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { Loader2 } from 'lucide-react'; @@ -17,9 +17,18 @@ import { useAuthStore } from '@/lib/auth-store'; export default function LoginPage() { const router = useRouter(); + const searchParams = useSearchParams(); const { login, isLoading, error, clearError } = useAuthStore(); const [showPassword, setShowPassword] = useState(false); + const oauthError = searchParams.get('error'); + const oauthErrorMessage = + oauthError === 'oauth_failed' + ? 'Đăng nhập bằng mạng xã hội thất bại. Vui lòng thử lại.' + : oauthError + ? decodeURIComponent(oauthError) + : null; + const { register, handleSubmit, @@ -45,6 +54,11 @@ export default function LoginPage() {
+ {oauthErrorMessage && ( +
+ {oauthErrorMessage} +
+ )} {error && (
{error} diff --git a/apps/web/app/auth/callback/google/page.tsx b/apps/web/app/auth/callback/google/page.tsx new file mode 100644 index 0000000..381beb7 --- /dev/null +++ b/apps/web/app/auth/callback/google/page.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; +import { useAuthStore } from '@/lib/auth-store'; + +export default function GoogleCallbackPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { handleOAuthCallback } = useAuthStore(); + const processed = useRef(false); + + useEffect(() => { + if (processed.current) return; + processed.current = true; + + const accessToken = searchParams.get('accessToken'); + const refreshToken = searchParams.get('refreshToken'); + const expiresIn = searchParams.get('expiresIn'); + const error = searchParams.get('error'); + + if (error) { + router.replace(`/login?error=${encodeURIComponent(error)}`); + return; + } + + if (!accessToken || !refreshToken) { + router.replace('/login?error=oauth_failed'); + return; + } + + handleOAuthCallback({ + accessToken, + refreshToken, + expiresIn: expiresIn ? Number(expiresIn) : 900, + }) + .then(() => { + const redirect = searchParams.get('redirect') || '/dashboard'; + router.replace(redirect); + }) + .catch(() => { + router.replace('/login?error=oauth_failed'); + }); + }, [searchParams, handleOAuthCallback, router]); + + return ( +
+
+ +

Đang xử lý đăng nhập Google...

+
+
+ ); +} diff --git a/apps/web/app/auth/callback/zalo/page.tsx b/apps/web/app/auth/callback/zalo/page.tsx new file mode 100644 index 0000000..4994ffe --- /dev/null +++ b/apps/web/app/auth/callback/zalo/page.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; +import { useAuthStore } from '@/lib/auth-store'; + +export default function ZaloCallbackPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { handleOAuthCallback } = useAuthStore(); + const processed = useRef(false); + + useEffect(() => { + if (processed.current) return; + processed.current = true; + + const accessToken = searchParams.get('accessToken'); + const refreshToken = searchParams.get('refreshToken'); + const expiresIn = searchParams.get('expiresIn'); + const error = searchParams.get('error'); + + if (error) { + router.replace(`/login?error=${encodeURIComponent(error)}`); + return; + } + + if (!accessToken || !refreshToken) { + router.replace('/login?error=oauth_failed'); + return; + } + + handleOAuthCallback({ + accessToken, + refreshToken, + expiresIn: expiresIn ? Number(expiresIn) : 900, + }) + .then(() => { + const redirect = searchParams.get('redirect') || '/dashboard'; + router.replace(redirect); + }) + .catch(() => { + router.replace('/login?error=oauth_failed'); + }); + }, [searchParams, handleOAuthCallback, router]); + + return ( +
+
+ +

Đang xử lý đăng nhập Zalo...

+
+
+ ); +} diff --git a/apps/web/lib/auth-store.ts b/apps/web/lib/auth-store.ts index 13364ab..5ae3ebb 100644 --- a/apps/web/lib/auth-store.ts +++ b/apps/web/lib/auth-store.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import { authApi, type TokenPair, type UserProfile, type LoginPayload, type RegisterPayload } from './auth-api'; import { ApiError } from './api-client'; +export type { TokenPair }; + const TOKEN_KEY = 'goodgo_tokens'; function persistTokens(tokens: TokenPair | null) { @@ -32,6 +34,7 @@ interface AuthState { login: (data: LoginPayload) => Promise; register: (data: RegisterPayload) => Promise; + handleOAuthCallback: (tokens: TokenPair) => Promise; logout: () => void; refreshToken: () => Promise; fetchProfile: () => Promise; @@ -73,6 +76,19 @@ export const useAuthStore = create((set, get) => ({ } }, + handleOAuthCallback: async (tokens) => { + set({ isLoading: true, error: null }); + try { + persistTokens(tokens); + set({ tokens, isLoading: false }); + await get().fetchProfile(); + } catch (e) { + const message = e instanceof ApiError ? e.message : 'Đăng nhập OAuth thất bại'; + set({ isLoading: false, error: message }); + throw e; + } + }, + logout: () => { persistTokens(null); set({ tokens: null, user: null, error: null }); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 6913f13..54afef0 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -const publicPaths = ['/login', '/register', '/search']; +const publicPaths = ['/login', '/register', '/search', '/auth/callback']; const publicExactPaths = ['/'];