diff --git a/frontend/src/app/router.ts b/frontend/src/app/router.ts index bfeda872..197b04a9 100644 --- a/frontend/src/app/router.ts +++ b/frontend/src/app/router.ts @@ -3,7 +3,8 @@ import { createRouter, createWebHistory } from 'vue-router' export const router = createRouter({ history: createWebHistory(), routes: [ - { path: '/', redirect: '/home' }, + { path: '/', redirect: '/login' }, + { path: '/login', component: () => import('../features/auth/pages/LoginPage.vue'), meta: { title: 'Login' } }, { path: '/home', component: () => import('../features/home/pages/HomePage.vue'), meta: { screenId: 'SCR-000', templateId: 'T00', module: 'Home', section: 'Home', title: '홈', order: 0, favoriteAllowed: false } }, { path: '/research/sell-decision', component: () => import('../features/sell-decision/pages/SellDecisionPage.vue'), meta: { screenId: 'SCR-002', templateId: 'T03', module: 'Research', section: 'Research', title: '매도 의사결정', order: 1, favoriteAllowed: true } }, { path: '/ops/data-quality', component: () => import('../features/data-quality/pages/DataQualityPage.vue'), meta: { screenId: 'SCR-013', templateId: 'T08', module: 'Operations', section: 'Operations', title: '데이터 품질', order: 1, favoriteAllowed: true } }, diff --git a/frontend/src/features/auth/composables/__tests__/useAuthApi.test.ts b/frontend/src/features/auth/composables/__tests__/useAuthApi.test.ts new file mode 100644 index 00000000..5e4e9efe --- /dev/null +++ b/frontend/src/features/auth/composables/__tests__/useAuthApi.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useAuthApi } from '../useAuthApi' + +describe('useAuthApi', () => { + beforeEach(() => { + // Clear localStorage + localStorage.clear() + vi.clearAllMocks() + }) + + it('should initialize with no authentication', () => { + const { authState } = useAuthApi() + expect(authState.value.isAuthenticated).toBe(false) + expect(authState.value.token).toBeNull() + }) + + it('should login successfully', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + accessToken: 'test-token', + expiresIn: 3600, + tokenType: 'Bearer', + }), + }) + + const { login, authState } = useAuthApi() + const result = await login('testuser', 'password', 'Admin') + + expect(result).toBe(true) + expect(authState.value.token).toBe('test-token') + expect(authState.value.isAuthenticated).toBe(true) + expect(localStorage.getItem('kartsell_auth_token')).toBe('test-token') + }) + + it('should handle login failure', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ message: 'Invalid credentials' }), + }) + + const { login, authState, error } = useAuthApi() + const result = await login('testuser', 'wrongpassword', 'Admin') + + expect(result).toBe(false) + expect(authState.value.isAuthenticated).toBe(false) + expect(error.value).toBeTruthy() + }) + + it('should logout successfully', () => { + localStorage.setItem('kartsell_auth_token', 'test-token') + localStorage.setItem('kartsell_expires_at', (Date.now() + 3600000).toString()) + + const { logout, authState } = useAuthApi() + logout() + + expect(authState.value.token).toBeNull() + expect(authState.value.isAuthenticated).toBe(false) + expect(localStorage.getItem('kartsell_auth_token')).toBeNull() + }) + + it('should get token if valid', () => { + const expiresAt = Date.now() + 3600000 // 1 hour from now + localStorage.setItem('kartsell_auth_token', 'test-token') + localStorage.setItem('kartsell_expires_at', expiresAt.toString()) + + const { getToken } = useAuthApi() + const token = getToken() + + expect(token).toBe('test-token') + }) + + it('should clear token if expired', () => { + const expiresAt = Date.now() - 3600000 // 1 hour ago + localStorage.setItem('kartsell_auth_token', 'test-token') + localStorage.setItem('kartsell_expires_at', expiresAt.toString()) + + const { getToken, authState } = useAuthApi() + const token = getToken() + + expect(token).toBeNull() + expect(authState.value.isAuthenticated).toBe(false) + }) +}) diff --git a/frontend/src/features/auth/composables/useAuthApi.ts b/frontend/src/features/auth/composables/useAuthApi.ts new file mode 100644 index 00000000..0238cb6d --- /dev/null +++ b/frontend/src/features/auth/composables/useAuthApi.ts @@ -0,0 +1,163 @@ +import { ref, computed } from 'vue' + +interface LoginRequest { + username: string + password: string + role?: string +} + +interface LoginResponse { + accessToken: string + expiresIn: number + tokenType: string +} + +interface AuthState { + token: string | null + expiresAt: number | null + isAuthenticated: boolean +} + +const API_BASE = '/api' +const TOKEN_STORAGE_KEY = 'kartsell_auth_token' +const EXPIRES_AT_KEY = 'kartsell_expires_at' + +// Initialize from localStorage +function loadStoredAuth(): AuthState { + if (typeof window === 'undefined') { + return { token: null, expiresAt: null, isAuthenticated: false } + } + + const token = localStorage.getItem(TOKEN_STORAGE_KEY) + const expiresAtStr = localStorage.getItem(EXPIRES_AT_KEY) + const expiresAt = expiresAtStr ? parseInt(expiresAtStr, 10) : null + + return { + token, + expiresAt, + isAuthenticated: !!(token && expiresAt && expiresAt > Date.now()), + } +} + +export function useAuthApi() { + const loading = ref(false) + const error = ref(null) + + const authState = ref(loadStoredAuth()) + + const login = async (username: string, password: string, role?: string): Promise => { + loading.value = true + error.value = null + + try { + const response = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + password, + role: role || 'User', + } as LoginRequest), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Login failed' })) + throw new Error(errorData.message || `HTTP ${response.status}`) + } + + const data = await response.json() as LoginResponse + + // Store token and expiration + const expiresAt = Date.now() + data.expiresIn * 1000 + localStorage.setItem(TOKEN_STORAGE_KEY, data.accessToken) + localStorage.setItem(EXPIRES_AT_KEY, expiresAt.toString()) + + authState.value = { + token: data.accessToken, + expiresAt, + isAuthenticated: true, + } + + return true + } catch (err) { + error.value = err instanceof Error ? err.message : 'Login failed' + console.error('Login error:', err) + return false + } finally { + loading.value = false + } + } + + const logout = (): void => { + localStorage.removeItem(TOKEN_STORAGE_KEY) + localStorage.removeItem(EXPIRES_AT_KEY) + authState.value = { + token: null, + expiresAt: null, + isAuthenticated: false, + } + } + + const getToken = (): string | null => { + // Check if token is still valid + const expiresAt = authState.value.expiresAt + if (!authState.value.token || !expiresAt || expiresAt < Date.now()) { + logout() + return null + } + return authState.value.token + } + + const refreshAuthState = (): void => { + authState.value = loadStoredAuth() + } + + return { + // State + loading, + error, + authState: computed(() => authState.value), + + // Computed + isAuthenticated: computed(() => authState.value.isAuthenticated), + hasError: computed(() => error.value !== null), + + // Methods + login, + logout, + getToken, + refreshAuthState, + } +} + +// Global API interceptor - inject auth token into all requests +export function setupAuthInterceptor() { + const originalFetch = window.fetch + + window.fetch = function ( + input: RequestInfo | URL, + init?: RequestInit + ): Promise { + // Load token from localStorage + const token = localStorage.getItem(TOKEN_STORAGE_KEY) + const expiresAtStr = localStorage.getItem(EXPIRES_AT_KEY) + const expiresAt = expiresAtStr ? parseInt(expiresAtStr, 10) : null + + // Only add auth header if token is valid + if (token && expiresAt && expiresAt > Date.now()) { + const headers = new Headers(init?.headers || {}) + headers.set('Authorization', `Bearer ${token}`) + + return originalFetch(input, { + ...init, + headers, + }) + } + + return originalFetch(input, init) + } +} + +export type { LoginRequest, LoginResponse } diff --git a/frontend/src/features/auth/pages/LoginPage.vue b/frontend/src/features/auth/pages/LoginPage.vue new file mode 100644 index 00000000..03c7ec65 --- /dev/null +++ b/frontend/src/features/auth/pages/LoginPage.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index a3c8ce5a..7f5b5647 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -6,8 +6,12 @@ import { router } from './app/router' import { queryClient } from './app/queryClient' import { resolveUiProvider } from './shared/ui/provider' import { installKbx } from './app/installKbx' +import { setupAuthInterceptor } from './features/auth/composables/useAuthApi' import './design-system/base.css' +// Setup JWT auth interceptor - adds Authorization header to all fetch requests +setupAuthInterceptor() + const app = createApp(App) app.use(createPinia()) app.use(router)