Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,23 @@ export const ErrorMessage: Record<ErrorCodeType, string> = {
[ErrorCode.INVALID_KAKAO_RESPONSE]: '카카오 응답 데이터가 올바르지 않습니다.',
} as const

/**
* 페이지 접근 권한 에러 코드
*
* @description
* 이 코드들이 발생하면 해당 페이지에 접근 권한이 없음을 의미합니다.
* interceptors.ts에서 감지하여 홈으로 리다이렉트합니다.
* 액션 레벨 에러(리더 강퇴 불가, 리뷰 접근 등)는 포함하지 않습니다.
*/
export const PAGE_ACCESS_ERROR_CODES: Set<string> = 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 에러 클래스
*
Expand Down
20 changes: 19 additions & 1 deletion src/api/interceptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -34,6 +34,10 @@ import { setupRetryInterceptor } from './retry'
*/
let isInitialized = false

/** 중복 permission-denied 이벤트 방지용 타임스탬프 */
let lastPermissionDeniedAt = 0
const PERMISSION_DENIED_DEDUP_MS = 1000

/**
* API 클라이언트 인터셉터 초기화
*
Expand Down Expand Up @@ -119,6 +123,20 @@ export const setupInterceptors = (): void => {
// 서버 에러 응답에서 code와 message 추출
const { code, message } = (error.response?.data as { code?: string; message?: string }) ?? {}

// 페이지 접근 권한 에러: React에 커스텀 이벤트로 전달
// usePermissionRedirect 훅에서 listen하여 토스트 + 홈 리다이렉트 처리
// 동시에 여러 쿼리가 실패해도 1초 내 중복 이벤트는 억제
if (code && PAGE_ACCESS_ERROR_CODES.has(code)) {
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
// 이를 통해 사용처에서 error.is(ErrorCode.XXX)로 에러 유형 판단 가능
return Promise.reject(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
AiLoadingOverlay,
type GetCollectedAnswersResponse,
Expand Down Expand Up @@ -62,8 +63,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 })
}
Expand All @@ -85,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 ?? '요약 생성에 실패했습니다.')
},
}
Expand Down
2 changes: 2 additions & 0 deletions src/pages/Topics/TopicCreatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,6 +67,7 @@ export default function TopicCreatePage() {
navigate(-1)
},
onError: (error) => {
if (PAGE_ACCESS_ERROR_CODES.has(error.code)) return
openError('주제 제안 실패', error.userMessage)
},
}
Expand Down
1 change: 1 addition & 0 deletions src/shared/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './useDebounce'
export * from './useDeferredLoading'
export * from './useInfiniteScroll'
export * from './usePermissionRedirect'
export * from './useScrollCollapse'
export * from './useScrollShadow'
30 changes: 30 additions & 0 deletions src/shared/hooks/usePermissionRedirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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) => {
if (!(e instanceof CustomEvent) || !e.detail) return
const message =
typeof e.detail.message === 'string' ? e.detail.message : '접근 권한이 없습니다.'
showErrorToast(message)
navigate(ROUTES.HOME, { replace: true })
}

window.addEventListener('permission-denied', handler)
return () => window.removeEventListener('permission-denied', handler)
}, [navigate])
}
4 changes: 4 additions & 0 deletions src/shared/layout/RootLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { Outlet } from 'react-router-dom'

import { usePermissionRedirect } from '@/shared/hooks'

/**
* RootLayout (앱 전체 래퍼)
*
* - 전역 관심사만 담당 (Provider, 에러바운더리, 토스트 등)
* - 시각적 레이아웃은 MainLayout, AuthLayout에서 담당
*/
export default function RootLayout() {
usePermissionRedirect()

return (
<div className="min-h-screen bg-white">
<Outlet />
Expand Down
Loading