diff --git a/src/app/(empty-layout)/(auth)/login/callback/page.tsx b/src/app/(empty-layout)/(auth)/login/callback/page.tsx index 5cac38b..a316c32 100644 --- a/src/app/(empty-layout)/(auth)/login/callback/page.tsx +++ b/src/app/(empty-layout)/(auth)/login/callback/page.tsx @@ -1,67 +1,53 @@ 'use client'; - -import { useAuthStore } from '@/entities/user'; -import { userService } from '@/entities/user/api/userService'; -import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import { toast } from 'react-toastify'; +import { useAuthStore } from '@/entities/User'; +import { userService } from '@/entities/User/api/userService'; export default function LoginCallbackPage() { const router = useRouter(); const { socialLoginComplete } = useAuthStore(); useEffect(() => { - const handleSocialLoginCallback = async () => { + const handleKakaoLoginCallback = async () => { const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); const state = urlParams.get('state'); - const provider = urlParams.get('provider'); - const savedState = localStorage.getItem(`oauth_state_${provider}`); + // state 검증 + const savedState = localStorage.getItem('kakao_oauth_state'); if (!state || state !== savedState) { toast.error('인증 상태가 일치하지 않습니다.'); router.push('/login'); return; } - if (!code || !provider) { + if (!code) { toast.error('유효하지 않은 인증 정보입니다.'); router.push('/login'); return; } try { - const result = await userService.socialLoginCallback({ - provider, - code, - state - }); + const result = await userService.kakaoLoginCallback(code, state); + await socialLoginComplete(result); - if (result) { - await socialLoginComplete(result); - toast.success('로그인 성공'); + // state 제거 + localStorage.removeItem('kakao_oauth_state'); - // 로컬 스토리지 상태 초기화 - localStorage.removeItem(`oauth_state_${provider}`); - - router.push('/'); - } else { - throw new Error('로그인 정보를 받아오지 못했습니다.'); - } + toast.success('카카오 로그인 성공'); + router.push('/'); } catch (error) { - console.error('소셜 로그인 에러:', error); - - const errorMessage = - error instanceof Error - ? error.message - : '소셜 로그인에 실패했습니다.'; - - toast.error(errorMessage); + console.error('카카오 로그인 에러:', error); + toast.error( + error instanceof Error ? error.message : '카카오 로그인 실패' + ); router.push('/login'); } }; - handleSocialLoginCallback(); + handleKakaoLoginCallback(); }, [router, socialLoginComplete]); return
로그인 처리 중...
; diff --git a/src/app/(empty-layout)/(auth)/login/page.tsx b/src/app/(empty-layout)/(auth)/login/page.tsx index 159728f..5d9ca65 100644 --- a/src/app/(empty-layout)/(auth)/login/page.tsx +++ b/src/app/(empty-layout)/(auth)/login/page.tsx @@ -3,36 +3,27 @@ import Image from 'next/image'; import Link from 'next/link'; import { toast } from 'react-toastify'; import useRedirect from '@/features/auth/hooks/useRedirect'; -import { SOCIAL_PROVIDERS } from '@/features/auth/model/constants'; -import { SocialLoginButton } from '@/features/auth/ui/SocialLoginButton'; import LoginContainer from '@/features/auth/ui/LoginContainer'; -import { AxiosError } from 'axios'; -import { useAuthStore, SocialProvider } from '@/entities/user'; +import { useAuthStore } from '@/entities/user'; export default function LoginPage() { - const { isAuthenticated, userId, tokens, socialLogin } = useAuthStore(); + const { isAuthenticated, userId, tokens, kakaoLogin } = useAuthStore(); useRedirect(isAuthenticated, userId, tokens); - const handleSocialLogin = async (provider: SocialProvider) => { + + const handleKakaoLogin = async () => { try { - await socialLogin(provider); + // 상태(state) 생성 및 저장 + const state = crypto.randomUUID(); + localStorage.setItem('kakao_oauth_state', state); + + // 카카오 로그인 URL로 리다이렉트 + await kakaoLogin(state); } catch (error) { - const errorMsg = error as AxiosError; - console.error(errorMsg); - toast.error('소셜 로그인 실패'); + console.error(error); + toast.error('카카오 로그인 실패'); } }; - const SocialLoginButtons = () => ( -
- {SOCIAL_PROVIDERS.map(provider => ( - - ))} -
- ); return (
@@ -48,7 +39,13 @@ export default function LoginPage() { - +
+ +
회원가입
diff --git a/src/app/(empty-layout)/(auth)/oauth/page.tsx b/src/app/(empty-layout)/(auth)/oauth/page.tsx deleted file mode 100644 index f88a9a9..0000000 --- a/src/app/(empty-layout)/(auth)/oauth/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client'; - -import { userService } from '@/entities/user/api/userService'; -import { useRouter } from 'next/navigation'; -import { useEffect } from 'react'; - -export default function OAuthCallback() { - const router = useRouter(); - - useEffect(() => { - const { code, provider } = router.query; - - const performOAuthLogin = async () => { - try { - if (provider) { - const result = await userService.handleOAuthCallback(provider); - - // 토큰 저장 - localStorage.setItem('access_token', result.access_token); - localStorage.setItem('refresh_token', result.refresh_token); - - // 로그인 후 리다이렉트 - router.push('/dashboard'); - } - } catch (error) { - // 에러 처리 - console.error(error); - router.push('/login'); - } - }; - - if (code && provider) { - performOAuthLogin(); - } - }, [router.query]); - - return
로그인 처리 중...
; -} diff --git a/src/entities/User/api/userService.ts b/src/entities/User/api/userService.ts index e349dfa..012d13d 100644 --- a/src/entities/User/api/userService.ts +++ b/src/entities/User/api/userService.ts @@ -1,5 +1,5 @@ import { api } from '@/shared/api/client'; -import axios, { AxiosError } from 'axios'; +import axios from 'axios'; import type { UserLoginDTO, UserJoinDTO, @@ -43,12 +43,35 @@ export const userService = { try { const response = await axiosInstance.post( `${process.env.NEXT_PUBLIC_API_URL}/api/oauth2/${params.provider}/callback`, - { provider: params.provider, code: params.code, state: params.state } + { + provider: params.provider, + code: params.code, + state: params.state + } ); - if (!response.data || !response.data.userId) { - throw new Error('소셜 로그인 정보를 받아올 수 없습니다.'); + return { + authentication: 'Bearer', + userId: response.data.userId, + access_token: response.data.accessToken, + refresh_token: response.data.refreshToken + }; + } catch (error) { + if (axios.isAxiosError(error)) { + const errorResponse = error.response?.data; + throw new Error( + errorResponse?.message || '소셜 로그인에 실패했습니다.' + ); } + throw error; + } + }, + kakaoLoginCallback: async (code: string, state: string) => { + try { + const response = await axiosInstance.post( + `${process.env.NEXT_PUBLIC_API_URL}/api/oauth2/kakao/callback`, + { code, state } + ); return { authentication: 'Bearer', @@ -57,11 +80,15 @@ export const userService = { refresh_token: response.data.refreshToken }; } catch (error) { - const errorMessage = error as AxiosError; - console.error(errorMessage); + if (axios.isAxiosError(error)) { + const errorResponse = error.response?.data; + throw new Error( + errorResponse?.message || '카카오 로그인에 실패했습니다.' + ); + } + throw error; } }, - join: async (joinData: UserJoinDTO) => { if (!joinData.email) { throw new Error('이메일을 입력해주세요.'); diff --git a/src/entities/User/model/store/authStore.ts b/src/entities/User/model/store/authStore.ts index 620c8bc..16d45fa 100644 --- a/src/entities/User/model/store/authStore.ts +++ b/src/entities/User/model/store/authStore.ts @@ -60,11 +60,13 @@ export const useAuthStore = create()( return false; } }, - socialLogin: async (provider: 'google' | 'kakao' | 'naver') => { + socialLogin: async (provider: SocialProvider, state?: string) => { try { - const authorizationUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/oauth2/${provider}`; - window.location.href = authorizationUrl; + const authorizationUrl = state + ? `${process.env.NEXT_PUBLIC_API_URL}/api/oauth2/${provider}?state=${encodeURIComponent(state)}` + : `${process.env.NEXT_PUBLIC_API_URL}/api/oauth2/${provider}`; + window.location.href = authorizationUrl; return true; } catch (error) { set({ @@ -75,12 +77,27 @@ export const useAuthStore = create()( return false; } }, + kakaoLogin: async (state: string) => { + try { + const authorizationUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/oauth2/kakao?state=${encodeURIComponent(state)}`; + window.location.href = authorizationUrl; + return true; + } catch (error) { + set({ + isAuthenticated: false, + isLoading: false, + error: error instanceof Error ? error.message : '카카오 로그인 실패' + }); + return false; + } + }, socialLoginComplete: async (loginResponse: { authentication: string; access_token: string; refresh_token: string; userId: string; }) => { + // 기존 로그인 완료 로직과 동일 document.cookie = `auth-storage=${JSON.stringify({ state: { userId: loginResponse.userId, diff --git a/src/entities/User/model/types/AuthState.ts b/src/entities/User/model/types/AuthState.ts index 2602c7a..0dcf8cc 100644 --- a/src/entities/User/model/types/AuthState.ts +++ b/src/entities/User/model/types/AuthState.ts @@ -19,4 +19,5 @@ export interface AuthState { refreshToken: () => Promise; deleteUser: () => Promise; socialLoginComplete: (loginResponse: LoginResponse) => Promise; + kakaoLogin: (state: string) => Promise; } diff --git a/src/features/meeting/ui/MeetingDetail/MeetingDetail.tsx b/src/features/meeting/ui/MeetingDetail/MeetingDetail.tsx index 0bf65b9..a36c306 100644 --- a/src/features/meeting/ui/MeetingDetail/MeetingDetail.tsx +++ b/src/features/meeting/ui/MeetingDetail/MeetingDetail.tsx @@ -6,9 +6,9 @@ import { toast } from 'react-toastify'; import { useMeetingBoardDetail } from '@/features/meeting/model/queries'; import { FullScreenLoading } from '@/shared/ui/components/Loading/Loading'; import ErrorFallback from '@/shared/ui/components/ErrorBoundary/ErrorFallback'; -import { useAuthStore } from '@/entities/User/model/store/authStore'; import { MeetingHeader, MeetingInfo, MeetingTabs } from '@/features/meeting'; import Link from 'next/link'; +import { useAuthStore } from '@/entities/user'; export default function MeetingDetail() { const params = useParams(); diff --git a/src/features/mypage/ui/MypageTap.tsx b/src/features/mypage/ui/MypageTap.tsx index 9e3b44e..352035d 100644 --- a/src/features/mypage/ui/MypageTap.tsx +++ b/src/features/mypage/ui/MypageTap.tsx @@ -1,7 +1,6 @@ 'use client'; import { useMemo, useState } from 'react'; import { UserInfo } from './UserInfo'; -import { UserReviews } from '@/features/review/ui/UserReviews'; type ProfileTabType = 'intro' | 'review' | 'favorites'; @@ -12,8 +11,6 @@ export const MypageTap = () => { switch (activeTab) { case 'intro': return ; - case 'review': - return ; } }, [activeTab]); @@ -29,11 +26,7 @@ export const MypageTap = () => { ? 'border-b-2 border-primary-800 text-primary-800' : 'border-b-2 border-white text-gray-500' }`}> - {tab === 'intro' - ? '소개글' - : tab === 'review' - ? '내 후기' - : '즐겨찾기'} + {tab === 'intro' ? '소개글' : ''} ))}
diff --git a/src/features/mypage/ui/UserProfile.tsx b/src/features/mypage/ui/UserProfile.tsx index e0b764d..90fb91d 100644 --- a/src/features/mypage/ui/UserProfile.tsx +++ b/src/features/mypage/ui/UserProfile.tsx @@ -2,8 +2,8 @@ import Image from 'next/image'; import { LogOut, Pencil } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useAuthStore } from '@/entities/User'; -import { useUserQuery } from '@/entities/User/model/userQueries'; +import { useAuthStore } from '@/entities/user'; +import { useUserQuery } from '@/entities/user/model/userQueries'; export const UserProfile = () => { const router = useRouter(); diff --git a/src/features/notifications/api/pushService.ts b/src/features/notifications/api/pushService.ts index 6412463..5cdc871 100644 --- a/src/features/notifications/api/pushService.ts +++ b/src/features/notifications/api/pushService.ts @@ -26,11 +26,9 @@ export enum NotificationMessageType { // 알림 응답 타입 export interface NotificationResponse { - id: number; + notificationId: number; title: string; message: string; - isRead: boolean; - createdAt: string; } class PushService { @@ -44,7 +42,8 @@ class PushService { // 알림 목록 조회 async getNotifications(): Promise { - const response = await axios.get('/api/push'); + const response = + await axios.get('/api/notifications'); return response.data; } @@ -52,7 +51,10 @@ class PushService { async createNotification( data: NotificationCreateDTO ): Promise { - const response = await axios.post('/api/push', data); + const response = await axios.post( + '/api/notifications', + data + ); return response.data; } @@ -68,7 +70,7 @@ class PushService { await axios.post('/api/notification/topic/message', data); } - // 특정 타입의 토픽 메시지 발송 (예: 모임 알림) + // 특정 타입의 토픽 메시지 발송 async sendTopicTypeMessage(data: TopicSendTypeDTO): Promise { await axios.post('/api/notification', data); } diff --git a/src/features/notifications/hooks/NotificationHandler.tsx b/src/features/notifications/hooks/NotificationHandler.tsx index 5ba5bc0..a57e292 100644 --- a/src/features/notifications/hooks/NotificationHandler.tsx +++ b/src/features/notifications/hooks/NotificationHandler.tsx @@ -5,7 +5,6 @@ import { requestNotificationPermission } from '@/shared/lib/firebase/messaging'; import { useEffect } from 'react'; -import { toast } from 'react-toastify'; import { useRouter } from 'next/navigation'; export function NotificationHandler() { @@ -16,19 +15,20 @@ export function NotificationHandler() { await requestNotificationPermission(); const unsubscribe = onMessageListener().then((payload: any) => { - // 알림 toast 표시 - const notificationToast = toast.info( - payload.notification?.title || '새로운 알림', - { - position: 'top-right', - autoClose: 5000, - onClick: () => { - // 알림 클릭 시 알림 페이지로 이동 - router.push('/notifications'); - toast.dismiss(notificationToast); + if ('Notification' in window) { + const notification = new Notification( + payload.notification?.title || '공공플러스 알림', + { + body: payload.notification?.body || '', + icon: payload.notification?.icon || '' } - } - ); + ); + + notification.onclick = () => { + router.push('/'); + notification.close(); + }; + } }); return () => { diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..d14024b --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,27 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + const pathname = request.nextUrl.pathname; + const authToken = request.cookies.get('auth-storage'); + + // 공개 경로 정의 + const publicPaths = ['/login', '/']; + + // 인증이 필요한 경로 + const protectedPaths = ['/profile']; + + if (publicPaths.includes(pathname) && authToken) { + return NextResponse.redirect(new URL('/', request.url)); + } + + if (protectedPaths.includes(pathname) && !authToken) { + return NextResponse.redirect(new URL('/login', request.url)); + } + + return NextResponse.next(); +} + +// 미들웨어가 적용될 경로 +export const config = { + matcher: ['/login', '/profile'] +}; diff --git a/src/shared/hooks/useNotifications.ts b/src/shared/hooks/useNotifications.ts index bd22d5e..b4193a1 100644 --- a/src/shared/hooks/useNotifications.ts +++ b/src/shared/hooks/useNotifications.ts @@ -26,15 +26,12 @@ export function useNotifications() { setHasNewNotifications(true); const newNotification: NotificationResponse = { - id: Date.now(), // 임시 ID + notificationId: Date.now(), // 임시 ID title: (listener as FirebaseNotification).notification?.title || '새로운 알림', message: - (listener as FirebaseNotification).notification?.body || - '알림 내용', - isRead: false, - createdAt: new Date().toISOString() + (listener as FirebaseNotification).notification?.body || '알림 내용' }; // 새 알림을 리스트 맨 앞에 추가 @@ -53,7 +50,7 @@ export function useNotifications() { const markNotificationAsRead = (id: number) => { setNotifications(prev => prev.map(notification => - notification.id === id + notification.notificationId === id ? { ...notification, isRead: true } : notification ) diff --git a/src/styles/globals.css b/src/styles/globals.css index bdb1fc8..9272e25 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -42,7 +42,7 @@ @media (prefers-color-scheme: dark) { :root { --background: #0a0a0a; - --foreground: #ededed; + /* --foreground: #ededed; */ } }