Merge feature/theme-language-selector into master
- 테마 선택 및 언어 선택 기능 추가 - 보호된 대시보드 및 API 라우트 추가 - 인증 시스템 구현 - 미들웨어 인증 로직 강화
This commit is contained in:
39
.env.example
Normal file
39
.env.example
Normal file
@@ -0,0 +1,39 @@
|
||||
# ==============================================
|
||||
# API Configuration
|
||||
# ==============================================
|
||||
NEXT_PUBLIC_API_URL=https://api.5130.co.kr
|
||||
|
||||
# Frontend URL (for CORS)
|
||||
NEXT_PUBLIC_FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# ==============================================
|
||||
# Authentication Mode
|
||||
# ==============================================
|
||||
# 인증 모드 선택: sanctum | bearer
|
||||
# - sanctum: 웹 브라우저 사용자 (HTTP-only 쿠키)
|
||||
# - bearer: 모바일/SPA (토큰 기반)
|
||||
NEXT_PUBLIC_AUTH_MODE=sanctum
|
||||
|
||||
# ==============================================
|
||||
# API Key (⚠️ 서버 사이드 전용 - 절대 공개 금지!)
|
||||
# ==============================================
|
||||
# 개발팀 공유: 팀 내부 문서에서 키 값 확인
|
||||
# 주기적 갱신: PHP 백엔드 팀에서 새 키 발급 시 업데이트 필요
|
||||
#
|
||||
# ⚠️ 주의사항:
|
||||
# 1. 절대 NEXT_PUBLIC_ 접두사 붙이지 말 것!
|
||||
# 2. Git에 커밋하지 말 것! (.gitignore에 포함됨)
|
||||
# 3. 브라우저에서 접근 불가 (서버 사이드 전용)
|
||||
#
|
||||
# 사용처:
|
||||
# - 서버 간 통신 (Next.js API Routes)
|
||||
# - 백그라운드 작업 (Cron, Scripts)
|
||||
# - 외부 시스템 연동
|
||||
API_KEY=your-secret-api-key-here
|
||||
|
||||
# ==============================================
|
||||
# Development Notes
|
||||
# ==============================================
|
||||
# 1. .env.example을 복사하여 .env.local 생성
|
||||
# 2. .env.local에 실제 키 값 입력
|
||||
# 3. .env.local은 Git에 커밋되지 않음
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -97,3 +97,5 @@ build/
|
||||
|
||||
# ---> Claude
|
||||
claudedocs/
|
||||
.env.local
|
||||
.env*.local
|
||||
|
||||
@@ -47,6 +47,11 @@ const eslintConfig = [
|
||||
__dirname: "readonly",
|
||||
__filename: "readonly",
|
||||
Buffer: "readonly",
|
||||
localStorage: "readonly",
|
||||
window: "readonly",
|
||||
document: "readonly",
|
||||
HTMLButtonElement: "readonly",
|
||||
HTMLInputElement: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import createNextIntlPlugin from 'next-intl/plugin';
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
|
||||
1077
package-lock.json
generated
1077
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "^15.5.6",
|
||||
"next-intl": "^4.4.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
101
src/app/[locale]/(protected)/dashboard/page.tsx
Normal file
101
src/app/[locale]/(protected)/dashboard/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
import WelcomeMessage from '@/components/WelcomeMessage';
|
||||
import NavigationMenu from '@/components/NavigationMenu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Dashboard Page with Internationalization
|
||||
*
|
||||
* Note: Authentication protection is handled by (protected)/layout.tsx
|
||||
*/
|
||||
export default function Dashboard() {
|
||||
const t = useTranslations('common');
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// ✅ HttpOnly Cookie 방식: Next.js API Route로 프록시
|
||||
const response = await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('✅ 로그아웃 완료: HttpOnly 쿠키 삭제됨');
|
||||
}
|
||||
|
||||
// 로그인 페이지로 리다이렉트
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.error('로그아웃 처리 중 오류:', error);
|
||||
// 에러가 나도 로그인 페이지로 이동
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header with Language Switcher */}
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('appName')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLogout}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="space-y-8">
|
||||
{/* Welcome Section */}
|
||||
<WelcomeMessage />
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
{t('appName')} Modules
|
||||
</h2>
|
||||
<NavigationMenu />
|
||||
</div>
|
||||
|
||||
{/* Information Section */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-100">
|
||||
Multi-language Support
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
This ERP system supports Korean (한국어), English, and Japanese (日本語).
|
||||
Use the language switcher above to change the interface language.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Developer Info */}
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
For Developers
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-2">
|
||||
Check the documentation in <code className="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">claudedocs/i18n-usage-guide.md</code>
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Message files: <code className="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">src/messages/</code>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/app/[locale]/(protected)/layout.tsx
Normal file
29
src/app/[locale]/(protected)/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
|
||||
/**
|
||||
* Protected Layout
|
||||
*
|
||||
* Purpose:
|
||||
* - Apply authentication guard to all protected pages
|
||||
* - Prevent browser back button cache issues
|
||||
* - Centralized protection for all routes under (protected)
|
||||
*
|
||||
* Protected Routes:
|
||||
* - /dashboard
|
||||
* - /profile
|
||||
* - /settings
|
||||
* - /admin/*
|
||||
* - All other authenticated pages
|
||||
*/
|
||||
export default function ProtectedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// 🔒 모든 하위 페이지에 인증 보호 적용
|
||||
useAuthGuard();
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,20 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { locales } from '@/i18n/config';
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import { locales, type Locale } from '@/i18n/config';
|
||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||
import "../globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -65,7 +55,7 @@ export default async function RootLayout({
|
||||
const { locale } = await params;
|
||||
|
||||
// Ensure the incoming locale is valid
|
||||
if (!locales.includes(locale as any)) {
|
||||
if (!locales.includes(locale as Locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -74,12 +64,12 @@ export default async function RootLayout({
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
<body className="antialiased">
|
||||
<ThemeProvider>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
5
src/app/[locale]/login/page.tsx
Normal file
5
src/app/[locale]/login/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LoginPage } from "@/components/auth/LoginPage";
|
||||
|
||||
export default function Login() {
|
||||
return <LoginPage />;
|
||||
}
|
||||
@@ -1,65 +1,17 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
import WelcomeMessage from '@/components/WelcomeMessage';
|
||||
import NavigationMenu from '@/components/NavigationMenu';
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Home Page with Internationalization
|
||||
*
|
||||
* Demonstrates i18n implementation in Next.js 16 with next-intl
|
||||
* Root Page - Redirects to Login
|
||||
*/
|
||||
export default function Home() {
|
||||
const t = useTranslations('common');
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header with Language Switcher */}
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('appName')}
|
||||
</h1>
|
||||
<LanguageSwitcher />
|
||||
</header>
|
||||
useEffect(() => {
|
||||
router.replace('/login');
|
||||
}, [router]);
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="space-y-8">
|
||||
{/* Welcome Section */}
|
||||
<WelcomeMessage />
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
{t('appName')} Modules
|
||||
</h2>
|
||||
<NavigationMenu />
|
||||
</div>
|
||||
|
||||
{/* Information Section */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-100">
|
||||
Multi-language Support
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
This ERP system supports Korean (한국어), English, and Japanese (日本語).
|
||||
Use the language switcher above to change the interface language.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Developer Info */}
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
For Developers
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-2">
|
||||
Check the documentation in <code className="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">claudedocs/i18n-usage-guide.md</code>
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Message files: <code className="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">src/messages/</code>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
5
src/app/[locale]/signup/page.tsx
Normal file
5
src/app/[locale]/signup/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SignupPage } from "@/components/auth/SignupPage";
|
||||
|
||||
export default function Signup() {
|
||||
return <SignupPage />;
|
||||
}
|
||||
39
src/app/api/auth/check/route.ts
Normal file
39
src/app/api/auth/check/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Auth Check Route Handler
|
||||
*
|
||||
* Purpose:
|
||||
* - Check if user is authenticated (HttpOnly cookie validation)
|
||||
* - Prevent browser back button cache issues
|
||||
* - Real-time authentication validation
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get token from HttpOnly cookie
|
||||
const token = request.cookies.get('user_token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authenticated', authenticated: false },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Optional: Verify token with PHP backend
|
||||
// (현재는 토큰 존재 여부만 확인, 필요시 PHP API 호출 추가 가능)
|
||||
|
||||
return NextResponse.json(
|
||||
{ authenticated: true },
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Auth check error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error', authenticated: false },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
83
src/app/api/auth/login/route.ts
Normal file
83
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Login Proxy Route Handler
|
||||
*
|
||||
* Purpose:
|
||||
* - Proxy login requests to PHP backend
|
||||
* - Store token in HttpOnly cookie (XSS protection)
|
||||
* - Never expose token to client JavaScript
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { user_id, user_pwd } = body;
|
||||
|
||||
// Validate input
|
||||
if (!user_id || !user_pwd) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User ID and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Call PHP backend API
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify({ user_id, user_pwd }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: errorData.message || 'Login failed',
|
||||
status: response.status
|
||||
},
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Prepare response with user data (no token exposed)
|
||||
const responseData = {
|
||||
message: data.message,
|
||||
user: data.user,
|
||||
tenant: data.tenant,
|
||||
menus: data.menus,
|
||||
};
|
||||
|
||||
// Set HttpOnly cookie with token
|
||||
const cookieOptions = [
|
||||
`user_token=${data.user_token}`,
|
||||
'HttpOnly', // ✅ JavaScript cannot access
|
||||
'Secure', // ✅ HTTPS only (production)
|
||||
'SameSite=Strict', // ✅ CSRF protection
|
||||
'Path=/',
|
||||
'Max-Age=604800', // 7 days
|
||||
].join('; ');
|
||||
|
||||
console.log('✅ Login successful - Token stored in HttpOnly cookie');
|
||||
|
||||
return NextResponse.json(responseData, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Set-Cookie': cookieOptions,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
64
src/app/api/auth/logout/route.ts
Normal file
64
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Logout Proxy Route Handler
|
||||
*
|
||||
* Purpose:
|
||||
* - Call PHP backend logout API
|
||||
* - Clear HttpOnly cookie
|
||||
* - Ensure complete session cleanup
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get token from HttpOnly cookie
|
||||
const token = request.cookies.get('user_token')?.value;
|
||||
|
||||
if (token) {
|
||||
// Call PHP backend logout API
|
||||
try {
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
},
|
||||
});
|
||||
console.log('✅ Backend logout API called successfully');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Backend logout API failed (continuing with cookie deletion):', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear HttpOnly cookie
|
||||
const cookieOptions = [
|
||||
'user_token=',
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
'Path=/',
|
||||
'Max-Age=0', // Delete immediately
|
||||
].join('; ');
|
||||
|
||||
console.log('✅ Logout complete - HttpOnly cookie cleared');
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Logged out successfully' },
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Set-Cookie': cookieOptions,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Logout proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
317
src/app/globals.css
Normal file
317
src/app/globals.css
Normal file
@@ -0,0 +1,317 @@
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css');
|
||||
@import "tailwindcss";
|
||||
|
||||
@variant dark (&:is(.dark *));
|
||||
@variant senior (&:is(.senior *));
|
||||
|
||||
:root {
|
||||
--font-size: 16px;
|
||||
/* Clean minimalist background */
|
||||
--background: #FAFAFA;
|
||||
--foreground: #1A1A1A;
|
||||
--card: #FFFFFF;
|
||||
--card-foreground: #1A1A1A;
|
||||
--popover: #FFFFFF;
|
||||
--popover-foreground: #1A1A1A;
|
||||
/* Modern blue primary palette */
|
||||
--primary: #3B82F6;
|
||||
--primary-foreground: #FFFFFF;
|
||||
--secondary: #F1F5F9;
|
||||
--secondary-foreground: #1A1A1A;
|
||||
--muted: #F8FAFC;
|
||||
--muted-foreground: #6B7280;
|
||||
--accent: #F0F9FF;
|
||||
--accent-foreground: #1A1A1A;
|
||||
/* Clean destructive color */
|
||||
--destructive: #EF4444;
|
||||
--destructive-foreground: #FFFFFF;
|
||||
--border: #E2E8F0;
|
||||
--input: #FFFFFF;
|
||||
--input-background: #F8FAFC;
|
||||
--switch-background: #F1F5F9;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-normal: 400;
|
||||
--ring: rgba(59, 130, 246, 0.3);
|
||||
/* Clean chart colors */
|
||||
--chart-1: #3B82F6;
|
||||
--chart-2: #10B981;
|
||||
--chart-3: #F59E0B;
|
||||
--chart-4: #EF4444;
|
||||
--chart-5: #8B5CF6;
|
||||
/* Minimal rounded corners */
|
||||
--radius: 0.75rem;
|
||||
/* Clean sidebar */
|
||||
--sidebar: #FFFFFF;
|
||||
--sidebar-foreground: #1A1A1A;
|
||||
--sidebar-primary: #3B82F6;
|
||||
--sidebar-primary-foreground: #FFFFFF;
|
||||
--sidebar-accent: #F0F9FF;
|
||||
--sidebar-accent-foreground: #1A1A1A;
|
||||
--sidebar-border: #E2E8F0;
|
||||
--sidebar-ring: rgba(59, 130, 246, 0.3);
|
||||
|
||||
/* Clean design tokens */
|
||||
--clean-blur: blur(8px);
|
||||
--clean-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--clean-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--clean-shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
--clean-shadow-xl: 0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Clean dark theme */
|
||||
--background: #0F172A;
|
||||
--foreground: #F8FAFC;
|
||||
--card: #1E293B;
|
||||
--card-foreground: #F8FAFC;
|
||||
--popover: #1E293B;
|
||||
--popover-foreground: #F8FAFC;
|
||||
--primary: #60A5FA;
|
||||
--primary-foreground: #1E293B;
|
||||
--secondary: #334155;
|
||||
--secondary-foreground: #F8FAFC;
|
||||
--muted: #475569;
|
||||
--muted-foreground: #94A3B8;
|
||||
--accent: #1E40AF;
|
||||
--accent-foreground: #F8FAFC;
|
||||
--destructive: #F87171;
|
||||
--destructive-foreground: #1E293B;
|
||||
--border: #334155;
|
||||
--input: #1E293B;
|
||||
--input-background: #334155;
|
||||
--switch-background: #475569;
|
||||
--ring: rgba(96, 165, 250, 0.3);
|
||||
|
||||
/* Dark mode chart colors */
|
||||
--chart-1: #60A5FA;
|
||||
--chart-2: #34D399;
|
||||
--chart-3: #FBBF24;
|
||||
--chart-4: #F87171;
|
||||
--chart-5: #A78BFA;
|
||||
|
||||
/* Dark sidebar */
|
||||
--sidebar: #1E293B;
|
||||
--sidebar-foreground: #F8FAFC;
|
||||
--sidebar-primary: #60A5FA;
|
||||
--sidebar-primary-foreground: #1E293B;
|
||||
--sidebar-accent: #1E40AF;
|
||||
--sidebar-accent-foreground: #F8FAFC;
|
||||
--sidebar-border: #334155;
|
||||
--sidebar-ring: rgba(96, 165, 250, 0.3);
|
||||
|
||||
/* Dark mode clean shadows */
|
||||
--clean-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--clean-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--clean-shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
--clean-shadow-xl: 0 10px 15px rgba(0, 0, 0, 0.4), 0 4px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.senior {
|
||||
/* Senior-friendly theme - High contrast, larger text */
|
||||
--font-size: 18px;
|
||||
--background: #FFFFFF;
|
||||
--foreground: #000000;
|
||||
--card: #FFFFFF;
|
||||
--card-foreground: #000000;
|
||||
--popover: #FFFFFF;
|
||||
--popover-foreground: #000000;
|
||||
/* High contrast primary */
|
||||
--primary: #2563EB;
|
||||
--primary-foreground: #FFFFFF;
|
||||
--secondary: #E5E7EB;
|
||||
--secondary-foreground: #000000;
|
||||
--muted: #F3F4F6;
|
||||
--muted-foreground: #374151;
|
||||
--accent: #DBEAFE;
|
||||
--accent-foreground: #000000;
|
||||
/* High contrast destructive */
|
||||
--destructive: #DC2626;
|
||||
--destructive-foreground: #FFFFFF;
|
||||
--border: #9CA3AF;
|
||||
--input: #FFFFFF;
|
||||
--input-background: #F9FAFB;
|
||||
--switch-background: #E5E7EB;
|
||||
--font-weight-medium: 600;
|
||||
--font-weight-normal: 500;
|
||||
--ring: rgba(37, 99, 235, 0.5);
|
||||
|
||||
/* Senior mode chart colors - High contrast */
|
||||
--chart-1: #2563EB;
|
||||
--chart-2: #059669;
|
||||
--chart-3: #D97706;
|
||||
--chart-4: #DC2626;
|
||||
--chart-5: #7C3AED;
|
||||
|
||||
/* Senior sidebar */
|
||||
--sidebar: #FFFFFF;
|
||||
--sidebar-foreground: #000000;
|
||||
--sidebar-primary: #2563EB;
|
||||
--sidebar-primary-foreground: #FFFFFF;
|
||||
--sidebar-accent: #DBEAFE;
|
||||
--sidebar-accent-foreground: #000000;
|
||||
--sidebar-border: #9CA3AF;
|
||||
--sidebar-ring: rgba(37, 99, 235, 0.5);
|
||||
|
||||
/* Larger shadows for better depth perception */
|
||||
--clean-shadow: 0 2px 4px rgba(0, 0, 0, 0.15), 0 2px 3px rgba(0, 0, 0, 0.1);
|
||||
--clean-shadow-lg: 0 6px 8px rgba(0, 0, 0, 0.12), 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-input-background: var(--input-background);
|
||||
--color-switch-background: var(--switch-background);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
font-feature-settings: "kern" 1, "liga" 1, "calt" 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
background: var(--background);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Clean glass utilities */
|
||||
.clean-glass {
|
||||
backdrop-filter: var(--clean-blur);
|
||||
-webkit-backdrop-filter: var(--clean-blur);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dark .clean-glass {
|
||||
background: rgba(30, 41, 59, 0.95);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Clean shadows */
|
||||
.clean-shadow-sm {
|
||||
box-shadow: var(--clean-shadow-sm);
|
||||
}
|
||||
|
||||
.clean-shadow {
|
||||
box-shadow: var(--clean-shadow);
|
||||
}
|
||||
|
||||
.clean-shadow-lg {
|
||||
box-shadow: var(--clean-shadow-lg);
|
||||
}
|
||||
|
||||
.clean-shadow-xl {
|
||||
box-shadow: var(--clean-shadow-xl);
|
||||
}
|
||||
|
||||
/* Smooth transitions for all interactive elements */
|
||||
* {
|
||||
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Clean modern component styles */
|
||||
@layer components {
|
||||
.clean-input {
|
||||
@apply bg-input-background border border-border rounded-lg px-4 py-3;
|
||||
@apply focus:bg-card focus:border-primary focus:ring-2 focus:ring-primary/20;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
}
|
||||
|
||||
.clean-input::placeholder {
|
||||
@apply text-muted-foreground;
|
||||
}
|
||||
|
||||
.clean-button {
|
||||
@apply rounded-lg px-6 py-3 font-medium transition-colors duration-200;
|
||||
@apply bg-primary text-primary-foreground hover:bg-primary/90;
|
||||
}
|
||||
|
||||
.clean-button-secondary {
|
||||
@apply rounded-lg px-6 py-3 font-medium transition-colors duration-200;
|
||||
@apply bg-secondary text-secondary-foreground hover:bg-secondary/80;
|
||||
}
|
||||
|
||||
.clean-card {
|
||||
@apply bg-card border border-border rounded-lg p-6;
|
||||
@apply transition-shadow duration-200;
|
||||
box-shadow: var(--clean-shadow);
|
||||
}
|
||||
|
||||
.clean-card:hover {
|
||||
box-shadow: var(--clean-shadow-lg);
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
59
src/components/LanguageSelect.tsx
Normal file
59
src/components/LanguageSelect.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useLocale } from "next-intl";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
const languages = [
|
||||
{ code: "ko", label: "한국어", flag: "🇰🇷" },
|
||||
{ code: "en", label: "English", flag: "🇺🇸" },
|
||||
{ code: "ja", label: "日本語", flag: "🇯🇵" },
|
||||
];
|
||||
|
||||
export function LanguageSelect() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const locale = useLocale();
|
||||
|
||||
const handleLanguageChange = (newLocale: string) => {
|
||||
// Remove current locale from pathname
|
||||
const pathnameWithoutLocale = pathname.replace(`/${locale}`, "");
|
||||
// Navigate to new locale
|
||||
router.push(`/${newLocale}${pathnameWithoutLocale}`);
|
||||
};
|
||||
|
||||
const currentLanguage = languages.find((lang) => lang.code === locale);
|
||||
|
||||
return (
|
||||
<Select value={locale} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger className="w-[140px] rounded-xl border-border/50 bg-background/50 backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
<SelectValue>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{currentLanguage?.flag}</span>
|
||||
<span className="text-sm">{currentLanguage?.label}</span>
|
||||
</span>
|
||||
</SelectValue>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -15,14 +15,14 @@ export default function NavigationMenu() {
|
||||
const locale = useLocale();
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'dashboard', href: '/dashboard' },
|
||||
{ key: 'inventory', href: '/inventory' },
|
||||
{ key: 'finance', href: '/finance' },
|
||||
{ key: 'hr', href: '/hr' },
|
||||
{ key: 'crm', href: '/crm' },
|
||||
{ key: 'reports', href: '/reports' },
|
||||
{ key: 'settings', href: '/settings' },
|
||||
];
|
||||
{ key: 'dashboard' as const, href: '/dashboard' },
|
||||
{ key: 'inventory' as const, href: '/inventory' },
|
||||
{ key: 'finance' as const, href: '/finance' },
|
||||
{ key: 'hr' as const, href: '/hr' },
|
||||
{ key: 'crm' as const, href: '/crm' },
|
||||
{ key: 'reports' as const, href: '/reports' },
|
||||
{ key: 'settings' as const, href: '/settings' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<nav className="bg-gray-100 dark:bg-gray-900 p-4 rounded-lg">
|
||||
@@ -33,7 +33,7 @@ export default function NavigationMenu() {
|
||||
href={`/${locale}${item.href}`}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
{t(item.key as any)}
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
||||
50
src/components/ThemeSelect.tsx
Normal file
50
src/components/ThemeSelect.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Sun, Moon, Accessibility } from "lucide-react";
|
||||
|
||||
const themes = [
|
||||
{ value: "light", label: "일반 모드", icon: Sun, color: "text-orange-500" },
|
||||
{ value: "dark", label: "다크 모드", icon: Moon, color: "text-blue-400" },
|
||||
{ value: "senior", label: "시니어 모드", icon: Accessibility, color: "text-green-500" },
|
||||
];
|
||||
|
||||
export function ThemeSelect() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const currentTheme = themes.find((t) => t.value === theme);
|
||||
const CurrentIcon = currentTheme?.icon || Sun;
|
||||
|
||||
return (
|
||||
<Select value={theme} onValueChange={(value) => setTheme(value as "light" | "dark" | "senior")}>
|
||||
<SelectTrigger className="w-[160px] rounded-xl border-border/50 bg-background/50 backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<CurrentIcon className={`w-4 h-4 ${currentTheme?.color}`} />
|
||||
<SelectValue>
|
||||
<span className="text-sm">{currentTheme?.label}</span>
|
||||
</SelectValue>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{themes.map((themeOption) => {
|
||||
const Icon = themeOption.icon;
|
||||
return (
|
||||
<SelectItem key={themeOption.value} value={themeOption.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`w-4 h-4 ${themeOption.color}`} />
|
||||
<span>{themeOption.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
234
src/components/auth/LoginPage.tsx
Normal file
234
src/components/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { LanguageSelect } from "@/components/LanguageSelect";
|
||||
import { ThemeSelect } from "@/components/ThemeSelect";
|
||||
import {
|
||||
User,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowRight
|
||||
} from "lucide-react";
|
||||
|
||||
export function LoginPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations('auth');
|
||||
const tCommon = useTranslations('common');
|
||||
const tValidation = useTranslations('validation');
|
||||
const [userId, setUserId] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError("");
|
||||
|
||||
// Validation
|
||||
if (!userId || !password) {
|
||||
setError(tValidation('required'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// ✅ HttpOnly Cookie 방식: Next.js API Route로 프록시
|
||||
// 토큰은 JavaScript에서 접근 불가능한 HttpOnly 쿠키로 저장됨
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
user_pwd: password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw {
|
||||
status: response.status,
|
||||
message: data.error || 'Login failed',
|
||||
};
|
||||
}
|
||||
|
||||
console.log('✅ 로그인 성공:', data.message);
|
||||
console.log('📦 사용자 정보:', data.user);
|
||||
console.log('🔐 토큰은 안전한 HttpOnly 쿠키에 저장됨 (JavaScript 접근 불가)');
|
||||
|
||||
// 대시보드로 이동
|
||||
router.push("/dashboard");
|
||||
} catch (err: any) {
|
||||
console.error('❌ 로그인 실패:', err);
|
||||
|
||||
if (err.status === 422) {
|
||||
setError(t('invalidCredentials'));
|
||||
} else if (err.status === 429) {
|
||||
setError('너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.');
|
||||
} else {
|
||||
setError(err.message || t('invalidCredentials'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="clean-glass border-b border-border">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="flex items-center space-x-3 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center clean-shadow relative overflow-hidden" style={{ backgroundColor: '#3B82F6' }}>
|
||||
<div className="text-white font-bold text-lg">S</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent opacity-30"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-wide">SAM</h1>
|
||||
<p className="text-xs text-muted-foreground">{t('login')}</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeSelect />
|
||||
<LanguageSelect />
|
||||
<Button variant="ghost" onClick={() => router.push("/signup")} className="rounded-xl">
|
||||
{t('signUp')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Login Card */}
|
||||
<div className="clean-glass rounded-2xl p-8 clean-shadow space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-foreground">{t('login')}</h2>
|
||||
<p className="text-muted-foreground">{tCommon('welcome')} SAM MES</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-xl p-4">
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="userId" className="flex items-center space-x-2 mb-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{t('userId')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
type="text"
|
||||
placeholder={t('userIdPlaceholder')}
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="flex items-center space-x-2 mb-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
<span>{t('password')}</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
|
||||
className="clean-input pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t('rememberMe')}</span>
|
||||
</label>
|
||||
<button className="text-sm text-primary hover:underline">
|
||||
{t('forgotPassword')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
className="w-full rounded-xl bg-primary hover:bg-primary/90"
|
||||
>
|
||||
{t('login')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-card text-muted-foreground">{tCommon('or')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/signup")}
|
||||
className="w-full rounded-xl"
|
||||
>
|
||||
{t('createAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signup Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('noAccount')}{" "}
|
||||
<button
|
||||
onClick={() => router.push("/signup")}
|
||||
className="text-primary font-medium hover:underline"
|
||||
>
|
||||
{t('signUp')}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
589
src/components/auth/SignupPage.tsx
Normal file
589
src/components/auth/SignupPage.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { LanguageSelect } from "@/components/LanguageSelect";
|
||||
import { ThemeSelect } from "@/components/ThemeSelect";
|
||||
import { companyInfoSchema, userInfoSchema, planSelectionSchema } from "@/lib/validations/auth";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Lock,
|
||||
Briefcase,
|
||||
Users,
|
||||
FileText
|
||||
} from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
export function SignupPage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(1);
|
||||
const [formData, setFormData] = useState({
|
||||
// 회사 정보
|
||||
companyName: "",
|
||||
businessNumber: "",
|
||||
industry: "",
|
||||
companySize: "",
|
||||
|
||||
// 담당자 정보
|
||||
name: "",
|
||||
position: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
userId: "",
|
||||
password: "",
|
||||
passwordConfirm: "",
|
||||
|
||||
// 플랜 및 추천인
|
||||
plan: "demo",
|
||||
salesCode: "",
|
||||
|
||||
// 약관
|
||||
agreeTerms: false,
|
||||
agreePrivacy: false,
|
||||
});
|
||||
|
||||
const handleInputChange = (field: string, value: string | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// 회원가입 처리 (실제로는 API 호출)
|
||||
const userData = {
|
||||
...formData,
|
||||
role: "CEO", // 기본 역할
|
||||
};
|
||||
|
||||
// Save user data to localStorage (client-side only)
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("user", JSON.stringify(userData));
|
||||
}
|
||||
|
||||
// Navigate to dashboard
|
||||
router.push("/dashboard");
|
||||
};
|
||||
|
||||
const [stepErrors, setStepErrors] = useState<{ [key: string]: string }>({});
|
||||
|
||||
const validateStep1 = () => {
|
||||
const result = companyInfoSchema.safeParse({
|
||||
companyName: formData.companyName,
|
||||
businessNumber: formData.businessNumber,
|
||||
industry: formData.industry,
|
||||
companySize: formData.companySize,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const firstError = result.error.issues[0];
|
||||
setStepErrors({ step1: firstError.message });
|
||||
return false;
|
||||
}
|
||||
setStepErrors({});
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateStep2 = () => {
|
||||
const result = userInfoSchema.safeParse({
|
||||
name: formData.name,
|
||||
position: formData.position,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
userId: formData.userId,
|
||||
password: formData.password,
|
||||
passwordConfirm: formData.passwordConfirm,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const firstError = result.error.issues[0];
|
||||
setStepErrors({ step2: firstError.message });
|
||||
return false;
|
||||
}
|
||||
setStepErrors({});
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateStep3 = () => {
|
||||
const result = planSelectionSchema.safeParse({
|
||||
plan: formData.plan,
|
||||
salesCode: formData.salesCode,
|
||||
agreeTerms: formData.agreeTerms,
|
||||
agreePrivacy: formData.agreePrivacy,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const firstError = result.error.issues[0];
|
||||
setStepErrors({ step3: firstError.message });
|
||||
return false;
|
||||
}
|
||||
setStepErrors({});
|
||||
return true;
|
||||
};
|
||||
|
||||
const isStep1Valid = formData.companyName && formData.businessNumber && formData.industry && formData.companySize;
|
||||
const isStep2Valid = formData.name && formData.email && formData.phone && formData.userId && formData.password && formData.password === formData.passwordConfirm;
|
||||
const isStep3Valid = formData.agreeTerms && formData.agreePrivacy;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="clean-glass border-b border-border">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="flex items-center space-x-3 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center clean-shadow relative overflow-hidden" style={{ backgroundColor: '#3B82F6' }}>
|
||||
<div className="text-white font-bold text-lg">S</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent opacity-30"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-wide">SAM</h1>
|
||||
<p className="text-xs text-muted-foreground">회원가입</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeSelect />
|
||||
<LanguageSelect />
|
||||
<Button variant="ghost" onClick={() => router.push("/login")} className="rounded-xl">
|
||||
로그인
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 container mx-auto px-6 py-12">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{[1, 2, 3].map((stepNumber) => (
|
||||
<div key={stepNumber} className={`flex items-center ${stepNumber === 2 ? 'justify-center' : 'flex-1'}`}>
|
||||
{(stepNumber === 2 || stepNumber === 3) && (
|
||||
<div className={`flex-1 h-1 mx-4 rounded transition-colors ${
|
||||
step > stepNumber - 1 ? "bg-primary" : "bg-muted"
|
||||
}`} />
|
||||
)}
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold transition-colors ${
|
||||
step >= stepNumber
|
||||
? "bg-primary text-white"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{stepNumber}
|
||||
</div>
|
||||
{(stepNumber === 1 || stepNumber === 2) && (
|
||||
<div className={`flex-1 h-1 mx-4 rounded transition-colors ${
|
||||
step > stepNumber ? "bg-primary" : "bg-muted"
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className={step >= 1 ? "text-foreground font-medium" : "text-muted-foreground"}>
|
||||
회사 정보
|
||||
</span>
|
||||
<span className={step >= 2 ? "text-foreground font-medium" : "text-muted-foreground"}>
|
||||
담당자 정보
|
||||
</span>
|
||||
<span className={step >= 3 ? "text-foreground font-medium" : "text-muted-foreground"}>
|
||||
플랜 선택
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: 회사 정보 */}
|
||||
{step === 1 && (
|
||||
<div className="clean-glass rounded-2xl p-8 clean-shadow space-y-6">
|
||||
<div>
|
||||
<h2 className="mb-2 text-foreground">회사 정보를 입력해주세요</h2>
|
||||
<p className="text-muted-foreground">MES 시스템을 도입할 회사의 기본 정보를 알려주세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="companyName" className="flex items-center space-x-2 mb-2">
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span>회사명 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="companyName"
|
||||
placeholder="예: 삼성전자"
|
||||
value={formData.companyName}
|
||||
onChange={(e) => handleInputChange("companyName", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="businessNumber" className="flex items-center space-x-2 mb-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>사업자등록번호 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="businessNumber"
|
||||
placeholder="000-00-00000"
|
||||
value={formData.businessNumber}
|
||||
onChange={(e) => handleInputChange("businessNumber", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="industry" className="flex items-center space-x-2 mb-2">
|
||||
<Briefcase className="w-4 h-4" />
|
||||
<span>업종 *</span>
|
||||
</Label>
|
||||
<Select value={formData.industry} onValueChange={(value) => handleInputChange("industry", value)}>
|
||||
<SelectTrigger className="clean-input">
|
||||
<SelectValue placeholder="업종을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="electronics">전자/반도체</SelectItem>
|
||||
<SelectItem value="machinery">기계/장비</SelectItem>
|
||||
<SelectItem value="automotive">자동차/부품</SelectItem>
|
||||
<SelectItem value="chemical">화학/소재</SelectItem>
|
||||
<SelectItem value="food">식품/제약</SelectItem>
|
||||
<SelectItem value="textile">섬유/의류</SelectItem>
|
||||
<SelectItem value="metal">금속/철강</SelectItem>
|
||||
<SelectItem value="other">기타 제조업</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="companySize" className="flex items-center space-x-2 mb-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>기업 규모 *</span>
|
||||
</Label>
|
||||
<Select value={formData.companySize} onValueChange={(value) => handleInputChange("companySize", value)}>
|
||||
<SelectTrigger className="clean-input">
|
||||
<SelectValue placeholder="기업 규모를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">중소기업 (직원 10-50명)</SelectItem>
|
||||
<SelectItem value="medium">중견기업 (직원 50-300명)</SelectItem>
|
||||
<SelectItem value="large">대기업 (직원 300명 이상)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stepErrors.step1 && (
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-xl p-4">
|
||||
<p className="text-sm text-destructive text-center">{stepErrors.step1}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (validateStep1()) {
|
||||
setStep(2);
|
||||
}
|
||||
}}
|
||||
disabled={!isStep1Valid}
|
||||
className="w-full rounded-xl bg-primary hover:bg-primary/90"
|
||||
>
|
||||
다음 단계
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: 담당자 정보 */}
|
||||
{step === 2 && (
|
||||
<div className="clean-glass rounded-2xl p-8 clean-shadow space-y-6">
|
||||
<div>
|
||||
<h2 className="mb-2 text-foreground">담당자 정보를 입력해주세요</h2>
|
||||
<p className="text-muted-foreground">시스템 관리자 계정으로 사용될 정보입니다</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="flex items-center space-x-2 mb-2">
|
||||
<User className="w-4 h-4"/>
|
||||
<span>성명 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="홍길동"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="position" className="flex items-center space-x-2 mb-2">
|
||||
<Briefcase className="w-4 h-4"/>
|
||||
<span>직책</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="position"
|
||||
placeholder="예: 생산관리팀장"
|
||||
value={formData.position}
|
||||
onChange={(e) => handleInputChange("position", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email" className="flex items-center space-x-2 mb-2">
|
||||
<Mail className="w-4 h-4"/>
|
||||
<span>이메일 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="example@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone" className="flex items-center space-x-2 mb-2">
|
||||
<Phone className="w-4 h-4"/>
|
||||
<span>연락처 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="010-0000-0000"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange("phone", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="userId" className="flex items-center space-x-2 mb-2">
|
||||
<User className="w-4 h-4"/>
|
||||
<span>아이디 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
placeholder="영문, 숫자 조합 6자 이상"
|
||||
value={formData.userId}
|
||||
onChange={(e) => handleInputChange("userId", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="flex items-center space-x-2 mb-2">
|
||||
<Lock className="w-4 h-4"/>
|
||||
<span>비밀번호 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="8자 이상 입력"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange("password", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="passwordConfirm" className="flex items-center space-x-2 mb-2">
|
||||
<Lock className="w-4 h-4"/>
|
||||
<span>비밀번호 확인 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="passwordConfirm"
|
||||
type="password"
|
||||
placeholder="비밀번호 재입력"
|
||||
value={formData.passwordConfirm}
|
||||
onChange={(e) => handleInputChange("passwordConfirm", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
{formData.passwordConfirm && formData.password !== formData.passwordConfirm && (
|
||||
<p className="text-sm text-destructive mt-1">비밀번호가 일치하지 않습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stepErrors.step2 && (
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-xl p-4">
|
||||
<p className="text-sm text-destructive text-center">{stepErrors.step2}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep(1)}
|
||||
className="flex-1 rounded-xl"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2"/>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (validateStep2()) {
|
||||
setStep(3);
|
||||
}
|
||||
}}
|
||||
disabled={!isStep2Valid}
|
||||
className="flex-1 rounded-xl bg-primary hover:bg-primary/90"
|
||||
>
|
||||
다음 단계
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: 플랜 선택 */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
<div className="clean-glass rounded-2xl p-8 clean-shadow space-y-6">
|
||||
{/*
|
||||
<div>
|
||||
<h2 className="mb-2 text-foreground">플랜을 선택해주세요</h2>
|
||||
<p className="text-muted-foreground">먼저 30일 무료 체험으로 시작해보세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ id: "demo", name: "데모 체험판", desc: "30일 무료 체험 (모든 기능 이용)", badge: "추천" },
|
||||
{ id: "standard", name: "스탠다드", desc: "중소기업 최적화 플랜" },
|
||||
{ id: "premium", name: "프리미엄", desc: "중견기업 맞춤형 플랜" },
|
||||
].map((plan) => (
|
||||
<button
|
||||
key={plan.id}
|
||||
onClick={() => handleInputChange("plan", plan.id)}
|
||||
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||
formData.plan === plan.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-semibold">{plan.name}</span>
|
||||
{plan.badge && (
|
||||
<Badge className="bg-primary text-white text-xs">
|
||||
{plan.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{plan.desc}</p>
|
||||
</div>
|
||||
{formData.plan === plan.id && (
|
||||
<CheckCircle2 className="w-6 h-6 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="salesCode" className="flex items-center space-x-2 mb-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
<span>영업사원 추천코드 (선택)</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="salesCode"
|
||||
placeholder="추천코드를 입력하면 할인 혜택을 받을 수 있습니다"
|
||||
value={formData.salesCode}
|
||||
onChange={(e) => handleSalesCodeChange(e.target.value)}
|
||||
className={`clean-input pr-10 ${
|
||||
salesCodeValid === true ? "border-green-500" :
|
||||
salesCodeValid === false ? "border-destructive" : ""
|
||||
}`}
|
||||
/>
|
||||
{salesCodeValid === true && (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
</div>
|
||||
{salesCodeValid === true && (
|
||||
<p className="text-sm text-green-600 mt-2 flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>유효한 코드입니다! {discount}% 할인이 적용됩니다</span>
|
||||
</p>
|
||||
)}
|
||||
{salesCodeValid === false && (
|
||||
<p className="text-sm text-destructive mt-2">유효하지 않은 코드입니다</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
💡 예시 코드: SALES2024 (20%), PARTNER30 (30%), VIP50 (50%)
|
||||
</p>
|
||||
</div>
|
||||
*/}
|
||||
<div className="space-y-3 pt-4 border-t border-border">
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.agreeTerms}
|
||||
onChange={(e) => handleInputChange("agreeTerms", e.target.checked)}
|
||||
className="mt-1 w-4 h-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-foreground">[필수]</span> 서비스 이용약관에 동의합니다
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.agreePrivacy}
|
||||
onChange={(e) => handleInputChange("agreePrivacy", e.target.checked)}
|
||||
className="mt-1 w-4 h-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-foreground">[필수]</span> 개인정보 수집 및 이용에 동의합니다
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep(2)}
|
||||
className="flex-1 rounded-xl"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (validateStep3()) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={!isStep3Valid}
|
||||
className="flex-1 rounded-xl bg-primary hover:bg-primary/90"
|
||||
>
|
||||
가입 완료
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
이미 계정이 있으신가요?{" "}
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="text-primary font-medium hover:underline"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9 rounded-md",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
190
src/components/ui/select.tsx
Normal file
190
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
sideOffset={4}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
55
src/contexts/ThemeContext.tsx
Normal file
55
src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
|
||||
type Theme = "light" | "dark" | "senior";
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>("light");
|
||||
|
||||
useEffect(() => {
|
||||
// Load theme from localStorage on mount
|
||||
const savedTheme = localStorage.getItem("theme") as Theme;
|
||||
if (savedTheme) {
|
||||
setThemeState(savedTheme);
|
||||
applyTheme(savedTheme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
applyTheme(newTheme);
|
||||
};
|
||||
|
||||
const applyTheme = (theme: Theme) => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Remove all theme classes
|
||||
root.classList.remove("light", "dark", "senior");
|
||||
|
||||
// Add new theme class
|
||||
root.classList.add(theme);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
60
src/hooks/useAuthGuard.ts
Normal file
60
src/hooks/useAuthGuard.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Auth Guard Hook
|
||||
*
|
||||
* Purpose:
|
||||
* - Protect pages from unauthenticated access
|
||||
* - Prevent browser back button cache issues
|
||||
* - Real-time authentication validation
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* export default function ProtectedPage() {
|
||||
* useAuthGuard();
|
||||
* // ... rest of component
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useAuthGuard() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// 페이지 로드 시 인증 확인
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/check', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 인증 실패 시 로그인 페이지로 이동
|
||||
console.log('⚠️ 인증 실패: 로그인 페이지로 이동');
|
||||
router.replace('/login');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 인증 확인 오류:', error);
|
||||
router.replace('/login');
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
|
||||
// 뒤로가기 시 페이지 새로고침 강제
|
||||
const handlePageShow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) {
|
||||
// 브라우저 캐시에서 로드된 경우 새로고침
|
||||
console.log('🔄 캐시된 페이지 감지: 새로고침');
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('pageshow', handlePageShow);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('pageshow', handlePageShow);
|
||||
};
|
||||
}, [router]);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { locales } from './config';
|
||||
import { locales, type Locale } from './config';
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
// This typically corresponds to the `[locale]` segment
|
||||
let locale = await requestLocale;
|
||||
|
||||
// Ensure that the incoming `locale` is valid
|
||||
if (!locale || !locales.includes(locale as any)) {
|
||||
if (!locale || !locales.includes(locale as Locale)) {
|
||||
locale = 'ko'; // fallback to default
|
||||
}
|
||||
|
||||
|
||||
56
src/lib/api/auth/auth-config.ts
Normal file
56
src/lib/api/auth/auth-config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// lib/api/auth/auth-config.ts
|
||||
|
||||
export const AUTH_CONFIG = {
|
||||
// API Base URL
|
||||
apiUrl: process.env.NEXT_PUBLIC_API_URL || 'https://api.5130.co.kr',
|
||||
|
||||
// Frontend URL
|
||||
frontendUrl: process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000',
|
||||
|
||||
// 인증 모드 (환경에 따라 선택)
|
||||
defaultAuthMode: (process.env.NEXT_PUBLIC_AUTH_MODE || 'sanctum') as 'sanctum' | 'bearer',
|
||||
|
||||
// 🔓 공개 라우트 (인증 불필요)
|
||||
// 명시적으로 여기에 추가된 경로만 비로그인 접근 가능
|
||||
// 기본 정책: 모든 페이지는 인증 필요
|
||||
publicRoutes: [
|
||||
// 비어있음 - 필요시 추가 (예: '/about', '/terms', '/privacy')
|
||||
],
|
||||
|
||||
// 🔒 보호된 라우트 (참고용, 실제로는 기본 정책으로 보호됨)
|
||||
// publicRoutes와 guestOnlyRoutes가 아닌 모든 경로는 자동으로 보호됨
|
||||
protectedRoutes: [
|
||||
'/dashboard',
|
||||
'/profile',
|
||||
'/settings',
|
||||
'/admin',
|
||||
'/tenant',
|
||||
'/users',
|
||||
'/reports',
|
||||
'/analytics',
|
||||
'/inventory',
|
||||
'/finance',
|
||||
'/hr',
|
||||
'/crm',
|
||||
'/employee',
|
||||
'/customer',
|
||||
'/supplier',
|
||||
'/orders',
|
||||
'/invoices',
|
||||
'/payroll',
|
||||
],
|
||||
|
||||
// 게스트 전용 라우트 (로그인 후 접근 불가)
|
||||
guestOnlyRoutes: [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/forgot-password',
|
||||
],
|
||||
|
||||
// 리다이렉트 설정
|
||||
redirects: {
|
||||
afterLogin: '/dashboard',
|
||||
afterLogout: '/login',
|
||||
unauthorized: '/login',
|
||||
},
|
||||
} as const;
|
||||
87
src/lib/api/auth/types.ts
Normal file
87
src/lib/api/auth/types.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// lib/api/auth/types.ts
|
||||
|
||||
/**
|
||||
* 인증 모드 타입
|
||||
*/
|
||||
export type AuthMode = 'sanctum' | 'api-key' | 'bearer';
|
||||
|
||||
/**
|
||||
* 사용자 정보
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role?: string;
|
||||
permissions?: string[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 정보 (추후 사용)
|
||||
*/
|
||||
export interface Tenant {
|
||||
id: number;
|
||||
name: string;
|
||||
// 추가 필드
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 정보 (추후 사용)
|
||||
*/
|
||||
export interface Menu {
|
||||
id: number;
|
||||
name: string;
|
||||
// 추가 필드
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 요청 (PHP API 형식)
|
||||
*/
|
||||
export interface LoginCredentials {
|
||||
user_id: string; // PHP API: user_id
|
||||
user_pwd: string; // PHP API: user_pwd
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 응답 (PHP API 형식)
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
message: string;
|
||||
user_token: string; // Bearer 토큰
|
||||
user: User;
|
||||
tenant: Tenant | null;
|
||||
menus: Menu[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 요청 (추후 구현)
|
||||
*/
|
||||
export interface RegisterData {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 에러 응답
|
||||
*/
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
errors?: Record<string, string[]>;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer Token 응답 (모바일/SPA용)
|
||||
*/
|
||||
export interface BearerTokenResponse {
|
||||
access_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
user: User;
|
||||
}
|
||||
153
src/lib/api/client.ts
Normal file
153
src/lib/api/client.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// lib/api/client.ts
|
||||
import { AUTH_CONFIG } from './auth/auth-config';
|
||||
import type { AuthMode } from './auth/types';
|
||||
|
||||
interface ClientConfig {
|
||||
mode: AuthMode;
|
||||
apiKey?: string; // API Key 모드용
|
||||
getToken?: () => string | null; // Bearer 모드용
|
||||
}
|
||||
|
||||
interface ApiErrorResponse {
|
||||
message: string;
|
||||
errors?: Record<string, string[]>;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private baseURL: string;
|
||||
private mode: AuthMode;
|
||||
private apiKey?: string;
|
||||
private getToken?: () => string | null;
|
||||
|
||||
constructor(config: ClientConfig) {
|
||||
this.baseURL = AUTH_CONFIG.apiUrl;
|
||||
this.mode = config.mode;
|
||||
this.apiKey = config.apiKey;
|
||||
this.getToken = config.getToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 헤더 생성
|
||||
*/
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// API Key는 모든 모드에서 기본으로 포함 (PHP API 요구사항)
|
||||
if (this.apiKey) {
|
||||
headers['X-API-KEY'] = this.apiKey;
|
||||
}
|
||||
|
||||
switch (this.mode) {
|
||||
case 'api-key':
|
||||
// API Key만 사용 (이미 위에서 추가됨)
|
||||
break;
|
||||
|
||||
case 'bearer':
|
||||
const token = this.getToken?.();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
// API Key도 함께 전송 (이미 위에서 추가됨)
|
||||
break;
|
||||
|
||||
case 'sanctum':
|
||||
// 쿠키 기반 - 별도 헤더 불필요
|
||||
break;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 요청 실행
|
||||
*/
|
||||
async request<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const headers = {
|
||||
...this.getAuthHeaders(),
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const config: RequestInit = {
|
||||
...options,
|
||||
headers,
|
||||
};
|
||||
|
||||
// Sanctum 모드는 쿠키 포함
|
||||
if (this.mode === 'sanctum') {
|
||||
config.credentials = 'include';
|
||||
}
|
||||
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handleError(response);
|
||||
}
|
||||
|
||||
// 204 No Content 처리
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 요청
|
||||
*/
|
||||
async get<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 요청
|
||||
*/
|
||||
async post<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 요청
|
||||
*/
|
||||
async put<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 요청
|
||||
*/
|
||||
async delete<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리
|
||||
*/
|
||||
private async handleError(response: Response): Promise<never> {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
const error: ApiErrorResponse = {
|
||||
message: data.message || 'An error occurred',
|
||||
errors: data.errors,
|
||||
code: data.code,
|
||||
};
|
||||
|
||||
throw {
|
||||
status: response.status,
|
||||
...error,
|
||||
};
|
||||
}
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
106
src/lib/validations/auth.ts
Normal file
106
src/lib/validations/auth.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// 로그인 스키마
|
||||
export const loginSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, "이메일을 입력해주세요")
|
||||
.email("올바른 이메일 형식이 아닙니다"),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, "비밀번호를 입력해주세요")
|
||||
.min(8, "비밀번호는 8자 이상이어야 합니다"),
|
||||
rememberMe: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
// 회원가입 Step 1: 회사 정보 스키마
|
||||
export const companyInfoSchema = z.object({
|
||||
companyName: z
|
||||
.string()
|
||||
.min(1, "회사명을 입력해주세요")
|
||||
.min(2, "회사명은 2자 이상이어야 합니다"),
|
||||
businessNumber: z
|
||||
.string()
|
||||
.min(1, "사업자등록번호를 입력해주세요")
|
||||
.regex(
|
||||
/^\d{3}-\d{2}-\d{5}$/,
|
||||
"올바른 사업자등록번호 형식이 아닙니다 (000-00-00000)"
|
||||
),
|
||||
industry: z.string().min(1, "업종을 선택해주세요"),
|
||||
companySize: z.string().min(1, "기업 규모를 선택해주세요"),
|
||||
});
|
||||
|
||||
export type CompanyInfoFormData = z.infer<typeof companyInfoSchema>;
|
||||
|
||||
// 회원가입 Step 2: 담당자 정보 스키마
|
||||
export const userInfoSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "성명을 입력해주세요")
|
||||
.min(2, "성명은 2자 이상이어야 합니다"),
|
||||
position: z.string().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, "이메일을 입력해주세요")
|
||||
.email("올바른 이메일 형식이 아닙니다"),
|
||||
phone: z
|
||||
.string()
|
||||
.min(1, "연락처를 입력해주세요")
|
||||
.regex(
|
||||
/^01[0-9]-\d{4}-\d{4}$/,
|
||||
"올바른 연락처 형식이 아닙니다 (010-0000-0000)"
|
||||
),
|
||||
userId: z
|
||||
.string()
|
||||
.min(1, "아이디를 입력해주세요")
|
||||
.min(6, "아이디는 6자 이상이어야 합니다")
|
||||
.regex(
|
||||
/^[a-zA-Z0-9]+$/,
|
||||
"아이디는 영문과 숫자만 사용 가능합니다"
|
||||
),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, "비밀번호를 입력해주세요")
|
||||
.min(8, "비밀번호는 8자 이상이어야 합니다")
|
||||
.regex(
|
||||
/^(?=.*[a-zA-Z])(?=.*\d)/,
|
||||
"비밀번호는 영문과 숫자를 포함해야 합니다"
|
||||
),
|
||||
passwordConfirm: z.string().min(1, "비밀번호 확인을 입력해주세요"),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordConfirm, {
|
||||
message: "비밀번호가 일치하지 않습니다",
|
||||
path: ["passwordConfirm"],
|
||||
});
|
||||
|
||||
export type UserInfoFormData = z.infer<typeof userInfoSchema>;
|
||||
|
||||
// 회원가입 Step 3: 플랜 선택 스키마
|
||||
export const planSelectionSchema = z.object({
|
||||
plan: z.enum(["demo", "standard", "premium"], {
|
||||
message: "플랜을 선택해주세요",
|
||||
}),
|
||||
salesCode: z.string().optional(),
|
||||
agreeTerms: z
|
||||
.boolean()
|
||||
.refine((val) => val === true, {
|
||||
message: "서비스 이용약관에 동의해주세요",
|
||||
}),
|
||||
agreePrivacy: z
|
||||
.boolean()
|
||||
.refine((val) => val === true, {
|
||||
message: "개인정보 수집 및 이용에 동의해주세요",
|
||||
}),
|
||||
});
|
||||
|
||||
export type PlanSelectionFormData = z.infer<typeof planSelectionSchema>;
|
||||
|
||||
// 전체 회원가입 스키마 (모든 단계 통합)
|
||||
export const signupSchema = companyInfoSchema
|
||||
.merge(userInfoSchema)
|
||||
.merge(planSelectionSchema);
|
||||
|
||||
export type SignupFormData = z.infer<typeof signupSchema>;
|
||||
@@ -21,7 +21,8 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All"
|
||||
"deselectAll": "Deselect All",
|
||||
"or": "or"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
@@ -36,7 +37,39 @@
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"loginSuccess": "Login successful",
|
||||
"loginFailed": "Login failed",
|
||||
"invalidCredentials": "Invalid email or password"
|
||||
"invalidCredentials": "Invalid user ID or password",
|
||||
"userId": "User ID",
|
||||
"userIdPlaceholder": "Enter your user ID",
|
||||
"createAccount": "Create New Account",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"noAccount": "Don't have an account yet?",
|
||||
"demoAccount": "Demo Account Guide",
|
||||
"tryDemo": "Try logging in with the following account",
|
||||
"companyInfo": "Company Information",
|
||||
"userInfo": "User Information",
|
||||
"planSelection": "Plan Selection",
|
||||
"companyName": "Company Name",
|
||||
"businessNumber": "Business Number",
|
||||
"industry": "Industry",
|
||||
"companySize": "Company Size",
|
||||
"name": "Name",
|
||||
"phone": "Phone",
|
||||
"passwordConfirm": "Confirm Password",
|
||||
"agreeTerms": "I agree to the Terms of Service",
|
||||
"agreePrivacy": "I agree to the Privacy Policy",
|
||||
"previousStep": "Previous",
|
||||
"nextStep": "Next Step",
|
||||
"complete": "Complete",
|
||||
"step1Title": "Enter company information",
|
||||
"step1Desc": "Please provide basic information about your company",
|
||||
"step2Title": "Enter user information",
|
||||
"step2Desc": "This will be used for the system administrator account",
|
||||
"ceo": "CEO",
|
||||
"productionManager": "Production Manager",
|
||||
"worker": "Worker",
|
||||
"systemAdmin": "System Admin",
|
||||
"salesPerson": "Sales Person",
|
||||
"leadManagement": "Lead Management"
|
||||
},
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"previous": "前へ",
|
||||
"next": "次へ",
|
||||
"selectAll": "すべて選択",
|
||||
"deselectAll": "すべて解除"
|
||||
"deselectAll": "すべて解除",
|
||||
"or": "または"
|
||||
},
|
||||
"auth": {
|
||||
"login": "ログイン",
|
||||
@@ -36,7 +37,39 @@
|
||||
"passwordPlaceholder": "パスワードを入力してください",
|
||||
"loginSuccess": "ログインに成功しました",
|
||||
"loginFailed": "ログインに失敗しました",
|
||||
"invalidCredentials": "メールアドレスまたはパスワードが正しくありません"
|
||||
"invalidCredentials": "ユーザーIDまたはパスワードが正しくありません",
|
||||
"userId": "ユーザーID",
|
||||
"userIdPlaceholder": "ユーザーIDを入力してください",
|
||||
"createAccount": "新しいアカウントを作成",
|
||||
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
|
||||
"noAccount": "まだアカウントをお持ちではありませんか?",
|
||||
"demoAccount": "デモアカウントのご案内",
|
||||
"tryDemo": "以下のアカウントでログインしてお試しください",
|
||||
"companyInfo": "会社情報",
|
||||
"userInfo": "担当者情報",
|
||||
"planSelection": "プラン選択",
|
||||
"companyName": "会社名",
|
||||
"businessNumber": "事業者登録番号",
|
||||
"industry": "業種",
|
||||
"companySize": "会社規模",
|
||||
"name": "氏名",
|
||||
"phone": "電話番号",
|
||||
"passwordConfirm": "パスワード確認",
|
||||
"agreeTerms": "利用規約に同意します",
|
||||
"agreePrivacy": "個人情報の収集と利用に同意します",
|
||||
"previousStep": "戻る",
|
||||
"nextStep": "次のステップ",
|
||||
"complete": "登録完了",
|
||||
"step1Title": "会社情報を入力してください",
|
||||
"step1Desc": "MESシステムを導入する会社の基本情報をお知らせください",
|
||||
"step2Title": "担当者情報を入力してください",
|
||||
"step2Desc": "システム管理者アカウントとして使用される情報です",
|
||||
"ceo": "代表取締役",
|
||||
"productionManager": "生産管理者",
|
||||
"worker": "作業者",
|
||||
"systemAdmin": "システム管理者",
|
||||
"salesPerson": "営業担当",
|
||||
"leadManagement": "リード管理"
|
||||
},
|
||||
"navigation": {
|
||||
"dashboard": "ダッシュボード",
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"previous": "이전",
|
||||
"next": "다음",
|
||||
"selectAll": "전체 선택",
|
||||
"deselectAll": "전체 해제"
|
||||
"deselectAll": "전체 해제",
|
||||
"or": "또는"
|
||||
},
|
||||
"auth": {
|
||||
"login": "로그인",
|
||||
@@ -36,7 +37,39 @@
|
||||
"passwordPlaceholder": "비밀번호를 입력하세요",
|
||||
"loginSuccess": "로그인에 성공했습니다",
|
||||
"loginFailed": "로그인에 실패했습니다",
|
||||
"invalidCredentials": "이메일 또는 비밀번호가 올바르지 않습니다"
|
||||
"invalidCredentials": "아이디 또는 비밀번호가 올바르지 않습니다",
|
||||
"userId": "아이디",
|
||||
"userIdPlaceholder": "아이디를 입력하세요",
|
||||
"createAccount": "새 계정 만들기",
|
||||
"alreadyHaveAccount": "이미 계정이 있으신가요?",
|
||||
"noAccount": "아직 계정이 없으신가요?",
|
||||
"demoAccount": "데모 계정 안내",
|
||||
"tryDemo": "바로 체험해보시려면 아래 계정으로 로그인하세요",
|
||||
"companyInfo": "회사 정보",
|
||||
"userInfo": "담당자 정보",
|
||||
"planSelection": "플랜 선택",
|
||||
"companyName": "회사명",
|
||||
"businessNumber": "사업자등록번호",
|
||||
"industry": "업종",
|
||||
"companySize": "기업 규모",
|
||||
"name": "성명",
|
||||
"phone": "연락처",
|
||||
"passwordConfirm": "비밀번호 확인",
|
||||
"agreeTerms": "서비스 이용약관에 동의합니다",
|
||||
"agreePrivacy": "개인정보 수집 및 이용에 동의합니다",
|
||||
"previousStep": "이전",
|
||||
"nextStep": "다음 단계",
|
||||
"complete": "가입 완료",
|
||||
"step1Title": "회사 정보를 입력해주세요",
|
||||
"step1Desc": "MES 시스템을 도입할 회사의 기본 정보를 알려주세요",
|
||||
"step2Title": "담당자 정보를 입력해주세요",
|
||||
"step2Desc": "시스템 관리자 계정으로 사용될 정보입니다",
|
||||
"ceo": "대표이사",
|
||||
"productionManager": "생산관리자",
|
||||
"worker": "생산작업자",
|
||||
"systemAdmin": "시스템관리자",
|
||||
"salesPerson": "영업사원",
|
||||
"leadManagement": "리드 관리"
|
||||
},
|
||||
"navigation": {
|
||||
"dashboard": "대시보드",
|
||||
|
||||
@@ -9,13 +9,18 @@ import { locales, defaultLocale } from '@/i18n/config';
|
||||
* Features:
|
||||
* 1. Internationalization (i18n) with locale detection
|
||||
* 2. Bot Detection and blocking for security
|
||||
* 3. Authentication and authorization (Sanctum/Bearer/API-Key)
|
||||
*
|
||||
* Strategy: Moderate bot blocking
|
||||
* Strategy: Moderate bot blocking + Session-based auth
|
||||
* - Allows legitimate browsers and necessary crawlers
|
||||
* - Blocks bots from accessing sensitive ERP areas
|
||||
* - Protects routes with session/token authentication
|
||||
* - Prevents Chrome security warnings by not being too aggressive
|
||||
*/
|
||||
|
||||
// Auth configuration
|
||||
import { AUTH_CONFIG } from '@/lib/api/auth/auth-config';
|
||||
|
||||
// Create i18n middleware
|
||||
const intlMiddleware = createMiddleware({
|
||||
locales,
|
||||
@@ -114,21 +119,82 @@ function getPathnameWithoutLocale(pathname: string): string {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 체크 함수
|
||||
* 3가지 인증 방식 지원: Bearer Token/Sanctum/API-Key
|
||||
*/
|
||||
function checkAuthentication(request: NextRequest): {
|
||||
isAuthenticated: boolean;
|
||||
authMode: 'sanctum' | 'bearer' | 'api-key' | null;
|
||||
} {
|
||||
// 1. Bearer Token 확인 (쿠키에서)
|
||||
// 클라이언트에서 localStorage → 쿠키로 복사하는 방식
|
||||
const tokenCookie = request.cookies.get('user_token');
|
||||
if (tokenCookie && tokenCookie.value) {
|
||||
return { isAuthenticated: true, authMode: 'bearer' };
|
||||
}
|
||||
|
||||
// 2. Bearer Token 확인 (Authorization 헤더)
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
return { isAuthenticated: true, authMode: 'bearer' };
|
||||
}
|
||||
|
||||
// 3. Sanctum 세션 쿠키 확인 (레거시 지원)
|
||||
const sessionCookie = request.cookies.get('laravel_session');
|
||||
if (sessionCookie) {
|
||||
return { isAuthenticated: true, authMode: 'sanctum' };
|
||||
}
|
||||
|
||||
// 4. API Key 확인
|
||||
const apiKey = request.headers.get('x-api-key');
|
||||
if (apiKey) {
|
||||
return { isAuthenticated: true, authMode: 'api-key' };
|
||||
}
|
||||
|
||||
return { isAuthenticated: false, authMode: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우트 타입 확인 함수들
|
||||
*/
|
||||
function isProtectedRoute(pathname: string): boolean {
|
||||
return AUTH_CONFIG.protectedRoutes.some(route =>
|
||||
pathname.startsWith(route)
|
||||
);
|
||||
}
|
||||
|
||||
function isGuestOnlyRoute(pathname: string): boolean {
|
||||
return AUTH_CONFIG.guestOnlyRoutes.some(route =>
|
||||
pathname === route || pathname.startsWith(route)
|
||||
);
|
||||
}
|
||||
|
||||
function isPublicRoute(pathname: string): boolean {
|
||||
return AUTH_CONFIG.publicRoutes.some(route => {
|
||||
// '/' 는 정확히 일치해야만 public
|
||||
if (route === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
// 다른 라우트는 시작 일치 허용
|
||||
return pathname === route || pathname.startsWith(route + '/');
|
||||
});
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
|
||||
// Remove locale prefix for path checking
|
||||
// 1️⃣ 로케일 제거
|
||||
const pathnameWithoutLocale = getPathnameWithoutLocale(pathname);
|
||||
|
||||
// Check if request is from a bot
|
||||
// 2️⃣ Bot Detection (기존 로직)
|
||||
const isBotRequest = isBot(userAgent);
|
||||
|
||||
// Block bots from protected paths (check both with and without locale)
|
||||
// Bot Detection: Block bots from protected paths
|
||||
if (isBotRequest && (isProtectedPath(pathname) || isProtectedPath(pathnameWithoutLocale))) {
|
||||
console.log(`[Bot Blocked] ${userAgent} attempted to access ${pathname}`);
|
||||
|
||||
// Return 403 Forbidden with appropriate message
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
error: 'Access Denied',
|
||||
@@ -145,16 +211,59 @@ export function middleware(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Run i18n middleware for locale detection and routing
|
||||
// 3️⃣ 정적 파일 제외
|
||||
if (
|
||||
pathname.includes('/_next/') ||
|
||||
pathname.includes('/api/') ||
|
||||
pathname.match(/\.(ico|png|jpg|jpeg|svg|gif|webp)$/)
|
||||
) {
|
||||
return intlMiddleware(request);
|
||||
}
|
||||
|
||||
// 4️⃣ 인증 체크
|
||||
const { isAuthenticated, authMode } = checkAuthentication(request);
|
||||
|
||||
// 5️⃣ 게스트 전용 라우트 (로그인/회원가입)
|
||||
if (isGuestOnlyRoute(pathnameWithoutLocale)) {
|
||||
// 이미 로그인한 경우 대시보드로
|
||||
if (isAuthenticated) {
|
||||
console.log(`[Already Authenticated] Redirecting to /dashboard from ${pathname}`);
|
||||
return NextResponse.redirect(new URL(AUTH_CONFIG.redirects.afterLogin, request.url));
|
||||
}
|
||||
// 비로그인 상태면 접근 허용
|
||||
return intlMiddleware(request);
|
||||
}
|
||||
|
||||
// 6️⃣ 공개 라우트 (명시적으로 지정된 경우만)
|
||||
if (isPublicRoute(pathnameWithoutLocale)) {
|
||||
return intlMiddleware(request);
|
||||
}
|
||||
|
||||
// 7️⃣ 기본 정책: 모든 페이지는 인증 필요
|
||||
// guestOnlyRoutes와 publicRoutes가 아닌 모든 경로는 보호됨
|
||||
if (!isAuthenticated) {
|
||||
console.log(`[Auth Required] Redirecting to /login from ${pathname}`);
|
||||
|
||||
const url = new URL('/login', request.url);
|
||||
url.searchParams.set('redirect', pathname);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// 8️⃣ 인증 모드 로깅 (디버깅용)
|
||||
if (isAuthenticated) {
|
||||
console.log(`[Authenticated] Mode: ${authMode}, Path: ${pathname}`);
|
||||
}
|
||||
|
||||
// 9️⃣ i18n 미들웨어 실행
|
||||
const intlResponse = intlMiddleware(request);
|
||||
|
||||
// Add security headers to the response
|
||||
// 🔟 보안 헤더 추가
|
||||
intlResponse.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet');
|
||||
intlResponse.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
intlResponse.headers.set('X-Frame-Options', 'DENY');
|
||||
intlResponse.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
// Log bot access attempts (for monitoring)
|
||||
// Bot 로깅 (모니터링용)
|
||||
if (isBotRequest) {
|
||||
console.log(`[Bot Allowed] ${userAgent} accessed ${pathname}`);
|
||||
}
|
||||
@@ -172,12 +281,13 @@ export function middleware(request: NextRequest) {
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except:
|
||||
* Match all pathnames except:
|
||||
* - api routes
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public files (images, etc.)
|
||||
* - favicon.ico, robots.txt
|
||||
* - files with extensions (images, etc.)
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)',
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|.*\\..*|robots\\.txt).*)',
|
||||
],
|
||||
};
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user