[feat]: 보호된 대시보드 및 API 라우트 추가

- 인증된 사용자용 대시보드 페이지 구현 ((protected) 라우트 그룹)
- API 엔드포인트 추가 (인증, 사용자 관리)
- 커스텀 훅 추가 (useAuth)
- 미들웨어 인증 로직 강화
- 환경변수 예제 업데이트
- 기존 dashboard 페이지 제거 후 보호된 라우트로 이동

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-10 09:38:59 +09:00
parent 56386e6d88
commit bf39fd22bd
14 changed files with 804 additions and 40 deletions

153
src/lib/api/client.ts Normal file
View File

@@ -0,0 +1,153 @@
// lib/api/client.ts
import { AUTH_CONFIG } from './auth/auth-config';
import type { AuthMode } from './auth/types';
interface ClientConfig {
mode: AuthMode;
apiKey?: string; // API Key 모드용
getToken?: () => string | null; // Bearer 모드용
}
interface ApiErrorResponse {
message: string;
errors?: Record<string, string[]>;
code?: string;
}
export class ApiClient {
private baseURL: string;
private mode: AuthMode;
private apiKey?: string;
private getToken?: () => string | null;
constructor(config: ClientConfig) {
this.baseURL = AUTH_CONFIG.apiUrl;
this.mode = config.mode;
this.apiKey = config.apiKey;
this.getToken = config.getToken;
}
/**
* 인증 헤더 생성
*/
private getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
// API Key는 모든 모드에서 기본으로 포함 (PHP API 요구사항)
if (this.apiKey) {
headers['X-API-KEY'] = this.apiKey;
}
switch (this.mode) {
case 'api-key':
// API Key만 사용 (이미 위에서 추가됨)
break;
case 'bearer':
const token = this.getToken?.();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// API Key도 함께 전송 (이미 위에서 추가됨)
break;
case 'sanctum':
// 쿠키 기반 - 별도 헤더 불필요
break;
}
return headers;
}
/**
* HTTP 요청 실행
*/
async request<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const headers = {
...this.getAuthHeaders(),
...options?.headers,
};
const config: RequestInit = {
...options,
headers,
};
// Sanctum 모드는 쿠키 포함
if (this.mode === 'sanctum') {
config.credentials = 'include';
}
const response = await fetch(url, config);
if (!response.ok) {
await this.handleError(response);
}
// 204 No Content 처리
if (response.status === 204) {
return undefined as T;
}
return await response.json();
}
/**
* GET 요청
*/
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' });
}
/**
* POST 요청
*/
async post<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
/**
* PUT 요청
*/
async put<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
/**
* DELETE 요청
*/
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
/**
* 에러 처리
*/
private async handleError(response: Response): Promise<never> {
const data = await response.json().catch(() => ({}));
const error: ApiErrorResponse = {
message: data.message || 'An error occurred',
errors: data.errors,
code: data.code,
};
throw {
status: response.status,
...error,
};
}
}