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(/(?