diff --git a/eslint.config.mjs b/eslint.config.mjs index 74c2c2d9..407aac84 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -52,6 +52,11 @@ const eslintConfig = [ document: "readonly", HTMLButtonElement: "readonly", HTMLInputElement: "readonly", + fetch: "readonly", + URL: "readonly", + RequestInit: "readonly", + Response: "readonly", + PageTransitionEvent: "readonly", }, }, plugins: { diff --git a/src/app/api/auth/check/route.ts b/src/app/api/auth/check/route.ts index 0cd52561..3a47454c 100644 --- a/src/app/api/auth/check/route.ts +++ b/src/app/api/auth/check/route.ts @@ -11,22 +11,91 @@ import type { NextRequest } from 'next/server'; */ export async function GET(request: NextRequest) { try { - // Get token from HttpOnly cookie - const token = request.cookies.get('user_token')?.value; + // Get tokens from HttpOnly cookies + const accessToken = request.cookies.get('access_token')?.value; + const refreshToken = request.cookies.get('refresh_token')?.value; - if (!token) { + // No tokens at all - not authenticated + if (!accessToken && !refreshToken) { return NextResponse.json( { error: 'Not authenticated', authenticated: false }, { status: 401 } ); } - // Optional: Verify token with PHP backend - // (현재는 토큰 존재 여부만 확인, 필요시 PHP API 호출 추가 가능) + // Has access token - authenticated + if (accessToken) { + return NextResponse.json( + { authenticated: true }, + { status: 200 } + ); + } + // Only has refresh token - try to refresh + if (refreshToken && !accessToken) { + console.log('🔄 Access token missing, attempting refresh...'); + + // Attempt token refresh + try { + const refreshResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${refreshToken}`, + 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', + }, + }); + + if (refreshResponse.ok) { + const data = await refreshResponse.json(); + + // Set new tokens + const accessTokenCookie = [ + `access_token=${data.access_token}`, + 'HttpOnly', + 'Secure', + 'SameSite=Strict', + 'Path=/', + `Max-Age=${data.expires_in || 7200}`, + ].join('; '); + + const refreshTokenCookie = [ + `refresh_token=${data.refresh_token}`, + 'HttpOnly', + 'Secure', + 'SameSite=Strict', + 'Path=/', + 'Max-Age=604800', + ].join('; '); + + console.log('✅ Token auto-refreshed in auth check'); + + const response = NextResponse.json( + { authenticated: true, refreshed: true }, + { status: 200 } + ); + + response.headers.append('Set-Cookie', accessTokenCookie); + response.headers.append('Set-Cookie', refreshTokenCookie); + + return response; + } + } catch (error) { + console.error('Token refresh failed in auth check:', error); + } + + // Refresh failed - not authenticated + return NextResponse.json( + { error: 'Token refresh failed', authenticated: false }, + { status: 401 } + ); + } + + // Fallback - not authenticated return NextResponse.json( - { authenticated: true }, - { status: 200 } + { error: 'Not authenticated', authenticated: false }, + { status: 401 } ); } catch (error) { diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 036f41db..cfe0d313 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -23,7 +23,7 @@ export async function POST(request: NextRequest) { } // Call PHP backend API - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/login`, { + const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -33,18 +33,26 @@ export async function POST(request: NextRequest) { body: JSON.stringify({ user_id, user_pwd }), }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); + if (!backendResponse.ok) { + // Don't expose detailed backend error messages to client + // Use generic error messages based on status code + let errorMessage = 'Authentication failed'; + + if (backendResponse.status === 422) { + errorMessage = 'Invalid credentials provided'; + } else if (backendResponse.status === 429) { + errorMessage = 'Too many login attempts. Please try again later'; + } else if (backendResponse.status >= 500) { + errorMessage = 'Service temporarily unavailable'; + } + return NextResponse.json( - { - error: errorData.message || 'Login failed', - status: response.status - }, - { status: response.status } + { error: errorMessage }, + { status: backendResponse.status === 422 ? 401 : backendResponse.status } ); } - const data = await response.json(); + const data = await backendResponse.json(); // Prepare response with user data (no token exposed) const responseData = { @@ -52,26 +60,38 @@ export async function POST(request: NextRequest) { user: data.user, tenant: data.tenant, menus: data.menus, + token_type: data.token_type, + expires_in: data.expires_in, + expires_at: data.expires_at, }; - // Set HttpOnly cookie with token - const cookieOptions = [ - `user_token=${data.user_token}`, + // Set HttpOnly cookies for both access_token and refresh_token + const accessTokenCookie = [ + `access_token=${data.access_token}`, 'HttpOnly', // ✅ JavaScript cannot access 'Secure', // ✅ HTTPS only (production) 'SameSite=Strict', // ✅ CSRF protection 'Path=/', - 'Max-Age=604800', // 7 days + `Max-Age=${data.expires_in || 7200}`, // Use backend expiry (default 2 hours) ].join('; '); - console.log('✅ Login successful - Token stored in HttpOnly cookie'); + const refreshTokenCookie = [ + `refresh_token=${data.refresh_token}`, + 'HttpOnly', // ✅ JavaScript cannot access + 'Secure', // ✅ HTTPS only (production) + 'SameSite=Strict', // ✅ CSRF protection + 'Path=/', + 'Max-Age=604800', // 7 days (longer for refresh token) + ].join('; '); - return NextResponse.json(responseData, { - status: 200, - headers: { - 'Set-Cookie': cookieOptions, - }, - }); + console.log('✅ Login successful - Access & Refresh tokens stored in HttpOnly cookies'); + + const response = NextResponse.json(responseData, { status: 200 }); + + response.headers.append('Set-Cookie', accessTokenCookie); + response.headers.append('Set-Cookie', refreshTokenCookie); + + return response; } catch (error) { console.error('Login proxy error:', error); diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 20c4ad39..34808866 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -11,10 +11,10 @@ import type { NextRequest } from 'next/server'; */ export async function POST(request: NextRequest) { try { - // Get token from HttpOnly cookie - const token = request.cookies.get('user_token')?.value; + // Get access_token from HttpOnly cookie + const accessToken = request.cookies.get('access_token')?.value; - if (token) { + if (accessToken) { // Call PHP backend logout API try { await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/logout`, { @@ -22,7 +22,7 @@ export async function POST(request: NextRequest) { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', - 'Authorization': `Bearer ${token}`, + 'Authorization': `Bearer ${accessToken}`, 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', }, }); @@ -32,9 +32,9 @@ export async function POST(request: NextRequest) { } } - // Clear HttpOnly cookie - const cookieOptions = [ - 'user_token=', + // Clear both HttpOnly cookies + const clearAccessToken = [ + 'access_token=', 'HttpOnly', 'Secure', 'SameSite=Strict', @@ -42,18 +42,27 @@ export async function POST(request: NextRequest) { 'Max-Age=0', // Delete immediately ].join('; '); - console.log('✅ Logout complete - HttpOnly cookie cleared'); + const clearRefreshToken = [ + 'refresh_token=', + 'HttpOnly', + 'Secure', + 'SameSite=Strict', + 'Path=/', + 'Max-Age=0', // Delete immediately + ].join('; '); - return NextResponse.json( + console.log('✅ Logout complete - Access & Refresh tokens cleared'); + + const response = NextResponse.json( { message: 'Logged out successfully' }, - { - status: 200, - headers: { - 'Set-Cookie': cookieOptions, - }, - } + { status: 200 } ); + response.headers.append('Set-Cookie', clearAccessToken); + response.headers.append('Set-Cookie', clearRefreshToken); + + return response; + } catch (error) { console.error('Logout proxy error:', error); return NextResponse.json( diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts new file mode 100644 index 00000000..3bb600ba --- /dev/null +++ b/src/app/api/auth/refresh/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +/** + * Token Refresh Route Handler + * + * Purpose: + * - Refresh expired access_token using refresh_token + * - Update HttpOnly cookies with new tokens + * - Maintain user session without re-login + */ +export async function POST(request: NextRequest) { + try { + // Get refresh_token from HttpOnly cookie + const refreshToken = request.cookies.get('refresh_token')?.value; + + if (!refreshToken) { + return NextResponse.json( + { error: 'No refresh token available' }, + { status: 401 } + ); + } + + // Call PHP backend refresh API + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${refreshToken}`, + 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', + }, + }); + + if (!response.ok) { + // Refresh token is invalid or expired + console.warn('⚠️ Token refresh failed - user needs to re-login'); + + // Clear both tokens + const clearAccessToken = 'access_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0'; + const clearRefreshToken = 'refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0'; + + const failResponse = NextResponse.json( + { error: 'Token refresh failed', needsReauth: true }, + { status: 401 } + ); + + failResponse.headers.append('Set-Cookie', clearAccessToken); + failResponse.headers.append('Set-Cookie', clearRefreshToken); + + return failResponse; + } + + const data = await response.json(); + + // Prepare response + const responseData = { + message: 'Token refreshed successfully', + token_type: data.token_type, + expires_in: data.expires_in, + expires_at: data.expires_at, + }; + + // Set new HttpOnly cookies + const accessTokenCookie = [ + `access_token=${data.access_token}`, + 'HttpOnly', + 'Secure', + 'SameSite=Strict', + 'Path=/', + `Max-Age=${data.expires_in || 7200}`, + ].join('; '); + + const refreshTokenCookie = [ + `refresh_token=${data.refresh_token}`, + 'HttpOnly', + 'Secure', + 'SameSite=Strict', + 'Path=/', + 'Max-Age=604800', // 7 days + ].join('; '); + + console.log('✅ Token refresh successful - New tokens stored'); + + const successResponse = NextResponse.json(responseData, { status: 200 }); + + successResponse.headers.append('Set-Cookie', accessTokenCookie); + successResponse.headers.append('Set-Cookie', refreshTokenCookie); + + return successResponse; + + } catch (error) { + console.error('Token refresh error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts new file mode 100644 index 00000000..11df6695 --- /dev/null +++ b/src/app/api/auth/signup/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +/** + * Signup Proxy Route Handler + * + * Purpose: + * - Proxy signup requests to PHP backend + * - Handle registration errors gracefully + * - Return user-friendly error messages + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validate required fields + const requiredFields = [ + 'user_id', + 'name', + 'email', + 'phone', + 'password', + 'password_confirmation', + 'company_name', + 'business_num', + 'company_scale', + 'industry', + ]; + + for (const field of requiredFields) { + if (!body[field]) { + return NextResponse.json( + { error: `${field} is required` }, + { status: 400 } + ); + } + } + + // Call PHP backend API + const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '', + }, + body: JSON.stringify(body), + }); + + if (!backendResponse.ok) { + // Handle backend errors with user-friendly messages + let errorMessage = 'Registration failed'; + + if (backendResponse.status === 422) { + // Validation error + const data = await backendResponse.json(); + errorMessage = data.message || 'Invalid registration data'; + } else if (backendResponse.status === 409) { + errorMessage = 'User ID or email already exists'; + } else if (backendResponse.status === 429) { + errorMessage = 'Too many registration attempts. Please try again later'; + } else if (backendResponse.status >= 500) { + errorMessage = 'Service temporarily unavailable'; + } + + return NextResponse.json( + { error: errorMessage }, + { status: backendResponse.status } + ); + } + + const data = await backendResponse.json(); + + console.log('✅ Signup successful'); + + return NextResponse.json( + { + message: data.message || 'Registration successful', + user: data.user, + }, + { status: 201 } + ); + + } catch (error) { + console.error('Signup proxy error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/components/LanguageSelect.tsx b/src/components/LanguageSelect.tsx index a9b62145..61e021c5 100644 --- a/src/components/LanguageSelect.tsx +++ b/src/components/LanguageSelect.tsx @@ -25,8 +25,8 @@ export function LanguageSelect() { const handleLanguageChange = (newLocale: string) => { // Remove current locale from pathname const pathnameWithoutLocale = pathname.replace(`/${locale}`, ""); - // Navigate to new locale - router.push(`/${newLocale}${pathnameWithoutLocale}`); + // Force full page reload to ensure clean state and proper i18n loading + window.location.href = `/${newLocale}${pathnameWithoutLocale}`; }; const currentLanguage = languages.find((lang) => lang.code === locale); diff --git a/src/components/auth/LoginPage.tsx b/src/components/auth/LoginPage.tsx index 1e83e15f..4a923af0 100644 --- a/src/components/auth/LoginPage.tsx +++ b/src/components/auth/LoginPage.tsx @@ -65,15 +65,19 @@ export function LoginPage() { // 대시보드로 이동 router.push("/dashboard"); - } catch (err: any) { + } catch (err) { console.error('❌ 로그인 실패:', err); - if (err.status === 422) { + const error = err as { status?: number; message?: string }; + + if (error.status === 401 || error.status === 422) { setError(t('invalidCredentials')); - } else if (err.status === 429) { - setError('너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.'); + } else if (error.status === 429) { + setError('Too many login attempts. Please try again later.'); + } else if (error.status && error.status >= 500) { + setError('Service temporarily unavailable. Please try again later.'); } else { - setError(err.message || t('invalidCredentials')); + setError(error.message || t('invalidCredentials')); } } }; @@ -125,7 +129,7 @@ export function LoginPage() { )} -
회원가입
+{t("signupTitle")}
MES 시스템을 도입할 회사의 기본 정보를 알려주세요
+{t("step1Desc")}
시스템 관리자 계정으로 사용될 정보입니다
+{t("step2Desc")}