불필요 파일 제거
This commit is contained in:
@@ -1,3 +1,2 @@
|
|||||||
export { useAuth } from './useAuth';
|
|
||||||
export { useAppBridge } from './useAppBridge';
|
export { useAppBridge } from './useAppBridge';
|
||||||
export { useScrollDirection } from './useScrollDirection';
|
export { useScrollDirection } from './useScrollDirection';
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
|
||||||
import { AuthContextType, AuthState, LoginCredentials, RegisterData, UserRole } from '@/types';
|
|
||||||
import { authService } from '@/utils/auth';
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null);
|
|
||||||
|
|
||||||
interface AuthProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|
||||||
const [authState, setAuthState] = useState<AuthState>({
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
token: null,
|
|
||||||
isLoading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const login = async (credentials: LoginCredentials): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setAuthState(prev => ({ ...prev, isLoading: true }));
|
|
||||||
|
|
||||||
const result = await authService.login(credentials);
|
|
||||||
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: true,
|
|
||||||
user: result.user,
|
|
||||||
token: result.accessToken,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setAuthState(prev => ({ ...prev, isLoading: false }));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const register = async (data: RegisterData): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setAuthState(prev => ({ ...prev, isLoading: true }));
|
|
||||||
|
|
||||||
const result = await authService.register(data);
|
|
||||||
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: true,
|
|
||||||
user: result.user,
|
|
||||||
token: result.accessToken,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setAuthState(prev => ({ ...prev, isLoading: false }));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = useCallback((): void => {
|
|
||||||
authService.logout();
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
token: null,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshToken = useCallback(async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const newToken = await authService.refreshToken();
|
|
||||||
const user = authService.getCurrentUserFromToken();
|
|
||||||
|
|
||||||
setAuthState(prev => ({
|
|
||||||
...prev,
|
|
||||||
token: newToken,
|
|
||||||
user,
|
|
||||||
isAuthenticated: true,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
logout();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [logout]);
|
|
||||||
|
|
||||||
const hasRole = (role: UserRole): boolean => {
|
|
||||||
return authService.hasRole(role);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasPermission = (permission: string): boolean => {
|
|
||||||
return authService.hasPermission(permission);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadUserFromToken = useCallback(async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (authService.isAuthenticated()) {
|
|
||||||
const user = authService.getCurrentUserFromToken();
|
|
||||||
const token = authService.getAccessToken();
|
|
||||||
|
|
||||||
if (user && token) {
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: true,
|
|
||||||
user,
|
|
||||||
token,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 토큰 갱신 체크
|
|
||||||
if (authService.shouldRefreshToken()) {
|
|
||||||
await refreshToken();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 토큰이 유효하지 않으면 최신 사용자 정보 가져오기
|
|
||||||
const currentUser = await authService.getCurrentUser();
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: true,
|
|
||||||
user: currentUser,
|
|
||||||
token,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setAuthState({
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
token: null,
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load user from token:', error);
|
|
||||||
logout();
|
|
||||||
}
|
|
||||||
}, [refreshToken, logout]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadUserFromToken();
|
|
||||||
}, [loadUserFromToken]);
|
|
||||||
|
|
||||||
// 토큰 자동 갱신 체크
|
|
||||||
useEffect(() => {
|
|
||||||
if(!authState.isAuthenticated){
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkTokenExpiration = setInterval(() => {
|
|
||||||
if (authService.shouldRefreshToken()) {
|
|
||||||
refreshToken().catch(() => {
|
|
||||||
logout();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 60000); // 1분마다 체크
|
|
||||||
|
|
||||||
return () => clearInterval(checkTokenExpiration);
|
|
||||||
}, [authState.isAuthenticated, refreshToken, logout]);
|
|
||||||
|
|
||||||
const contextValue: AuthContextType = {
|
|
||||||
...authState,
|
|
||||||
login,
|
|
||||||
register,
|
|
||||||
logout,
|
|
||||||
refreshToken,
|
|
||||||
hasRole,
|
|
||||||
hasPermission,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const useAuth = (): AuthContextType => {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { appBridge } from '../utils/appBridge';
|
|
||||||
import { LanguageSwitcher } from '../components';
|
|
||||||
import { SearchFilter } from '../types/filter';
|
|
||||||
|
|
||||||
interface TestResult {
|
|
||||||
method: string;
|
|
||||||
success: boolean;
|
|
||||||
result?: any;
|
|
||||||
error?: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Test: React.FC = () => {
|
|
||||||
const [results, setResults] = useState<TestResult[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState<string | null>(null);
|
|
||||||
const [messageCount, setMessageCount] = useState<number>(0);
|
|
||||||
const [searchData, setSearchData] = useState<Array<{
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
amount: number;
|
|
||||||
date: Date;
|
|
||||||
status: string;
|
|
||||||
description: string;
|
|
||||||
}>>([]);
|
|
||||||
|
|
||||||
const addResult = (method: string, success: boolean, result?: any, error?: string) => {
|
|
||||||
const newResult: TestResult = {
|
|
||||||
method,
|
|
||||||
success,
|
|
||||||
result,
|
|
||||||
error,
|
|
||||||
timestamp: new Date().toLocaleTimeString()
|
|
||||||
};
|
|
||||||
setResults(prev => [newResult, ...prev]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const executeTest = async (method: string, testFn: () => Promise<any>) => {
|
|
||||||
setIsLoading(method);
|
|
||||||
try {
|
|
||||||
const result = await testFn();
|
|
||||||
addResult(method, true, result);
|
|
||||||
} catch (error) {
|
|
||||||
addResult(method, false, undefined, error instanceof Error ? error.message : String(error));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearResults = () => {
|
|
||||||
setResults([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 앱 정보 테스트
|
|
||||||
const testGetAppInfo = () => executeTest('getAppInfo', () => appBridge.getAppInfo());
|
|
||||||
const testGetDeviceInfo = () => executeTest('getDeviceInfo', () => appBridge.getDeviceInfo());
|
|
||||||
|
|
||||||
// 네비게이션 테스트
|
|
||||||
const testNavigateBack = () => executeTest('navigateBack', () => appBridge.navigateBack());
|
|
||||||
const testNavigateTo = () => executeTest('navigateTo', () => appBridge.navigateTo('/home'));
|
|
||||||
const testCloseWebView = () => executeTest('closeWebView', () => appBridge.closeWebView());
|
|
||||||
const testNavigateToLogin = () => executeTest('navigateToLogin', () => appBridge.navigateToLogin());
|
|
||||||
|
|
||||||
// 알림 테스트
|
|
||||||
const testShowToast = () => executeTest('showToast', () => appBridge.showToast('테스트 토스트 메시지', 3000));
|
|
||||||
const testShowAlert = () => executeTest('showAlert', () => appBridge.showAlert('테스트 알림', '이것은 테스트 알림입니다.'));
|
|
||||||
const testShowConfirm = () => executeTest('showConfirm', () => appBridge.showConfirm('확인', '계속하시겠습니까?'));
|
|
||||||
|
|
||||||
// 저장소 테스트
|
|
||||||
const testSetStorage = () => executeTest('setStorage', () => appBridge.setStorage('test_key', { message: '테스트 데이터', timestamp: new Date().toISOString() }));
|
|
||||||
const testGetStorage = () => executeTest('getStorage', () => appBridge.getStorage('test_key'));
|
|
||||||
const testRemoveStorage = () => executeTest('removeStorage', () => appBridge.removeStorage('test_key'));
|
|
||||||
|
|
||||||
// 공유 테스트
|
|
||||||
const testShareContent = () => executeTest('shareContent', () => appBridge.shareContent({
|
|
||||||
title: '테스트 공유',
|
|
||||||
text: '이것은 테스트 공유 내용입니다.',
|
|
||||||
url: 'https://nicepayments.com'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 인증 테스트
|
|
||||||
const testLogin = () => executeTest('login', () => appBridge.login({ email: 'test@example.com', password: 'test123' }));
|
|
||||||
const testLogout = () => executeTest('logout', () => appBridge.logout());
|
|
||||||
|
|
||||||
// 안전한 호출 테스트
|
|
||||||
const testSafeCall = () => executeTest('safeCall', () => appBridge.safeCall(
|
|
||||||
() => appBridge.getAppInfo(),
|
|
||||||
{ version: 'fallback', buildNumber: 'fallback' },
|
|
||||||
(error) => console.error('Safe call error:', error)
|
|
||||||
));
|
|
||||||
|
|
||||||
// 타임아웃 테스트
|
|
||||||
const testCallWithTimeout = () => executeTest('callWithTimeout', () => appBridge.callWithTimeout(
|
|
||||||
() => appBridge.getDeviceInfo(),
|
|
||||||
2000
|
|
||||||
));
|
|
||||||
|
|
||||||
// 메시지 카운트 업데이트 테스트
|
|
||||||
const testUpdateMessageCount = () => executeTest('updateMessageCount', () => appBridge.updateMessageCount(messageCount));
|
|
||||||
|
|
||||||
// 조회조건 변경 핸들러
|
|
||||||
const handleFilterChange = (filter: SearchFilter) => {
|
|
||||||
addResult('filterChange', true, filter);
|
|
||||||
|
|
||||||
// 가상 데이터로 검색 결과 시뮬레이션
|
|
||||||
const mockData: any = generateMockData(filter);
|
|
||||||
setSearchData(mockData);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 가상 거래 데이터 생성
|
|
||||||
const generateMockData = (filter: SearchFilter) => {
|
|
||||||
const transactionTypes = ['deposit', 'withdrawal'];
|
|
||||||
const data = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const type = filter.transactionType === 'all'
|
|
||||||
? transactionTypes[Math.floor(Math.random() * transactionTypes.length)]
|
|
||||||
: filter.transactionType;
|
|
||||||
|
|
||||||
const amount = Math.floor(Math.random() * 1000000) + 10000;
|
|
||||||
const date = new Date();
|
|
||||||
date.setDate(date.getDate() - Math.floor(Math.random() * 180));
|
|
||||||
|
|
||||||
data.push({
|
|
||||||
id: `TXN${String(i + 1).padStart(3, '0')}`,
|
|
||||||
type: type,
|
|
||||||
amount: amount,
|
|
||||||
date: date,
|
|
||||||
status: Math.random() > 0.1 ? 'completed' : 'failed',
|
|
||||||
description: type === 'deposit' ? '입금' : '출금'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 정렬 적용
|
|
||||||
data.sort((a, b) => {
|
|
||||||
if (filter.sortOrder === 'latest') {
|
|
||||||
return b.date.getTime() - a.date.getTime();
|
|
||||||
} else {
|
|
||||||
return a.date.getTime() - b.date.getTime();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 py-8">
|
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">AppBridge 테스트</h1>
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
<LanguageSwitcher />
|
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
|
||||||
appBridge.isNativeEnvironment()
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}>
|
|
||||||
{appBridge.isNativeEnvironment() ? '네이티브 환경' : '웹 환경'}
|
|
||||||
</span>
|
|
||||||
{appBridge.isNativeEnvironment() && (
|
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
|
||||||
appBridge.isAndroid()
|
|
||||||
? 'bg-blue-100 text-blue-800'
|
|
||||||
: 'bg-gray-100 text-gray-800'
|
|
||||||
}`}>
|
|
||||||
{appBridge.isAndroid() ? 'Android' : appBridge.isIOS() ? 'iOS' : 'Unknown'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!appBridge.isNativeEnvironment() && (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-yellow-800">
|
|
||||||
웹 환경에서 실행 중
|
|
||||||
</h3>
|
|
||||||
<div className="mt-2 text-sm text-yellow-700">
|
|
||||||
<p>대부분의 AppBridge 기능은 네이티브 환경에서만 작동합니다. 실제 테스트를 위해서는 모바일 앱에서 실행해주세요.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* 검색 결과 표시 */}
|
|
||||||
{searchData.length > 0 && (
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
|
||||||
검색 결과 ({searchData.length}건)
|
|
||||||
</h2>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
거래ID
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
구분
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
금액
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
날짜
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
상태
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{searchData.map((item, index) => (
|
|
||||||
<tr key={index} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
||||||
{item.id}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
|
||||||
item.type === 'deposit'
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}>
|
|
||||||
{item.description}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{item.amount.toLocaleString()}원
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{item.date.toLocaleDateString('ko-KR')}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
|
||||||
item.status === 'completed'
|
|
||||||
? 'bg-blue-100 text-blue-800'
|
|
||||||
: 'bg-gray-100 text-gray-800'
|
|
||||||
}`}>
|
|
||||||
{item.status === 'completed' ? '완료' : '실패'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* 테스트 버튼들 */}
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">테스트 기능</h2>
|
|
||||||
|
|
||||||
{/* 앱 정보 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-800 mb-2">앱 정보</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
onClick={testGetAppInfo}
|
|
||||||
disabled={isLoading === 'getAppInfo'}
|
|
||||||
className="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'getAppInfo' ? '실행 중...' : '앱 정보'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={testGetDeviceInfo}
|
|
||||||
disabled={isLoading === 'getDeviceInfo'}
|
|
||||||
className="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'getDeviceInfo' ? '실행 중...' : '디바이스 정보'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 네비게이션 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-800 mb-2">네비게이션</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
onClick={testNavigateBack}
|
|
||||||
disabled={isLoading === 'navigateBack'}
|
|
||||||
className="bg-green-500 hover:bg-green-600 disabled:bg-green-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'navigateBack' ? '실행 중...' : '뒤로 가기'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={testNavigateTo}
|
|
||||||
disabled={isLoading === 'navigateTo'}
|
|
||||||
className="bg-green-500 hover:bg-green-600 disabled:bg-green-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'navigateTo' ? '실행 중...' : '페이지 이동'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={testNavigateToLogin}
|
|
||||||
disabled={isLoading === 'navigateToLogin'}
|
|
||||||
className="bg-green-500 hover:bg-green-600 disabled:bg-green-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'navigateTo' ? '실행 중...' : '로그인 화면'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={testCloseWebView}
|
|
||||||
disabled={isLoading === 'closeWebView'}
|
|
||||||
className="bg-red-500 hover:bg-red-600 disabled:bg-red-300 text-white px-4 py-2 rounded text-sm col-span-2"
|
|
||||||
>
|
|
||||||
{isLoading === 'closeWebView' ? '실행 중...' : '웹뷰 닫기'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 알림 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-800 mb-2">알림</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
onClick={testShowToast}
|
|
||||||
disabled={isLoading === 'showToast'}
|
|
||||||
className="bg-yellow-500 hover:bg-yellow-600 disabled:bg-yellow-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'showToast' ? '실행 중...' : '토스트'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={testShowAlert}
|
|
||||||
disabled={isLoading === 'showAlert'}
|
|
||||||
className="bg-yellow-500 hover:bg-yellow-600 disabled:bg-yellow-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'showAlert' ? '실행 중...' : '알림'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={testShowConfirm}
|
|
||||||
disabled={isLoading === 'showConfirm'}
|
|
||||||
className="bg-yellow-500 hover:bg-yellow-600 disabled:bg-yellow-300 text-white px-4 py-2 rounded text-sm col-span-2"
|
|
||||||
>
|
|
||||||
{isLoading === 'showConfirm' ? '실행 중...' : '확인 대화상자'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 저장소 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-800 mb-2">저장소</h3>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<button
|
|
||||||
onClick={testSetStorage}
|
|
||||||
disabled={isLoading === 'setStorage'}
|
|
||||||
className="bg-purple-500 hover:bg-purple-600 disabled:bg-purple-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'setStorage' ? '실행 중...' : '저장'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={testGetStorage}
|
|
||||||
disabled={isLoading === 'getStorage'}
|
|
||||||
className="bg-purple-500 hover:bg-purple-600 disabled:bg-purple-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'getStorage' ? '실행 중...' : '읽기'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={testRemoveStorage}
|
|
||||||
disabled={isLoading === 'removeStorage'}
|
|
||||||
className="bg-purple-500 hover:bg-purple-600 disabled:bg-purple-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'removeStorage' ? '실행 중...' : '삭제'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* 결제 및 인증 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-800 mb-2">결제 & 인증</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
onClick={testLogin}
|
|
||||||
disabled={isLoading === 'login'}
|
|
||||||
className="bg-green-600 hover:bg-green-700 disabled:bg-green-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'login' ? '실행 중...' : '로그인'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={testLogout}
|
|
||||||
disabled={isLoading === 'logout'}
|
|
||||||
className="bg-red-600 hover:bg-red-700 disabled:bg-red-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'logout' ? '실행 중...' : '로그아웃'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 공유 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-800 mb-2">공유</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<button
|
|
||||||
onClick={testShareContent}
|
|
||||||
disabled={isLoading === 'shareContent'}
|
|
||||||
className="bg-cyan-500 hover:bg-cyan-600 disabled:bg-cyan-300 text-white px-4 py-2 rounded text-sm w-full"
|
|
||||||
>
|
|
||||||
{isLoading === 'shareContent' ? '실행 중...' : '공유'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메시지 카운트 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-800 mb-2">메시지 카운트</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={messageCount}
|
|
||||||
onChange={(e) => setMessageCount(parseInt(e.target.value) || 0)}
|
|
||||||
min="0"
|
|
||||||
className="border border-gray-300 rounded px-3 py-1 text-sm w-20"
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={testUpdateMessageCount}
|
|
||||||
disabled={isLoading === 'updateMessageCount'}
|
|
||||||
className="bg-orange-500 hover:bg-orange-600 disabled:bg-orange-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'updateMessageCount' ? '실행 중...' : '배지 업데이트'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-600">
|
|
||||||
iOS: 앱 아이콘 배지 / Android: 로그 출력
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 고급 테스트 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-800 mb-2">고급 테스트</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
onClick={testSafeCall}
|
|
||||||
disabled={isLoading === 'safeCall'}
|
|
||||||
className="bg-gray-500 hover:bg-gray-600 disabled:bg-gray-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'safeCall' ? '실행 중...' : '안전한 호출'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={testCallWithTimeout}
|
|
||||||
disabled={isLoading === 'callWithTimeout'}
|
|
||||||
className="bg-gray-500 hover:bg-gray-600 disabled:bg-gray-300 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
{isLoading === 'callWithTimeout' ? '실행 중...' : '타임아웃 호출'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테스트 결과 */}
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900">테스트 결과</h2>
|
|
||||||
<button
|
|
||||||
onClick={clearResults}
|
|
||||||
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded text-sm"
|
|
||||||
>
|
|
||||||
결과 지우기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<p className="text-gray-500 text-center py-8">
|
|
||||||
테스트 버튼을 클릭하여 결과를 확인하세요.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
results.map((result, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`p-3 rounded-lg border ${
|
|
||||||
result.success
|
|
||||||
? 'bg-green-50 border-green-200'
|
|
||||||
: 'bg-red-50 border-red-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium text-gray-900">
|
|
||||||
{result.method}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{result.timestamp}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{result.success ? (
|
|
||||||
<div className="mt-2">
|
|
||||||
<span className="text-green-800 text-sm">✅ 성공</span>
|
|
||||||
{result.result && (
|
|
||||||
<pre className="mt-1 text-xs bg-green-100 p-2 rounded overflow-x-auto">
|
|
||||||
{JSON.stringify(result.result, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-2">
|
|
||||||
<span className="text-red-800 text-sm">❌ 실패</span>
|
|
||||||
<p className="mt-1 text-xs text-red-700 bg-red-100 p-2 rounded">
|
|
||||||
{result.error}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Test;
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
export { default as Home } from './Home';
|
export { default as Home } from './Home';
|
||||||
export { default as Contact } from './Contact';
|
export { default as Contact } from './Contact';
|
||||||
export { default as Test } from './Test';
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
import { Test } from '@/pages'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/test')({
|
|
||||||
component: Test,
|
|
||||||
})
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
ShareContent
|
ShareContent
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { LoginCredentials, UserInfo } from '@/types/auth';
|
|
||||||
|
|
||||||
class AppBridge {
|
class AppBridge {
|
||||||
private static instance: AppBridge;
|
private static instance: AppBridge;
|
||||||
@@ -114,10 +113,6 @@ class AppBridge {
|
|||||||
return this.sendMessage(BridgeMessageType.NAVIGATE_TO, { path });
|
return this.sendMessage(BridgeMessageType.NAVIGATE_TO, { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
async navigateToLogin(): Promise<void> {
|
|
||||||
return this.sendMessage(BridgeMessageType.NAVIGATE_TO_LOGIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
async closeWebView(): Promise<void> {
|
async closeWebView(): Promise<void> {
|
||||||
return this.sendMessage(BridgeMessageType.CLOSE_WEBVIEW);
|
return this.sendMessage(BridgeMessageType.CLOSE_WEBVIEW);
|
||||||
}
|
}
|
||||||
@@ -158,11 +153,6 @@ class AppBridge {
|
|||||||
return this.sendMessage(BridgeMessageType.SHARE_CONTENT, content);
|
return this.sendMessage(BridgeMessageType.SHARE_CONTENT, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그인 요청
|
|
||||||
async login(credentials?: LoginCredentials): Promise<UserInfo> {
|
|
||||||
return this.sendMessage(BridgeMessageType.LOGIN, credentials);
|
|
||||||
}
|
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
return this.sendMessage(BridgeMessageType.LOGOUT);
|
return this.sendMessage(BridgeMessageType.LOGOUT);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export const convertCurrencyStringToNumber = (currencyString: string): number => {
|
|
||||||
const cleanedString = currencyString.replace(/[^\d]/g, '');
|
|
||||||
return parseInt(cleanedString, 10);
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
export * from './use-navigate';
|
export * from './use-navigate';
|
||||||
export * from './use-app-version';
|
|
||||||
export * from './use-change-bg-color';
|
export * from './use-change-bg-color';
|
||||||
export * from './use-device-uid';
|
export * from './use-device-uid';
|
||||||
export * from './use-fix-scroll-view-safari';
|
export * from './use-fix-scroll-view-safari';
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import useLocalStorageState from 'use-local-storage-state';
|
|
||||||
import { config } from '@/shared/configs';
|
|
||||||
import { StorageKeys } from '@/shared/constants/local-storage';
|
|
||||||
|
|
||||||
export const DEFAULT_APP_VERSION = '1.0';
|
|
||||||
export const useAppVersion = () => {
|
|
||||||
const [appVersion] = useLocalStorageState(StorageKeys.AppVersion, { defaultValue: DEFAULT_APP_VERSION });
|
|
||||||
return appVersion;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const webVersion = config.WEB_VERSION.replace(/\b(\d+)(?:\.0\.0)?\b/g, '$1');
|
|
||||||
export const useFullVersion = () => {
|
|
||||||
const appVersion = useAppVersion();
|
|
||||||
const fullVersion = useMemo(() => `${appVersion}.${webVersion}`, [appVersion]);
|
|
||||||
return { fullVersion };
|
|
||||||
};
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Fragment } from 'react/jsx-runtime';
|
|
||||||
import { useFullVersion } from '@/shared/lib/hooks';
|
|
||||||
|
|
||||||
export const FullAppVersion = React.memo(function FullAppVersion() {
|
|
||||||
const { fullVersion } = useFullVersion();
|
|
||||||
return <Fragment>{fullVersion}</Fragment>;
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
export interface ApiResponse<T = unknown> {
|
|
||||||
success: boolean;
|
|
||||||
data: T;
|
|
||||||
message: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiErrorResponse {
|
|
||||||
success: false;
|
|
||||||
error: {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
details?: any;
|
|
||||||
};
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
data: T[];
|
|
||||||
pagination: {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total: number;
|
|
||||||
totalPages: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiRequestConfig {
|
|
||||||
timeout?: number;
|
|
||||||
retries?: number;
|
|
||||||
retryDelay?: number;
|
|
||||||
headers?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileUploadProgress {
|
|
||||||
loaded: number;
|
|
||||||
total: number;
|
|
||||||
percentage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileUploadResponse {
|
|
||||||
fileId: string;
|
|
||||||
filename: string;
|
|
||||||
url: string;
|
|
||||||
size: number;
|
|
||||||
mimeType: string;
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
export interface LoginCredentials {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterData {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
name: string;
|
|
||||||
phone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserInfo {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
phone?: string;
|
|
||||||
role: UserRole;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum UserRole {
|
|
||||||
ADMIN = 'admin',
|
|
||||||
USER = 'user',
|
|
||||||
MERCHANT = 'merchant'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JWTPayload {
|
|
||||||
userId: string;
|
|
||||||
email: string;
|
|
||||||
role: UserRole;
|
|
||||||
iat: number;
|
|
||||||
exp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthState {
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
user: UserInfo | null;
|
|
||||||
token: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthContextType extends AuthState {
|
|
||||||
login: (credentials: LoginCredentials) => Promise<void>;
|
|
||||||
register: (data: RegisterData) => Promise<void>;
|
|
||||||
logout: () => void;
|
|
||||||
refreshToken: () => Promise<void>;
|
|
||||||
hasRole: (role: UserRole) => boolean;
|
|
||||||
hasPermission: (permission: string) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type aliases for React Query hooks
|
|
||||||
export type User = UserInfo;
|
|
||||||
|
|
||||||
export interface UserPermission {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
resource: string;
|
|
||||||
action: string;
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
export type DatePeriod = '1month' | '3months' | '6months' | 'custom';
|
|
||||||
|
|
||||||
export type TransactionType = 'all' | 'deposit' | 'withdrawal';
|
|
||||||
|
|
||||||
export type SortOrder = 'latest' | 'oldest';
|
|
||||||
|
|
||||||
export interface DateRange {
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchFilter {
|
|
||||||
period: DatePeriod;
|
|
||||||
dateRange?: DateRange;
|
|
||||||
transactionType: TransactionType;
|
|
||||||
sortOrder: SortOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchFilterOptions {
|
|
||||||
periods: Array<{
|
|
||||||
value: DatePeriod;
|
|
||||||
label: string;
|
|
||||||
}>;
|
|
||||||
transactionTypes: Array<{
|
|
||||||
value: TransactionType;
|
|
||||||
label: string;
|
|
||||||
}>;
|
|
||||||
sortOrders: Array<{
|
|
||||||
value: SortOrder;
|
|
||||||
label: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchFilterModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: (filter: SearchFilter) => void;
|
|
||||||
initialFilter?: SearchFilter;
|
|
||||||
options?: Partial<SearchFilterOptions>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기본 옵션 상수
|
|
||||||
// locale 적용: label을 i18n key로 지정
|
|
||||||
export const DEFAULT_FILTER_OPTIONS: SearchFilterOptions = {
|
|
||||||
periods: [
|
|
||||||
{ value: '1month', label: 'filter.periods.1month' },
|
|
||||||
{ value: '3months', label: 'filter.periods.3months' },
|
|
||||||
{ value: '6months', label: 'filter.periods.6months' },
|
|
||||||
{ value: 'custom', label: 'filter.periods.custom' }
|
|
||||||
],
|
|
||||||
transactionTypes: [
|
|
||||||
{ value: 'all', label: 'filter.transactionTypes.all' },
|
|
||||||
{ value: 'deposit', label: 'filter.transactionTypes.deposit' },
|
|
||||||
{ value: 'withdrawal', label: 'filter.transactionTypes.withdrawal' }
|
|
||||||
],
|
|
||||||
sortOrders: [
|
|
||||||
{ value: 'latest', label: 'filter.sortOrders.latest' },
|
|
||||||
{ value: 'oldest', label: 'filter.sortOrders.oldest' }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// 기본 필터 값
|
|
||||||
export const DEFAULT_SEARCH_FILTER: SearchFilter = {
|
|
||||||
period: '1month',
|
|
||||||
transactionType: 'all',
|
|
||||||
sortOrder: 'latest'
|
|
||||||
};
|
|
||||||
|
|
||||||
// 날짜 유틸리티 함수들
|
|
||||||
export const getDateRangeFromPeriod = (period: DatePeriod): DateRange | null => {
|
|
||||||
const endDate = new Date();
|
|
||||||
const startDate = new Date();
|
|
||||||
|
|
||||||
switch (period) {
|
|
||||||
case '1month':
|
|
||||||
startDate.setMonth(endDate.getMonth() - 1);
|
|
||||||
break;
|
|
||||||
case '3months':
|
|
||||||
startDate.setMonth(endDate.getMonth() - 3);
|
|
||||||
break;
|
|
||||||
case '6months':
|
|
||||||
startDate.setMonth(endDate.getMonth() - 6);
|
|
||||||
break;
|
|
||||||
case 'custom':
|
|
||||||
return null; // 사용자가 직접 설정
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { startDate, endDate };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatDateRange = (dateRange: DateRange): string => {
|
|
||||||
const formatDate = (date: Date) => {
|
|
||||||
return date.toLocaleDateString('ko-KR', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return `${formatDate(dateRange.startDate)} ~ ${formatDate(dateRange.endDate)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFilterDisplayText = (filter: SearchFilter, t: (key: string) => string): string => {
|
|
||||||
const periodOption = DEFAULT_FILTER_OPTIONS.periods.find(p => p.value === filter.period);
|
|
||||||
const transactionOption = DEFAULT_FILTER_OPTIONS.transactionTypes.find(tt => tt.value === filter.transactionType);
|
|
||||||
const sortOption = DEFAULT_FILTER_OPTIONS.sortOrders.find(s => s.value === filter.sortOrder);
|
|
||||||
|
|
||||||
const parts = [
|
|
||||||
periodOption?.label ? t(periodOption.label) : undefined,
|
|
||||||
transactionOption?.label ? t(transactionOption.label) : undefined,
|
|
||||||
sortOption?.label ? t(sortOption.label) : undefined
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
return parts.join(' · ');
|
|
||||||
};
|
|
||||||
@@ -1,4 +1 @@
|
|||||||
export * from './auth';
|
|
||||||
export * from './api';
|
|
||||||
export * from './bridge';
|
export * from './bridge';
|
||||||
export * from './filter';
|
|
||||||
260
src/utils/api.ts
260
src/utils/api.ts
@@ -1,260 +0,0 @@
|
|||||||
import axios, { AxiosInstance, AxiosResponse, AxiosError, AxiosRequestConfig } from 'axios';
|
|
||||||
import { ApiResponse, ApiErrorResponse, FileUploadProgress, FileUploadResponse } from '@/types';
|
|
||||||
import { tokenManager } from './tokenManager';
|
|
||||||
import config from '@/config';
|
|
||||||
import { useAppBridge } from '@/hooks';
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClient {
|
|
||||||
private instance: AxiosInstance;
|
|
||||||
private isRefreshing = false;
|
|
||||||
private failedQueue: Array<{
|
|
||||||
resolve: (value?: unknown) => void;
|
|
||||||
reject: (error?: unknown) => void;
|
|
||||||
}> = [];
|
|
||||||
private isNativeEnvironment: any;
|
|
||||||
private requestRefreshToken: any;
|
|
||||||
|
|
||||||
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();
|
|
||||||
console.log('setupInterceptors request ==> ', token)
|
|
||||||
|
|
||||||
if(token){
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
(error) => Promise.reject(error)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 응답 인터셉터
|
|
||||||
this.instance.interceptors.response.use(
|
|
||||||
(response: AxiosResponse) => response,
|
|
||||||
async (error: AxiosError) => {
|
|
||||||
console.log(' this.instance.interceptors.response' );
|
|
||||||
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
|
|
||||||
console.log('originalRequest ==> ', JSON.stringify(originalRequest));
|
|
||||||
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;
|
|
||||||
|
|
||||||
console.log('refreshToken!!');
|
|
||||||
/*
|
|
||||||
this.requestRefreshToken1().then((token) => {
|
|
||||||
console.log('requestRefreshToken +[' + JSON.stringify(token) + ']' );
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
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 requestRefreshAccessToken(): Promise<any> {
|
|
||||||
const {
|
|
||||||
isNativeEnvironment,
|
|
||||||
requestRefreshToken
|
|
||||||
} = useAppBridge();
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
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;
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
ShareContent
|
ShareContent
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { LoginCredentials, UserInfo } from '@/types/auth';
|
|
||||||
|
|
||||||
class AppBridge {
|
class AppBridge {
|
||||||
private static instance: AppBridge;
|
private static instance: AppBridge;
|
||||||
@@ -114,10 +113,6 @@ class AppBridge {
|
|||||||
return this.sendMessage(BridgeMessageType.NAVIGATE_TO, { path });
|
return this.sendMessage(BridgeMessageType.NAVIGATE_TO, { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
async navigateToLogin(): Promise<void> {
|
|
||||||
return this.sendMessage(BridgeMessageType.NAVIGATE_TO_LOGIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
async closeWebView(): Promise<void> {
|
async closeWebView(): Promise<void> {
|
||||||
return this.sendMessage(BridgeMessageType.CLOSE_WEBVIEW);
|
return this.sendMessage(BridgeMessageType.CLOSE_WEBVIEW);
|
||||||
}
|
}
|
||||||
@@ -166,11 +161,6 @@ class AppBridge {
|
|||||||
return this.sendMessage(BridgeMessageType.SHARE_CONTENT, content);
|
return this.sendMessage(BridgeMessageType.SHARE_CONTENT, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그인 요청
|
|
||||||
async login(credentials?: LoginCredentials): Promise<UserInfo> {
|
|
||||||
return this.sendMessage(BridgeMessageType.LOGIN, credentials);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로그아웃 요청
|
// 로그아웃 요청
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
return this.sendMessage(BridgeMessageType.LOGOUT);
|
return this.sendMessage(BridgeMessageType.LOGOUT);
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,4 +1 @@
|
|||||||
export { apiClient } from './api';
|
|
||||||
export { authService } from './auth';
|
|
||||||
export { tokenManager } from './tokenManager';
|
|
||||||
export { appBridge } from './appBridge';
|
export { appBridge } from './appBridge';
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// safeArea.ts
|
|
||||||
export function setSafeAreaInsets(top: number, right: number, bottom: number, left: number) {
|
|
||||||
const root = document.documentElement;
|
|
||||||
root.style.setProperty("--safe-area-inset-top", `${top}px`);
|
|
||||||
root.style.setProperty("--safe-area-inset-right", `${right}px`);
|
|
||||||
root.style.setProperty("--safe-area-inset-bottom", `${bottom}px`);
|
|
||||||
root.style.setProperty("--safe-area-inset-left", `${left}px`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전역에서 호출할 수 있게 window에 바인딩 (Android WebView 네이티브에서 실행할 수 있도록)
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
setSafeAreaInsets: typeof setSafeAreaInsets;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.setSafeAreaInsets = setSafeAreaInsets;
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
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 {
|
|
||||||
console.log('getAccessToken')
|
|
||||||
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