diff options
| -rw-r--r-- | app/api/auth/[...nextauth]/saml/utils.ts | 19 | ||||
| -rw-r--r-- | app/api/auth/saml/authn-request/route.ts | 10 | ||||
| -rw-r--r-- | app/api/auth/saml/mock-idp/route.ts | 8 | ||||
| -rw-r--r-- | app/api/saml/callback/route.ts | 61 | ||||
| -rw-r--r-- | components/login/login-form copy 2.tsx | 470 | ||||
| -rw-r--r-- | components/login/login-form copy.tsx | 468 | ||||
| -rw-r--r-- | components/login/saml-login-button.tsx | 8 |
7 files changed, 73 insertions, 971 deletions
diff --git a/app/api/auth/[...nextauth]/saml/utils.ts b/app/api/auth/[...nextauth]/saml/utils.ts index 73c00bf6..a5bcfe7a 100644 --- a/app/api/auth/[...nextauth]/saml/utils.ts +++ b/app/api/auth/[...nextauth]/saml/utils.ts @@ -97,15 +97,15 @@ export function createSAMLConfig() { } // SAML AuthnRequest 생성 (서버 액션) -export async function createAuthnRequest(): Promise<string> { +export async function createAuthnRequest(relayState?: string): Promise<string> { "use server"; - console.log("SSO STEP 2: Create AuthnRequest"); + console.log("SSO STEP 2: Create AuthnRequest", { relayState }); // Mock IdP 모드 체크 if (process.env.SAML_MOCKING_IDP === 'true') { debugMock("Mock IdP mode enabled - simulating SAML response"); - return createMockSAMLFlow(); + return createMockSAMLFlow(relayState); } try { @@ -117,7 +117,7 @@ export async function createAuthnRequest(): Promise<string> { const startTime = Date.now(); const authorizeUrl = await saml.getAuthorizeUrlAsync( - "", // RelayState + relayState || "", // RelayState - 원래 가려던 페이지 undefined, // host { additionalParams: {}, @@ -406,12 +406,17 @@ export function mapSAMLProfileToUser(profile: SAMLProfile): SAMLUser { } // Mock SAML 플로우 생성 (테스트용) -function createMockSAMLFlow(): string { - debugMock("Creating mock SAML flow..."); +function createMockSAMLFlow(relayState?: string): string { + debugMock("Creating mock SAML flow...", { relayState }); // Mock 모드에서는 Mock IdP 엔드포인트로 리다이렉션 const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; - const mockIdpUrl = `${baseUrl}/api/auth/saml/mock-idp`; + let mockIdpUrl = `${baseUrl}/api/auth/saml/mock-idp`; + + // RelayState가 있으면 URL 파라미터로 전달 + if (relayState) { + mockIdpUrl += `?RelayState=${encodeURIComponent(relayState)}`; + } debugMock("Mock SAML Flow - redirecting to Mock IdP:", mockIdpUrl); diff --git a/app/api/auth/saml/authn-request/route.ts b/app/api/auth/saml/authn-request/route.ts index f079aea0..6544a765 100644 --- a/app/api/auth/saml/authn-request/route.ts +++ b/app/api/auth/saml/authn-request/route.ts @@ -50,17 +50,23 @@ function validateSAMLEnvironment() { * * @returns {JSON} { loginUrl: string, success: boolean, isThisMocking?: boolean } */ -export async function GET() { +export async function GET(request: Request) { debugProcess('🚀 SAML AuthnRequest API started') try { + // URL에서 RelayState 매개변수 추출 + const url = new URL(request.url) + const relayState = url.searchParams.get('relayState') + + debugLog('RelayState parameter:', relayState) + // 환경변수 검증 const environment = validateSAMLEnvironment() debugProcess('SSO STEP 1: Create AuthnRequest') const startTime = Date.now() - const loginUrl = await createAuthnRequest() + const loginUrl = await createAuthnRequest(relayState || undefined) const endTime = Date.now() debugSuccess('SAML AuthnRequest created successfully:', { diff --git a/app/api/auth/saml/mock-idp/route.ts b/app/api/auth/saml/mock-idp/route.ts index 45c670b0..eccb6035 100644 --- a/app/api/auth/saml/mock-idp/route.ts +++ b/app/api/auth/saml/mock-idp/route.ts @@ -3,7 +3,11 @@ import { NextRequest, NextResponse } from 'next/server' // Mock IdP 엔드포인트 - SAML Response HTML 폼 반환 export async function GET(request: NextRequest) { try { - console.log('🎭 Mock IdP endpoint accessed'); + // RelayState 파라미터 추출 + const url = new URL(request.url) + const relayState = url.searchParams.get('RelayState') || 'mock_test' + + console.log('🎭 Mock IdP endpoint accessed', { relayState }); // Mock SAML Response 데이터 (실제 형태와 일치하도록 문자열 형태) const mockSAMLResponseData = { @@ -83,7 +87,7 @@ export async function GET(request: NextRequest) { </div> <form id="mockForm" method="POST" action="${callbackUrl}"> <input type="hidden" name="SAMLResponse" value="${encodedSAMLResponse}" /> - <input type="hidden" name="RelayState" value="mock_test" /> + <input type="hidden" name="RelayState" value="${relayState}" /> <button type="submit" class="button">Continue with Mock Login</button> </form> <div class="details"> diff --git a/app/api/saml/callback/route.ts b/app/api/saml/callback/route.ts index cf9ea772..7f454cb9 100644 --- a/app/api/saml/callback/route.ts +++ b/app/api/saml/callback/route.ts @@ -12,18 +12,16 @@ import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-ut // GET 요청시 SP 메타데이터를 반환해주는데, 이건 필요 없으면 지우면 된다. export async function POST(request: NextRequest) { + // 안전한 baseUrl - 모든 리다이렉트에서 사용 + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + try { const isMockMode = process.env.SAML_MOCKING_IDP === 'true'; debugProcess(`SAML Callback received at /api/saml/callback ${isMockMode ? '(🎭 Mock Mode)' : ''}`) debugLog('Request info:', { - url: request.url, nextUrl: request.nextUrl?.toString(), mockMode: isMockMode, - headers: { - host: request.headers.get('host'), - origin: request.headers.get('origin'), - referer: request.headers.get('referer') - } + baseUrl: baseUrl }) // FormData에서 SAML Response 추출 @@ -75,10 +73,7 @@ export async function POST(request: NextRequest) { if (!samlResponse) { debugError('No SAML Response found in request') - const baseUrl = request.url || process.env.NEXTAUTH_URL || 'http://localhost:3000' - return NextResponse.redirect( - new URL('/ko/evcp', baseUrl) - ) + return NextResponse.redirect(new URL('/ko/evcp', baseUrl), 303) } // SAML Response 검증 및 파싱 @@ -99,10 +94,7 @@ export async function POST(request: NextRequest) { }) // SAML 검증 실패 시 evcp 페이지로 리다이렉트 - const baseUrl = request.url || process.env.NEXTAUTH_URL || 'http://localhost:3000' - return NextResponse.redirect( - new URL('/ko/evcp', baseUrl) - ) + return NextResponse.redirect(new URL('/ko/evcp', baseUrl), 303) } // SAML 프로필을 사용자 객체로 매핑 @@ -121,8 +113,7 @@ export async function POST(request: NextRequest) { if (!authenticatedUser) { debugError('SAML user authentication failed') - const baseUrl = request.url || process.env.NEXTAUTH_URL || 'http://localhost:3000' - return NextResponse.redirect(new URL('/ko/evcp', baseUrl)) + return NextResponse.redirect(new URL('/ko/evcp', baseUrl), 303) } debugSuccess('User authenticated successfully:', { @@ -137,7 +128,38 @@ export async function POST(request: NextRequest) { // NextAuth 세션 쿠키 설정 const cookieName = getSessionCookieName() - const response = NextResponse.redirect(new URL('/ko/evcp', request.url || process.env.NEXTAUTH_URL || 'http://localhost:3000')) + // RelayState를 활용한 스마트 리다이렉트 + let redirectPath = '/ko/evcp' // 기본값 + + // RelayState 안전 처리 - null, 'null', undefined, 빈 문자열 모두 처리 + const isValidRelayState = relayState && + relayState !== 'null' && + relayState !== 'undefined' && + relayState.trim() !== '' && + typeof relayState === 'string'; + + if (isValidRelayState) { + debugLog('Using RelayState for redirect:', relayState) + // RelayState가 유효한 경로인지 확인 + if (relayState.startsWith('/') && !relayState.includes('//')) { + redirectPath = relayState + } else { + debugLog('Invalid RelayState format, using default:', relayState) + } + } else { + debugLog('No valid RelayState, using default path. RelayState value:', relayState) + } + + // URL 생성 전 최종 안전성 검사 + if (!redirectPath || typeof redirectPath !== 'string' || redirectPath.trim() === '') { + redirectPath = '/ko/evcp' // 안전한 기본값으로 재설정 + debugLog('redirectPath was invalid, reset to default:', redirectPath) + } + + debugLog('Final redirect path:', redirectPath) + + // POST 요청에 대한 응답으로는 303 See Other를 사용하여 GET으로 강제 변환 + const response = NextResponse.redirect(new URL(redirectPath, baseUrl), 303) response.cookies.set(cookieName, encodedToken, { httpOnly: true, @@ -153,10 +175,7 @@ export async function POST(request: NextRequest) { } catch (error) { debugError('SAML Callback processing failed:', error) - const baseUrl = request.url || process.env.NEXTAUTH_URL || 'http://localhost:3000' - return NextResponse.redirect( - new URL('/ko/evcp', baseUrl) - ) + return NextResponse.redirect(new URL('/ko/evcp', baseUrl), 303) } } diff --git a/components/login/login-form copy 2.tsx b/components/login/login-form copy 2.tsx deleted file mode 100644 index d5ac01b9..00000000 --- a/components/login/login-form copy 2.tsx +++ /dev/null @@ -1,470 +0,0 @@ -'use client'; - -import { useState, useEffect } from "react"; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" -import { Input } from "@/components/ui/input" -import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship, InfoIcon, ArrowRight } from "lucide-react"; -import { useToast } from "@/hooks/use-toast"; -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu" -import { useTranslation } from '@/i18n/client' -import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation'; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from "@/components/ui/input-otp" -import { signIn } from 'next-auth/react'; -import { sendOtpAction } from "@/lib/users/send-otp"; -import { verifyTokenAction } from "@/lib/users/verifyToken"; -import { buttonVariants } from "@/components/ui/button" -import Link from "next/link" -import Image from 'next/image'; -import { - Alert, - AlertDescription, - AlertTitle, -} from "@/components/ui/alert"; - -export function LoginForm({ - className, - ...props -}: React.ComponentProps<"div">) { - - const params = useParams() || {}; - const pathname = usePathname() || ''; - const router = useRouter(); - const searchParams = useSearchParams(); - const token = searchParams?.get('token') || null; - const [showCredentialsForm, setShowCredentialsForm] = useState(false); - // 새로운 상태: 업체 등록 안내 표시 여부 - const [showVendorRegistrationInfo, setShowVendorRegistrationInfo] = useState(false); - - const lng = params.lng as string; - const { t, i18n } = useTranslation(lng, 'login'); - - const { toast } = useToast(); - - const handleChangeLanguage = (lang: string) => { - const segments = pathname.split('/'); - segments[1] = lang; - router.push(segments.join('/')); - }; - - const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english'); - - const [email, setEmail] = useState(''); - const [otpSent, setOtpSent] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [otp, setOtp] = useState(''); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - - // 업체 등록 페이지로 이동하는 함수 - const goToVendorRegistration = () => { - router.push(`/${lng}/partners/repository`); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - try { - const result = await sendOtpAction(email, lng); - - if (result.success) { - setOtpSent(true); - toast({ - title: t('otpSentTitle'), - description: t('otpSentMessage'), - }); - } else { - // Handle specific error types - let errorMessage = t('defaultErrorMessage'); - - // 업체 미등록 사용자 에러 처리 - if (result.error === 'userNotFound' || result.error === 'vendorNotRegistered') { - setShowVendorRegistrationInfo(true); - errorMessage = t('vendorNotRegistered') || '등록된 업체가 아닙니다. 먼저 업체 등록 신청을 해주세요.'; - } - - toast({ - title: t('errorTitle'), - description: result.message || errorMessage, - variant: 'destructive', - }); - } - } catch (error) { - // This will catch network errors or other unexpected issues - console.error(error); - toast({ - title: t('errorTitle'), - description: t('networkErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - - async function handleOtpSubmit(e: React.FormEvent) { - e.preventDefault(); - setIsLoading(true); - - try { - // next-auth의 Credentials Provider로 로그인 시도 - const result = await signIn('credentials', { - email, - code: otp, - redirect: false, // 커스텀 처리 위해 redirect: false - }); - - if (result?.ok) { - // 토스트 메시지 표시 - toast({ - title: t('loginSuccess'), - description: t('youAreLoggedIn'), - }); - - router.push(`/${lng}/partners/dashboard`); - } else { - // 로그인 실패 시 에러 메시지에 업체 등록 관련 정보 포함 - if (result?.error === 'vendorNotRegistered') { - setShowVendorRegistrationInfo(true); - toast({ - title: t('errorTitle'), - description: t('vendorNotRegistered') || '등록된 업체가 아닙니다. 먼저 업체 등록 신청을 해주세요.', - variant: 'destructive', - }); - } else { - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } - } - } catch (error) { - console.error('Login error:', error); - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - } - - // 새로운 로그인 처리 함수 추가 - const handleCredentialsLogin = async () => { - if (!username || !password) { - toast({ - title: t('errorTitle'), - description: t('credentialsRequired'), - variant: 'destructive', - }); - return; - } - - setIsLoading(true); - - try { - // next-auth의 다른 credentials provider로 로그인 시도 - const result = await signIn('credentials-password', { - username, - password, - redirect: false, - }); - - if (result?.ok) { - toast({ - title: t('loginSuccess'), - description: t('youAreLoggedIn'), - }); - - router.push(`/${lng}/partners/dashboard`); - } else { - // 로그인 실패 시 업체 등록 관련 정보 표시 여부 결정 - if (result?.error === 'vendorNotRegistered') { - setShowVendorRegistrationInfo(true); - toast({ - title: t('errorTitle'), - description: t('vendorNotRegistered') || '등록된 업체가 아닙니다. 먼저 업체 등록 신청을 해주세요.', - variant: 'destructive', - }); - } else { - toast({ - title: t('errorTitle'), - description: t('invalidCredentials'), - variant: 'destructive', - }); - } - } - } catch (error) { - console.error('Login error:', error); - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - const verifyToken = async () => { - if (!token) return; - setIsLoading(true); - - try { - const data = await verifyTokenAction(token); - - if (data.valid) { - setOtpSent(true); - setEmail(data.email ?? ''); - } else { - toast({ - title: t('errorTitle'), - description: t('invalidToken'), - variant: 'destructive', - }); - } - } catch (error) { - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - verifyToken(); - }, [token, toast, t]); - - return ( - <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> - {/* Left Content */} - <div className="flex flex-col w-full h-screen lg:p-2"> - {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */} - <div className="flex items-center justify-between p-4"> - <div className="flex items-center space-x-2"> - <Ship className="w-4 h-4" /> - <span className="text-md font-bold">eVCP</span> - </div> - - {/* 업체 등록 신청 버튼 - 가시성 향상을 위해 variant 변경 */} - <Link - href={`/${lng}/partners/repository`} - className={cn(buttonVariants({ variant: "outline" }), "border-blue-500 text-blue-600 hover:bg-blue-50")} - > - <InfoIcon className="w-4 h-4 mr-2" /> - {t('registerVendor') || '업체 등록 신청'} - </Link> - </div> - - {/* Content section that occupies remaining space, centered vertically */} - <div className="flex-1 flex items-center justify-center"> - {/* Your form container */} - <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> - {/* 업체 등록 안내 알림 - 특정 상황에서만 표시 */} - {showVendorRegistrationInfo && ( - <Alert className="border-blue-500 bg-blue-50"> - <InfoIcon className="h-4 w-4 text-blue-600" /> - <AlertTitle className="text-blue-700"> - {t('vendorRegistrationRequired') || '업체 등록이 필요합니다'} - </AlertTitle> - <AlertDescription className="text-blue-600"> - {t('vendorRegistrationMessage') || '로그인하시려면 먼저 업체 등록이 필요합니다. 아래 버튼을 클릭하여 등록을 진행해주세요.'} - </AlertDescription> - <Button - onClick={goToVendorRegistration} - className="mt-2 w-full bg-blue-600 hover:bg-blue-700 text-white" - > - {t('goToVendorRegistration') || '업체 등록 신청하기'} - <ArrowRight className="ml-2 h-4 w-4" /> - </Button> - </Alert> - )} - - <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> - <div className="flex flex-col gap-6"> - <div className="flex flex-col items-center text-center"> - <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> - - {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */} - <p className="text-sm text-muted-foreground mt-2"> - {t('loginDescription') || '등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} - </p> - </div> - - {/* S-chips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} - {!showCredentialsForm && ( - <> - <div className="grid gap-2"> - <Input - id="email" - type="email" - placeholder={t('email')} - required - className="h-10" - value={email} - onChange={(e) => setEmail(e.target.value)} - /> - </div> - <Button - type="submit" - className="w-full" - variant="samsung" - disabled={isLoading} - onClick={handleSubmit} - > - {isLoading ? t('sending') : t('ContinueWithEmail')} - </Button> - - {/* 구분선과 "Or continue with" 섹션 추가 */} - <div className="relative"> - <div className="absolute inset-0 flex items-center"> - <span className="w-full border-t"></span> - </div> - <div className="relative flex justify-center text-xs uppercase"> - <span className="bg-background px-2 text-muted-foreground"> - {t('orContinueWith')} - </span> - </div> - </div> - - {/* S-chips 로그인 버튼 */} - <Button - type="button" - className="w-full" - onClick={() => setShowCredentialsForm(true)} - > - S-Gips로 로그인하기 - </Button> - - {/* 업체 등록 안내 링크 추가 */} - <Button - type="button" - variant="link" - className="text-blue-600 hover:text-blue-800" - onClick={goToVendorRegistration} - > - {t('newVendor') || '신규 업체이신가요? 여기서 등록하세요'} - </Button> - </> - )} - - {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */} - {showCredentialsForm && ( - <> - <div className="grid gap-4"> - <Input - id="username" - type="text" - placeholder="S-chips ID" - className="h-10" - value={username} - onChange={(e) => setUsername(e.target.value)} - /> - <Input - id="password" - type="password" - placeholder="비밀번호" - className="h-10" - value={password} - onChange={(e) => setPassword(e.target.value)} - /> - <Button - type="button" - className="w-full" - variant="samsung" - onClick={handleCredentialsLogin} - disabled={isLoading} - > - {isLoading ? "로그인 중..." : "로그인"} - </Button> - - {/* 뒤로 가기 버튼 */} - <Button - type="button" - variant="ghost" - className="w-full text-sm" - onClick={() => setShowCredentialsForm(false)} - > - 이메일로 로그인하기 - </Button> - - {/* 업체 등록 안내 링크 추가 */} - <Button - type="button" - variant="link" - className="text-blue-600 hover:text-blue-800" - onClick={goToVendorRegistration} - > - {t('newVendor') || '신규 업체이신가요? 여기서 등록하세요'} - </Button> - </div> - </> - )} - - <div className="text-center text-sm mx-auto"> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="flex items-center gap-2"> - <GlobeIcon className="h-4 w-4" /> - <span>{currentLanguageText}</span> - <ChevronDownIcon className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuRadioGroup - value={i18n.language} - onValueChange={(value) => handleChangeLanguage(value)} - > - <DropdownMenuRadioItem value="en"> - {t('languages.english')} - </DropdownMenuRadioItem> - <DropdownMenuRadioItem value="ko"> - {t('languages.korean')} - </DropdownMenuRadioItem> - </DropdownMenuRadioGroup> - </DropdownMenuContent> - </DropdownMenu> - </div> - </div> - </form> - - <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> - {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')} - <a href="#">{t('privacyPolicy')}</a>. - </div> - </div> - </div> - </div> - - {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} - <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex"> - {/* Image 컴포넌트로 대체 */} - <div className="absolute inset-0"> - <Image - src="/images/02.jpg" - alt="Background image" - fill - priority - sizes="(max-width: 1024px) 100vw, 50vw" - className="object-cover" - /> - </div> - <div className="relative z-10 mt-auto"> - <blockquote className="space-y-2"> - <p className="text-sm">“{t("blockquote")}”</p> - {/* <footer className="text-sm">SHI</footer> */} - </blockquote> - </div> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/components/login/login-form copy.tsx b/components/login/login-form copy.tsx deleted file mode 100644 index ef9eba10..00000000 --- a/components/login/login-form copy.tsx +++ /dev/null @@ -1,468 +0,0 @@ -'use client'; - -import { useState, useEffect } from "react"; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" -import { Input } from "@/components/ui/input" -import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship, InfoIcon } from "lucide-react"; -import { useToast } from "@/hooks/use-toast"; -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu" -import { useTranslation } from '@/i18n/client' -import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation'; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from "@/components/ui/input-otp" -import { signIn } from 'next-auth/react'; -import { sendOtpAction } from "@/lib/users/send-otp"; -import { verifyTokenAction } from "@/lib/users/verifyToken"; -import { buttonVariants } from "@/components/ui/button" -import Link from "next/link" -import Image from 'next/image'; // 추가: Image 컴포넌트 import - -export function LoginForm({ - className, - ...props -}: React.ComponentProps<"div">) { - - const params = useParams() || {}; - const pathname = usePathname() || ''; - const router = useRouter(); - const searchParams = useSearchParams(); - const token = searchParams?.get('token') || null; - const [showCredentialsForm, setShowCredentialsForm] = useState(false); - - - const lng = params.lng as string; - const { t, i18n } = useTranslation(lng, 'login'); - - const { toast } = useToast(); - - const handleChangeLanguage = (lang: string) => { - const segments = pathname.split('/'); - segments[1] = lang; - router.push(segments.join('/')); - }; - - const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english'); - - const [email, setEmail] = useState(''); - const [otpSent, setOtpSent] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [otp, setOtp] = useState(''); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - - const goToVendorRegistration = () => { - router.push(`/${lng}/partners/repository`); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - try { - const result = await sendOtpAction(email, lng); - - if (result.success) { - setOtpSent(true); - toast({ - title: t('otpSentTitle'), - description: t('otpSentMessage'), - }); - } else { - // Handle specific error types - let errorMessage = t('defaultErrorMessage'); - - // You can handle different error types differently - if (result.error === 'userNotFound') { - errorMessage = t('userNotFoundMessage'); - } - - toast({ - title: t('errorTitle'), - description: result.message || errorMessage, - variant: 'destructive', - }); - } - } catch (error) { - // This will catch network errors or other unexpected issues - console.error(error); - toast({ - title: t('errorTitle'), - description: t('networkErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - - async function handleOtpSubmit(e: React.FormEvent) { - e.preventDefault(); - setIsLoading(true); - - try { - // next-auth의 Credentials Provider로 로그인 시도 - const result = await signIn('credentials', { - email, - code: otp, - redirect: false, // 커스텀 처리 위해 redirect: false - }); - - if (result?.ok) { - // 토스트 메시지 표시 - toast({ - title: t('loginSuccess'), - description: t('youAreLoggedIn'), - }); - - router.push(`/${lng}/partners/dashboard`); - - } else { - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } - } catch (error) { - console.error('Login error:', error); - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - } - - // 새로운 로그인 처리 함수 추가 - const handleCredentialsLogin = async () => { - if (!username || !password) { - toast({ - title: t('errorTitle'), - description: t('credentialsRequired'), - variant: 'destructive', - }); - return; - } - - setIsLoading(true); - - try { - // next-auth의 다른 credentials provider로 로그인 시도 - const result = await signIn('credentials-password', { - username, - password, - redirect: false, - }); - - if (result?.ok) { - toast({ - title: t('loginSuccess'), - description: t('youAreLoggedIn'), - }); - - router.push(`/${lng}/partners/dashboard`); - } else { - toast({ - title: t('errorTitle'), - description: t('invalidCredentials'), - variant: 'destructive', - }); - } - } catch (error) { - console.error('Login error:', error); - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - const verifyToken = async () => { - if (!token) return; - setIsLoading(true); - - try { - const data = await verifyTokenAction(token); - - if (data.valid) { - setOtpSent(true); - setEmail(data.email ?? ''); - } else { - toast({ - title: t('errorTitle'), - description: t('invalidToken'), - variant: 'destructive', - }); - } - } catch (error) { - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - verifyToken(); - }, [token, toast, t]); - - return ( - <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> - {/* Left Content */} - <div className="flex flex-col w-full h-screen lg:p-2"> - {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */} - <div className="flex items-center justify-between"> - <div className="flex items-center space-x-2"> - {/* <img - src="/images/logo.png" - alt="logo" - className="h-8 w-auto" - /> */} - <Ship className="w-4 h-4" /> - <span className="text-md font-bold">eVCP</span> - </div> - <Link - href="/partners/repository" - className={cn(buttonVariants({ variant: "ghost" }))} - > - <InfoIcon className="w-4 h-4 mr-1" /> - {'업체 등록 신청'} - </Link> - </div> - - {/* Content section that occupies remaining space, centered vertically */} - <div className="flex-1 flex items-center justify-center"> - {/* Your form container */} - <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> - - {/* Here's your existing login/OTP forms: */} - {/* {!otpSent ? ( */} - - {/* <form onSubmit={handleSubmit} className="p-6 md:p-8"> */} - <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> - <div className="flex flex-col gap-6"> - <div className="flex flex-col items-center text-center"> - <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> - - {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */} - <p className="text-xs text-muted-foreground mt-2"> - {'등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} - </p> - </div> - - {/* S-Gips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} - {!showCredentialsForm && ( - <> - <div className="grid gap-2"> - <Input - id="email" - type="email" - placeholder={t('email')} - required - className="h-10" - value={email} - onChange={(e) => setEmail(e.target.value)} - /> - </div> - <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}> - {isLoading ? t('sending') : t('ContinueWithEmail')} - </Button> - - {/* 구분선과 "Or continue with" 섹션 추가 */} - <div className="relative"> - <div className="absolute inset-0 flex items-center"> - <span className="w-full border-t"></span> - </div> - <div className="relative flex justify-center text-xs uppercase"> - <span className="bg-background px-2 text-muted-foreground"> - {t('orContinueWith')} - </span> - </div> - </div> - - {/* S-Gips 로그인 버튼 */} - <Button - type="button" - className="w-full" - // variant="" - onClick={() => setShowCredentialsForm(true)} - > - S-Gips로 로그인하기 - </Button> - - {/* 업체 등록 안내 링크 추가 */} - <Button - type="button" - variant="link" - className="text-blue-600 hover:text-blue-800" - onClick={goToVendorRegistration} - > - {'신규 업체이신가요? 여기서 등록하세요'} - </Button> - </> - )} - - {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */} - {showCredentialsForm && ( - <> - <div className="grid gap-4"> - <Input - id="username" - type="text" - placeholder="S-Gips ID" - className="h-10" - value={username} - onChange={(e) => setUsername(e.target.value)} - /> - <Input - id="password" - type="password" - placeholder="비밀번호" - className="h-10" - value={password} - onChange={(e) => setPassword(e.target.value)} - /> - <Button - type="button" - className="w-full" - variant="samsung" - onClick={handleCredentialsLogin} - disabled={isLoading} - > - {isLoading ? "로그인 중..." : "로그인"} - </Button> - - {/* 뒤로 가기 버튼 */} - <Button - type="button" - variant="ghost" - className="w-full text-sm" - onClick={() => setShowCredentialsForm(false)} - > - 이메일로 로그인하기 - </Button> - </div> - </> - )} - - <div className="text-center text-sm mx-auto"> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="flex items-center gap-2"> - <GlobeIcon className="h-4 w-4" /> - <span>{currentLanguageText}</span> - <ChevronDownIcon className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuRadioGroup - value={i18n.language} - onValueChange={(value) => handleChangeLanguage(value)} - > - <DropdownMenuRadioItem value="en"> - {t('languages.english')} - </DropdownMenuRadioItem> - <DropdownMenuRadioItem value="ko"> - {t('languages.korean')} - </DropdownMenuRadioItem> - </DropdownMenuRadioGroup> - </DropdownMenuContent> - </DropdownMenu> - </div> - </div> - </form> - {/* ) : ( - <form onSubmit={handleOtpSubmit} className="flex flex-col gap-4 p-6 md:p-8"> - <div className="flex flex-col gap-6"> - <div className="flex flex-col items-center text-center"> - <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> - </div> - <div className="grid gap-2 justify-center"> - <InputOTP - maxLength={6} - value={otp} - onChange={(value) => setOtp(value)} - > - <InputOTPGroup> - <InputOTPSlot index={0} /> - <InputOTPSlot index={1} /> - <InputOTPSlot index={2} /> - <InputOTPSlot index={3} /> - <InputOTPSlot index={4} /> - <InputOTPSlot index={5} /> - </InputOTPGroup> - </InputOTP> - </div> - <Button type="submit" className="w-full" disabled={isLoading}> - {isLoading ? t('verifying') : t('verifyOtp')} - </Button> - <div className="mx-auto"> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="flex items-center gap-2"> - <GlobeIcon className="h-4 w-4" /> - <span>{currentLanguageText}</span> - <ChevronDownIcon className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuRadioGroup - value={i18n.language} - onValueChange={(value) => handleChangeLanguage(value)} - > - <DropdownMenuRadioItem value="en"> - {t('languages.english')} - </DropdownMenuRadioItem> - <DropdownMenuRadioItem value="ko"> - {t('languages.korean')} - </DropdownMenuRadioItem> - </DropdownMenuRadioGroup> - </DropdownMenuContent> - </DropdownMenu> - </div> - </div> - </form> - )} */} - - <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> - {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')} - <a href="#">{t('privacyPolicy')}</a>. - </div> - </div> - </div> - </div> - - {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} - <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex"> - {/* Image 컴포넌트로 대체 */} - <div className="absolute inset-0"> - <Image - src="/images/02.jpg" - alt="Background image" - fill - priority - sizes="(max-width: 1024px) 100vw, 50vw" - className="object-cover" - /> - </div> - <div className="relative z-10 mt-auto"> - <blockquote className="space-y-2"> - <p className="text-sm">“{t("blockquote")}”</p> - {/* <footer className="text-sm">SHI</footer> */} - </blockquote> - </div> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/components/login/saml-login-button.tsx b/components/login/saml-login-button.tsx index c0aae0f1..02825e2f 100644 --- a/components/login/saml-login-button.tsx +++ b/components/login/saml-login-button.tsx @@ -32,8 +32,14 @@ export function SAMLLoginButton({ try { setIsLoading(true) + // 현재 페이지 경로를 RelayState로 설정 (로그인 후 이 페이지로 돌아옴) + const currentPath = window.location.pathname + window.location.search + const relayState = encodeURIComponent(currentPath) + + console.log('Setting RelayState to:', currentPath) + // API 엔드포인트를 통해 SAML AuthnRequest URL 생성 - const response = await fetch('/api/auth/saml/authn-request', { + const response = await fetch(`/api/auth/saml/authn-request?relayState=${relayState}`, { method: 'GET', headers: { 'Content-Type': 'application/json', |
