[feat]: 회원가입 페이지 개선 및 폼 자동 포맷팅 기능 추가
주요 변경사항: - 회원가입 폼에 사업자등록번호 자동 포맷팅 (000-00-00000) - 핸드폰 번호 자동 포맷팅 (010-1111-1111 / 010-111-1111) - 약관 전체 동의 체크박스 추가 및 개별 약관 연동 - 모든 입력 필드에 autocomplete 속성 추가 (브라우저 자동완성 지원) - 회원가입 API 연동 및 백엔드 통신 구현 - LoginPage 폼 태그 추가 및 DOM 경고 수정 - LanguageSelect 언어 변경 시 전체 페이지 새로고침으로 변경 - 다국어 번역 키 추가 (ko, en, ja) 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,11 @@ const eslintConfig = [
|
||||
document: "readonly",
|
||||
HTMLButtonElement: "readonly",
|
||||
HTMLInputElement: "readonly",
|
||||
fetch: "readonly",
|
||||
URL: "readonly",
|
||||
RequestInit: "readonly",
|
||||
Response: "readonly",
|
||||
PageTransitionEvent: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -11,22 +11,91 @@ import type { NextRequest } from 'next/server';
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get token from HttpOnly cookie
|
||||
const token = request.cookies.get('user_token')?.value;
|
||||
// Get tokens from HttpOnly cookies
|
||||
const accessToken = request.cookies.get('access_token')?.value;
|
||||
const refreshToken = request.cookies.get('refresh_token')?.value;
|
||||
|
||||
if (!token) {
|
||||
// No tokens at all - not authenticated
|
||||
if (!accessToken && !refreshToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authenticated', authenticated: false },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Optional: Verify token with PHP backend
|
||||
// (현재는 토큰 존재 여부만 확인, 필요시 PHP API 호출 추가 가능)
|
||||
// Has access token - authenticated
|
||||
if (accessToken) {
|
||||
return NextResponse.json(
|
||||
{ authenticated: true },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// Only has refresh token - try to refresh
|
||||
if (refreshToken && !accessToken) {
|
||||
console.log('🔄 Access token missing, attempting refresh...');
|
||||
|
||||
// Attempt token refresh
|
||||
try {
|
||||
const refreshResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${refreshToken}`,
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (refreshResponse.ok) {
|
||||
const data = await refreshResponse.json();
|
||||
|
||||
// Set new tokens
|
||||
const accessTokenCookie = [
|
||||
`access_token=${data.access_token}`,
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
'Path=/',
|
||||
`Max-Age=${data.expires_in || 7200}`,
|
||||
].join('; ');
|
||||
|
||||
const refreshTokenCookie = [
|
||||
`refresh_token=${data.refresh_token}`,
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
'Path=/',
|
||||
'Max-Age=604800',
|
||||
].join('; ');
|
||||
|
||||
console.log('✅ Token auto-refreshed in auth check');
|
||||
|
||||
const response = NextResponse.json(
|
||||
{ authenticated: true, refreshed: true },
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
response.headers.append('Set-Cookie', accessTokenCookie);
|
||||
response.headers.append('Set-Cookie', refreshTokenCookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed in auth check:', error);
|
||||
}
|
||||
|
||||
// Refresh failed - not authenticated
|
||||
return NextResponse.json(
|
||||
{ error: 'Token refresh failed', authenticated: false },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback - not authenticated
|
||||
return NextResponse.json(
|
||||
{ authenticated: true },
|
||||
{ status: 200 }
|
||||
{ error: 'Not authenticated', authenticated: false },
|
||||
{ status: 401 }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Call PHP backend API
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/login`, {
|
||||
const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -33,18 +33,26 @@ export async function POST(request: NextRequest) {
|
||||
body: JSON.stringify({ user_id, user_pwd }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (!backendResponse.ok) {
|
||||
// Don't expose detailed backend error messages to client
|
||||
// Use generic error messages based on status code
|
||||
let errorMessage = 'Authentication failed';
|
||||
|
||||
if (backendResponse.status === 422) {
|
||||
errorMessage = 'Invalid credentials provided';
|
||||
} else if (backendResponse.status === 429) {
|
||||
errorMessage = 'Too many login attempts. Please try again later';
|
||||
} else if (backendResponse.status >= 500) {
|
||||
errorMessage = 'Service temporarily unavailable';
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: errorData.message || 'Login failed',
|
||||
status: response.status
|
||||
},
|
||||
{ status: response.status }
|
||||
{ error: errorMessage },
|
||||
{ status: backendResponse.status === 422 ? 401 : backendResponse.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await backendResponse.json();
|
||||
|
||||
// Prepare response with user data (no token exposed)
|
||||
const responseData = {
|
||||
@@ -52,26 +60,38 @@ export async function POST(request: NextRequest) {
|
||||
user: data.user,
|
||||
tenant: data.tenant,
|
||||
menus: data.menus,
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
expires_at: data.expires_at,
|
||||
};
|
||||
|
||||
// Set HttpOnly cookie with token
|
||||
const cookieOptions = [
|
||||
`user_token=${data.user_token}`,
|
||||
// Set HttpOnly cookies for both access_token and refresh_token
|
||||
const accessTokenCookie = [
|
||||
`access_token=${data.access_token}`,
|
||||
'HttpOnly', // ✅ JavaScript cannot access
|
||||
'Secure', // ✅ HTTPS only (production)
|
||||
'SameSite=Strict', // ✅ CSRF protection
|
||||
'Path=/',
|
||||
'Max-Age=604800', // 7 days
|
||||
`Max-Age=${data.expires_in || 7200}`, // Use backend expiry (default 2 hours)
|
||||
].join('; ');
|
||||
|
||||
console.log('✅ Login successful - Token stored in HttpOnly cookie');
|
||||
const refreshTokenCookie = [
|
||||
`refresh_token=${data.refresh_token}`,
|
||||
'HttpOnly', // ✅ JavaScript cannot access
|
||||
'Secure', // ✅ HTTPS only (production)
|
||||
'SameSite=Strict', // ✅ CSRF protection
|
||||
'Path=/',
|
||||
'Max-Age=604800', // 7 days (longer for refresh token)
|
||||
].join('; ');
|
||||
|
||||
return NextResponse.json(responseData, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Set-Cookie': cookieOptions,
|
||||
},
|
||||
});
|
||||
console.log('✅ Login successful - Access & Refresh tokens stored in HttpOnly cookies');
|
||||
|
||||
const response = NextResponse.json(responseData, { status: 200 });
|
||||
|
||||
response.headers.append('Set-Cookie', accessTokenCookie);
|
||||
response.headers.append('Set-Cookie', refreshTokenCookie);
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login proxy error:', error);
|
||||
|
||||
@@ -11,10 +11,10 @@ import type { NextRequest } from 'next/server';
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get token from HttpOnly cookie
|
||||
const token = request.cookies.get('user_token')?.value;
|
||||
// Get access_token from HttpOnly cookie
|
||||
const accessToken = request.cookies.get('access_token')?.value;
|
||||
|
||||
if (token) {
|
||||
if (accessToken) {
|
||||
// Call PHP backend logout API
|
||||
try {
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/logout`, {
|
||||
@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
},
|
||||
});
|
||||
@@ -32,9 +32,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear HttpOnly cookie
|
||||
const cookieOptions = [
|
||||
'user_token=',
|
||||
// Clear both HttpOnly cookies
|
||||
const clearAccessToken = [
|
||||
'access_token=',
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
@@ -42,18 +42,27 @@ export async function POST(request: NextRequest) {
|
||||
'Max-Age=0', // Delete immediately
|
||||
].join('; ');
|
||||
|
||||
console.log('✅ Logout complete - HttpOnly cookie cleared');
|
||||
const clearRefreshToken = [
|
||||
'refresh_token=',
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
'Path=/',
|
||||
'Max-Age=0', // Delete immediately
|
||||
].join('; ');
|
||||
|
||||
return NextResponse.json(
|
||||
console.log('✅ Logout complete - Access & Refresh tokens cleared');
|
||||
|
||||
const response = NextResponse.json(
|
||||
{ message: 'Logged out successfully' },
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Set-Cookie': cookieOptions,
|
||||
},
|
||||
}
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
response.headers.append('Set-Cookie', clearAccessToken);
|
||||
response.headers.append('Set-Cookie', clearRefreshToken);
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Logout proxy error:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
99
src/app/api/auth/refresh/route.ts
Normal file
99
src/app/api/auth/refresh/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Token Refresh Route Handler
|
||||
*
|
||||
* Purpose:
|
||||
* - Refresh expired access_token using refresh_token
|
||||
* - Update HttpOnly cookies with new tokens
|
||||
* - Maintain user session without re-login
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get refresh_token from HttpOnly cookie
|
||||
const refreshToken = request.cookies.get('refresh_token')?.value;
|
||||
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No refresh token available' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Call PHP backend refresh API
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${refreshToken}`,
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Refresh token is invalid or expired
|
||||
console.warn('⚠️ Token refresh failed - user needs to re-login');
|
||||
|
||||
// Clear both tokens
|
||||
const clearAccessToken = 'access_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0';
|
||||
const clearRefreshToken = 'refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0';
|
||||
|
||||
const failResponse = NextResponse.json(
|
||||
{ error: 'Token refresh failed', needsReauth: true },
|
||||
{ status: 401 }
|
||||
);
|
||||
|
||||
failResponse.headers.append('Set-Cookie', clearAccessToken);
|
||||
failResponse.headers.append('Set-Cookie', clearRefreshToken);
|
||||
|
||||
return failResponse;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Prepare response
|
||||
const responseData = {
|
||||
message: 'Token refreshed successfully',
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
expires_at: data.expires_at,
|
||||
};
|
||||
|
||||
// Set new HttpOnly cookies
|
||||
const accessTokenCookie = [
|
||||
`access_token=${data.access_token}`,
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
'Path=/',
|
||||
`Max-Age=${data.expires_in || 7200}`,
|
||||
].join('; ');
|
||||
|
||||
const refreshTokenCookie = [
|
||||
`refresh_token=${data.refresh_token}`,
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Strict',
|
||||
'Path=/',
|
||||
'Max-Age=604800', // 7 days
|
||||
].join('; ');
|
||||
|
||||
console.log('✅ Token refresh successful - New tokens stored');
|
||||
|
||||
const successResponse = NextResponse.json(responseData, { status: 200 });
|
||||
|
||||
successResponse.headers.append('Set-Cookie', accessTokenCookie);
|
||||
successResponse.headers.append('Set-Cookie', refreshTokenCookie);
|
||||
|
||||
return successResponse;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Token refresh error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
91
src/app/api/auth/signup/route.ts
Normal file
91
src/app/api/auth/signup/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Signup Proxy Route Handler
|
||||
*
|
||||
* Purpose:
|
||||
* - Proxy signup requests to PHP backend
|
||||
* - Handle registration errors gracefully
|
||||
* - Return user-friendly error messages
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
const requiredFields = [
|
||||
'user_id',
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'company_name',
|
||||
'business_num',
|
||||
'company_scale',
|
||||
'industry',
|
||||
];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!body[field]) {
|
||||
return NextResponse.json(
|
||||
{ error: `${field} is required` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Call PHP backend API
|
||||
const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!backendResponse.ok) {
|
||||
// Handle backend errors with user-friendly messages
|
||||
let errorMessage = 'Registration failed';
|
||||
|
||||
if (backendResponse.status === 422) {
|
||||
// Validation error
|
||||
const data = await backendResponse.json();
|
||||
errorMessage = data.message || 'Invalid registration data';
|
||||
} else if (backendResponse.status === 409) {
|
||||
errorMessage = 'User ID or email already exists';
|
||||
} else if (backendResponse.status === 429) {
|
||||
errorMessage = 'Too many registration attempts. Please try again later';
|
||||
} else if (backendResponse.status >= 500) {
|
||||
errorMessage = 'Service temporarily unavailable';
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
{ status: backendResponse.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await backendResponse.json();
|
||||
|
||||
console.log('✅ Signup successful');
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: data.message || 'Registration successful',
|
||||
user: data.user,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Signup proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,8 @@ export function LanguageSelect() {
|
||||
const handleLanguageChange = (newLocale: string) => {
|
||||
// Remove current locale from pathname
|
||||
const pathnameWithoutLocale = pathname.replace(`/${locale}`, "");
|
||||
// Navigate to new locale
|
||||
router.push(`/${newLocale}${pathnameWithoutLocale}`);
|
||||
// Force full page reload to ensure clean state and proper i18n loading
|
||||
window.location.href = `/${newLocale}${pathnameWithoutLocale}`;
|
||||
};
|
||||
|
||||
const currentLanguage = languages.find((lang) => lang.code === locale);
|
||||
|
||||
@@ -65,15 +65,19 @@ export function LoginPage() {
|
||||
|
||||
// 대시보드로 이동
|
||||
router.push("/dashboard");
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
console.error('❌ 로그인 실패:', err);
|
||||
|
||||
if (err.status === 422) {
|
||||
const error = err as { status?: number; message?: string };
|
||||
|
||||
if (error.status === 401 || error.status === 422) {
|
||||
setError(t('invalidCredentials'));
|
||||
} else if (err.status === 429) {
|
||||
setError('너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.');
|
||||
} else if (error.status === 429) {
|
||||
setError('Too many login attempts. Please try again later.');
|
||||
} else if (error.status && error.status >= 500) {
|
||||
setError('Service temporarily unavailable. Please try again later.');
|
||||
} else {
|
||||
setError(err.message || t('invalidCredentials'));
|
||||
setError(error.message || t('invalidCredentials'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -125,7 +129,7 @@ export function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleLogin(); }} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="userId" className="flex items-center space-x-2 mb-2">
|
||||
<User className="w-4 h-4" />
|
||||
@@ -133,11 +137,12 @@ export function LoginPage() {
|
||||
</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder={t('userIdPlaceholder')}
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -150,11 +155,12 @@ export function LoginPage() {
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
|
||||
className="clean-input pr-10"
|
||||
/>
|
||||
<button
|
||||
@@ -181,19 +187,19 @@ export function LoginPage() {
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{t('rememberMe')}</span>
|
||||
</label>
|
||||
<button className="text-sm text-primary hover:underline">
|
||||
<button type="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>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full rounded-xl bg-primary hover:bg-primary/90"
|
||||
>
|
||||
{t('login')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
@@ -23,6 +24,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
|
||||
export function SignupPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("auth");
|
||||
const tSignup = useTranslations("signup");
|
||||
const tValidation = useTranslations("validation");
|
||||
const [step, setStep] = useState(1);
|
||||
const [formData, setFormData] = useState({
|
||||
// 회사 정보
|
||||
@@ -53,20 +57,116 @@ export function SignupPage() {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// 회원가입 처리 (실제로는 API 호출)
|
||||
const userData = {
|
||||
...formData,
|
||||
role: "CEO", // 기본 역할
|
||||
};
|
||||
// 사업자등록번호 자동 포맷팅 (000-00-00000)
|
||||
const formatBusinessNumber = (value: string) => {
|
||||
// 숫자만 추출
|
||||
const numbers = value.replace(/[^\d]/g, '');
|
||||
|
||||
// Save user data to localStorage (client-side only)
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("user", JSON.stringify(userData));
|
||||
// 최대 10자리까지만
|
||||
const limited = numbers.slice(0, 10);
|
||||
|
||||
// 형식에 맞게 하이픈 추가
|
||||
if (limited.length <= 3) {
|
||||
return limited;
|
||||
} else if (limited.length <= 5) {
|
||||
return `${limited.slice(0, 3)}-${limited.slice(3)}`;
|
||||
} else {
|
||||
return `${limited.slice(0, 3)}-${limited.slice(3, 5)}-${limited.slice(5)}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to dashboard
|
||||
router.push("/dashboard");
|
||||
const handleBusinessNumberChange = (value: string) => {
|
||||
const formatted = formatBusinessNumber(value);
|
||||
handleInputChange("businessNumber", formatted);
|
||||
};
|
||||
|
||||
// 핸드폰 번호 자동 포맷팅 (010-1111-1111 or 010-111-1111)
|
||||
const formatPhoneNumber = (value: string) => {
|
||||
// 숫자만 추출
|
||||
const numbers = value.replace(/[^\d]/g, '');
|
||||
|
||||
// 최대 11자리까지만
|
||||
const limited = numbers.slice(0, 11);
|
||||
|
||||
// 형식에 맞게 하이픈 추가
|
||||
if (limited.length <= 3) {
|
||||
return limited;
|
||||
} else if (limited.length <= 6) {
|
||||
return `${limited.slice(0, 3)}-${limited.slice(3)}`;
|
||||
} else if (limited.length === 10) {
|
||||
// 10자리: 010-111-1111
|
||||
return `${limited.slice(0, 3)}-${limited.slice(3, 6)}-${limited.slice(6)}`;
|
||||
} else {
|
||||
// 11자리: 010-1111-1111
|
||||
return `${limited.slice(0, 3)}-${limited.slice(3, 7)}-${limited.slice(7)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhoneNumberChange = (value: string) => {
|
||||
const formatted = formatPhoneNumber(value);
|
||||
handleInputChange("phone", formatted);
|
||||
};
|
||||
|
||||
// 전체 동의 처리
|
||||
const handleAgreeAll = (checked: boolean) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
agreeTerms: checked,
|
||||
agreePrivacy: checked,
|
||||
}));
|
||||
};
|
||||
|
||||
// 전체 동의 체크박스 상태 계산
|
||||
const isAllAgreed = formData.agreeTerms && formData.agreePrivacy;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// Prepare request body matching backend format
|
||||
const requestBody = {
|
||||
user_id: formData.userId,
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
password: formData.password,
|
||||
password_confirmation: formData.passwordConfirm,
|
||||
position: formData.position || "",
|
||||
company_name: formData.companyName,
|
||||
business_num: formData.businessNumber,
|
||||
company_scale: formData.companySize,
|
||||
industry: formData.industry,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Registration failed');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ Signup successful:', data);
|
||||
|
||||
// Navigate to login page after successful signup
|
||||
router.push("/login?registered=true");
|
||||
|
||||
} catch (err) {
|
||||
console.error('Signup error:', err);
|
||||
setError('Network error. Please try again.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [stepErrors, setStepErrors] = useState<{ [key: string]: string }>({});
|
||||
@@ -145,14 +245,14 @@ export function SignupPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-wide">SAM</h1>
|
||||
<p className="text-xs text-muted-foreground">회원가입</p>
|
||||
<p className="text-xs text-muted-foreground">{t("signupTitle")}</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeSelect />
|
||||
<LanguageSelect />
|
||||
<Button variant="ghost" onClick={() => router.push("/login")} className="rounded-xl">
|
||||
로그인
|
||||
{t("login")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,13 +289,13 @@ export function SignupPage() {
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className={step >= 1 ? "text-foreground font-medium" : "text-muted-foreground"}>
|
||||
회사 정보
|
||||
{t("companyInfo")}
|
||||
</span>
|
||||
<span className={step >= 2 ? "text-foreground font-medium" : "text-muted-foreground"}>
|
||||
담당자 정보
|
||||
{t("userInfo")}
|
||||
</span>
|
||||
<span className={step >= 3 ? "text-foreground font-medium" : "text-muted-foreground"}>
|
||||
플랜 선택
|
||||
{t("planSelection")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,19 +304,21 @@ export function SignupPage() {
|
||||
{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>
|
||||
<h2 className="mb-2 text-foreground">{t("step1Title")}</h2>
|
||||
<p className="text-muted-foreground">{t("step1Desc")}</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>
|
||||
<span>{t("companyName")} {t("required")}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="companyName"
|
||||
placeholder="예: 삼성전자"
|
||||
name="company_name"
|
||||
autoComplete="organization"
|
||||
placeholder={t("companyNamePlaceholder")}
|
||||
value={formData.companyName}
|
||||
onChange={(e) => handleInputChange("companyName", e.target.value)}
|
||||
className="clean-input"
|
||||
@@ -226,13 +328,15 @@ export function SignupPage() {
|
||||
<div>
|
||||
<Label htmlFor="businessNumber" className="flex items-center space-x-2 mb-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>사업자등록번호 *</span>
|
||||
<span>{t("businessNumber")} {t("required")}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="businessNumber"
|
||||
placeholder="000-00-00000"
|
||||
name="business_number"
|
||||
autoComplete="off"
|
||||
placeholder={t("businessNumberPlaceholder")}
|
||||
value={formData.businessNumber}
|
||||
onChange={(e) => handleInputChange("businessNumber", e.target.value)}
|
||||
onChange={(e) => handleBusinessNumberChange(e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -240,21 +344,21 @@ export function SignupPage() {
|
||||
<div>
|
||||
<Label htmlFor="industry" className="flex items-center space-x-2 mb-2">
|
||||
<Briefcase className="w-4 h-4" />
|
||||
<span>업종 *</span>
|
||||
<span>{t("industry")} {t("required")}</span>
|
||||
</Label>
|
||||
<Select value={formData.industry} onValueChange={(value) => handleInputChange("industry", value)}>
|
||||
<SelectTrigger className="clean-input">
|
||||
<SelectValue placeholder="업종을 선택하세요" />
|
||||
<SelectValue placeholder={t("industryPlaceholder")} />
|
||||
</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>
|
||||
<SelectItem value="electronics">{tSignup("industries.electronics")}</SelectItem>
|
||||
<SelectItem value="machinery">{tSignup("industries.machinery")}</SelectItem>
|
||||
<SelectItem value="automotive">{tSignup("industries.automotive")}</SelectItem>
|
||||
<SelectItem value="chemical">{tSignup("industries.chemical")}</SelectItem>
|
||||
<SelectItem value="food">{tSignup("industries.food")}</SelectItem>
|
||||
<SelectItem value="textile">{tSignup("industries.textile")}</SelectItem>
|
||||
<SelectItem value="metal">{tSignup("industries.metal")}</SelectItem>
|
||||
<SelectItem value="other">{tSignup("industries.other")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -262,16 +366,16 @@ export function SignupPage() {
|
||||
<div>
|
||||
<Label htmlFor="companySize" className="flex items-center space-x-2 mb-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>기업 규모 *</span>
|
||||
<span>{t("companySize")} {t("required")}</span>
|
||||
</Label>
|
||||
<Select value={formData.companySize} onValueChange={(value) => handleInputChange("companySize", value)}>
|
||||
<SelectTrigger className="clean-input">
|
||||
<SelectValue placeholder="기업 규모를 선택하세요" />
|
||||
<SelectValue placeholder={t("companySizePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">중소기업 (직원 10-50명)</SelectItem>
|
||||
<SelectItem value="medium">중견기업 (직원 50-300명)</SelectItem>
|
||||
<SelectItem value="large">대기업 (직원 300명 이상)</SelectItem>
|
||||
<SelectItem value="small">{tSignup("companySizes.small")}</SelectItem>
|
||||
<SelectItem value="medium">{tSignup("companySizes.medium")}</SelectItem>
|
||||
<SelectItem value="large">{tSignup("companySizes.large")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -292,7 +396,7 @@ export function SignupPage() {
|
||||
disabled={!isStep1Valid}
|
||||
className="w-full rounded-xl bg-primary hover:bg-primary/90"
|
||||
>
|
||||
다음 단계
|
||||
{t("nextStep")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -301,19 +405,21 @@ export function SignupPage() {
|
||||
{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>
|
||||
<h2 className="mb-2 text-foreground">{t("step2Title")}</h2>
|
||||
<p className="text-muted-foreground">{t("step2Desc")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={(e) => { e.preventDefault(); if (validateStep2()) setStep(3); }} 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>
|
||||
<span>{t("name")} {t("required")}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="홍길동"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
placeholder={t("namePlaceholder")}
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||
className="clean-input"
|
||||
@@ -323,11 +429,13 @@ export function SignupPage() {
|
||||
<div>
|
||||
<Label htmlFor="position" className="flex items-center space-x-2 mb-2">
|
||||
<Briefcase className="w-4 h-4"/>
|
||||
<span>직책</span>
|
||||
<span>{t("position")} {t("optional")}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="position"
|
||||
placeholder="예: 생산관리팀장"
|
||||
name="position"
|
||||
autoComplete="organization-title"
|
||||
placeholder={t("positionPlaceholder")}
|
||||
value={formData.position}
|
||||
onChange={(e) => handleInputChange("position", e.target.value)}
|
||||
className="clean-input"
|
||||
@@ -337,12 +445,14 @@ export function SignupPage() {
|
||||
<div>
|
||||
<Label htmlFor="email" className="flex items-center space-x-2 mb-2">
|
||||
<Mail className="w-4 h-4"/>
|
||||
<span>이메일 *</span>
|
||||
<span>{t("email")} {t("required")}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="example@company.com"
|
||||
autoComplete="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||
className="clean-input"
|
||||
@@ -352,13 +462,16 @@ export function SignupPage() {
|
||||
<div>
|
||||
<Label htmlFor="phone" className="flex items-center space-x-2 mb-2">
|
||||
<Phone className="w-4 h-4"/>
|
||||
<span>연락처 *</span>
|
||||
<span>{t("phone")} {t("required")}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="010-0000-0000"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder={t("phonePlaceholder")}
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange("phone", e.target.value)}
|
||||
onChange={(e) => handlePhoneNumberChange(e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -366,11 +479,13 @@ export function SignupPage() {
|
||||
<div>
|
||||
<Label htmlFor="userId" className="flex items-center space-x-2 mb-2">
|
||||
<User className="w-4 h-4"/>
|
||||
<span>아이디 *</span>
|
||||
<span>{t("userId")} {t("required")}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
placeholder="영문, 숫자 조합 6자 이상"
|
||||
name="user_id"
|
||||
autoComplete="username"
|
||||
placeholder={t("userIdPlaceholder2")}
|
||||
value={formData.userId}
|
||||
onChange={(e) => handleInputChange("userId", e.target.value)}
|
||||
className="clean-input"
|
||||
@@ -380,12 +495,14 @@ export function SignupPage() {
|
||||
<div>
|
||||
<Label htmlFor="password" className="flex items-center space-x-2 mb-2">
|
||||
<Lock className="w-4 h-4"/>
|
||||
<span>비밀번호 *</span>
|
||||
<span>{t("password")} {t("required")}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="8자 이상 입력"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("passwordPlaceholder2")}
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange("password", e.target.value)}
|
||||
className="clean-input"
|
||||
@@ -395,49 +512,48 @@ export function SignupPage() {
|
||||
<div>
|
||||
<Label htmlFor="passwordConfirm" className="flex items-center space-x-2 mb-2">
|
||||
<Lock className="w-4 h-4"/>
|
||||
<span>비밀번호 확인 *</span>
|
||||
<span>{t("passwordConfirm")} {t("required")}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="passwordConfirm"
|
||||
name="password_confirm"
|
||||
type="password"
|
||||
placeholder="비밀번호 재입력"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("passwordConfirmPlaceholder")}
|
||||
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>
|
||||
<p className="text-sm text-destructive mt-1">{tValidation("passwordMismatch")}</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>
|
||||
{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
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setStep(1)}
|
||||
className="flex-1 rounded-xl"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2"/>
|
||||
{t("previousStep")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isStep2Valid}
|
||||
className="flex-1 rounded-xl bg-primary hover:bg-primary/90"
|
||||
>
|
||||
{t("nextStep")}
|
||||
</Button>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -521,29 +637,51 @@ export function SignupPage() {
|
||||
</div>
|
||||
*/}
|
||||
<div className="space-y-3 pt-4 border-t border-border">
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
{/* 전체 동의 */}
|
||||
<label className="flex items-center space-x-3 cursor-pointer pt-3 pb-3 pe-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.agreeTerms}
|
||||
onChange={(e) => handleInputChange("agreeTerms", e.target.checked)}
|
||||
className="mt-1 w-4 h-4 rounded border-border"
|
||||
checked={isAllAgreed}
|
||||
onChange={(e) => handleAgreeAll(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-border flex-shrink-0"
|
||||
/>
|
||||
<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 className="text-sm font-semibold text-foreground">
|
||||
{t("agreeAll")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* 개별 약관 */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.agreeTerms}
|
||||
onChange={(e) => handleInputChange("agreeTerms", e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border flex-shrink-0"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-foreground">{t("required")}</span> {t("agreeTerms")}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.agreePrivacy}
|
||||
onChange={(e) => handleInputChange("agreePrivacy", e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border flex-shrink-0"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-foreground">{t("required")}</span> {t("agreePrivacy")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
@@ -551,20 +689,21 @@ export function SignupPage() {
|
||||
variant="outline"
|
||||
onClick={() => setStep(2)}
|
||||
className="flex-1 rounded-xl"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
이전
|
||||
{t("previousStep")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (validateStep3()) {
|
||||
handleSubmit();
|
||||
void handleSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={!isStep3Valid}
|
||||
disabled={!isStep3Valid || isLoading}
|
||||
className="flex-1 rounded-xl bg-primary hover:bg-primary/90"
|
||||
>
|
||||
가입 완료
|
||||
{isLoading ? t("processing") || "처리 중..." : t("complete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,12 +712,12 @@ export function SignupPage() {
|
||||
{/* Login Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
이미 계정이 있으신가요?{" "}
|
||||
{t("alreadyHaveAccount")}{" "}
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="text-primary font-medium hover:underline"
|
||||
>
|
||||
로그인
|
||||
{t("login")}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -46,13 +46,14 @@ export class ApiClient {
|
||||
// API Key만 사용 (이미 위에서 추가됨)
|
||||
break;
|
||||
|
||||
case 'bearer':
|
||||
case 'bearer': {
|
||||
const token = this.getToken?.();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
// API Key도 함께 전송 (이미 위에서 추가됨)
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sanctum':
|
||||
// 쿠키 기반 - 별도 헤더 불필요
|
||||
@@ -134,11 +135,25 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리
|
||||
* 에러 처리 (자동 토큰 갱신 포함)
|
||||
*/
|
||||
private async handleError(response: Response): Promise<never> {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
// 401 Unauthorized - Try token refresh
|
||||
if (response.status === 401) {
|
||||
console.warn('⚠️ 401 Unauthorized - Token may be expired');
|
||||
|
||||
// Client-side: Suggest token refresh to caller
|
||||
throw {
|
||||
status: 401,
|
||||
message: 'Unauthorized - Token expired',
|
||||
needsTokenRefresh: true,
|
||||
errors: data.errors,
|
||||
code: data.code,
|
||||
};
|
||||
}
|
||||
|
||||
const error: ApiErrorResponse = {
|
||||
message: data.message || 'An error occurred',
|
||||
errors: data.errors,
|
||||
@@ -150,4 +165,49 @@ export class ApiClient {
|
||||
...error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to handle API calls with automatic token refresh
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const data = await withTokenRefresh(() => apiClient.get('/protected'));
|
||||
* ```
|
||||
*/
|
||||
export async function withTokenRefresh<T>(
|
||||
apiCall: () => Promise<T>,
|
||||
maxRetries: number = 1
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await apiCall();
|
||||
} catch (error: unknown) {
|
||||
const apiError = error as { status?: number; needsTokenRefresh?: boolean };
|
||||
|
||||
// If 401 and token refresh needed, try refreshing
|
||||
if (apiError.status === 401 && apiError.needsTokenRefresh && maxRetries > 0) {
|
||||
console.log('🔄 Attempting token refresh...');
|
||||
|
||||
// Call refresh endpoint
|
||||
const refreshResponse = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (refreshResponse.ok) {
|
||||
console.log('✅ Token refreshed, retrying API call');
|
||||
// Retry the original API call
|
||||
return withTokenRefresh(apiCall, maxRetries - 1);
|
||||
} else {
|
||||
console.error('❌ Token refresh failed - redirecting to login');
|
||||
// Refresh failed - redirect to login
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw error if not handled
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
96
src/lib/auth/token-refresh.ts
Normal file
96
src/lib/auth/token-refresh.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Token Refresh Utility
|
||||
*
|
||||
* Handles automatic token refresh logic for client and server
|
||||
*/
|
||||
|
||||
/**
|
||||
* Client-side token refresh
|
||||
* Call this when you receive 401 errors from protected API calls
|
||||
*/
|
||||
export async function refreshTokenClient(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include', // Include HttpOnly cookies
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// If refresh failed and needs re-authentication
|
||||
if (data.needsReauth) {
|
||||
console.warn('🔄 Token refresh failed - redirecting to login');
|
||||
window.location.href = '/login';
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ Token refreshed successfully');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Token refresh error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side token refresh (for API routes and middleware)
|
||||
*/
|
||||
export async function refreshTokenServer(refreshToken: string): Promise<{
|
||||
success: boolean;
|
||||
accessToken?: string;
|
||||
newRefreshToken?: string;
|
||||
expiresIn?: number;
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${refreshToken}`,
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: data.access_token,
|
||||
newRefreshToken: data.refresh_token,
|
||||
expiresIn: data.expires_in,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Token refresh error:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired or about to expire (within 5 minutes)
|
||||
* @param expiresAt - Expiration timestamp from backend (e.g., "2025-11-10 15:49:38")
|
||||
*/
|
||||
export function shouldRefreshToken(expiresAt: string | null): boolean {
|
||||
if (!expiresAt) return true; // If no expiration info, assume expired
|
||||
|
||||
try {
|
||||
const expiryTime = new Date(expiresAt).getTime();
|
||||
const currentTime = Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
// Refresh if token expires in less than 5 minutes
|
||||
return (expiryTime - currentTime) < fiveMinutes;
|
||||
} catch {
|
||||
return true; // If parsing fails, assume expired
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,11 @@
|
||||
"industry": "Industry",
|
||||
"companySize": "Company Size",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "John Doe",
|
||||
"phone": "Phone",
|
||||
"phonePlaceholder": "010-0000-0000",
|
||||
"passwordConfirm": "Confirm Password",
|
||||
"agreeAll": "Agree to All Terms",
|
||||
"agreeTerms": "I agree to the Terms of Service",
|
||||
"agreePrivacy": "I agree to the Privacy Policy",
|
||||
"previousStep": "Previous",
|
||||
@@ -69,7 +72,37 @@
|
||||
"worker": "Worker",
|
||||
"systemAdmin": "System Admin",
|
||||
"salesPerson": "Sales Person",
|
||||
"leadManagement": "Lead Management"
|
||||
"leadManagement": "Lead Management",
|
||||
"signupTitle": "Sign Up",
|
||||
"position": "Position",
|
||||
"positionPlaceholder": "e.g. Production Manager",
|
||||
"companyNamePlaceholder": "e.g. Samsung Electronics",
|
||||
"businessNumberPlaceholder": "000-00-00000",
|
||||
"industryPlaceholder": "Select industry",
|
||||
"companySizePlaceholder": "Select company size",
|
||||
"userIdPlaceholder2": "Alphanumeric, 6+ characters",
|
||||
"passwordPlaceholder2": "8+ characters",
|
||||
"passwordConfirmPlaceholder": "Re-enter password",
|
||||
"required": "[Required]",
|
||||
"optional": "[Optional]",
|
||||
"processing": "Processing..."
|
||||
},
|
||||
"signup": {
|
||||
"industries": {
|
||||
"electronics": "Electronics/Semiconductor",
|
||||
"machinery": "Machinery/Equipment",
|
||||
"automotive": "Automotive/Parts",
|
||||
"chemical": "Chemical/Materials",
|
||||
"food": "Food/Pharmaceutical",
|
||||
"textile": "Textile/Apparel",
|
||||
"metal": "Metal/Steel",
|
||||
"other": "Other Manufacturing"
|
||||
},
|
||||
"companySizes": {
|
||||
"small": "Small (10-50 employees)",
|
||||
"medium": "Medium (50-300 employees)",
|
||||
"large": "Large (300+ employees)"
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
|
||||
@@ -53,8 +53,11 @@
|
||||
"industry": "業種",
|
||||
"companySize": "会社規模",
|
||||
"name": "氏名",
|
||||
"namePlaceholder": "山田太郎",
|
||||
"phone": "電話番号",
|
||||
"phonePlaceholder": "010-0000-0000",
|
||||
"passwordConfirm": "パスワード確認",
|
||||
"agreeAll": "約款全て同意",
|
||||
"agreeTerms": "利用規約に同意します",
|
||||
"agreePrivacy": "個人情報の収集と利用に同意します",
|
||||
"previousStep": "戻る",
|
||||
@@ -69,7 +72,37 @@
|
||||
"worker": "作業者",
|
||||
"systemAdmin": "システム管理者",
|
||||
"salesPerson": "営業担当",
|
||||
"leadManagement": "リード管理"
|
||||
"leadManagement": "リード管理",
|
||||
"signupTitle": "会員登録",
|
||||
"position": "役職",
|
||||
"positionPlaceholder": "例: 生産管理課長",
|
||||
"companyNamePlaceholder": "例: サムスン電子",
|
||||
"businessNumberPlaceholder": "000-00-00000",
|
||||
"industryPlaceholder": "業種を選択してください",
|
||||
"companySizePlaceholder": "会社規模を選択してください",
|
||||
"userIdPlaceholder2": "英数字組み合わせ6文字以上",
|
||||
"passwordPlaceholder2": "8文字以上入力",
|
||||
"passwordConfirmPlaceholder": "パスワード再入力",
|
||||
"required": "[必須]",
|
||||
"optional": "[任意]",
|
||||
"processing": "処理中..."
|
||||
},
|
||||
"signup": {
|
||||
"industries": {
|
||||
"electronics": "電子/半導体",
|
||||
"machinery": "機械/設備",
|
||||
"automotive": "自動車/部品",
|
||||
"chemical": "化学/素材",
|
||||
"food": "食品/製薬",
|
||||
"textile": "繊維/衣類",
|
||||
"metal": "金属/鉄鋼",
|
||||
"other": "その他製造業"
|
||||
},
|
||||
"companySizes": {
|
||||
"small": "中小企業 (従業員10-50名)",
|
||||
"medium": "中堅企業 (従業員50-300名)",
|
||||
"large": "大企業 (従業員300名以上)"
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"dashboard": "ダッシュボード",
|
||||
|
||||
@@ -53,8 +53,11 @@
|
||||
"industry": "업종",
|
||||
"companySize": "기업 규모",
|
||||
"name": "성명",
|
||||
"namePlaceholder": "홍길동",
|
||||
"phone": "연락처",
|
||||
"phonePlaceholder": "010-0000-0000",
|
||||
"passwordConfirm": "비밀번호 확인",
|
||||
"agreeAll": "약관 전체 동의",
|
||||
"agreeTerms": "서비스 이용약관에 동의합니다",
|
||||
"agreePrivacy": "개인정보 수집 및 이용에 동의합니다",
|
||||
"previousStep": "이전",
|
||||
@@ -69,7 +72,37 @@
|
||||
"worker": "생산작업자",
|
||||
"systemAdmin": "시스템관리자",
|
||||
"salesPerson": "영업사원",
|
||||
"leadManagement": "리드 관리"
|
||||
"leadManagement": "리드 관리",
|
||||
"signupTitle": "회원가입",
|
||||
"position": "직책",
|
||||
"positionPlaceholder": "예: 생산관리팀장",
|
||||
"companyNamePlaceholder": "예: 삼성전자",
|
||||
"businessNumberPlaceholder": "000-00-00000",
|
||||
"industryPlaceholder": "업종을 선택하세요",
|
||||
"companySizePlaceholder": "기업 규모를 선택하세요",
|
||||
"userIdPlaceholder2": "영문, 숫자 조합 6자 이상",
|
||||
"passwordPlaceholder2": "8자 이상 입력",
|
||||
"passwordConfirmPlaceholder": "비밀번호 재입력",
|
||||
"required": "[필수]",
|
||||
"optional": "[선택]",
|
||||
"processing": "처리 중..."
|
||||
},
|
||||
"signup": {
|
||||
"industries": {
|
||||
"electronics": "전자/반도체",
|
||||
"machinery": "기계/장비",
|
||||
"automotive": "자동차/부품",
|
||||
"chemical": "화학/소재",
|
||||
"food": "식품/제약",
|
||||
"textile": "섬유/의류",
|
||||
"metal": "금속/철강",
|
||||
"other": "기타 제조업"
|
||||
},
|
||||
"companySizes": {
|
||||
"small": "중소기업 (직원 10-50명)",
|
||||
"medium": "중견기업 (직원 50-300명)",
|
||||
"large": "대기업 (직원 300명 이상)"
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"dashboard": "대시보드",
|
||||
|
||||
@@ -128,9 +128,10 @@ function checkAuthentication(request: NextRequest): {
|
||||
authMode: 'sanctum' | 'bearer' | 'api-key' | null;
|
||||
} {
|
||||
// 1. Bearer Token 확인 (쿠키에서)
|
||||
// 클라이언트에서 localStorage → 쿠키로 복사하는 방식
|
||||
const tokenCookie = request.cookies.get('user_token');
|
||||
if (tokenCookie && tokenCookie.value) {
|
||||
// access_token 또는 refresh_token이 있으면 인증된 것으로 간주
|
||||
const accessToken = request.cookies.get('access_token');
|
||||
const refreshToken = request.cookies.get('refresh_token');
|
||||
if ((accessToken && accessToken.value) || (refreshToken && refreshToken.value)) {
|
||||
return { isAuthenticated: true, authMode: 'bearer' };
|
||||
}
|
||||
|
||||
@@ -158,12 +159,6 @@ function checkAuthentication(request: NextRequest): {
|
||||
/**
|
||||
* 라우트 타입 확인 함수들
|
||||
*/
|
||||
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)
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user