From 15f8d4c7114c1ff82490c7cb96a1a0202cc5ffc9 Mon Sep 17 00:00:00 2001 From: mgYang53 Date: Thu, 5 Mar 2026 21:45:32 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EA=B6=8C=ED=95=9C=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B5=AC=ED=98=84=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인터셉터에서 권한 에러 감지 시 커스텀 DOM 이벤트를 dispatch하고, RootLayout의 usePermissionRedirect 훅에서 토스트와 홈 리다이렉트를 처리하여 한 곳에서 통합 관리. - errors.ts: PAGE_ACCESS_ERROR_CODES Set 추가 (6개 에러 코드) - interceptors.ts: permission-denied 커스텀 이벤트 dispatch - usePermissionRedirect: 이벤트 → 토스트 + navigate(HOME) - RootLayout: usePermissionRedirect 훅 연결 - MeetingRetrospectiveCreatePage: 이중 내비게이션 방지 --- src/api/errors.ts | 17 +++++++++++ src/api/interceptors.ts | 11 +++++++- .../MeetingRetrospectiveCreatePage.tsx | 3 ++ src/shared/hooks/index.ts | 1 + src/shared/hooks/usePermissionRedirect.ts | 28 +++++++++++++++++++ src/shared/layout/RootLayout.tsx | 4 +++ 6 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/shared/hooks/usePermissionRedirect.ts diff --git a/src/api/errors.ts b/src/api/errors.ts index dcf3062..ae0cae7 100644 --- a/src/api/errors.ts +++ b/src/api/errors.ts @@ -295,6 +295,23 @@ export const ErrorMessage: Record = { [ErrorCode.INVALID_KAKAO_RESPONSE]: '카카오 응답 데이터가 올바르지 않습니다.', } as const +/** + * 페이지 접근 권한 에러 코드 + * + * @description + * 이 코드들이 발생하면 해당 페이지에 접근 권한이 없음을 의미합니다. + * interceptors.ts에서 감지하여 홈으로 리다이렉트합니다. + * 액션 레벨 에러(리더 강퇴 불가, 리뷰 접근 등)는 포함하지 않습니다. + */ +export const PAGE_ACCESS_ERROR_CODES: Set = new Set([ + ErrorCode.NOT_GATHERING_MEMBER, + ErrorCode.NOT_GATHERING_LEADER, + ErrorCode.NOT_GATHERING_MEETING, + ErrorCode.NOT_MEETING_MEMBER, + ErrorCode.NOT_MEETING_LEADER, + ErrorCode.NO_ACCESS_RETROSPECTIVE, +]) + /** * API 에러 클래스 * diff --git a/src/api/interceptors.ts b/src/api/interceptors.ts index 63fb391..d8fbd50 100644 --- a/src/api/interceptors.ts +++ b/src/api/interceptors.ts @@ -20,7 +20,7 @@ import { ROUTES } from '@/shared/constants' import { queryClient } from '@/shared/lib/tanstack-query/queryClient' import { apiClient } from './client' -import { ApiError } from './errors' +import { ApiError, type ErrorCodeType, ErrorMessage, PAGE_ACCESS_ERROR_CODES } from './errors' import { logger } from './logger' import { setupRetryInterceptor } from './retry' @@ -119,6 +119,15 @@ export const setupInterceptors = (): void => { // 서버 에러 응답에서 code와 message 추출 const { code, message } = (error.response?.data as { code?: string; message?: string }) ?? {} + // 페이지 접근 권한 에러: React에 커스텀 이벤트로 전달 + // usePermissionRedirect 훅에서 listen하여 토스트 + 홈 리다이렉트 처리 + if (code && PAGE_ACCESS_ERROR_CODES.has(code)) { + const errorMessage = ErrorMessage[code as ErrorCodeType] ?? '접근 권한이 없습니다.' + window.dispatchEvent( + new CustomEvent('permission-denied', { detail: { message: errorMessage } }) + ) + } + // AxiosError를 ApiError로 변환하여 reject // 이를 통해 사용처에서 error.is(ErrorCode.XXX)로 에러 유형 판단 가능 return Promise.reject( diff --git a/src/pages/Retrospectives/MeetingRetrospectiveCreatePage.tsx b/src/pages/Retrospectives/MeetingRetrospectiveCreatePage.tsx index 85b68ef..0ffd2e5 100644 --- a/src/pages/Retrospectives/MeetingRetrospectiveCreatePage.tsx +++ b/src/pages/Retrospectives/MeetingRetrospectiveCreatePage.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' +import { PAGE_ACCESS_ERROR_CODES } from '@/api/errors' import type { GetCollectedAnswersResponse } from '@/features/retrospectives' import { AiLoadingOverlay, useCollectedAnswers, useCreateSttJob } from '@/features/retrospectives' import SubPageHeader from '@/shared/components/SubPageHeader' @@ -58,8 +59,10 @@ export default function MeetingRetrospectiveCreatePage() { }) // 에러 처리 및 리다이렉트 + // 권한 에러(PAGE_ACCESS_ERROR_CODES)는 usePermissionRedirect 전역 핸들러에서 처리하므로 제외 useEffect(() => { if (error && gatheringId && meetingId) { + if (PAGE_ACCESS_ERROR_CODES.has(error.code)) return showErrorToast(error.userMessage) navigate(ROUTES.MEETING_RETROSPECTIVE(gatheringId, meetingId), { replace: true }) } diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 31b3203..eb0c427 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,5 +1,6 @@ export * from './useDebounce' export * from './useDeferredLoading' export * from './useInfiniteScroll' +export * from './usePermissionRedirect' export * from './useScrollCollapse' export * from './useScrollShadow' diff --git a/src/shared/hooks/usePermissionRedirect.ts b/src/shared/hooks/usePermissionRedirect.ts new file mode 100644 index 0000000..7ee8611 --- /dev/null +++ b/src/shared/hooks/usePermissionRedirect.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' + +import { ROUTES } from '@/shared/constants' +import { showErrorToast } from '@/shared/lib/toast' + +/** + * 페이지 접근 권한 에러 전역 핸들러 + * + * @description + * interceptors.ts에서 dispatch한 'permission-denied' 커스텀 이벤트를 listen하여 + * 에러 토스트를 표시하고 홈으로 리다이렉트합니다. + * RootLayout에서 한 번만 등록하여 모든 페이지에 적용됩니다. + */ +export function usePermissionRedirect() { + const navigate = useNavigate() + + useEffect(() => { + const handler = (e: Event) => { + const { message } = (e as CustomEvent<{ message: string }>).detail + showErrorToast(message) + navigate(ROUTES.HOME, { replace: true }) + } + + window.addEventListener('permission-denied', handler) + return () => window.removeEventListener('permission-denied', handler) + }, [navigate]) +} diff --git a/src/shared/layout/RootLayout.tsx b/src/shared/layout/RootLayout.tsx index 2860afe..d904676 100644 --- a/src/shared/layout/RootLayout.tsx +++ b/src/shared/layout/RootLayout.tsx @@ -1,5 +1,7 @@ import { Outlet } from 'react-router-dom' +import { usePermissionRedirect } from '@/shared/hooks' + /** * RootLayout (앱 전체 래퍼) * @@ -7,6 +9,8 @@ import { Outlet } from 'react-router-dom' * - 시각적 레이아웃은 MainLayout, AuthLayout에서 담당 */ export default function RootLayout() { + usePermissionRedirect() + return (
From 0737b6955c5891210d66dbaa6edf994558bc48a1 Mon Sep 17 00:00:00 2001 From: mgYang53 Date: Thu, 5 Mar 2026 22:04:14 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EA=B6=8C=ED=95=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=A4=91=EB=B3=B5=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=96=B5=EC=A0=9C=20=EB=B0=8F=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/interceptors.ts | 17 +++++++++++++---- src/shared/hooks/usePermissionRedirect.ts | 4 +++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/api/interceptors.ts b/src/api/interceptors.ts index d8fbd50..523cfb8 100644 --- a/src/api/interceptors.ts +++ b/src/api/interceptors.ts @@ -34,6 +34,10 @@ import { setupRetryInterceptor } from './retry' */ let isInitialized = false +/** 중복 permission-denied 이벤트 방지용 타임스탬프 */ +let lastPermissionDeniedAt = 0 +const PERMISSION_DENIED_DEDUP_MS = 1000 + /** * API 클라이언트 인터셉터 초기화 * @@ -121,11 +125,16 @@ export const setupInterceptors = (): void => { // 페이지 접근 권한 에러: React에 커스텀 이벤트로 전달 // usePermissionRedirect 훅에서 listen하여 토스트 + 홈 리다이렉트 처리 + // 동시에 여러 쿼리가 실패해도 1초 내 중복 이벤트는 억제 if (code && PAGE_ACCESS_ERROR_CODES.has(code)) { - const errorMessage = ErrorMessage[code as ErrorCodeType] ?? '접근 권한이 없습니다.' - window.dispatchEvent( - new CustomEvent('permission-denied', { detail: { message: errorMessage } }) - ) + const now = Date.now() + if (now - lastPermissionDeniedAt >= PERMISSION_DENIED_DEDUP_MS) { + lastPermissionDeniedAt = now + const errorMessage = ErrorMessage[code as ErrorCodeType] ?? '접근 권한이 없습니다.' + window.dispatchEvent( + new CustomEvent('permission-denied', { detail: { message: errorMessage } }) + ) + } } // AxiosError를 ApiError로 변환하여 reject diff --git a/src/shared/hooks/usePermissionRedirect.ts b/src/shared/hooks/usePermissionRedirect.ts index 7ee8611..f7c9c64 100644 --- a/src/shared/hooks/usePermissionRedirect.ts +++ b/src/shared/hooks/usePermissionRedirect.ts @@ -17,7 +17,9 @@ export function usePermissionRedirect() { useEffect(() => { const handler = (e: Event) => { - const { message } = (e as CustomEvent<{ message: string }>).detail + if (!(e instanceof CustomEvent) || !e.detail) return + const message = + typeof e.detail.message === 'string' ? e.detail.message : '접근 권한이 없습니다.' showErrorToast(message) navigate(ROUTES.HOME, { replace: true }) } From 6061c0fcd95b8650d5ff5c705d9a44fff7244a73 Mon Sep 17 00:00:00 2001 From: mgYang53 Date: Sun, 8 Mar 2026 01:01:01 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EA=B6=8C=ED=95=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8/=EB=AA=A8=EB=8B=AC=20=EB=B0=A9=EC=A7=80=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Retrospectives/meeting/MeetingRetrospectiveCreatePage.tsx | 1 + src/pages/Topics/TopicCreatePage.tsx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/pages/Retrospectives/meeting/MeetingRetrospectiveCreatePage.tsx b/src/pages/Retrospectives/meeting/MeetingRetrospectiveCreatePage.tsx index 85297b5..735e68a 100644 --- a/src/pages/Retrospectives/meeting/MeetingRetrospectiveCreatePage.tsx +++ b/src/pages/Retrospectives/meeting/MeetingRetrospectiveCreatePage.tsx @@ -88,6 +88,7 @@ export default function MeetingRetrospectiveCreatePage() { }, onError: (err) => { if (err.message === 'canceled') return + if (PAGE_ACCESS_ERROR_CODES.has(err.code)) return showErrorToast(err.userMessage ?? '요약 생성에 실패했습니다.') }, } diff --git a/src/pages/Topics/TopicCreatePage.tsx b/src/pages/Topics/TopicCreatePage.tsx index 6f89911..acba41f 100644 --- a/src/pages/Topics/TopicCreatePage.tsx +++ b/src/pages/Topics/TopicCreatePage.tsx @@ -2,6 +2,7 @@ import { Info } from 'lucide-react' import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' +import { PAGE_ACCESS_ERROR_CODES } from '@/api/errors' import { TOPIC_TYPE_META, TOPIC_TYPE_OPTIONS, @@ -66,6 +67,7 @@ export default function TopicCreatePage() { navigate(-1) }, onError: (error) => { + if (PAGE_ACCESS_ERROR_CODES.has(error.code)) return openError('주제 제안 실패', error.userMessage) }, }