첫 커밋
This commit is contained in:
235
src/utils/api.ts
Normal file
235
src/utils/api.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import axios, { AxiosInstance, AxiosResponse, AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { ApiResponse, ApiErrorResponse, FileUploadProgress, FileUploadResponse } from '@/types';
|
||||
import { tokenManager } from './tokenManager';
|
||||
import config from '@/config';
|
||||
|
||||
class ApiClient {
|
||||
private instance: AxiosInstance;
|
||||
private isRefreshing = false;
|
||||
private failedQueue: Array<{
|
||||
resolve: (value?: unknown) => void;
|
||||
reject: (error?: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
constructor() {
|
||||
this.instance = axios.create({
|
||||
baseURL: config.api.baseURL,
|
||||
timeout: config.api.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors(): void {
|
||||
// 요청 인터셉터
|
||||
this.instance.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = tokenManager.getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// 응답 인터셉터
|
||||
this.instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (this.isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.failedQueue.push({ resolve, reject });
|
||||
}).then(token => {
|
||||
originalRequest.headers!.Authorization = `Bearer ${token}`;
|
||||
return this.instance(originalRequest);
|
||||
}).catch(err => {
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
const newToken = await this.refreshAccessToken();
|
||||
this.processQueue(null, newToken);
|
||||
originalRequest.headers!.Authorization = `Bearer ${newToken}`;
|
||||
return this.instance(originalRequest);
|
||||
} catch (refreshError) {
|
||||
this.processQueue(refreshError, null);
|
||||
tokenManager.clearTokens();
|
||||
// 로그인 페이지로 리디렉션 또는 로그아웃 처리
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(this.handleError(error));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private processQueue(error: unknown, token: string | null): void {
|
||||
this.failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(token);
|
||||
}
|
||||
});
|
||||
|
||||
this.failedQueue = [];
|
||||
}
|
||||
|
||||
private async refreshAccessToken(): Promise<string> {
|
||||
const refreshToken = tokenManager.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${config.api.baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
const { access_token, refresh_token: newRefreshToken } = response.data.data;
|
||||
tokenManager.setTokens(access_token, newRefreshToken);
|
||||
return access_token;
|
||||
} catch {
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: AxiosError): ApiErrorResponse {
|
||||
const errorResponse: ApiErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: '알 수 없는 오류가 발생했습니다.',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (error.response) {
|
||||
// 서버 응답이 있는 경우
|
||||
const { status, data } = error.response;
|
||||
errorResponse.error.code = `HTTP_${status}`;
|
||||
|
||||
if (data && typeof data === 'object' && 'message' in data) {
|
||||
errorResponse.error.message = (data as { message: string }).message;
|
||||
} else {
|
||||
errorResponse.error.message = this.getErrorMessage(status);
|
||||
}
|
||||
|
||||
errorResponse.error.details = data;
|
||||
} else if (error.request) {
|
||||
// 네트워크 오류
|
||||
errorResponse.error.code = 'NETWORK_ERROR';
|
||||
errorResponse.error.message = '네트워크 연결을 확인해주세요.';
|
||||
} else {
|
||||
// 요청 설정 오류
|
||||
errorResponse.error.code = 'REQUEST_ERROR';
|
||||
errorResponse.error.message = '요청 처리 중 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
return errorResponse;
|
||||
}
|
||||
|
||||
private getErrorMessage(status: number): string {
|
||||
const messages: Record<number, string> = {
|
||||
400: '잘못된 요청입니다.',
|
||||
401: '인증이 필요합니다.',
|
||||
403: '접근 권한이 없습니다.',
|
||||
404: '요청한 리소스를 찾을 수 없습니다.',
|
||||
409: '데이터 충돌이 발생했습니다.',
|
||||
422: '입력 데이터를 확인해주세요.',
|
||||
500: '서버 오류가 발생했습니다.',
|
||||
502: '서버 연결에 문제가 있습니다.',
|
||||
503: '서비스를 일시적으로 사용할 수 없습니다.',
|
||||
};
|
||||
|
||||
return messages[status] || '알 수 없는 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
async get<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.instance.get(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.instance.post(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.instance.put(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.instance.patch(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.instance.delete(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
url: string,
|
||||
file: File,
|
||||
onProgress?: (progress: FileUploadProgress) => void
|
||||
): Promise<ApiResponse<FileUploadResponse>> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress: FileUploadProgress = {
|
||||
loaded: progressEvent.loaded,
|
||||
total: progressEvent.total,
|
||||
percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total),
|
||||
};
|
||||
onProgress(progress);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const response = await this.instance.post(url, formData, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async downloadFile(url: string, filename: string): Promise<void> {
|
||||
const response = await this.instance.get(url, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const blob = new Blob([response.data]);
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
export default apiClient;
|
||||
255
src/utils/appBridge.ts
Normal file
255
src/utils/appBridge.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
AppBridgeMessage,
|
||||
AppBridgeResponse,
|
||||
BridgeMessageType,
|
||||
DeviceInfo,
|
||||
ShareContent
|
||||
} from '@/types';
|
||||
import { LoginCredentials, UserInfo } from '@/types/auth';
|
||||
|
||||
class AppBridge {
|
||||
private static instance: AppBridge;
|
||||
private messageId = 0;
|
||||
private pendingCallbacks = new Map<string, (response: AppBridgeResponse) => void>();
|
||||
private responseListeners: Set<(response: AppBridgeResponse) => void> = new Set();
|
||||
|
||||
private constructor() {
|
||||
this.setupMessageListener();
|
||||
}
|
||||
|
||||
// 외부에서 네이티브 응답을 구독할 수 있도록 리스너 등록/해제 메서드 추가
|
||||
public addResponseListener(listener: (response: AppBridgeResponse) => void) {
|
||||
this.responseListeners.add(listener);
|
||||
}
|
||||
|
||||
public removeResponseListener(listener: (response: AppBridgeResponse) => void) {
|
||||
this.responseListeners.delete(listener);
|
||||
}
|
||||
|
||||
static getInstance(): AppBridge {
|
||||
if (!AppBridge.instance) {
|
||||
AppBridge.instance = new AppBridge();
|
||||
}
|
||||
return AppBridge.instance;
|
||||
}
|
||||
|
||||
private setupMessageListener(): void {
|
||||
window.addEventListener('message', (event) => {
|
||||
try {
|
||||
const response: AppBridgeResponse & { callbackId?: string } = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||
console.log('response', response);
|
||||
if (response.callbackId && this.pendingCallbacks.has(response.callbackId)) {
|
||||
const callback = this.pendingCallbacks.get(response.callbackId);
|
||||
if (callback) {
|
||||
callback(response);
|
||||
this.pendingCallbacks.delete(response.callbackId);
|
||||
}
|
||||
}
|
||||
// 등록된 리스너들에게 모든 응답 전달
|
||||
this.responseListeners.forEach(listener => listener(response));
|
||||
} catch (error) {
|
||||
console.error('Failed to parse bridge message:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private generateMessageId(): string {
|
||||
return `bridge_${++this.messageId}_${Date.now()}`;
|
||||
}
|
||||
|
||||
private sendMessage<T>(type: BridgeMessageType, data?: unknown): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const callbackId = this.generateMessageId();
|
||||
const message: AppBridgeMessage & { callbackId: string } = {
|
||||
type,
|
||||
data,
|
||||
callbackId
|
||||
};
|
||||
|
||||
this.pendingCallbacks.set(callbackId, (response: AppBridgeResponse) => {
|
||||
if (response.success) {
|
||||
resolve(response.data);
|
||||
} else {
|
||||
reject(new Error(response.error || 'Bridge call failed'));
|
||||
}
|
||||
});
|
||||
|
||||
// Android WebView 인터페이스
|
||||
if (window.AndroidBridge && window.AndroidBridge.postMessage) {
|
||||
console.log('Android postMessage', message);
|
||||
window.AndroidBridge.postMessage(JSON.stringify(message));
|
||||
return;
|
||||
}
|
||||
|
||||
// iOS WKWebView 인터페이스
|
||||
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.bridge) {
|
||||
console.log('iOS postMessage', message);
|
||||
window.webkit.messageHandlers.bridge.postMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 네이티브 환경이 아닌 경우 에러 발생
|
||||
setTimeout(() => {
|
||||
this.pendingCallbacks.delete(callbackId);
|
||||
reject(new Error('Native bridge not available'));
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// 앱 정보 관련
|
||||
async getAppInfo(): Promise<{ version: string; buildNumber: string }> {
|
||||
return this.sendMessage(BridgeMessageType.GET_APP_INFO);
|
||||
}
|
||||
|
||||
async getDeviceInfo(): Promise<DeviceInfo> {
|
||||
return this.sendMessage(BridgeMessageType.GET_DEVICE_INFO);
|
||||
}
|
||||
|
||||
// 네비게이션 관련
|
||||
async navigateBack(): Promise<void> {
|
||||
return this.sendMessage(BridgeMessageType.NAVIGATE_BACK);
|
||||
}
|
||||
|
||||
async navigateTo(path: string): Promise<void> {
|
||||
return this.sendMessage(BridgeMessageType.NAVIGATE_TO, { path });
|
||||
}
|
||||
|
||||
async navigateToLogin(): Promise<void> {
|
||||
return this.sendMessage(BridgeMessageType.NAVIGATE_TO_LOGIN);
|
||||
}
|
||||
|
||||
async closeWebView(): Promise<void> {
|
||||
return this.sendMessage(BridgeMessageType.CLOSE_WEBVIEW);
|
||||
}
|
||||
|
||||
// 알림 관련
|
||||
async showToast(message: string, duration: number = 3000): Promise<void> {
|
||||
return this.sendMessage(BridgeMessageType.SHOW_TOAST, { message, duration });
|
||||
}
|
||||
|
||||
async showAlert(title: string, message: string): Promise<void> {
|
||||
return this.sendMessage(BridgeMessageType.SHOW_ALERT, { title, message });
|
||||
}
|
||||
|
||||
async showConfirm(title: string, message: string): Promise<boolean> {
|
||||
return this.sendMessage(BridgeMessageType.SHOW_CONFIRM, { title, message });
|
||||
}
|
||||
|
||||
// 저장소 관련
|
||||
async setStorage(key: string, value: unknown): Promise<void> {
|
||||
return this.sendMessage(BridgeMessageType.SET_STORAGE, { key, value: JSON.stringify(value) });
|
||||
}
|
||||
|
||||
async getStorage<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const result = await this.sendMessage<string>(BridgeMessageType.GET_STORAGE, { key });
|
||||
return result ? JSON.parse(result) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async removeStorage(key: string): Promise<void> {
|
||||
return this.sendMessage(BridgeMessageType.REMOVE_STORAGE, { key });
|
||||
}
|
||||
|
||||
// 공유 관련
|
||||
async shareContent(content: ShareContent): Promise<void> {
|
||||
return this.sendMessage(BridgeMessageType.SHARE_CONTENT, content);
|
||||
}
|
||||
|
||||
// 로그인 요청
|
||||
async login(credentials?: LoginCredentials): Promise<UserInfo> {
|
||||
return this.sendMessage(BridgeMessageType.LOGIN, credentials);
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
return this.sendMessage(BridgeMessageType.LOGOUT);
|
||||
}
|
||||
|
||||
// 언어 설정
|
||||
async setLanguage(language: string): Promise<void> {
|
||||
return this.sendMessage(BridgeMessageType.SET_LANGUAGE, { language });
|
||||
}
|
||||
|
||||
async getLanguage(): Promise<string> {
|
||||
return this.sendMessage(BridgeMessageType.GET_LANGUAGE);
|
||||
}
|
||||
|
||||
// 메시지 카운트 업데이트
|
||||
async updateMessageCount(count: number): Promise<void> {
|
||||
return this.sendMessage(BridgeMessageType.UPDATE_MESSAGE_COUNT, { count });
|
||||
}
|
||||
|
||||
// 네이티브 환경 체크
|
||||
isNativeEnvironment(): boolean {
|
||||
return !!(
|
||||
(window.AndroidBridge && window.AndroidBridge.postMessage) ||
|
||||
(window.Android && window.Android.processMessage) ||
|
||||
(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.bridge)
|
||||
);
|
||||
}
|
||||
|
||||
isAndroid(): boolean {
|
||||
return !!(
|
||||
(window.AndroidBridge && window.AndroidBridge.postMessage) ||
|
||||
(window.Android && window.Android.processMessage)
|
||||
);
|
||||
}
|
||||
|
||||
isIOS(): boolean {
|
||||
return !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.bridge);
|
||||
}
|
||||
|
||||
// 에러 핸들링을 위한 래퍼 메소드들
|
||||
async safeCall<T>(
|
||||
bridgeMethod: () => Promise<T>,
|
||||
fallback?: T,
|
||||
onError?: (error: Error) => void
|
||||
): Promise<T | undefined> {
|
||||
try {
|
||||
return await bridgeMethod();
|
||||
} catch (error) {
|
||||
console.error('Bridge call failed:', error);
|
||||
if (onError) {
|
||||
onError(error as Error);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// 타임아웃을 가진 브리지 호출
|
||||
async callWithTimeout<T>(
|
||||
bridgeMethod: () => Promise<T>,
|
||||
timeout: number = 5000
|
||||
): Promise<T> {
|
||||
return Promise.race([
|
||||
bridgeMethod(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Bridge call timeout')), timeout)
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 글로벌 타입 선언 확장
|
||||
declare global {
|
||||
interface Window {
|
||||
AndroidBridge?: {
|
||||
postMessage: (message: string) => void;
|
||||
};
|
||||
Android?: {
|
||||
processMessage: (message: string) => void;
|
||||
};
|
||||
webkit?: {
|
||||
messageHandlers?: {
|
||||
bridge?: {
|
||||
postMessage: (message: unknown) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const appBridge = AppBridge.getInstance();
|
||||
export default appBridge;
|
||||
188
src/utils/auth.ts
Normal file
188
src/utils/auth.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { LoginCredentials, RegisterData, UserInfo, UserRole } from '@/types';
|
||||
import { apiClient } from './api';
|
||||
import { tokenManager } from './tokenManager';
|
||||
|
||||
export class AuthService {
|
||||
private static instance: AuthService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): AuthService {
|
||||
if (!AuthService.instance) {
|
||||
AuthService.instance = new AuthService();
|
||||
}
|
||||
return AuthService.instance;
|
||||
}
|
||||
|
||||
async login(credentials: LoginCredentials): Promise<{
|
||||
user: UserInfo;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}> {
|
||||
const response = await apiClient.post<{
|
||||
user: UserInfo;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}>('/auth/login', credentials);
|
||||
|
||||
const { user, access_token, refresh_token } = response.data;
|
||||
tokenManager.setTokens(access_token, refresh_token);
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
async register(data: RegisterData): Promise<{
|
||||
user: UserInfo;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}> {
|
||||
const response = await apiClient.post<{
|
||||
user: UserInfo;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}>('/auth/register', data);
|
||||
|
||||
const { user, access_token, refresh_token } = response.data;
|
||||
tokenManager.setTokens(access_token, refresh_token);
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/auth/logout');
|
||||
} catch (error) {
|
||||
// 로그아웃 API 호출 실패해도 로컬 토큰은 삭제
|
||||
console.error('Logout API call failed:', error);
|
||||
} finally {
|
||||
tokenManager.clearTokens();
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken(): Promise<string> {
|
||||
const refreshToken = tokenManager.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await apiClient.post<{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}>('/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token: newRefreshToken } = response.data;
|
||||
tokenManager.setTokens(access_token, newRefreshToken);
|
||||
|
||||
return access_token;
|
||||
}
|
||||
|
||||
async getCurrentUser(): Promise<UserInfo> {
|
||||
const response = await apiClient.get<UserInfo>('/auth/me');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateProfile(data: Partial<UserInfo>): Promise<UserInfo> {
|
||||
const response = await apiClient.put<UserInfo>('/auth/profile', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
await apiClient.post('/auth/change-password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
async forgotPassword(email: string): Promise<void> {
|
||||
await apiClient.post('/auth/forgot-password', { email });
|
||||
}
|
||||
|
||||
async resetPassword(token: string, password: string): Promise<void> {
|
||||
await apiClient.post('/auth/reset-password', {
|
||||
token,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return tokenManager.hasValidTokens();
|
||||
}
|
||||
|
||||
getCurrentUserFromToken(): UserInfo | null {
|
||||
return tokenManager.getUserInfoFromToken();
|
||||
}
|
||||
|
||||
hasRole(role: UserRole): boolean {
|
||||
const user = this.getCurrentUserFromToken();
|
||||
return user?.role === role;
|
||||
}
|
||||
|
||||
hasPermission(permission: string): boolean {
|
||||
const user = this.getCurrentUserFromToken();
|
||||
if (!user) return false;
|
||||
|
||||
// 권한 체크 로직 구현
|
||||
const permissions = this.getRolePermissions(user.role);
|
||||
return permissions.includes(permission);
|
||||
}
|
||||
|
||||
private getRolePermissions(role: UserRole): string[] {
|
||||
const permissionMap: Record<UserRole, string[]> = {
|
||||
[UserRole.ADMIN]: [
|
||||
'user:read',
|
||||
'user:write',
|
||||
'user:delete',
|
||||
'payment:read',
|
||||
'payment:write',
|
||||
'payment:refund',
|
||||
'system:config',
|
||||
],
|
||||
[UserRole.MERCHANT]: [
|
||||
'payment:read',
|
||||
'payment:write',
|
||||
'payment:refund',
|
||||
'profile:read',
|
||||
'profile:write',
|
||||
],
|
||||
[UserRole.USER]: [
|
||||
'payment:read',
|
||||
'profile:read',
|
||||
'profile:write',
|
||||
],
|
||||
};
|
||||
|
||||
return permissionMap[role] || [];
|
||||
}
|
||||
|
||||
async verifyEmail(token: string): Promise<void> {
|
||||
await apiClient.post('/auth/verify-email', { token });
|
||||
}
|
||||
|
||||
async resendVerificationEmail(): Promise<void> {
|
||||
await apiClient.post('/auth/resend-verification');
|
||||
}
|
||||
|
||||
shouldRefreshToken(): boolean {
|
||||
return tokenManager.shouldRefreshToken();
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return tokenManager.getAccessToken();
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return tokenManager.getRefreshToken();
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = AuthService.getInstance();
|
||||
4
src/utils/index.ts
Normal file
4
src/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { apiClient } from './api';
|
||||
export { authService } from './auth';
|
||||
export { tokenManager } from './tokenManager';
|
||||
export { appBridge } from './appBridge';
|
||||
121
src/utils/jwtDecoder.ts
Normal file
121
src/utils/jwtDecoder.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { JWTPayload } from '@/types';
|
||||
|
||||
/**
|
||||
* Browser-compatible JWT decoder
|
||||
* Only supports decoding (reading) JWT tokens, not signing/verifying
|
||||
*/
|
||||
export class JWTDecoder {
|
||||
/**
|
||||
* Decode a JWT token to extract the payload
|
||||
* @param token - The JWT token string
|
||||
* @returns The decoded payload or null if invalid
|
||||
*/
|
||||
static decode(token: string): JWTPayload | null {
|
||||
try {
|
||||
if (!token || typeof token !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// JWT tokens have 3 parts separated by dots: header.payload.signature
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the payload (second part)
|
||||
const payload = parts[1];
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode base64url
|
||||
const decoded = this.base64UrlDecode(payload);
|
||||
|
||||
// Parse JSON
|
||||
return JSON.parse(decoded) as JWTPayload;
|
||||
} catch (error) {
|
||||
console.error('JWT decode error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a JWT token is expired
|
||||
* @param token - The JWT token string
|
||||
* @returns true if expired, false if valid
|
||||
*/
|
||||
static isExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = this.decode(token);
|
||||
if (!payload || !payload.exp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentTime = Date.now() / 1000;
|
||||
return payload.exp < currentTime;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expiration timestamp from a JWT token
|
||||
* @param token - The JWT token string
|
||||
* @returns The expiration timestamp or null if not found
|
||||
*/
|
||||
static getExpiration(token: string): number | null {
|
||||
try {
|
||||
const payload = this.decode(token);
|
||||
return payload?.exp || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time until token expiration in seconds
|
||||
* @param token - The JWT token string
|
||||
* @returns Seconds until expiration, 0 if expired or invalid
|
||||
*/
|
||||
static getTimeUntilExpiration(token: string): number {
|
||||
try {
|
||||
const expirationTime = this.getExpiration(token);
|
||||
if (!expirationTime) return 0;
|
||||
|
||||
const currentTime = Date.now() / 1000;
|
||||
const timeUntilExpiration = expirationTime - currentTime;
|
||||
return Math.max(0, timeUntilExpiration);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64url string to regular string
|
||||
* @param str - base64url encoded string
|
||||
* @returns decoded string
|
||||
*/
|
||||
private static base64UrlDecode(str: string): string {
|
||||
// Convert base64url to base64
|
||||
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
// Add padding if needed
|
||||
while (base64.length % 4) {
|
||||
base64 += '=';
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
const decoded = atob(base64);
|
||||
|
||||
// Convert to UTF-8
|
||||
return decodeURIComponent(
|
||||
decoded
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export a default instance for convenience
|
||||
export const jwtDecoder = JWTDecoder;
|
||||
188
src/utils/tokenManager.ts
Normal file
188
src/utils/tokenManager.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { JWTPayload, UserInfo } from '@/types';
|
||||
import { appBridge } from './appBridge';
|
||||
import { jwtDecoder } from './jwtDecoder';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'nice_access_token';
|
||||
const REFRESH_TOKEN_KEY = 'nice_refresh_token';
|
||||
|
||||
export class TokenManager {
|
||||
private static instance: TokenManager;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): TokenManager {
|
||||
if (!TokenManager.instance) {
|
||||
TokenManager.instance = new TokenManager();
|
||||
}
|
||||
return TokenManager.instance;
|
||||
}
|
||||
|
||||
setTokens(accessToken: string, refreshToken: string): void {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
this.saveTokensToAppBridge(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
clearTokens(): void {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
isTokenExpired(token: string): boolean {
|
||||
return jwtDecoder.isExpired(token);
|
||||
}
|
||||
|
||||
isAccessTokenExpired(): boolean {
|
||||
const token = this.getAccessToken();
|
||||
if (!token) return true;
|
||||
return this.isTokenExpired(token);
|
||||
}
|
||||
|
||||
isRefreshTokenExpired(): boolean {
|
||||
const token = this.getRefreshToken();
|
||||
if (!token) return true;
|
||||
return this.isTokenExpired(token);
|
||||
}
|
||||
|
||||
decodeToken(token: string): JWTPayload | null {
|
||||
return jwtDecoder.decode(token);
|
||||
}
|
||||
|
||||
getUserInfoFromToken(token?: string): UserInfo | null {
|
||||
const accessToken = token || this.getAccessToken();
|
||||
if (!accessToken) return null;
|
||||
|
||||
const decoded = this.decodeToken(accessToken);
|
||||
if (!decoded) return null;
|
||||
|
||||
return {
|
||||
id: decoded.userId,
|
||||
email: decoded.email,
|
||||
name: '', // 토큰에서 이름을 가져오지 않으므로 빈 문자열
|
||||
role: decoded.role,
|
||||
createdAt: '',
|
||||
updatedAt: ''
|
||||
};
|
||||
}
|
||||
|
||||
getTokenExpirationTime(token: string): number | null {
|
||||
return jwtDecoder.getExpiration(token);
|
||||
}
|
||||
|
||||
getTimeUntilExpiration(token: string): number {
|
||||
return jwtDecoder.getTimeUntilExpiration(token);
|
||||
}
|
||||
|
||||
shouldRefreshToken(): boolean {
|
||||
const accessToken = this.getAccessToken();
|
||||
if (!accessToken) return false;
|
||||
|
||||
const timeUntilExpiration = this.getTimeUntilExpiration(accessToken);
|
||||
// 토큰이 5분 이내에 만료되면 갱신
|
||||
const REFRESH_BUFFER_SECONDS = 5 * 60; // 5 minutes
|
||||
return timeUntilExpiration < REFRESH_BUFFER_SECONDS;
|
||||
}
|
||||
|
||||
hasValidTokens(): boolean {
|
||||
const accessToken = this.getAccessToken();
|
||||
const refreshToken = this.getRefreshToken();
|
||||
|
||||
if (!accessToken || !refreshToken) return false;
|
||||
|
||||
// 리프레시 토큰이 유효하면 액세스 토큰은 갱신 가능
|
||||
return !this.isRefreshTokenExpired();
|
||||
}
|
||||
|
||||
async refreshTokens(): Promise<{accessToken: string, refreshToken: string}> {
|
||||
const refreshToken = this.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await fetch('http://3.35.79.250:8090/auth/v1/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const newAccessToken = data.accessToken;
|
||||
const newRefreshToken = data.refreshToken;
|
||||
|
||||
this.setTokens(newAccessToken, newRefreshToken);
|
||||
|
||||
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
|
||||
}
|
||||
|
||||
async makeAuthenticatedRequest(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
let accessToken = this.getAccessToken();
|
||||
|
||||
if (!accessToken || this.isAccessTokenExpired()) {
|
||||
try {
|
||||
const refreshed = await this.refreshTokens();
|
||||
accessToken = refreshed.accessToken;
|
||||
} catch (error) {
|
||||
this.clearTokens();
|
||||
throw new Error('Authentication failed - please log in again');
|
||||
}
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url, requestOptions);
|
||||
|
||||
if (response.status === 401) {
|
||||
try {
|
||||
const refreshed = await this.refreshTokens();
|
||||
const retryOptions = {
|
||||
...requestOptions,
|
||||
headers: {
|
||||
...requestOptions.headers,
|
||||
'Authorization': `Bearer ${refreshed.accessToken}`,
|
||||
},
|
||||
};
|
||||
return await fetch(url, retryOptions);
|
||||
} catch (error) {
|
||||
this.clearTokens();
|
||||
throw new Error('Authentication failed - please log in again');
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async saveTokensToAppBridge(accessToken: string, refreshToken: string): Promise<void> {
|
||||
try {
|
||||
if (appBridge.isNativeEnvironment()) {
|
||||
await Promise.all([
|
||||
appBridge.setStorage(ACCESS_TOKEN_KEY, accessToken),
|
||||
appBridge.setStorage(REFRESH_TOKEN_KEY, refreshToken)
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save tokens to AppBridge:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenManager = TokenManager.getInstance();
|
||||
Reference in New Issue
Block a user