From a0395622ab4171640548e2fc27d24db053b6ad4c Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 14 Feb 2026 13:25:11 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D/=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85/=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20scaffold=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthProvider + ProtectedRoute + AdminRoute (인증 가드) - LoginPage (Google OAuth), PendingPage, DeniedPage - AppLayout (사이드바 + 메인 콘텐츠) - HomePage (퀵링크 카드), GuidePage (섹션 동적 렌더링) - BrowserRouter 라우팅 구성 - API fetch 래퍼 + 메뉴 네비게이션 유틸 - 타입 정의 (User, Role, AuthResponse, NavItem, Issue) - CLAUDE.md 상세화 (별도 세션 작업 가이드) --- CLAUDE.md | 182 ++++++++++++++++++++++++++-- src/App.tsx | 51 ++++++-- src/auth/AdminRoute.tsx | 12 ++ src/auth/AuthProvider.tsx | 70 +++++++++++ src/auth/ProtectedRoute.tsx | 28 +++++ src/auth/useAuth.ts | 6 + src/components/layout/AppLayout.tsx | 98 +++++++++++++++ src/pages/DeniedPage.tsx | 33 +++++ src/pages/GuidePage.tsx | 27 +++++ src/pages/HomePage.tsx | 36 ++++++ src/pages/LoginPage.tsx | 51 ++++++++ src/pages/PendingPage.tsx | 36 ++++++ src/types/index.ts | 52 ++++++++ src/utils/api.ts | 33 +++++ src/utils/navigation.ts | 31 +++++ 15 files changed, 721 insertions(+), 25 deletions(-) create mode 100644 src/auth/AdminRoute.tsx create mode 100644 src/auth/AuthProvider.tsx create mode 100644 src/auth/ProtectedRoute.tsx create mode 100644 src/auth/useAuth.ts create mode 100644 src/components/layout/AppLayout.tsx create mode 100644 src/pages/DeniedPage.tsx create mode 100644 src/pages/GuidePage.tsx create mode 100644 src/pages/HomePage.tsx create mode 100644 src/pages/LoginPage.tsx create mode 100644 src/pages/PendingPage.tsx create mode 100644 src/types/index.ts create mode 100644 src/utils/api.ts create mode 100644 src/utils/navigation.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4ac7c8f..4a2e005 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# gc-guide — 개발자 가이드 사이트 +# gc-guide — 개발자 가이드 사이트 (프론트엔드) ## 프로젝트 개요 GC SI 팀 개발자를 위한 온보딩 가이드 사이트. @@ -6,10 +6,11 @@ GC SI 팀 개발자를 위한 온보딩 가이드 사이트. ## 기술 스택 - React 19 + TypeScript + Vite 7 -- Tailwind CSS v4 -- React Router v7 +- Tailwind CSS v4 (@tailwindcss/vite 플러그인) +- React Router v7 (BrowserRouter) - @react-oauth/google (Google OAuth2 인증) - react-markdown + remark-gfm + rehype-highlight (마크다운 렌더링) +- highlight.js (코드 블록 구문 강조) ## 빌드 & 실행 @@ -20,19 +21,174 @@ npm run preview # 빌드 프리뷰 npm run lint # ESLint 검사 ``` -## 인증 -- Google OAuth2 (@gcsc.co.kr 도메인 제한) -- 미인증 사용자는 로그인 페이지만 표시 -- 백엔드 API (gc-guide-api)에서 Google ID Token 검증 → JWT 발급 - ## 배포 - 서버: guide.gc-si.dev (Nginx 정적 서빙) -- main 브랜치 MR 머지 시 자동 배포 (CI/CD) -- 개발 서버 API 프록시: /api/* → localhost:8080 +- main 브랜치 MR 머지 시 자동 배포 (CI/CD, Gitea Actions) +- 개발 서버 API 프록시: `/api/*` → `localhost:8080` (vite.config.ts) +- Gitea: https://gitea.gc-si.dev/gc/gc-guide ## 의존성 레포지토리 -- npm: https://nexus.gc-si.dev/repository/npm-public/ +- npm: https://nexus.gc-si.dev/repository/npm-public/ (.npmrc에 _auth 포함) ## 관련 프로젝트 -- gc-guide-api: 백엔드 API (Spring Boot 3, JDK 17, PostgreSQL) -- Gitea: https://gitea.gc-si.dev/gc/gc-guide +- gc-guide-api: 백엔드 API (Spring Boot 3.5, JDK 17, PostgreSQL) +- Gitea: https://gitea.gc-si.dev/gc/gc-guide-api + +--- + +## 현재 구현 상태 + +### 완료 (scaffold) +- 프로젝트 초기화 (Vite + React + TypeScript + Tailwind CSS v4) +- 인증 시스템 뼈대: AuthProvider, useAuth, ProtectedRoute, AdminRoute +- 페이지 뼈대: LoginPage, PendingPage, DeniedPage, HomePage, GuidePage +- 레이아웃: AppLayout (좌측 사이드바 + 메인 콘텐츠) +- 라우팅 구성 (App.tsx): `/login`, `/pending`, `/denied`, `/`, `/dev/:section`, `/admin/*` +- 유틸: api.ts (fetch 래퍼), navigation.ts (메뉴 + ant-style 패턴 매칭) +- 타입 정의: User, Role, AuthResponse, NavItem, Issue +- 빌드 검증: `tsc -b && vite build` 성공 + +### 미구현 (별도 세션에서 작업) +아래 순서대로 구현 필요: + +#### 1단계: 공통 컴포넌트 +- `src/components/common/CodeBlock.tsx` — 코드 블록 (highlight.js + 복사 버튼) +- `src/components/common/Alert.tsx` — 정보/경고/에러 알림 박스 +- `src/components/common/StepGuide.tsx` — 단계별 가이드 UI +- `src/components/common/CopyButton.tsx` — 클립보드 복사 버튼 + +#### 2단계: 가이드 콘텐츠 (7개 섹션) +`src/content/` 디렉토리에 TSX 컴포넌트로 작성: + +| 파일 | URL | 내용 | +|------|-----|------| +| DevEnvIntro.tsx | /dev/env-intro | 인프라 구성도, 서비스 카드, 도메인 테이블 | +| InitialSetup.tsx | /dev/initial-setup | SSH 키, Git 설정, SDKMAN/fnm, Claude Code 설치 | +| GiteaUsage.tsx | /dev/gitea-usage | Google OAuth 로그인, 리포 브라우징, 이슈/MR | +| NexusUsage.tsx | /dev/nexus-usage | Maven/Gradle/npm 프록시 설정, 패키지 배포 | +| GitWorkflow.tsx | /dev/git-workflow | 브랜치 전략, Conventional Commits, 3계층 정책 | +| ChatBotIntegration.tsx | /dev/chat-bot | 스페이스 생성, 봇 명령어, 알림 유형 | +| StartingProject.tsx | /dev/starting-project | 템플릿 비교, 리포 생성, /init-project | + +GuidePage.tsx를 수정하여 section 파라미터에 따라 해당 콘텐츠 컴포넌트를 동적 렌더링. + +#### 3단계: 관리자 페이지 +- `src/pages/admin/UserManagement.tsx` — 사용자 목록, 승인/거절, 롤 배정 +- `src/pages/admin/RoleManagement.tsx` — 롤 CRUD +- `src/pages/admin/PermissionManagement.tsx` — 롤별 URL 패턴 CRUD +- `src/pages/admin/StatsPage.tsx` — 통계 대시보드 + +#### 4단계: 다크모드 + 반응형 +- `src/hooks/useTheme.ts` — 다크/라이트 모드 토글 (localStorage 저장) +- Header에 토글 버튼 추가 +- 모바일 반응형: 사이드바 접힘 (hamburger 메뉴) +- `src/hooks/useScrollSpy.ts` — 우측 목차(ToC) 스크롤 추적 + +--- + +## 인증/인가 흐름 (3단계) + +``` +1단계: Google OAuth (@gcsc.co.kr 필터) + 비인증 → LoginPage → "Google로 로그인" + → Google OAuth2 팝업 → ID Token 수신 + → POST /api/auth/google → 백엔드에서 @gcsc.co.kr 도메인 검증 + → 신규: status=PENDING으로 등록, JWT 발급 + → 기존: JWT 발급 + +2단계: 관리자 승인 + PENDING → /pending 페이지 표시 ("승인 대기 중") + 관리자 → /admin/users에서 승인/거절 + 승인 → status=ACTIVE, 롤 그룹 배정 + +3단계: 롤 기반 URL 접근 제어 + ACTIVE → 사이드바에 접근 가능한 메뉴만 표시 + 라우트 가드: 사용자 롤의 urlPatterns와 현재 경로 매칭 +``` + +- Google OAuth2 Client ID: `295080817934-1uqaqrkup9jnslajkl1ngpee7gm249fv.apps.googleusercontent.com` +- 사용자 상태: PENDING → ACTIVE / REJECTED / DISABLED +- 초기 관리자: htlee@gcsc.co.kr (auto-approve, isAdmin=true) + +## 라우팅 구조 + +``` +/login → LoginPage (공개) +/pending → PendingPage (PENDING 사용자) +/denied → DeniedPage (REJECTED/DISABLED) +/ → HomePage (ACTIVE, 퀵링크 카드) +/dev/:section → GuidePage → 콘텐츠 컴포넌트 (ACTIVE, 롤 기반) +/admin/users → UserManagement (ADMIN만) +/admin/roles → RoleManagement (ADMIN만) +/admin/permissions → PermissionManagement (ADMIN만) +/admin/stats → StatsPage (ADMIN만) +``` + +## 프로젝트 구조 + +``` +src/ +├── auth/ +│ ├── AuthProvider.tsx ✅ (Google OAuth → JWT 인증 컨텍스트) +│ ├── useAuth.ts ✅ (인증 훅) +│ ├── ProtectedRoute.tsx ✅ (인증+상태 가드) +│ └── AdminRoute.tsx ✅ (관리자 가드) +├── components/ +│ ├── layout/ +│ │ └── AppLayout.tsx ✅ (사이드바 + 메인 콘텐츠) +│ └── common/ ⬜ (CodeBlock, Alert, StepGuide, CopyButton) +├── pages/ +│ ├── LoginPage.tsx ✅ +│ ├── PendingPage.tsx ✅ +│ ├── DeniedPage.tsx ✅ +│ ├── HomePage.tsx ✅ (퀵링크 카드) +│ ├── GuidePage.tsx ✅ (section 기반 동적 렌더링 뼈대) +│ └── admin/ ⬜ (UserManagement, RoleManagement 등) +├── content/ ⬜ (7개 가이드 TSX) +├── hooks/ ⬜ (useTheme, useScrollSpy) +├── types/index.ts ✅ (User, Role, AuthResponse, NavItem, Issue) +├── utils/ +│ ├── api.ts ✅ (fetch 래퍼 + 401 자동 리다이렉트) +│ └── navigation.ts ✅ (메뉴 구성 + ant-style 패턴 매칭) +├── App.tsx ✅ (BrowserRouter + Routes) +├── main.tsx ✅ +└── index.css ✅ (Tailwind CSS) +``` + +## 백엔드 API (gc-guide-api) 엔드포인트 + +프론트엔드에서 호출하는 API 목록: + +``` +POST /api/auth/google → { idToken } → { token, user } +GET /api/auth/me → User (JWT Authorization 헤더) +POST /api/auth/logout → void + +GET /api/admin/users → User[] (ADMIN만) +PUT /api/admin/users/:id/approve → User +PUT /api/admin/users/:id/reject → User +PUT /api/admin/users/:id/disable → User +PUT /api/admin/users/:id/roles → { roleIds: number[] } → User + +GET /api/admin/roles → Role[] +POST /api/admin/roles → { name, description } → Role +PUT /api/admin/roles/:id → { name, description } → Role +DELETE /api/admin/roles/:id → void + +GET /api/admin/roles/:id/permissions → { urlPatterns: string[] } +POST /api/admin/roles/:id/permissions → { urlPattern: string } +DELETE /api/admin/permissions/:id → void + +GET /api/admin/stats → { totalUsers, activeUsers, ... } + +POST /api/activity/track → { pagePath } → void +GET /api/activity/login-history → LoginHistory[] +``` + +## UI 스타일 가이드 +- Tailwind CSS v4 (index.css에 `@import "tailwindcss"`) +- 사이드바: 좌측 고정 w-64, 흰색 배경 +- 활성 메뉴: bg-blue-50, text-blue-700 +- 카드: bg-white, border, rounded-xl, hover shadow +- 반응형: 추후 모바일 대응 (사이드바 접힘) +- 코드 블록: highlight.js 테마 (atom-one-dark 권장) diff --git a/src/App.tsx b/src/App.tsx index 9ad857d..d43bddf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,43 @@ +import { BrowserRouter, Route, Routes } from 'react-router'; +import { AuthProvider } from './auth/AuthProvider'; +import { ProtectedRoute } from './auth/ProtectedRoute'; +import { AdminRoute } from './auth/AdminRoute'; +import { AppLayout } from './components/layout/AppLayout'; +import { LoginPage } from './pages/LoginPage'; +import { PendingPage } from './pages/PendingPage'; +import { DeniedPage } from './pages/DeniedPage'; +import { HomePage } from './pages/HomePage'; +import { GuidePage } from './pages/GuidePage'; + function App() { return ( -
-
-

- GC SI 개발자 가이드 -

-

- 팀 개발 환경 설정 및 워크플로우 가이드 -

-
-
- ) + + + + {/* Public */} + } /> + } /> + } /> + + {/* Protected */} + }> + }> + } /> + } /> + + {/* Admin */} + }> +

사용자 관리

준비 중

} /> +

롤 관리

준비 중

} /> +

권한 관리

준비 중

} /> +

통계

준비 중

} /> +
+
+
+
+
+
+ ); } -export default App +export default App; diff --git a/src/auth/AdminRoute.tsx b/src/auth/AdminRoute.tsx new file mode 100644 index 0000000..b1c719d --- /dev/null +++ b/src/auth/AdminRoute.tsx @@ -0,0 +1,12 @@ +import { Navigate, Outlet } from 'react-router'; +import { useAuth } from './useAuth'; + +export function AdminRoute() { + const { user } = useAuth(); + + if (!user?.isAdmin) { + return ; + } + + return ; +} diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..6faca9c --- /dev/null +++ b/src/auth/AuthProvider.tsx @@ -0,0 +1,70 @@ +import { + createContext, + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import type { AuthResponse, User } from '../types'; +import { api } from '../utils/api'; + +interface AuthContextValue { + user: User | null; + token: string | null; + loading: boolean; + login: (googleToken: string) => Promise; + logout: () => void; +} + +export const AuthContext = createContext({ + user: null, + token: null, + loading: true, + login: async () => {}, + logout: () => {}, +}); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState( + () => localStorage.getItem('token'), + ); + const [loading, setLoading] = useState(true); + + const logout = useCallback(() => { + localStorage.removeItem('token'); + setToken(null); + setUser(null); + }, []); + + const login = useCallback(async (googleToken: string) => { + const res = await api.post('/auth/google', { + idToken: googleToken, + }); + localStorage.setItem('token', res.token); + setToken(res.token); + setUser(res.user); + }, []); + + useEffect(() => { + if (!token) { + setLoading(false); + return; + } + api + .get('/auth/me') + .then(setUser) + .catch(() => { + logout(); + }) + .finally(() => setLoading(false)); + }, [token, logout]); + + const value = useMemo( + () => ({ user, token, loading, login, logout }), + [user, token, loading, login, logout], + ); + + return {children}; +} diff --git a/src/auth/ProtectedRoute.tsx b/src/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..aabe486 --- /dev/null +++ b/src/auth/ProtectedRoute.tsx @@ -0,0 +1,28 @@ +import { Navigate, Outlet } from 'react-router'; +import { useAuth } from './useAuth'; + +export function ProtectedRoute() { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!user) { + return ; + } + + if (user.status === 'PENDING') { + return ; + } + + if (user.status === 'REJECTED' || user.status === 'DISABLED') { + return ; + } + + return ; +} diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts new file mode 100644 index 0000000..68013c4 --- /dev/null +++ b/src/auth/useAuth.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { AuthContext } from './AuthProvider'; + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..0f2eddc --- /dev/null +++ b/src/components/layout/AppLayout.tsx @@ -0,0 +1,98 @@ +import { NavLink, Outlet } from 'react-router'; +import { useAuth } from '../../auth/useAuth'; +import { DEV_NAV, ADMIN_NAV } from '../../utils/navigation'; + +export function AppLayout() { + const { user, logout } = useAuth(); + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ +
+
+ ); +} diff --git a/src/pages/DeniedPage.tsx b/src/pages/DeniedPage.tsx new file mode 100644 index 0000000..e940136 --- /dev/null +++ b/src/pages/DeniedPage.tsx @@ -0,0 +1,33 @@ +import { useAuth } from '../auth/useAuth'; +import { Navigate } from 'react-router'; + +export function DeniedPage() { + const { user, logout } = useAuth(); + + if (!user) return ; + if (user.status === 'ACTIVE') return ; + + return ( +
+
+
+ + + +
+

접근이 거부되었습니다

+

+ 계정이 {user.status === 'REJECTED' ? '거절' : '비활성화'}되었습니다. +
+ 관리자에게 문의하세요. +

+ +
+
+ ); +} diff --git a/src/pages/GuidePage.tsx b/src/pages/GuidePage.tsx new file mode 100644 index 0000000..8ce6217 --- /dev/null +++ b/src/pages/GuidePage.tsx @@ -0,0 +1,27 @@ +import { useParams } from 'react-router'; + +const GUIDE_TITLES: Record = { + 'env-intro': '개발환경 소개', + 'initial-setup': '초기 환경 설정', + 'gitea-usage': 'Gitea 사용법', + 'nexus-usage': 'Nexus 사용법', + 'git-workflow': 'Git 워크플로우', + 'chat-bot': 'Chat 봇 연동', + 'starting-project': '프로젝트 시작하기', +}; + +export function GuidePage() { + const { section } = useParams<{ section: string }>(); + const title = section ? GUIDE_TITLES[section] : '가이드'; + + return ( +
+

+ {title || '가이드'} +

+
+ 이 섹션의 콘텐츠는 준비 중입니다. +
+
+ ); +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 0000000..ab59a97 --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,36 @@ +import { useAuth } from '../auth/useAuth'; + +export function HomePage() { + const { user } = useAuth(); + + return ( +
+

+ GC SI 개발자 가이드 +

+

+ 환영합니다{user ? `, ${user.name}님` : ''}! 팀 개발 환경 설정 및 + 워크플로우 가이드입니다. +

+
+ {[ + { title: '개발환경 소개', desc: '인프라 구성 및 서비스 개요', path: '/dev/env-intro' }, + { title: '초기 환경 설정', desc: 'SSH, Git, SDK 설치', path: '/dev/initial-setup' }, + { title: 'Gitea 사용법', desc: 'Git 저장소 관리', path: '/dev/gitea-usage' }, + { title: 'Nexus 사용법', desc: '패키지 프록시 설정', path: '/dev/nexus-usage' }, + { title: 'Git 워크플로우', desc: '브랜치 전략 및 코드 리뷰', path: '/dev/git-workflow' }, + { title: 'Chat 봇 연동', desc: '알림 및 봇 명령어', path: '/dev/chat-bot' }, + ].map((item) => ( + +

{item.title}

+

{item.desc}

+
+ ))} +
+
+ ); +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..77c2c1c --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,51 @@ +import { GoogleLogin, GoogleOAuthProvider } from '@react-oauth/google'; +import { Navigate } from 'react-router'; +import { useAuth } from '../auth/useAuth'; + +const GOOGLE_CLIENT_ID = + '295080817934-1uqaqrkup9jnslajkl1ngpee7gm249fv.apps.googleusercontent.com'; + +export function LoginPage() { + const { user, login, loading } = useAuth(); + + if (loading) return null; + if (user && user.status === 'ACTIVE') return ; + if (user && user.status === 'PENDING') + return ; + + return ( + +
+
+
+
+ GC +
+

+ GC SI 개발자 가이드 +

+

+ @gcsc.co.kr 계정으로 로그인하세요 +

+
+
+ { + if (res.credential) login(res.credential); + }} + onError={() => { + console.error('Google Login failed'); + }} + theme="outline" + size="large" + width="280" + /> +
+

+ GC SI 사내 개발환경 전용 +

+
+
+
+ ); +} diff --git a/src/pages/PendingPage.tsx b/src/pages/PendingPage.tsx new file mode 100644 index 0000000..59b6f69 --- /dev/null +++ b/src/pages/PendingPage.tsx @@ -0,0 +1,36 @@ +import { useAuth } from '../auth/useAuth'; +import { Navigate } from 'react-router'; + +export function PendingPage() { + const { user, logout } = useAuth(); + + if (!user) return ; + if (user.status === 'ACTIVE') return ; + + return ( +
+
+
+ + + +
+

승인 대기 중

+

+ {user.email} +

+

+ 관리자의 승인 후 가이드에 접근할 수 있습니다. +
+ 승인이 완료되면 다시 로그인해주세요. +

+ +
+
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..07fd448 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,52 @@ +export interface User { + id: number; + email: string; + name: string; + avatarUrl: string | null; + status: UserStatus; + isAdmin: boolean; + roles: Role[]; + createdAt: string; + lastLoginAt: string | null; +} + +export type UserStatus = 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED'; + +export interface Role { + id: number; + name: string; + description: string; + urlPatterns: string[]; +} + +export interface AuthResponse { + token: string; + user: User; +} + +export interface NavItem { + path: string; + label: string; + icon?: string; + children?: NavItem[]; +} + +export interface Issue { + id: number; + title: string; + body: string; + status: 'OPEN' | 'IN_PROGRESS' | 'CLOSED'; + priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'; + author: User; + assignee: User | null; + comments: IssueComment[]; + createdAt: string; + updatedAt: string; +} + +export interface IssueComment { + id: number; + body: string; + author: User; + createdAt: string; +} diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 0000000..5e7f373 --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,33 @@ +const API_BASE = '/api'; + +async function request(path: string, options?: RequestInit): Promise { + const token = localStorage.getItem('token'); + const headers: Record = { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + + if (res.status === 401) { + localStorage.removeItem('token'); + window.location.href = '/login'; + throw new Error('Unauthorized'); + } + + if (!res.ok) { + const body = await res.text(); + throw new Error(body || `HTTP ${res.status}`); + } + + return res.json(); +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => + request(path, { method: 'POST', body: JSON.stringify(body) }), + put: (path: string, body?: unknown) => + request(path, { method: 'PUT', body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: 'DELETE' }), +}; diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts new file mode 100644 index 0000000..aec2982 --- /dev/null +++ b/src/utils/navigation.ts @@ -0,0 +1,31 @@ +import type { NavItem } from '../types'; + +export const DEV_NAV: NavItem[] = [ + { path: '/dev/env-intro', label: '개발환경 소개' }, + { path: '/dev/initial-setup', label: '초기 환경 설정' }, + { path: '/dev/gitea-usage', label: 'Gitea 사용법' }, + { path: '/dev/nexus-usage', label: 'Nexus 사용법' }, + { path: '/dev/git-workflow', label: 'Git 워크플로우' }, + { path: '/dev/chat-bot', label: 'Chat 봇 연동' }, + { path: '/dev/starting-project', label: '프로젝트 시작하기' }, +]; + +export const ADMIN_NAV: NavItem[] = [ + { path: '/admin/users', label: '사용자 관리' }, + { path: '/admin/roles', label: '롤 관리' }, + { path: '/admin/permissions', label: '권한 관리' }, + { path: '/admin/stats', label: '통계' }, +]; + +export function canAccessPath(path: string, urlPatterns: string[]): boolean { + return urlPatterns.some((pattern) => matchAntPattern(pattern, path)); +} + +function matchAntPattern(pattern: string, path: string): boolean { + if (pattern === '/**') return true; + const regex = pattern + .replace(/\*\*/g, '.*') + .replace(/(?