feat: 인증/라우팅/레이아웃 scaffold 구현
- AuthProvider + ProtectedRoute + AdminRoute (인증 가드) - LoginPage (Google OAuth), PendingPage, DeniedPage - AppLayout (사이드바 + 메인 콘텐츠) - HomePage (퀵링크 카드), GuidePage (섹션 동적 렌더링) - BrowserRouter 라우팅 구성 - API fetch 래퍼 + 메뉴 네비게이션 유틸 - 타입 정의 (User, Role, AuthResponse, NavItem, Issue) - CLAUDE.md 상세화 (별도 세션 작업 가이드)
This commit is contained in:
부모
c45f3d80a2
커밋
a0395622ab
182
CLAUDE.md
182
CLAUDE.md
@ -1,4 +1,4 @@
|
|||||||
# gc-guide — 개발자 가이드 사이트
|
# gc-guide — 개발자 가이드 사이트 (프론트엔드)
|
||||||
|
|
||||||
## 프로젝트 개요
|
## 프로젝트 개요
|
||||||
GC SI 팀 개발자를 위한 온보딩 가이드 사이트.
|
GC SI 팀 개발자를 위한 온보딩 가이드 사이트.
|
||||||
@ -6,10 +6,11 @@ GC SI 팀 개발자를 위한 온보딩 가이드 사이트.
|
|||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
- React 19 + TypeScript + Vite 7
|
- React 19 + TypeScript + Vite 7
|
||||||
- Tailwind CSS v4
|
- Tailwind CSS v4 (@tailwindcss/vite 플러그인)
|
||||||
- React Router v7
|
- React Router v7 (BrowserRouter)
|
||||||
- @react-oauth/google (Google OAuth2 인증)
|
- @react-oauth/google (Google OAuth2 인증)
|
||||||
- react-markdown + remark-gfm + rehype-highlight (마크다운 렌더링)
|
- react-markdown + remark-gfm + rehype-highlight (마크다운 렌더링)
|
||||||
|
- highlight.js (코드 블록 구문 강조)
|
||||||
|
|
||||||
## 빌드 & 실행
|
## 빌드 & 실행
|
||||||
|
|
||||||
@ -20,19 +21,174 @@ npm run preview # 빌드 프리뷰
|
|||||||
npm run lint # ESLint 검사
|
npm run lint # ESLint 검사
|
||||||
```
|
```
|
||||||
|
|
||||||
## 인증
|
|
||||||
- Google OAuth2 (@gcsc.co.kr 도메인 제한)
|
|
||||||
- 미인증 사용자는 로그인 페이지만 표시
|
|
||||||
- 백엔드 API (gc-guide-api)에서 Google ID Token 검증 → JWT 발급
|
|
||||||
|
|
||||||
## 배포
|
## 배포
|
||||||
- 서버: guide.gc-si.dev (Nginx 정적 서빙)
|
- 서버: guide.gc-si.dev (Nginx 정적 서빙)
|
||||||
- main 브랜치 MR 머지 시 자동 배포 (CI/CD)
|
- main 브랜치 MR 머지 시 자동 배포 (CI/CD, Gitea Actions)
|
||||||
- 개발 서버 API 프록시: /api/* → localhost:8080
|
- 개발 서버 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)
|
- gc-guide-api: 백엔드 API (Spring Boot 3.5, JDK 17, PostgreSQL)
|
||||||
- Gitea: https://gitea.gc-si.dev/gc/gc-guide
|
- 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 권장)
|
||||||
|
|||||||
51
src/App.tsx
51
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<AuthProvider>
|
||||||
<div className="text-center">
|
<BrowserRouter>
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
<Routes>
|
||||||
GC SI 개발자 가이드
|
{/* Public */}
|
||||||
</h1>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<p className="text-lg text-gray-600">
|
<Route path="/pending" element={<PendingPage />} />
|
||||||
팀 개발 환경 설정 및 워크플로우 가이드
|
<Route path="/denied" element={<DeniedPage />} />
|
||||||
</p>
|
|
||||||
</div>
|
{/* Protected */}
|
||||||
</div>
|
<Route element={<ProtectedRoute />}>
|
||||||
)
|
<Route element={<AppLayout />}>
|
||||||
|
<Route index element={<HomePage />} />
|
||||||
|
<Route path="/dev/:section" element={<GuidePage />} />
|
||||||
|
|
||||||
|
{/* Admin */}
|
||||||
|
<Route element={<AdminRoute />}>
|
||||||
|
<Route path="/admin/users" element={<div className="p-8"><h1 className="text-2xl font-bold">사용자 관리</h1><p className="text-gray-500 mt-2">준비 중</p></div>} />
|
||||||
|
<Route path="/admin/roles" element={<div className="p-8"><h1 className="text-2xl font-bold">롤 관리</h1><p className="text-gray-500 mt-2">준비 중</p></div>} />
|
||||||
|
<Route path="/admin/permissions" element={<div className="p-8"><h1 className="text-2xl font-bold">권한 관리</h1><p className="text-gray-500 mt-2">준비 중</p></div>} />
|
||||||
|
<Route path="/admin/stats" element={<div className="p-8"><h1 className="text-2xl font-bold">통계</h1><p className="text-gray-500 mt-2">준비 중</p></div>} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|||||||
12
src/auth/AdminRoute.tsx
Normal file
12
src/auth/AdminRoute.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Navigate, Outlet } from 'react-router';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
export function AdminRoute() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
if (!user?.isAdmin) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
70
src/auth/AuthProvider.tsx
Normal file
70
src/auth/AuthProvider.tsx
Normal file
@ -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<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextValue>({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
loading: true,
|
||||||
|
login: async () => {},
|
||||||
|
logout: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(
|
||||||
|
() => 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<AuthResponse>('/auth/google', {
|
||||||
|
idToken: googleToken,
|
||||||
|
});
|
||||||
|
localStorage.setItem('token', res.token);
|
||||||
|
setToken(res.token);
|
||||||
|
setUser(res.user);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.get<User>('/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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
28
src/auth/ProtectedRoute.tsx
Normal file
28
src/auth/ProtectedRoute.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Navigate, Outlet } from 'react-router';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
export function ProtectedRoute() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status === 'PENDING') {
|
||||||
|
return <Navigate to="/pending" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status === 'REJECTED' || user.status === 'DISABLED') {
|
||||||
|
return <Navigate to="/denied" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
6
src/auth/useAuth.ts
Normal file
6
src/auth/useAuth.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { AuthContext } from './AuthProvider';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
98
src/components/layout/AppLayout.tsx
Normal file
98
src/components/layout/AppLayout.tsx
Normal file
@ -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 (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 bg-white border-r border-gray-200 flex flex-col">
|
||||||
|
<div className="p-5 border-b border-gray-100">
|
||||||
|
<a href="/" className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-white text-sm font-bold">GC</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-gray-900">개발자 가이드</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-4 overflow-y-auto">
|
||||||
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
가이드
|
||||||
|
</p>
|
||||||
|
{DEV_NAV.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block px-3 py-2 rounded-lg text-sm mb-0.5 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-50 text-blue-700 font-medium'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mt-6 mb-2">
|
||||||
|
관리
|
||||||
|
</p>
|
||||||
|
{ADMIN_NAV.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block px-3 py-2 rounded-lg text-sm mb-0.5 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-50 text-blue-700 font-medium'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<div className="p-4 border-t border-gray-100">
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-8 h-8 rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center text-xs font-medium text-gray-600">
|
||||||
|
{user.name[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{user.name}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-xs text-gray-400 hover:text-red-500 cursor-pointer"
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/pages/DeniedPage.tsx
Normal file
33
src/pages/DeniedPage.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useAuth } from '../auth/useAuth';
|
||||||
|
import { Navigate } from 'react-router';
|
||||||
|
|
||||||
|
export function DeniedPage() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
if (user.status === 'ACTIVE') return <Navigate to="/" replace />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg p-10 max-w-md w-full text-center">
|
||||||
|
<div className="w-16 h-16 bg-red-100 rounded-full mx-auto mb-4 flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 mb-2">접근이 거부되었습니다</h1>
|
||||||
|
<p className="text-gray-500 text-sm mb-6">
|
||||||
|
계정이 {user.status === 'REJECTED' ? '거절' : '비활성화'}되었습니다.
|
||||||
|
<br />
|
||||||
|
관리자에게 문의하세요.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
다른 계정으로 로그인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/pages/GuidePage.tsx
Normal file
27
src/pages/GuidePage.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
|
const GUIDE_TITLES: Record<string, string> = {
|
||||||
|
'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 (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
|
{title || '가이드'}
|
||||||
|
</h1>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-blue-800 text-sm">
|
||||||
|
이 섹션의 콘텐츠는 준비 중입니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/pages/HomePage.tsx
Normal file
36
src/pages/HomePage.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { useAuth } from '../auth/useAuth';
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-12 px-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
|
GC SI 개발자 가이드
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
환영합니다{user ? `, ${user.name}님` : ''}! 팀 개발 환경 설정 및
|
||||||
|
워크플로우 가이드입니다.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<a
|
||||||
|
key={item.path}
|
||||||
|
href={item.path}
|
||||||
|
className="block p-5 bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-md transition"
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-gray-900">{item.title}</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{item.desc}</p>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/pages/LoginPage.tsx
Normal file
51
src/pages/LoginPage.tsx
Normal file
@ -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 <Navigate to="/" replace />;
|
||||||
|
if (user && user.status === 'PENDING')
|
||||||
|
return <Navigate to="/pending" replace />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900 flex items-center justify-center px-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl p-10 max-w-sm w-full text-center">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="w-16 h-16 bg-blue-600 rounded-xl mx-auto mb-4 flex items-center justify-center">
|
||||||
|
<span className="text-white text-2xl font-bold">GC</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
GC SI 개발자 가이드
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-2 text-sm">
|
||||||
|
@gcsc.co.kr 계정으로 로그인하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<GoogleLogin
|
||||||
|
onSuccess={(res) => {
|
||||||
|
if (res.credential) login(res.credential);
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
console.error('Google Login failed');
|
||||||
|
}}
|
||||||
|
theme="outline"
|
||||||
|
size="large"
|
||||||
|
width="280"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-6">
|
||||||
|
GC SI 사내 개발환경 전용
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GoogleOAuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/pages/PendingPage.tsx
Normal file
36
src/pages/PendingPage.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { useAuth } from '../auth/useAuth';
|
||||||
|
import { Navigate } from 'react-router';
|
||||||
|
|
||||||
|
export function PendingPage() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
if (user.status === 'ACTIVE') return <Navigate to="/" replace />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg p-10 max-w-md w-full text-center">
|
||||||
|
<div className="w-16 h-16 bg-amber-100 rounded-full mx-auto mb-4 flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 mb-2">승인 대기 중</h1>
|
||||||
|
<p className="text-gray-600 mb-1">
|
||||||
|
<span className="font-medium">{user.email}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-sm mb-6">
|
||||||
|
관리자의 승인 후 가이드에 접근할 수 있습니다.
|
||||||
|
<br />
|
||||||
|
승인이 완료되면 다시 로그인해주세요.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
다른 계정으로 로그인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/types/index.ts
Normal file
52
src/types/index.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
33
src/utils/api.ts
Normal file
33
src/utils/api.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'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: <T>(path: string) => request<T>(path),
|
||||||
|
post: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
put: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
31
src/utils/navigation.ts
Normal file
31
src/utils/navigation.ts
Normal file
@ -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(/(?<!\.)(\*)/g, '[^/]*')
|
||||||
|
.replace(/\?/g, '[^/]');
|
||||||
|
return new RegExp(`^${regex}$`).test(path);
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user