diff --git a/.claude/commands/api.md b/.claude/commands/api.md new file mode 100644 index 00000000..03521a5c --- /dev/null +++ b/.claude/commands/api.md @@ -0,0 +1,68 @@ +$ARGUMENTS 기능에 대한 API 호출 함수와 TanStack Query 훅을 생성해주세요. + +이 프로젝트의 API 패턴: + +### 1. API 함수 (src/lib/) +```tsx +// src/lib/xxxApi.ts +const API_BASE = process.env.NEXT_PUBLIC_API_URL; + +export const xxxApi = { + getList: async (params: ListParams): Promise => { + const res = await fetch(`${API_BASE}/xxx?${new URLSearchParams(params)}`); + if (!res.ok) throw new Error('Failed to fetch'); + return res.json(); + }, + + getById: async (id: string): Promise => { + const res = await fetch(`${API_BASE}/xxx/${id}`); + if (!res.ok) throw new Error('Failed to fetch'); + return res.json(); + }, + + create: async (data: CreateData): Promise => { + const res = await fetch(`${API_BASE}/xxx`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Failed to create'); + return res.json(); + }, +}; +``` + +### 2. Query 훅 (src/queries/ 또는 src/hooks/) +```tsx +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +export const useXxxList = (params: ListParams) => { + return useQuery({ + queryKey: ['xxx', 'list', params], + queryFn: () => xxxApi.getList(params), + }); +}; + +export const useXxxDetail = (id: string) => { + return useQuery({ + queryKey: ['xxx', 'detail', id], + queryFn: () => xxxApi.getById(id), + enabled: !!id, + }); +}; + +export const useCreateXxx = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: xxxApi.create, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['xxx'] }); + }, + }); +}; +``` + +사용 예시: +- `/api review` - 리뷰 API 및 쿼리 훅 생성 +- `/api whiskey` - 위스키 API 및 쿼리 훅 생성 +- `/api user` - 사용자 API 및 쿼리 훅 생성 diff --git a/.claude/commands/component.md b/.claude/commands/component.md new file mode 100644 index 00000000..8fa739f4 --- /dev/null +++ b/.claude/commands/component.md @@ -0,0 +1,29 @@ +$ARGUMENTS 이름으로 새로운 React 컴포넌트를 생성해주세요. + +이 프로젝트의 컴포넌트 규칙: +- 함수형 컴포넌트 사용 +- TypeScript로 props 타입 정의 +- Tailwind CSS로 스타일링 +- named export 사용 + +컴포넌트 구조: +```tsx +interface Props { + // props 정의 +} + +export function ComponentName({ ...props }: Props) { + return ( + // JSX + ); +} +``` + +생성 위치: +- 재사용 컴포넌트: `src/components/` +- 페이지 전용 컴포넌트: 해당 페이지의 `_components/` 폴더 + +사용 예시: +- `/component Button` - Button 컴포넌트 생성 +- `/component ReviewCard` - ReviewCard 컴포넌트 생성 +- `/component src/app/(primary)/settings/_components/ProfileForm` - 특정 경로에 생성 diff --git a/.claude/commands/fix.md b/.claude/commands/fix.md new file mode 100644 index 00000000..ec38706c --- /dev/null +++ b/.claude/commands/fix.md @@ -0,0 +1,21 @@ +$ARGUMENTS 버그를 분석하고 수정해주세요. + +(인자로 에러 메시지나 증상을 설명하면 더 정확한 분석이 가능합니다) + +다음 단계로 진행해주세요: + +1. **문제 파악**: 에러 메시지, 콘솔 로그, 재현 조건 확인 +2. **원인 분석**: 관련 코드를 찾아 근본 원인 파악 +3. **해결책 제시**: 가능한 해결 방안 설명 +4. **수정 적용**: 코드 수정 및 테스트 +5. **검증**: 수정 후 정상 동작 확인 + +수정 시 주의사항: +- 기존 기능에 영향 없도록 최소한의 변경 +- 타입 안전성 유지 +- 관련 테스트 업데이트 (필요시) + +사용 예시: +- `/fix` - 현재 에러 수정 +- `/fix TypeError: Cannot read property 'map' of undefined` - 특정 에러 수정 +- `/fix 로그인 후 리다이렉트가 안됨` - 증상 기반 수정 diff --git a/.claude/commands/hook.md b/.claude/commands/hook.md new file mode 100644 index 00000000..18e72caf --- /dev/null +++ b/.claude/commands/hook.md @@ -0,0 +1,35 @@ +$ARGUMENTS 이름으로 커스텀 훅을 생성해주세요. + +파일 위치: `src/hooks/` + +이 프로젝트의 훅 패턴: +```tsx +import { useState, useCallback, useRef } from 'react'; + +interface Options { + // 옵션 타입 정의 (필요시) +} + +export const useXxx = (options?: Options) => { + const [state, setState] = useState(initialValue); + const ref = useRef(null); + + const action = useCallback((params: ParamType) => { + // 로직 구현 + }, []); + + return { state, action }; +}; +``` + +규칙: +- `use` 접두사 사용 +- named export 사용 (`export const useXxx`) +- useCallback으로 함수 메모이제이션 +- 타입 명시적 정의 +- cleanup 로직 포함 (필요시) + +사용 예시: +- `/hook useBookmark` - 북마크 관련 훅 생성 +- `/hook useDebounce` - 디바운스 훅 생성 +- `/hook useLocalStorage` - 로컬스토리지 훅 생성 diff --git a/.claude/commands/page.md b/.claude/commands/page.md new file mode 100644 index 00000000..a483d912 --- /dev/null +++ b/.claude/commands/page.md @@ -0,0 +1,49 @@ +$ARGUMENTS 경로에 Next.js App Router 페이지를 생성해주세요. + +파일 위치: `src/app/` + +이 프로젝트의 페이지 패턴: + +### 기본 페이지 구조 +```tsx +// src/app/(primary)/xxx/page.tsx +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: '페이지 제목 | Bottle Note', + description: '페이지 설명', +}; + +export default function XxxPage() { + return ( +
+ {/* 페이지 컨텐츠 */} +
+ ); +} +``` + +### 동적 라우트 +```tsx +// src/app/(primary)/xxx/[id]/page.tsx +interface Props { + params: { id: string }; +} + +export default function XxxDetailPage({ params }: Props) { + const { id } = params; + // ... +} +``` + +### 레이아웃 그룹 +- `(primary)` - 메인 레이아웃 (헤더, 푸터 포함) +- `(custom)` - 커스텀 레이아웃 (로그인, OAuth 등) + +### 페이지 전용 컴포넌트 +- 해당 페이지 폴더 내 `_components/` 디렉토리에 생성 + +사용 예시: +- `/page settings/profile` - 프로필 설정 페이지 생성 +- `/page whiskey/[id]` - 위스키 상세 페이지 생성 +- `/page search` - 검색 페이지 생성 diff --git a/.claude/commands/refactor.md b/.claude/commands/refactor.md new file mode 100644 index 00000000..3b03a64a --- /dev/null +++ b/.claude/commands/refactor.md @@ -0,0 +1,21 @@ +$ARGUMENTS 코드를 리팩토링해주세요. + +(인자가 없으면 현재 파일을 리팩토링합니다) + +리팩토링 원칙: +1. **기능 유지**: 외부 동작은 변경하지 않음 +2. **가독성 향상**: 명확한 네이밍, 적절한 추상화 +3. **중복 제거**: DRY 원칙 적용 +4. **단순화**: 불필요한 복잡성 제거 +5. **타입 강화**: any 타입 제거, 더 정확한 타입 사용 + +체크리스트: +- [ ] 함수/컴포넌트가 단일 책임을 가지는가? +- [ ] 코드가 자기 문서화되어 있는가? +- [ ] 테스트가 깨지지 않는가? +- [ ] 성능에 부정적 영향이 없는가? + +사용 예시: +- `/refactor` - 현재 파일 리팩토링 +- `/refactor useAuth` - 특정 훅 리팩토링 +- `/refactor src/store/modalStore.ts` - 특정 파일 리팩토링 diff --git a/.claude/commands/review.md b/.claude/commands/review.md new file mode 100644 index 00000000..d1763e8f --- /dev/null +++ b/.claude/commands/review.md @@ -0,0 +1,18 @@ +$ARGUMENTS 파일/코드를 리뷰해주세요. + +(인자가 없으면 현재 작업 중인 코드를 리뷰합니다) + +다음 항목들을 확인해주세요: +1. **타입 안전성**: TypeScript 타입이 올바르게 정의되어 있는지 +2. **코드 품질**: 중복 코드, 불필요한 복잡성이 없는지 +3. **성능**: 불필요한 리렌더링, 메모리 누수 가능성 +4. **접근성**: 기본적인 a11y 준수 여부 +5. **에러 처리**: 예외 상황 처리가 적절한지 +6. **테스트 가능성**: 테스트하기 쉬운 구조인지 + +문제가 발견되면 구체적인 개선 방안을 제시해주세요. + +사용 예시: +- `/review` - 현재 작업 중인 코드 리뷰 +- `/review src/hooks/useToast.ts` - 특정 파일 리뷰 +- `/review useModalStore` - 특정 함수/클래스 리뷰 diff --git a/.claude/commands/store.md b/.claude/commands/store.md new file mode 100644 index 00000000..a5d57d5c --- /dev/null +++ b/.claude/commands/store.md @@ -0,0 +1,47 @@ +$ARGUMENTS 이름으로 Zustand 스토어를 생성해주세요. + +파일 위치: `src/store/` + +이 프로젝트의 스토어 패턴: +```tsx +import { create } from 'zustand'; + +interface XxxState { + value: string; + isOpen: boolean; +} + +interface XxxStore { + state: XxxState; + handleState: (newState: Partial) => void; + handleReset: () => void; +} + +const initialState: XxxState = { + value: '', + isOpen: false, +}; + +const useXxxStore = create((set) => ({ + state: initialState, + handleState: (newState) => + set((prev) => ({ + state: { ...prev.state, ...newState }, + })), + handleReset: () => set({ state: initialState }), +})); + +export default useXxxStore; +``` + +규칙: +- `use...Store` 네이밍 사용 +- State와 Store 인터페이스 분리 +- initialState 별도 정의 +- handleReset 함수 포함 +- default export 사용 + +사용 예시: +- `/store filter` - filterStore 생성 +- `/store cart` - cartStore 생성 (장바구니) +- `/store notification` - notificationStore 생성 diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 00000000..f43ace19 --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,25 @@ +$ARGUMENTS 에 대한 테스트 코드를 작성해주세요. + +(인자가 없으면 현재 파일에 대한 테스트를 작성합니다) + +이 프로젝트는 다음 테스트 도구를 사용합니다: +- Jest +- React Testing Library +- @testing-library/jest-dom + +테스트 파일 규칙: +- 파일명: `*.test.tsx` 또는 `*.test.ts` +- 위치: 테스트 대상 파일과 같은 디렉토리 + +다음을 포함해주세요: +1. 기본 렌더링 테스트 +2. 주요 기능 동작 테스트 +3. 엣지 케이스 테스트 +4. 에러 상태 테스트 (해당되는 경우) + +테스트 실행: `pnpm test` + +사용 예시: +- `/test` - 현재 파일 테스트 작성 +- `/test useToast` - useToast 훅 테스트 작성 +- `/test src/components/Button.tsx` - 특정 컴포넌트 테스트 작성 diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 00000000..198b6b72 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,69 @@ +# Bottle Note Frontend - Cursor Rules + +You are an expert in Next.js 14 (App Router), React 18, TypeScript, and Tailwind CSS. + +## Project Context +- 위스키 리뷰 및 평가 플랫폼 프론트엔드 +- 한국어 서비스이므로 한국어 주석/문서 가능 + +## Tech Stack +- Next.js 14 with App Router +- TypeScript strict mode +- React 18 with functional components +- TanStack Query v5 for server state +- Zustand v4 for client state +- React Hook Form + Yup for forms +- Tailwind CSS for styling +- NextAuth.js for authentication +- pnpm as package manager + +## Code Style + +### TypeScript +- Always use TypeScript with explicit types +- Define types in `src/types/` directory +- Avoid `any` type + +### React +- Use functional components only +- Use named exports for components +- Place page-specific components in `_components/` folder + +### Imports (ESLint enforced order) +1. react, next +2. External packages +3. Internal: components, hooks, types, utils, store, constants +4. Relative paths + +### Styling +- Use Tailwind CSS classes +- Use `clsx` or `tailwind-merge` for conditional classes + +### State Management +- Server state: TanStack Query (useQuery, useMutation) +- Client state: Zustand stores in `src/store/` +- Form state: React Hook Form + +## Conventions +- console.log is not allowed (use console.warn or console.error) +- Props spreading is allowed +- No defaultProps required (use default parameters) + +## File Structure +``` +src/ +├── app/ # Next.js pages +├── components/ # Reusable components +├── hooks/ # Custom hooks +├── lib/ # API clients, utilities +├── queries/ # TanStack Query hooks +├── store/ # Zustand stores +├── types/ # TypeScript types +├── utils/ # Utility functions +└── constants/ # Constants +``` + +## Testing +- Jest + React Testing Library +- Test files: `*.test.tsx` +- Run: `pnpm test` diff --git a/.eslintignore b/.eslintignore index a05bfbb7..91631d70 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,4 +4,6 @@ public/* /node_modules .eslintrc.js src/types/**/*.ts -next.config.mjs \ No newline at end of file +next.config.mjs +**/*.test.ts +**/*.test.tsx \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..38818f36 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,218 @@ +# Bottle Note Frontend + +위스키 리뷰 및 평가 플랫폼 프론트엔드 + +## 기술 스택 + +- **Framework**: Next.js 14 (App Router) +- **Language**: TypeScript (strict mode) +- **UI**: React 18, Tailwind CSS +- **상태 관리**: + - 서버 상태: TanStack Query v5 + - 클라이언트 상태: Zustand v4 +- **폼**: React Hook Form + Yup +- **인증**: NextAuth.js +- **패키지 매니저**: pnpm + +## 폴더 구조 + +``` +src/ +├── app/ # Next.js App Router 페이지 +│ ├── (custom)/ # 커스텀 레이아웃 (로그인, OAuth 등) +│ └── (primary)/ # 메인 레이아웃 (설정, 프로필 등) +├── components/ # 재사용 가능한 UI 컴포넌트 +├── hooks/ # 커스텀 React 훅 +├── lib/ # 라이브러리 설정 (API 클라이언트 등) +├── queries/ # TanStack Query 관련 +├── store/ # Zustand 스토어 +├── types/ # TypeScript 타입 정의 +├── utils/ # 유틸리티 함수 +└── constants/ # 상수 정의 +``` + +## 주요 스크립트 + +```bash +pnpm dev # 개발 서버 (dev 환경) +pnpm dev:local # 개발 서버 (local 환경) +pnpm build:dev # 빌드 (dev 환경) +pnpm build:prod # 빌드 (prod 환경) +pnpm test # Jest 테스트 실행 +pnpm lint # ESLint 검사 +pnpm lint:fix # ESLint 자동 수정 +``` + +## 코드 컨벤션 + +### Import 순서 (ESLint로 강제) +1. builtin (react, next) +2. external 패키지 +3. internal (pages, components, hooks, types, utils, store, constants) +4. 상대 경로 + +### 스타일 가이드 +- Prettier 사용 (endOfLine: auto) +- 함수형 컴포넌트 사용 +- console.warn, console.error만 허용 (console.log 금지) +- props spreading 허용 +- 한국어 주석 가능 + +## 환경변수 + +환경변수는 git submodule로 관리됩니다: +- `.env.local` - 로컬 개발 +- `.env.development` - 개발 서버 +- `.env.production` - 프로덕션 + +설정 방법: +```bash +pnpm run setenv:local # .env.local 설정 +pnpm run setenv:dev # .env.development 설정 +``` + +## 개발 가이드 + +### 새 컴포넌트 생성 시 +1. `src/components/` 또는 해당 페이지의 `_components/` 폴더에 생성 +2. TypeScript 타입 명시 +3. Tailwind CSS로 스타일링 + +### API 호출 시 +1. TanStack Query 사용 (useQuery, useMutation) +2. `src/lib/`에 API 클라이언트 정의 +3. `src/queries/`에 query/mutation 훅 정의 + +### 전역 상태 관리 시 +1. Zustand 스토어 사용 (`src/store/`) +2. 서버 상태는 TanStack Query로 관리 + +--- + +## 코드 패턴 예시 + +### 커스텀 훅 패턴 +```tsx +// src/hooks/useXxx.ts +import { useState, useCallback, useRef } from 'react'; + +interface Options { + // 옵션 타입 정의 +} + +export const useXxx = (options?: Options) => { + const [state, setState] = useState(initialValue); + const ref = useRef(null); + + const action = useCallback((params: ParamType) => { + // 로직 구현 + }, []); + + return { state, action }; +}; +``` + +### Zustand 스토어 패턴 +```tsx +// src/store/xxxStore.ts +import { create } from 'zustand'; + +interface XxxState { + value: string; + isOpen: boolean; +} + +interface XxxStore { + state: XxxState; + handleState: (newState: Partial) => void; + handleReset: () => void; +} + +const initialState: XxxState = { + value: '', + isOpen: false, +}; + +const useXxxStore = create((set) => ({ + state: initialState, + handleState: (newState) => + set((prev) => ({ + state: { ...prev.state, ...newState }, + })), + handleReset: () => set({ state: initialState }), +})); + +export default useXxxStore; +``` + +### 컴포넌트 패턴 +```tsx +// src/components/Xxx.tsx 또는 _components/Xxx.tsx +interface Props { + title: string; + onClick?: () => void; + children?: React.ReactNode; +} + +export function Xxx({ title, onClick, children }: Props) { + return ( +
+

{title}

+ {children} + {onClick && ( + + )} +
+ ); +} +``` + +### 무한 스크롤 패턴 +```tsx +// IntersectionObserver 기반 +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; + +function List() { + const { data, fetchNextPage } = useInfiniteQuery(...); + const { targetRef } = useInfiniteScroll({ fetchNextPage }); + + return ( +
+ {data?.pages.map((page) => + page.items.map((item) => ) + )} +
{/* 스크롤 감지 타겟 */} +
+ ); +} +``` + +### 모달 사용 패턴 +```tsx +import useModalStore from '@/store/modalStore'; + +function Component() { + const { handleModalState } = useModalStore(); + + const showAlert = () => { + handleModalState({ + isShowModal: true, + type: 'ALERT', + mainText: '알림 메시지', + subText: '상세 설명', + }); + }; + + const showConfirm = () => { + handleModalState({ + isShowModal: true, + type: 'CONFIRM', + mainText: '확인하시겠습니까?', + handleConfirm: () => { /* 확인 시 동작 */ }, + handleCancel: () => { /* 취소 시 동작 */ }, + }); + }; +} +``` diff --git a/README.md b/README.md index f6dc97f5..5a44d015 100644 --- a/README.md +++ b/README.md @@ -60,3 +60,81 @@ 더욱 즐겁고 유익한 시음 경험을 할 수 있도록 도와줍니다. ![boton](https://github.com/user-attachments/assets/d8750770-1e6a-4133-86a4-298f795420a6) + +--- + +## 🤖 AI 개발 도구 가이드 + +이 프로젝트는 AI 코딩 도구(Claude Code, Cursor)를 위한 설정이 포함되어 있습니다. + +### 지원 도구 + +| 도구 | 설정 파일 | 설명 | +|------|----------|------| +| Claude Code | `CLAUDE.md`, `.claude/commands/` | 프로젝트 컨텍스트 및 슬래시 커맨드 | +| Cursor | `.cursorrules` | 코딩 규칙 및 스타일 가이드 | + +### Claude Code 슬래시 커맨드 + +프로젝트에서 Claude Code 실행 후 아래 커맨드를 사용할 수 있습니다: + +| 커맨드 | 설명 | 사용 예시 | +|--------|------|----------| +| `/review` | 코드 리뷰 | `/review src/hooks/useToast.ts` | +| `/test` | 테스트 코드 작성 | `/test useModalStore` | +| `/fix` | 버그 분석 및 수정 | `/fix TypeError: Cannot read...` | +| `/refactor` | 코드 리팩토링 | `/refactor useAuth` | +| `/component` | 컴포넌트 생성 | `/component ReviewCard` | +| `/hook` | 커스텀 훅 생성 | `/hook useBookmark` | +| `/store` | Zustand 스토어 생성 | `/store cart` | +| `/api` | API + Query 훅 생성 | `/api review` | +| `/page` | Next.js 페이지 생성 | `/page whiskey/[id]` | + +### 사용 방법 + +**Claude Code** +```bash +# 프로젝트 폴더에서 Claude Code 실행 +claude + +# 슬래시 커맨드 사용 +> /hook useWishlist +> /review +``` + +**Cursor** +- 프로젝트를 열면 `.cursorrules`가 자동으로 적용됩니다 +- AI에게 코드 작성 요청 시 프로젝트 패턴에 맞는 코드가 생성됩니다 + +### 파일 구조 + +``` +📁 프로젝트 루트 +├── CLAUDE.md # 프로젝트 컨텍스트 (기술 스택, 폴더 구조, 코드 패턴) +├── .cursorrules # Cursor IDE 규칙 +└── .claude/commands/ # 슬래시 커맨드 정의 + ├── review.md + ├── test.md + ├── fix.md + ├── refactor.md + ├── component.md + ├── hook.md + ├── store.md + ├── api.md + └── page.md +``` + +### 커스텀 커맨드 추가하기 + +`.claude/commands/` 폴더에 마크다운 파일을 추가하면 새 커맨드가 생성됩니다: + +```markdown + +$ARGUMENTS 에 대해 작업해주세요. + +작업 지시사항... +``` + +→ `/my-command 인자` 형태로 사용 가능 + +`$ARGUMENTS`는 커맨드 뒤에 입력한 텍스트로 치환됩니다. diff --git a/src/app/(custom)/login/page.tsx b/src/app/(custom)/login/page.tsx index 906aee4d..f31777b5 100644 --- a/src/app/(custom)/login/page.tsx +++ b/src/app/(custom)/login/page.tsx @@ -9,11 +9,18 @@ import { DeviceService } from '@/lib/DeviceService'; import { useLogin } from '@/hooks/useLogin'; import { ROUTES } from '@/constants/routes'; import { useAuth } from '@/hooks/auth/useAuth'; +import useStatefulSearchParams from '@/hooks/useStatefulSearchParams'; +import { + setReturnToUrl, + getReturnToUrl, + isValidReturnUrl, +} from '@/utils/loginRedirect'; import SocialLoginBtn from './_components/SocialLoginBtn'; import LogoWhite from 'public/bottle_note_logo_white.svg'; export default function Login() { const router = useRouter(); + const [returnToParam] = useStatefulSearchParams('returnTo'); const { handleSendDeviceInfo, handleInitKakaoSdkLogin, @@ -22,13 +29,19 @@ export default function Login() { } = useLogin(); const { isLoggedIn } = useAuth(); + useEffect(() => { + if (returnToParam && isValidReturnUrl(returnToParam)) { + setReturnToUrl(returnToParam); + } + }, [returnToParam]); + useEffect(() => { handleSendDeviceInfo(); }, []); useEffect(() => { if (isLoggedIn) { - router.replace('/'); + router.replace(getReturnToUrl()); } }, [isLoggedIn]); diff --git a/src/app/(custom)/oauth/kakao/page.tsx b/src/app/(custom)/oauth/kakao/page.tsx index 03f364aa..f44f39de 100644 --- a/src/app/(custom)/oauth/kakao/page.tsx +++ b/src/app/(custom)/oauth/kakao/page.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import Loading from '@/components/ui/Loading/Loading'; import { ROUTES } from '@/constants/routes'; import { useAuth } from '@/hooks/auth/useAuth'; +import { getReturnToUrl } from '@/utils/loginRedirect'; export default function OauthKakaoCallbackPage() { const router = useRouter(); @@ -14,11 +15,13 @@ export default function OauthKakaoCallbackPage() { const loginHandler = async (code: string | string[]) => { try { + const returnTo = getReturnToUrl(); + await login( 'kakao-login', { authorizationCode: code, - callbackUrl: '/', + callbackUrl: returnTo, }, true, ); diff --git a/src/app/(primary)/user/[id]/edit/page.tsx b/src/app/(primary)/user/[id]/edit/page.tsx index 66e1a9a0..2d919b99 100644 --- a/src/app/(primary)/user/[id]/edit/page.tsx +++ b/src/app/(primary)/user/[id]/edit/page.tsx @@ -13,6 +13,7 @@ import { uploadImages } from '@/utils/S3Upload'; import { useWebviewCamera } from '@/hooks/useWebviewCamera'; import { useWebViewInit } from '@/hooks/useWebViewInit'; import { ROUTES } from '@/constants/routes'; +import { DeviceService } from '@/lib/DeviceService'; import EditForm from './_components/EditForm'; import ChangeProfile from 'public/change-profile.svg'; @@ -47,11 +48,18 @@ export default function UserEditPage({ handleImg: handleUploadImg, }); - const SELECT_OPTIONS = [ - { type: 'camera', name: '카메라' }, - { type: 'album', name: '앨범에서 선택' }, - { type: 'delete', name: '현재 이미지 삭제하기' }, - ]; + // Android에서는 카메라 옵션 제외 + const SELECT_OPTIONS = + DeviceService.platform === 'android' + ? [ + { type: 'album', name: '앨범에서 선택' }, + { type: 'delete', name: '현재 이미지 삭제하기' }, + ] + : [ + { type: 'camera', name: '카메라' }, + { type: 'album', name: '앨범에서 선택' }, + { type: 'delete', name: '현재 이미지 삭제하기' }, + ]; const handleOptionSelect = async ({ type }: { type: string }) => { if (type === 'camera') { diff --git a/src/components/domain/auth/LoginModal.tsx b/src/components/domain/auth/LoginModal.tsx index 219c0278..90e575f7 100644 --- a/src/components/domain/auth/LoginModal.tsx +++ b/src/components/domain/auth/LoginModal.tsx @@ -2,10 +2,11 @@ import React from 'react'; import Image from 'next/image'; -import { useRouter } from 'next/navigation'; +import { useRouter, usePathname } from 'next/navigation'; import Button from '@/components/ui/Button/Button'; import { ROUTES } from '@/constants/routes'; import BackDrop from '@/components/ui/Modal/BackDrop'; +import { setReturnToUrl } from '@/utils/loginRedirect'; interface Props { handleClose: () => void; @@ -13,6 +14,14 @@ interface Props { function LoginModal({ handleClose }: Props) { const router = useRouter(); + const pathname = usePathname(); + + const handleLoginClick = () => { + handleClose(); + setReturnToUrl(pathname); + router.push(ROUTES.LOGIN); + }; + return (
@@ -31,13 +40,7 @@ function LoginModal({ handleClose }: Props) {

로그인이 필요한 서비스입니다.

로그인 하시겠습니까?

- diff --git a/src/components/ui/Form/ImageUploader.tsx b/src/components/ui/Form/ImageUploader.tsx index afad56af..871220ce 100644 --- a/src/components/ui/Form/ImageUploader.tsx +++ b/src/components/ui/Form/ImageUploader.tsx @@ -7,11 +7,18 @@ import { useWebviewCamera } from '@/hooks/useWebviewCamera'; import { useImageUploader } from '@/hooks/useImageUploader'; import OptionDropdown from '@/components/ui/Modal/OptionDropdown'; import useModalStore from '@/store/modalStore'; - -const SELECT_OPTIONS = [ - { type: 'camera', name: '카메라' }, - { type: 'album', name: '앨범에서 선택' }, -]; +import { DeviceService } from '@/lib/DeviceService'; + +const getSelectOptions = () => { + // Android에서는 카메라 옵션 제외 + if (DeviceService.platform === 'android') { + return [{ type: 'album', name: '앨범에서 선택' }]; + } + return [ + { type: 'camera', name: '카메라' }, + { type: 'album', name: '앨범에서 선택' }, + ]; +}; interface ImageUploaderProps { onForceOpen?: (value: boolean) => void; @@ -147,7 +154,7 @@ export default function ImageUploader({ {isOptionShow && ( setIsOptionShow(false)} /> diff --git a/src/components/ui/Navigation/Navbar.tsx b/src/components/ui/Navigation/Navbar.tsx index f94b0b91..7d84e995 100644 --- a/src/components/ui/Navigation/Navbar.tsx +++ b/src/components/ui/Navigation/Navbar.tsx @@ -87,10 +87,7 @@ function Navbar({ maxWidth }: { maxWidth?: string }) { })); if (menu.requiresAuth && !isLoggedIn) { - if (menu.link === ROUTES.HISTORY.BASE) { - return handleLoginModal(); - } - return router.push(ROUTES.LOGIN); + return handleLoginModal(); } router.push(menu.link); diff --git a/src/hooks/useAppSocialLogin.ts b/src/hooks/useAppSocialLogin.ts index 7d4b852f..c2aad963 100644 --- a/src/hooks/useAppSocialLogin.ts +++ b/src/hooks/useAppSocialLogin.ts @@ -1,6 +1,7 @@ import { useRouter } from 'next/navigation'; import { sendLogToFlutter } from '@/utils/flutterUtil'; import useModalStore from '@/store/modalStore'; +import { getReturnToUrl } from '@/utils/loginRedirect'; import { useAuth } from './auth/useAuth'; export const useAppSocialLogin = () => { @@ -27,7 +28,7 @@ export const useAppSocialLogin = () => { accessToken: payload, }); } - router.replace('/'); + router.replace(getReturnToUrl()); } catch (e) { onKakaoLoginError((e as Error).message); } @@ -57,7 +58,7 @@ export const useAppSocialLogin = () => { false, ); - router.replace('/'); + router.replace(getReturnToUrl()); } catch (e) { sendLogToFlutter(`onAppleLoginError:${(e as Error).message}`); onAppleLoginError((e as Error).message); diff --git a/src/hooks/useLogin.ts b/src/hooks/useLogin.ts index dfbd885b..30c071b4 100644 --- a/src/hooks/useLogin.ts +++ b/src/hooks/useLogin.ts @@ -7,6 +7,7 @@ import { AuthApi } from '@/app/api/AuthApi'; import { ApiError } from '@/utils/ApiError'; import { UserApi } from '@/app/api/UserApi'; import { handleWebViewMessage } from '@/utils/flutterUtil'; +import { getReturnToUrl } from '@/utils/loginRedirect'; import { useAuth } from './auth/useAuth'; export type LoginFormValues = { @@ -46,12 +47,12 @@ export const useLogin = () => { window.sendLogToFlutter( `${result.data.message} / ${result.data.deviceToken} / ${result.data.platform}`, ); - router.replace('/'); + router.replace(getReturnToUrl()); return; } if (!isInApp && isLoggedIn) { - router.replace('/'); + router.replace(getReturnToUrl()); } } catch (e) { window.sendLogToFlutter((e as Error).message); diff --git a/src/utils/authOptions.ts b/src/utils/authOptions.ts index 1fc96e7b..9a4cab13 100644 --- a/src/utils/authOptions.ts +++ b/src/utils/authOptions.ts @@ -83,7 +83,16 @@ export const authOptions: NextAuthOptions = { return newSession; }, - async redirect({ baseUrl }) { + async redirect({ url, baseUrl }) { + // callbackUrl이 상대 경로인 경우 baseUrl과 결합 + if (url.startsWith('/')) { + return `${baseUrl}${url}`; + } + // callbackUrl이 동일 호스트의 절대 경로인 경우 그대로 사용 + if (url.startsWith(baseUrl)) { + return url; + } + // 그 외의 경우 baseUrl로 리다이렉트 return baseUrl; }, }, diff --git a/src/utils/loginRedirect.test.ts b/src/utils/loginRedirect.test.ts new file mode 100644 index 00000000..491804c9 --- /dev/null +++ b/src/utils/loginRedirect.test.ts @@ -0,0 +1,182 @@ +import { + isValidReturnUrl, + getReturnToUrl, + setReturnToUrl, + LOGIN_RETURN_TO_KEY, +} from './loginRedirect'; + +describe('loginRedirect 유틸리티', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + describe('isValidReturnUrl', () => { + describe('허용되는 URL', () => { + it('빈 문자열은 허용한다', () => { + expect(isValidReturnUrl('')).toBe(true); + }); + + it('루트 경로는 허용한다', () => { + expect(isValidReturnUrl('/')).toBe(true); + }); + + it('상대 경로는 허용한다', () => { + expect(isValidReturnUrl('/search')).toBe(true); + expect(isValidReturnUrl('/search/whisky/123')).toBe(true); + expect(isValidReturnUrl('/my-page')).toBe(true); + }); + + it('쿼리 파라미터가 포함된 상대 경로는 허용한다', () => { + expect(isValidReturnUrl('/search?category=whisky')).toBe(true); + expect(isValidReturnUrl('/explore?tab=review&sort=recent')).toBe(true); + }); + }); + + describe('차단되는 URL', () => { + it('프로토콜 상대 URL은 차단한다 (Open Redirect 방지)', () => { + expect(isValidReturnUrl('//evil.com')).toBe(false); + expect(isValidReturnUrl('//evil.com/path')).toBe(false); + }); + + it('절대 URL은 차단한다', () => { + expect(isValidReturnUrl('https://evil.com')).toBe(false); + expect(isValidReturnUrl('http://evil.com')).toBe(false); + expect(isValidReturnUrl('https://evil.com/login')).toBe(false); + }); + + it('javascript: 프로토콜은 차단한다', () => { + expect(isValidReturnUrl('javascript:alert(1)')).toBe(false); + }); + + it('data: 프로토콜은 차단한다', () => { + expect(isValidReturnUrl('data:text/html,')).toBe(false); + }); + + it('vbscript: 프로토콜은 차단한다', () => { + expect(isValidReturnUrl('vbscript:msgbox(1)')).toBe(false); + }); + + it('blob: 프로토콜은 차단한다', () => { + expect(isValidReturnUrl('blob:https://evil.com/uuid')).toBe(false); + }); + + // Oralyzer 기반 우회 케이스 + it('백슬래시 우회를 차단한다', () => { + expect(isValidReturnUrl('/\\evil.com')).toBe(false); + expect(isValidReturnUrl('/\\/evil.com')).toBe(false); + expect(isValidReturnUrl('/\\/\\evil.com')).toBe(false); + }); + + it('URL 인코딩된 슬래시 우회를 차단한다', () => { + expect(isValidReturnUrl('/%2fevil.com')).toBe(false); + expect(isValidReturnUrl('/%2Fevil.com')).toBe(false); + expect(isValidReturnUrl('/%2f%2fevil.com')).toBe(false); + }); + + it('탭/개행 문자 삽입 우회를 차단한다', () => { + expect(isValidReturnUrl('/\t/evil.com')).toBe(false); + expect(isValidReturnUrl('/\n/evil.com')).toBe(false); + expect(isValidReturnUrl('/\r/evil.com')).toBe(false); + expect(isValidReturnUrl('/ /evil.com')).toBe(false); + }); + + it('대소문자 혼합 프로토콜을 차단한다', () => { + expect(isValidReturnUrl('JAVASCRIPT:alert(1)')).toBe(false); + expect(isValidReturnUrl('JavaScript:alert(1)')).toBe(false); + expect(isValidReturnUrl('DATA:text/html')).toBe(false); + }); + + it('다중 슬래시 우회를 차단한다', () => { + expect(isValidReturnUrl('///evil.com')).toBe(false); + expect(isValidReturnUrl('////evil.com')).toBe(false); + }); + + it('로그인 관련 경로는 차단한다 (무한 루프 방지)', () => { + expect(isValidReturnUrl('/login')).toBe(false); + expect(isValidReturnUrl('/login?returnTo=/home')).toBe(false); + expect(isValidReturnUrl('/oauth')).toBe(false); + expect(isValidReturnUrl('/oauth/kakao')).toBe(false); + expect(isValidReturnUrl('/oauth/apple')).toBe(false); + }); + }); + }); + + describe('setReturnToUrl', () => { + it('유효한 URL을 sessionStorage에 저장한다', () => { + setReturnToUrl('/search/whisky/123'); + + expect(sessionStorage.getItem(LOGIN_RETURN_TO_KEY)).toBe('/search/whisky/123'); + }); + + it('유효하지 않은 URL은 저장하지 않는다', () => { + setReturnToUrl('https://evil.com'); + + expect(sessionStorage.getItem(LOGIN_RETURN_TO_KEY)).toBeNull(); + }); + + it('로그인 경로는 저장하지 않는다', () => { + setReturnToUrl('/login'); + + expect(sessionStorage.getItem(LOGIN_RETURN_TO_KEY)).toBeNull(); + }); + + it('이미 같은 값이 저장되어 있으면 다시 저장하지 않는다', () => { + const spy = jest.spyOn(Storage.prototype, 'setItem'); + + setReturnToUrl('/search'); + setReturnToUrl('/search'); + + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); + }); + + describe('getReturnToUrl', () => { + it('저장된 URL을 반환하고 sessionStorage에서 제거한다', () => { + sessionStorage.setItem(LOGIN_RETURN_TO_KEY, '/search/whisky/123'); + + const result = getReturnToUrl(); + + expect(result).toBe('/search/whisky/123'); + expect(sessionStorage.getItem(LOGIN_RETURN_TO_KEY)).toBeNull(); + }); + + it('저장된 URL이 없으면 루트를 반환한다', () => { + const result = getReturnToUrl(); + + expect(result).toBe('/'); + }); + + it('저장된 URL이 유효하지 않으면 루트를 반환한다', () => { + // 직접 sessionStorage에 악성 URL 주입 시도 + sessionStorage.setItem(LOGIN_RETURN_TO_KEY, 'https://evil.com'); + + const result = getReturnToUrl(); + + expect(result).toBe('/'); + }); + + it('저장된 URL이 로그인 경로면 루트를 반환한다', () => { + sessionStorage.setItem(LOGIN_RETURN_TO_KEY, '/login'); + + const result = getReturnToUrl(); + + expect(result).toBe('/'); + }); + }); + + describe('통합 시나리오', () => { + it('저장 후 가져오기 플로우가 정상 동작한다', () => { + // 1. 사용자가 /search/whisky/123 페이지에서 로그인 모달 클릭 + setReturnToUrl('/search/whisky/123'); + + // 2. 로그인 완료 후 원래 페이지로 리다이렉트 + const returnUrl = getReturnToUrl(); + expect(returnUrl).toBe('/search/whisky/123'); + + // 3. 한 번 사용 후 다시 호출하면 루트 반환 (일회용) + const secondCall = getReturnToUrl(); + expect(secondCall).toBe('/'); + }); + }); +}); diff --git a/src/utils/loginRedirect.ts b/src/utils/loginRedirect.ts new file mode 100644 index 00000000..58bf8efc --- /dev/null +++ b/src/utils/loginRedirect.ts @@ -0,0 +1,58 @@ +/** + * 로그인 후 리다이렉트를 위한 유틸리티 + */ + +export const LOGIN_RETURN_TO_KEY = 'login_return_to'; + +// 리다이렉트 제외 경로 (무한 루프 방지) +const BLOCKED_PATHS = ['/login', '/oauth']; + +/** + * URL이 안전한지 검증 (Open Redirect 방지) + * - 상대 경로만 허용 (/ 로 시작하고 // 로 시작하지 않는 경우) + * - 로그인 관련 경로 제외 + * - 백슬래시, URL 인코딩, 공백 문자 우회 차단 + */ +export const isValidReturnUrl = (url: string): boolean => { + if (!url || url === '/') return true; + + // 기본 검증: /로 시작하고 //로 시작하지 않아야 함 + if (!url.startsWith('/') || url.startsWith('//')) return false; + + // 백슬래시 우회 차단 (일부 브라우저에서 \를 /로 해석) + if (url.includes('\\')) return false; + + // URL 인코딩된 슬래시 우회 차단 (%2f, %2F) + if (/%2f/i.test(url)) return false; + + // 공백 문자 우회 차단 (탭, 개행, 공백 등) + if (/[\t\n\r ]/.test(url.slice(1, 3))) return false; + + // 로그인 관련 경로 차단 + if (BLOCKED_PATHS.some((path) => url.startsWith(path))) return false; + + return true; +}; + +/** + * returnTo URL을 안전하게 가져오고 sessionStorage에서 제거 + */ +export const getReturnToUrl = (): string => { + if (typeof window === 'undefined') return '/'; + + const returnTo = sessionStorage.getItem(LOGIN_RETURN_TO_KEY); + sessionStorage.removeItem(LOGIN_RETURN_TO_KEY); + + return returnTo && isValidReturnUrl(returnTo) ? returnTo : '/'; +}; + +/** + * returnTo URL을 sessionStorage에 저장 + */ +export const setReturnToUrl = (url: string): void => { + if (typeof window === 'undefined') return; + if (!isValidReturnUrl(url)) return; + if (sessionStorage.getItem(LOGIN_RETURN_TO_KEY) === url) return; + + sessionStorage.setItem(LOGIN_RETURN_TO_KEY, url); +};