From e9897d416b3e7327bbd4d4aef887eee37751ae82 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 27 Jun 2025 01:16:20 +0000 Subject: (대표님) 20250627 오전 10시 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/auth/simple-reauth-modal.tsx | 193 +++++ components/data-table/data-table-grobal-filter.tsx | 1 + components/form-data/form-data-table.tsx | 1 + components/form-data/spreadJS-dialog.tsx | 317 ++++++-- components/layout/SessionManager.tsx | 361 +++++++++ components/layout/providers.tsx | 31 +- components/login/InvalidTokenPage.tsx | 45 ++ components/login/SuccessPage.tsx | 53 ++ components/login/login-form copy.tsx | 485 ++++++++++++ components/login/login-form.tsx | 814 ++++++++++++++------- components/login/next-auth-reauth-modal.tsx | 215 ++++++ components/login/partner-auth-form.tsx | 16 +- components/login/privacy-policy-page.tsx | 733 +++++++++++++++++++ components/login/reset-password.tsx | 351 +++++++++ components/mail/mail-template-editor-client.tsx | 255 +++++++ components/mail/mail-templates-client.tsx | 218 ++++++ components/signup/join-form.tsx | 56 +- components/system/passwordPolicy.tsx | 530 ++++++++++++++ components/system/permissionsTreeVendor.tsx | 167 +++++ components/ui/badge.tsx | 2 + components/ui/button.tsx | 10 +- 21 files changed, 4509 insertions(+), 345 deletions(-) create mode 100644 components/auth/simple-reauth-modal.tsx create mode 100644 components/layout/SessionManager.tsx create mode 100644 components/login/InvalidTokenPage.tsx create mode 100644 components/login/SuccessPage.tsx create mode 100644 components/login/login-form copy.tsx create mode 100644 components/login/next-auth-reauth-modal.tsx create mode 100644 components/login/privacy-policy-page.tsx create mode 100644 components/login/reset-password.tsx create mode 100644 components/mail/mail-template-editor-client.tsx create mode 100644 components/mail/mail-templates-client.tsx create mode 100644 components/system/passwordPolicy.tsx create mode 100644 components/system/permissionsTreeVendor.tsx (limited to 'components') diff --git a/components/auth/simple-reauth-modal.tsx b/components/auth/simple-reauth-modal.tsx new file mode 100644 index 00000000..f00674e3 --- /dev/null +++ b/components/auth/simple-reauth-modal.tsx @@ -0,0 +1,193 @@ +// components/auth/simple-reauth-modal.tsx +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { z } from "zod" +import { verifyExternalCredentials } from "@/lib/users/auth/verifyCredentails" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { toast } from "@/hooks/use-toast" +import { Shield, AlertCircle } from "lucide-react" + +const reAuthSchema = z.object({ + password: z.string().min(1, "Password is required"), +}) + +type ReAuthFormValues = z.infer + +interface SimpleReAuthModalProps { + isOpen: boolean + onSuccess: () => void + userEmail: string +} + +export function SimpleReAuthModal({ + isOpen, + onSuccess, + userEmail +}: SimpleReAuthModalProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [attemptCount, setAttemptCount] = React.useState(0) + + const form = useForm({ + resolver: zodResolver(reAuthSchema), + defaultValues: { + password: "", + }, + }) + + async function onSubmit(data: ReAuthFormValues) { + setIsLoading(true) + + try { + // 직접 인증 함수 호출 (API 호출 없이) + const authResult = await verifyExternalCredentials( + userEmail, + data.password + ) + + if (!authResult.success || !authResult.user) { + setAttemptCount(prev => prev + 1) + + if (attemptCount >= 2) { + toast({ + title: "Too many failed attempts", + description: "Please wait a moment before trying again.", + variant: "destructive", + }) + setTimeout(() => setAttemptCount(0), 30000) + return + } + + toast({ + title: "Authentication failed", + description: `Invalid password. ${2 - attemptCount} attempts remaining.`, + variant: "destructive", + }) + + form.setError("password", { + type: "manual", + message: "Invalid password" + }) + } else { + // 인증 성공 + setAttemptCount(0) + onSuccess() + form.reset() + + toast({ + title: "Authentication successful", + description: "You can now access account settings.", + }) + } + } catch (error) { + console.error("Re-authentication error:", error) + toast({ + title: "Error", + description: "An unexpected error occurred. Please try again.", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + React.useEffect(() => { + if (!isOpen) { + form.reset() + setAttemptCount(0) + } + }, [isOpen, form]) + + return ( + {}}> + + + + + Verify Your Password + + + Please enter your password to access account settings. + + + +
+ +
+

+ Email: {userEmail} +

+
+ + {attemptCount >= 2 && ( +
+
+ +

+ Too many failed attempts. Please wait 30 seconds. +

+
+
+ )} + + ( + + Password + + = 3 || isLoading} + {...field} + autoFocus + /> + + + + )} + /> + + + + +
+
+ ) +} \ No newline at end of file diff --git a/components/data-table/data-table-grobal-filter.tsx b/components/data-table/data-table-grobal-filter.tsx index a1f0a6f3..240e9fa7 100644 --- a/components/data-table/data-table-grobal-filter.tsx +++ b/components/data-table/data-table-grobal-filter.tsx @@ -17,6 +17,7 @@ export function DataTableGlobalFilter() { eq: (a, b) => a === b, clearOnDefault: true, shallow: false, + history: "replace" }) // Local tempValue to update instantly on user keystroke diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 92ec3c56..57913192 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -923,6 +923,7 @@ export default function DynamicTable({ selectedRow={selectedRowsData[0]} formCode={formCode} contractItemId={contractItemId} + editableFieldsMap={editableFieldsMap} onUpdateSuccess={(updatedValues) => { // SpreadSheets에서 업데이트된 값을 테이블에 반영 const tagNo = updatedValues.TAG_NO; diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx index 4a8550cb..5a51c2b5 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -1,10 +1,10 @@ "use client"; import * as React from "react"; +import dynamic from "next/dynamic"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { GenericData } from "./export-excel-form"; -import { SpreadSheets, Worksheet, Column } from "@mescius/spread-sheets-react"; import * as GC from "@mescius/spread-sheets"; import { toast } from "sonner"; import { updateFormDataInDB } from "@/lib/forms/services"; @@ -16,6 +16,26 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; + +// SpreadSheets를 동적으로 import (SSR 비활성화) +const SpreadSheets = dynamic( + () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets), + { + ssr: false, + loading: () => ( +
+ + Loading SpreadSheets... +
+ ) + } +); + +// 라이센스 키 설정을 클라이언트에서만 실행 +if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) { + GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; +} interface TemplateItem { TMPL_ID: string; @@ -24,7 +44,7 @@ interface TemplateItem { SPR_LST_SETUP: { ACT_SHEET: string; HIDN_SHEETS: Array; - CONTENT?: string; // SpreadSheets JSON + CONTENT?: string; DATA_SHEETS: Array<{ SHEET_NAME: string; REG_TYPE_ID: string; @@ -42,7 +62,7 @@ interface TemplateItem { SPR_ITM_LST_SETUP: { ACT_SHEET: string; HIDN_SHEETS: Array; - CONTENT?: string; // SpreadSheets JSON + CONTENT?: string; DATA_SHEETS: Array<{ SHEET_NAME: string; REG_TYPE_ID: string; @@ -57,11 +77,11 @@ interface TemplateItem { interface TemplateViewDialogProps { isOpen: boolean; onClose: () => void; - templateData: TemplateItem[] | any; // 배열 또는 기존 형태 + templateData: TemplateItem[] | any; selectedRow: GenericData; formCode: string; contractItemId: number; - /** 업데이트 성공 시 호출될 콜백 */ + editableFieldsMap?: Map; // 편집 가능 필드 정보 onUpdateSuccess?: (updatedValues: Record) => void; } @@ -72,6 +92,7 @@ export function TemplateViewDialog({ selectedRow, formCode, contractItemId, + editableFieldsMap = new Map(), onUpdateSuccess }: TemplateViewDialogProps) { const [hostStyle, setHostStyle] = React.useState({ @@ -83,21 +104,25 @@ export function TemplateViewDialog({ const [hasChanges, setHasChanges] = React.useState(false); const [currentSpread, setCurrentSpread] = React.useState(null); const [selectedTemplateId, setSelectedTemplateId] = React.useState(""); + const [cellMappings, setCellMappings] = React.useState>([]); + const [isClient, setIsClient] = React.useState(false); + + // 클라이언트 사이드에서만 렌더링되도록 보장 + React.useEffect(() => { + setIsClient(true); + }, []); // 템플릿 데이터를 배열로 정규화하고 CONTENT가 있는 것만 필터링 const normalizedTemplates = React.useMemo((): TemplateItem[] => { if (!templateData) return []; let templates: TemplateItem[]; - // 이미 배열인 경우 if (Array.isArray(templateData)) { templates = templateData as TemplateItem[]; } else { - // 기존 형태인 경우 (하위 호환성) templates = [templateData as TemplateItem]; } - // CONTENT가 있는 템플릿만 필터링 return templates.filter(template => { const sprContent = template.SPR_LST_SETUP?.CONTENT; const sprItmContent = template.SPR_ITM_LST_SETUP?.CONTENT; @@ -107,10 +132,54 @@ export function TemplateViewDialog({ // 선택된 템플릿 가져오기 const selectedTemplate = React.useMemo(() => { - if (!selectedTemplateId) return normalizedTemplates[0]; // 기본값: 첫 번째 템플릿 + if (!selectedTemplateId) return normalizedTemplates[0]; return normalizedTemplates.find(t => t.TMPL_ID === selectedTemplateId) || normalizedTemplates[0]; }, [normalizedTemplates, selectedTemplateId]); + // 현재 TAG의 편집 가능한 필드 목록 가져오기 + const editableFields = React.useMemo(() => { + if (!selectedRow?.TAG_NO || !editableFieldsMap.has(selectedRow.TAG_NO)) { + return []; + } + return editableFieldsMap.get(selectedRow.TAG_NO) || []; + }, [selectedRow?.TAG_NO, editableFieldsMap]); + + // 필드가 편집 가능한지 판별하는 함수 + const isFieldEditable = React.useCallback((attId: string) => { + // TAG_NO와 TAG_DESC는 기본적으로 편집 가능 + if (attId === "TAG_NO" || attId === "TAG_DESC") { + return true; + } + + // editableFieldsMap이 있으면 해당 리스트에 있는지 확인 + if (selectedRow?.TAG_NO && editableFieldsMap.has(selectedRow.TAG_NO)) { + return editableFields.includes(attId); + } + + return false; + }, [selectedRow?.TAG_NO, editableFieldsMap, editableFields]); + + // 셀 주소를 행과 열로 변환하는 함수 (예: "M1" -> {row: 0, col: 12}) + const parseCellAddress = (address: string): {row: number, col: number} | null => { + if (!address || address.trim() === "") return null; + + const match = address.match(/^([A-Z]+)(\d+)$/); + if (!match) return null; + + const [, colStr, rowStr] = match; + + // 열 문자를 숫자로 변환 (A=0, B=1, ..., Z=25, AA=26, ...) + let col = 0; + for (let i = 0; i < colStr.length; i++) { + col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); + } + col -= 1; // 0-based index로 변환 + + const row = parseInt(rowStr) - 1; // 0-based index로 변환 + + return { row, col }; + }; + // 템플릿 변경 시 기본 선택 React.useEffect(() => { if (normalizedTemplates.length > 0 && !selectedTemplateId) { @@ -119,63 +188,176 @@ export function TemplateViewDialog({ }, [normalizedTemplates, selectedTemplateId]); const initSpread = React.useCallback((spread: any) => { - if (!spread || !selectedTemplate) return; + if (!spread || !selectedTemplate || !selectedRow) return; try { setCurrentSpread(spread); - setHasChanges(false); // 템플릿 로드 시 변경사항 초기화 + setHasChanges(false); - // CONTENT 찾기 (SPR_LST_SETUP 또는 SPR_ITM_LST_SETUP 중 하나) + // CONTENT 찾기 let contentJson = null; + let dataSheets = null; + if (selectedTemplate.SPR_LST_SETUP?.CONTENT) { contentJson = selectedTemplate.SPR_LST_SETUP.CONTENT; + dataSheets = selectedTemplate.SPR_LST_SETUP.DATA_SHEETS; console.log('Using SPR_LST_SETUP.CONTENT for template:', selectedTemplate.NAME); } else if (selectedTemplate.SPR_ITM_LST_SETUP?.CONTENT) { contentJson = selectedTemplate.SPR_ITM_LST_SETUP.CONTENT; + dataSheets = selectedTemplate.SPR_ITM_LST_SETUP.DATA_SHEETS; console.log('Using SPR_ITM_LST_SETUP.CONTENT for template:', selectedTemplate.NAME); } - if (contentJson) { - console.log('Loading template content for:', selectedTemplate.NAME); - - const jsonData = typeof contentJson === 'string' - ? JSON.parse(contentJson) - : contentJson; - - // fromJSON으로 템플릿 구조 로드 - spread.fromJSON(jsonData); - } else { + if (!contentJson) { console.warn('No CONTENT found in template:', selectedTemplate.NAME); return; } - // 값 변경 이벤트 리스너 추가 (간단한 변경사항 감지만) - const activeSheet = spread.getActiveSheet(); + console.log('Loading template content for:', selectedTemplate.NAME); - activeSheet.bind(GC.Spread.Sheets.Events.CellChanged, (event: any, info: any) => { - console.log('Cell changed:', info); - setHasChanges(true); - }); + const jsonData = typeof contentJson === 'string' + ? JSON.parse(contentJson) + : contentJson; - activeSheet.bind(GC.Spread.Sheets.Events.ValueChanged, (event: any, info: any) => { - console.log('Value changed:', info); - setHasChanges(true); - }); + // 렌더링 일시 중단 (성능 향상) + spread.suspendPaint(); + + try { + // fromJSON으로 템플릿 구조 로드 + spread.fromJSON(jsonData); + + // 활성 시트 가져오기 + const activeSheet = spread.getActiveSheet(); + + // 시트 보호 먼저 해제 + activeSheet.options.isProtected = false; + + // MAP_CELL_ATT 정보를 사용해서 셀에 데이터 매핑과 스타일을 한번에 처리 + if (dataSheets && dataSheets.length > 0) { + const mappings: Array<{attId: string, cellAddress: string, isEditable: boolean}> = []; + + dataSheets.forEach(dataSheet => { + if (dataSheet.MAP_CELL_ATT) { + dataSheet.MAP_CELL_ATT.forEach(mapping => { + const { ATT_ID, IN } = mapping; + + // 셀 주소가 비어있지 않은 경우만 처리 + if (IN && IN.trim() !== "") { + const cellPos = parseCellAddress(IN); + if (cellPos) { + const isEditable = isFieldEditable(ATT_ID); + mappings.push({ + attId: ATT_ID, + cellAddress: IN, + isEditable: isEditable + }); + + // 셀 객체 가져오기 + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + + // selectedRow에서 해당 값 가져와서 셀에 설정 + const value = selectedRow[ATT_ID]; + if (value !== undefined && value !== null) { + cell.value(value); + } + + // 편집 권한 설정 + cell.locked(!isEditable); + + // 즉시 스타일 적용 (기존 스타일 보존하면서) + const existingStyle = activeSheet.getStyle(cellPos.row, cellPos.col); + if (existingStyle) { + // 기존 스타일 복사 + const newStyle = Object.assign(new GC.Spread.Sheets.Style(), existingStyle); + + // 편집 권한에 따라 배경색만 변경 + if (isEditable) { + newStyle.backColor = "#f0fdf4"; // 연한 녹색 + } else { + newStyle.backColor = "#f9fafb"; // 연한 회색 + newStyle.foreColor = "#6b7280"; // 회색 글자 + } + + // 스타일 적용 + activeSheet.setStyle(cellPos.row, cellPos.col, newStyle); + } else { + // 기존 스타일이 없는 경우 새로운 스타일 생성 + const newStyle = new GC.Spread.Sheets.Style(); + if (isEditable) { + newStyle.backColor = "#f0fdf4"; + } else { + newStyle.backColor = "#f9fafb"; + newStyle.foreColor = "#6b7280"; + } + activeSheet.setStyle(cellPos.row, cellPos.col, newStyle); + } + + console.log(`Mapped ${ATT_ID} (${value}) to cell ${IN} - ${isEditable ? 'Editable' : 'Read-only'}`); + } + } + }); + } + }); + + setCellMappings(mappings); + + // 시트 보호 설정 + activeSheet.options.isProtected = true; + activeSheet.options.protectionOptions = { + allowSelectLockedCells: true, + allowSelectUnlockedCells: true, + allowSort: false, + allowFilter: false, + allowEditObjects: false, + allowResizeRows: false, + allowResizeColumns: false + }; + + // 이벤트 리스너 추가 + activeSheet.bind(GC.Spread.Sheets.Events.CellChanged, (event: any, info: any) => { + console.log('Cell changed:', info); + setHasChanges(true); + }); + + activeSheet.bind(GC.Spread.Sheets.Events.ValueChanged, (event: any, info: any) => { + console.log('Value changed:', info); + setHasChanges(true); + }); + + // 편집 시작 시 읽기 전용 셀 확인 + activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { + const mapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (mapping && !mapping.isEditable) { + toast.warning(`${mapping.attId} field is read-only`); + info.cancel = true; + } + }); + } + } finally { + // 렌더링 재개 (모든 변경사항이 한번에 화면에 표시됨) + spread.resumePaint(); + } } catch (error) { console.error('Error initializing spread:', error); toast.error('Failed to load template'); + // 에러 발생 시에도 렌더링 재개 + if (spread && spread.resumePaint) { + spread.resumePaint(); + } } - }, [selectedTemplate]); + }, [selectedTemplate, selectedRow, isFieldEditable]); // 템플릿 변경 핸들러 const handleTemplateChange = (templateId: string) => { setSelectedTemplateId(templateId); - setHasChanges(false); // 템플릿 변경 시 변경사항 초기화 + setHasChanges(false); - // SpreadSheets 재초기화는 useCallback 의존성에 의해 자동으로 처리됨 if (currentSpread) { - // 강제로 재초기화 setTimeout(() => { initSpread(currentSpread); }, 100); @@ -184,7 +366,7 @@ export function TemplateViewDialog({ // 변경사항 저장 함수 const handleSaveChanges = React.useCallback(async () => { - if (!currentSpread || !hasChanges) { + if (!currentSpread || !hasChanges || !selectedRow) { toast.info("No changes to save"); return; } @@ -192,24 +374,22 @@ export function TemplateViewDialog({ try { setIsPending(true); - // SpreadSheets에서 현재 데이터를 JSON으로 추출 - const spreadJson = currentSpread.toJSON(); - console.log('Current spread data:', spreadJson); - - // 실제 데이터 추출 방법은 SpreadSheets 구조에 따라 달라질 수 있음 - // 여기서는 기본적인 예시만 제공 const activeSheet = currentSpread.getActiveSheet(); - - // 간단한 예시: 특정 범위의 데이터를 추출하여 selectedRow 형태로 변환 - // 실제 구현에서는 템플릿의 구조에 맞춰 데이터를 추출해야 함 - const dataToSave = { - ...selectedRow, // 기본값으로 원본 데이터 사용 - // 여기에 SpreadSheets에서 변경된 값들을 추가 - // 예: TAG_DESC: activeSheet.getValue(특정행, 특정열) - }; + const dataToSave = { ...selectedRow }; + + // cellMappings를 사용해서 편집 가능한 셀의 값만 추출 + cellMappings.forEach(mapping => { + if (mapping.isEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + dataToSave[mapping.attId] = cellValue; + } + } + }); // TAG_NO는 절대 변경되지 않도록 원본 값으로 강제 설정 - dataToSave.TAG_NO = selectedRow?.TAG_NO; + dataToSave.TAG_NO = selectedRow.TAG_NO; console.log('Data to save (TAG_NO preserved):', dataToSave); @@ -240,7 +420,7 @@ export function TemplateViewDialog({ } finally { setIsPending(false); } - }, [currentSpread, hasChanges, formCode, contractItemId, selectedRow, onUpdateSuccess]); + }, [currentSpread, hasChanges, formCode, contractItemId, selectedRow, onUpdateSuccess, cellMappings]); if (!isOpen) return null; @@ -260,9 +440,21 @@ export function TemplateViewDialog({ )}
- - Template content will be loaded directly. Manual data entry may be required. - +
+ + + Editable fields + + + + Read-only fields + + {cellMappings.length > 0 && ( + + {cellMappings.filter(m => m.isEditable).length} of {cellMappings.length} fields editable + + )} +
@@ -295,15 +487,22 @@ export function TemplateViewDialog({ {/* SpreadSheets 컴포넌트 영역 */}
- {selectedTemplate ? ( + {selectedTemplate && isClient ? ( ) : (
- No template available + {!isClient ? ( + <> + + Loading... + + ) : ( + "No template available" + )}
)}
diff --git a/components/layout/SessionManager.tsx b/components/layout/SessionManager.tsx new file mode 100644 index 00000000..c917c5f3 --- /dev/null +++ b/components/layout/SessionManager.tsx @@ -0,0 +1,361 @@ +// components/layout/SessionManager.tsx +'use client' + +import { useSession } from "next-auth/react" +import { useEffect, useState, useCallback } from "react" +import { useRouter } from "next/navigation" +import { AlertCircle, Clock, RefreshCw, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Progress } from "@/components/ui/progress" +import { useToast } from "@/hooks/use-toast" +import { Card, CardContent } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +interface SessionManagerProps { + lng: string; +} + +// 다국어 메시지 +const messages = { + ko: { + sessionExpiring: "세션 만료 경고", + sessionWillExpire: "세션이 {minutes}분 후에 만료됩니다.", + sessionExpired: "세션이 만료되었습니다", + pleaseRelogin: "다시 로그인해주세요.", + extend: "연장", + extending: "연장 중...", + close: "닫기", + sessionExtended: "세션이 연장되었습니다", + sessionExtendFailed: "세션 연장에 실패했습니다", + autoLogoutIn: "{seconds}초 후 자동 로그아웃됩니다", + staySignedIn: "로그인 유지", + logout: "로그아웃" + }, + en: { + sessionExpiring: "Session Expiring", + sessionWillExpire: "Your session will expire in {minutes} minute(s).", + sessionExpired: "Session Expired", + pleaseRelogin: "Please log in again.", + extend: "Extend", + extending: "Extending...", + close: "Close", + sessionExtended: "Session has been extended", + sessionExtendFailed: "Failed to extend session", + autoLogoutIn: "Auto logout in {seconds} seconds", + staySignedIn: "Stay Signed In", + logout: "Logout" + } +} as const; + +export function SessionManager({ lng }: SessionManagerProps) { + const { data: session, update } = useSession() + const router = useRouter() + const { toast } = useToast() + + const [showWarning, setShowWarning] = useState(false) + const [showExpiredModal, setShowExpiredModal] = useState(false) + const [isExtending, setIsExtending] = useState(false) + const [autoLogoutCountdown, setAutoLogoutCountdown] = useState(0) + const [timeLeft, setTimeLeft] = useState(null) + + const t = messages[lng as keyof typeof messages] || messages.en + + // 세션 연장 함수 + const extendSession = useCallback(async () => { + if (isExtending) return; + + setIsExtending(true) + try { + await update({ + reAuthTime: Date.now() + }) + + setShowWarning(false) + setTimeLeft(null) + + toast({ + title: t.sessionExtended, + description: "세션이 성공적으로 연장되었습니다.", + duration: 3000, + }) + } catch (error) { + console.error('Failed to extend session:', error) + toast({ + title: t.sessionExtendFailed, + description: "다시 시도해주세요.", + variant: "destructive", + duration: 5000, + }) + } finally { + setIsExtending(false) + } + }, [isExtending, update, toast, t]) + + // 자동 로그아웃 처리 + const handleAutoLogout = useCallback(() => { + setShowExpiredModal(false) + setShowWarning(false) + window.location.href = `/${lng}/evcp?reason=expired` + }, [lng]) + + // 세션 만료 체크 + useEffect(() => { + if (!session?.user?.sessionExpiredAt) return + + const checkSession = () => { + const now = Date.now() + const expiresAt = session.user.sessionExpiredAt! + const timeUntilExpiry = expiresAt - now + const warningThreshold = 5 * 60 * 1000 // 5분 + const criticalThreshold = 1 * 60 * 1000 // 1분 + + setTimeLeft(timeUntilExpiry) + + // 세션 만료됨 + if (timeUntilExpiry <= 0) { + setShowWarning(false) + setShowExpiredModal(true) + setAutoLogoutCountdown(10) // 10초 후 자동 로그아웃 + return + } + + // 1분 이내 - 긴급 경고 + if (timeUntilExpiry <= criticalThreshold) { + setShowWarning(true) + return + } + + // 5분 이내 - 일반 경고 + if (timeUntilExpiry <= warningThreshold && !showWarning) { + setShowWarning(true) + return + } + + // 경고 해제 + if (timeUntilExpiry > warningThreshold && showWarning) { + setShowWarning(false) + } + } + + // 즉시 체크 + checkSession() + + // 5초마다 체크 (더 정확한 카운트다운을 위해) + const interval = setInterval(checkSession, 5000) + + return () => clearInterval(interval) + }, [session, showWarning]) + + // 자동 로그아웃 카운트다운 + useEffect(() => { + if (autoLogoutCountdown <= 0) return + + const timer = setTimeout(() => { + if (autoLogoutCountdown === 1) { + handleAutoLogout() + } else { + setAutoLogoutCountdown(prev => prev - 1) + } + }, 1000) + + return () => clearTimeout(timer) + }, [autoLogoutCountdown, handleAutoLogout]) + + // 사용자 활동 감지 + useEffect(() => { + let activityTimer: NodeJS.Timeout + let lastActivity = Date.now() + + const resetActivityTimer = () => { + const now = Date.now() + const timeSinceLastActivity = now - lastActivity + + // 5분 이상 비활성 후 첫 활동이면 세션 연장 + if (timeSinceLastActivity > 5 * 60 * 1000) { + extendSession() + } + + lastActivity = now + clearTimeout(activityTimer) + + // 10분간 비활성이면 경고 표시 + activityTimer = setTimeout(() => { + if (!showWarning && session?.user?.sessionExpiredAt) { + const timeUntilExpiry = session.user.sessionExpiredAt - Date.now() + if (timeUntilExpiry > 0 && timeUntilExpiry <= 10 * 60 * 1000) { + setShowWarning(true) + } + } + }, 10 * 60 * 1000) + } + + const activities = ['mousedown', 'keydown', 'scroll', 'touchstart', 'click'] + + activities.forEach(activity => { + document.addEventListener(activity, resetActivityTimer, true) + }) + + resetActivityTimer() // 초기 타이머 설정 + + return () => { + clearTimeout(activityTimer) + activities.forEach(activity => { + document.removeEventListener(activity, resetActivityTimer, true) + }) + } + }, [extendSession, showWarning, session]) + + const formatTime = (ms: number) => { + const minutes = Math.floor(ms / (1000 * 60)) + const seconds = Math.floor((ms % (1000 * 60)) / 1000) + return { minutes, seconds } + } + + // 세션 만료 모달 + if (showExpiredModal) { + return ( +
+ + +
+ +

{t.sessionExpired}

+
+ +

+ {t.pleaseRelogin} +

+ + {autoLogoutCountdown > 0 && ( +
+

+ {t.autoLogoutIn.replace('{seconds}', autoLogoutCountdown.toString())} +

+ +
+ )} + +
+ + +
+
+
+
+ ) + } + + // 세션 경고 알림 + if (showWarning && timeLeft) { + const { minutes, seconds } = formatTime(timeLeft) + const isCritical = timeLeft <= 60000 // 1분 이내 + const progressValue = Math.max(0, Math.min(100, (timeLeft / (5 * 60 * 1000)) * 100)) + + return ( +
+ + + +
+
+

+ {t.sessionExpiring} +

+ +
+ + + {minutes > 0 + ? t.sessionWillExpire.replace('{minutes}', minutes.toString()) + : `${seconds}초 후 세션이 만료됩니다.` + } + + +
+ + +
+ + +
+
+
+
+
+ ) + } + + return null +} \ No newline at end of file diff --git a/components/layout/providers.tsx b/components/layout/providers.tsx index 376c419a..78f96d61 100644 --- a/components/layout/providers.tsx +++ b/components/layout/providers.tsx @@ -6,9 +6,9 @@ import { ThemeProvider as NextThemesProvider } from "next-themes" import { NuqsAdapter } from "nuqs/adapters/next/app" import { SessionProvider } from "next-auth/react" import { CacheProvider } from '@emotion/react' -import { SWRConfig } from 'swr' // ✅ SWR 추가 - +import { SWRConfig } from 'swr' import { TooltipProvider } from "@/components/ui/tooltip" +import { SessionManager } from "@/components/layout/SessionManager" // ✅ SessionManager 추가 import createEmotionCache from './createEmotionCashe' const cache = createEmotionCache() @@ -21,18 +21,18 @@ const swrConfig = { shouldRetryOnError: false, // 에러시 자동 재시도 비활성화 (수동으로 제어) dedupingInterval: 2000, // 2초 내 중복 요청 방지 refreshInterval: 0, // 기본적으로 자동 갱신 비활성화 (개별 훅에서 설정) - + // 간단한 전역 에러 핸들러 (토스트 없이 로깅만) onError: (error: any, key: string) => { // 개발 환경에서만 상세 로깅 if (process.env.NODE_ENV === 'development') { - console.warn('SWR fetch failed:', { - url: key, - status: error?.status, - message: error?.message + console.warn('SWR fetch failed:', { + url: key, + status: error?.status, + message: error?.message }) } - + // 401 Unauthorized의 경우 특별 처리 (선택사항) if (error?.status === 401) { console.warn('Authentication required') @@ -40,16 +40,21 @@ const swrConfig = { // window.location.href = '/login' } }, - + // 전역 성공 핸들러는 제거 (너무 많은 로그 방지) - + // 기본 fetcher 제거 (각 훅에서 개별 관리) } +interface ThemeProviderProps extends React.ComponentProps { + lng?: string; // ✅ lng prop 추가 +} + export function ThemeProvider({ children, + lng = 'ko', // ✅ 기본값 설정 ...props -}: React.ComponentProps) { +}: ThemeProviderProps) { return ( @@ -60,6 +65,8 @@ export function ThemeProvider({ {/* ✅ 간소화된 SWR 설정 적용 */} {children} + {/* ✅ SessionManager 추가 - 모든 프로바이더 내부에 위치 */} + @@ -68,4 +75,4 @@ export function ThemeProvider({ ) -} +} \ No newline at end of file diff --git a/components/login/InvalidTokenPage.tsx b/components/login/InvalidTokenPage.tsx new file mode 100644 index 00000000..da97a568 --- /dev/null +++ b/components/login/InvalidTokenPage.tsx @@ -0,0 +1,45 @@ +// app/[lng]/auth/reset-password/components/InvalidTokenPage.tsx + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { XCircle } from 'lucide-react'; +import Link from 'next/link'; + +interface Props { + expired: boolean; + error?: string; +} + +export default function InvalidTokenPage({ expired, error }: Props) { + return ( +
+ + +
+ +
+ 링크 오류 + + {expired + ? '재설정 링크가 만료되었습니다. 새로운 재설정 요청을 해주세요.' + : error || '유효하지 않은 재설정 링크입니다.'} + +
+ +
+ + + + + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/login/SuccessPage.tsx b/components/login/SuccessPage.tsx new file mode 100644 index 00000000..f9a3c525 --- /dev/null +++ b/components/login/SuccessPage.tsx @@ -0,0 +1,53 @@ +// app/[lng]/auth/reset-password/components/SuccessPage.tsx + +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { CheckCircle } from 'lucide-react'; +import Link from 'next/link'; + +interface Props { + message?: string; +} + +export default function SuccessPage({ message }: Props) { + const router = useRouter(); + + // 3초 후 자동 리다이렉트 + useEffect(() => { + const timer = setTimeout(() => { + router.push('/parnters'); + }, 3000); + + return () => clearTimeout(timer); + }, [router]); + + return ( + + +
+ +
+ 재설정 완료 + + {message || '비밀번호가 성공적으로 변경되었습니다.'} + +
+ +
+

+ 잠시 후 로그인 페이지로 자동 이동합니다... +

+ + + +
+
+
+ ); +} \ No newline at end of file diff --git a/components/login/login-form copy.tsx b/components/login/login-form copy.tsx new file mode 100644 index 00000000..4f9fbb53 --- /dev/null +++ b/components/login/login-form copy.tsx @@ -0,0 +1,485 @@ +'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'), + }); + + const callbackUrlParam = searchParams?.get('callbackUrl'); + + if (callbackUrlParam) { + try { + // URL 객체로 파싱 + const callbackUrl = new URL(callbackUrlParam); + + // pathname + search만 사용 (호스트 제거) + const relativeUrl = callbackUrl.pathname + callbackUrl.search; + router.push(relativeUrl); + } catch (e) { + // 유효하지 않은 URL이면 그대로 사용 (이미 상대 경로일 수 있음) + router.push(callbackUrlParam); + } + } else { + // callbackUrl이 없으면 기본 대시보드로 리다이렉트 + 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 ( +
+ {/* Left Content */} +
+ {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */} +
+
+ {/* logo */} + + eVCP +
+ + + {'업체 등록 신청'} + +
+ + {/* Content section that occupies remaining space, centered vertically */} +
+ {/* Your form container */} +
+ + {/* Here's your existing login/OTP forms: */} + {!otpSent ? ( + +
+ {/* */} +
+
+

{t('loginMessage')}

+ + {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */} +

+ {'등록된 업체만 로그인하실 수 있습니다. 아직도 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} +

+
+ + {/* S-Gips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} + {!showCredentialsForm && ( + <> +
+ setEmail(e.target.value)} + /> +
+ + + {/* 구분선과 "Or continue with" 섹션 추가 */} +
+
+ +
+
+ + {t('orContinueWith')} + +
+
+ + {/* S-Gips 로그인 버튼 */} + + + {/* 업체 등록 안내 링크 추가 */} + + + )} + + {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */} + {showCredentialsForm && ( + <> +
+ setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + + + {/* 뒤로 가기 버튼 */} + +
+ + )} + +
+ + + + + + handleChangeLanguage(value)} + > + + {t('languages.english')} + + + {t('languages.korean')} + + + + +
+
+
+ ) : ( +
+
+
+

{t('loginMessage')}

+
+
+ setOtp(value)} + > + + + + + + + + + +
+ +
+ + + + + + handleChangeLanguage(value)} + > + + {t('languages.english')} + + + {t('languages.korean')} + + + + +
+
+
+ )} + +
+ {t('termsMessage')} {t('termsOfService')} {t('and')} + {t('privacyPolicy')}. +
+
+
+
+ + {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} +
+ {/* Image 컴포넌트로 대체 */} +
+ Background image +
+
+
+

“{t("blockquote")}”

+ {/*
SHI
*/} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx index 4f9fbb53..7af607b5 100644 --- a/components/login/login-form.tsx +++ b/components/login/login-form.tsx @@ -3,43 +3,66 @@ 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 { Ship, InfoIcon, GlobeIcon, ChevronDownIcon, ArrowLeft } 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 { signIn, getSession } from 'next-auth/react'; +import { buttonVariants } from "@/components/ui/button" +import Link from "next/link" +import Image from 'next/image'; +import { useFormState } from 'react-dom'; 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 +import { requestPasswordResetAction } from "@/lib/users/auth/partners-auth"; + +type LoginMethod = 'username' | 'sgips'; 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 [loginMethod, setLoginMethod] = useState('username'); + const [isLoading, setIsLoading] = useState(false); + const [showForgotPassword, setShowForgotPassword] = useState(false); + + // MFA 관련 상태 + const [showMfaForm, setShowMfaForm] = useState(false); + const [mfaToken, setMfaToken] = useState(''); + const [mfaUserId, setMfaUserId] = useState(''); + const [mfaUserEmail, setMfaUserEmail] = useState(''); + const [mfaCountdown, setMfaCountdown] = useState(0); + + // 일반 로그인 폼 데이터 + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + // S-Gips 로그인 폼 데이터 + const [sgipsUsername, setSgipsUsername] = useState(''); + const [sgipsPassword, setSgipsPassword] = useState(''); + + // 서버 액션 상태 + const [passwordResetState, passwordResetAction] = useFormState(requestPasswordResetAction, { + success: false, + error: undefined, + message: undefined, + }); + const handleChangeLanguage = (lang: string) => { const segments = pathname.split('/'); segments[1] = lang; @@ -48,50 +71,66 @@ export function LoginForm({ 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(); + // MFA 카운트다운 효과 + useEffect(() => { + if (mfaCountdown > 0) { + const timer = setTimeout(() => setMfaCountdown(mfaCountdown - 1), 1000); + return () => clearTimeout(timer); + } + }, [mfaCountdown]); + + // 서버 액션 결과 처리 + useEffect(() => { + if (passwordResetState.success && passwordResetState.message) { + toast({ + title: '재설정 링크 전송', + description: passwordResetState.message, + }); + setShowForgotPassword(false); + } else if (passwordResetState.error) { + toast({ + title: t('errorTitle'), + description: passwordResetState.error, + variant: 'destructive', + }); + } + }, [passwordResetState, toast, t]); + + // SMS 토큰 전송 + const handleSendSms = async () => { + if (!mfaUserId || mfaCountdown > 0) return; + setIsLoading(true); try { - const result = await sendOtpAction(email, lng); + // SMS 전송 API 호출 (실제 구현 필요) + const response = await fetch('/api/auth/send-sms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: mfaUserId }), + }); - if (result.success) { - setOtpSent(true); + if (response.ok) { + setMfaCountdown(60); // 60초 카운트다운 toast({ - title: t('otpSentTitle'), - description: t('otpSentMessage'), + title: 'SMS 전송 완료', + description: '인증번호를 전송했습니다.', }); } 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, + description: 'SMS 전송에 실패했습니다.', variant: 'destructive', }); } } catch (error) { - // This will catch network errors or other unexpected issues - console.error(error); + console.error('SMS send error:', error); toast({ title: t('errorTitle'), - description: t('networkErrorMessage'), + description: 'SMS 전송 중 오류가 발생했습니다.', variant: 'destructive', }); } finally { @@ -99,65 +138,75 @@ export function LoginForm({ } }; - async function handleOtpSubmit(e: React.FormEvent) { + // MFA 토큰 검증 + const handleMfaSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + if (!mfaToken || mfaToken.length !== 6) { + toast({ + title: t('errorTitle'), + description: '6자리 인증번호를 입력해주세요.', + variant: 'destructive', + }); + return; + } + setIsLoading(true); try { - // next-auth의 Credentials Provider로 로그인 시도 - const result = await signIn('credentials', { - email, - code: otp, - redirect: false, // 커스텀 처리 위해 redirect: false + // MFA 토큰 검증 API 호출 + const response = await fetch('/api/auth/verify-mfa', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: mfaUserId, + token: mfaToken + }), }); - if (result?.ok) { - // 토스트 메시지 표시 + if (response.ok) { toast({ - title: t('loginSuccess'), - description: t('youAreLoggedIn'), + title: '인증 완료', + description: '로그인이 완료되었습니다.', }); + // callbackUrl 처리 const callbackUrlParam = searchParams?.get('callbackUrl'); - if (callbackUrlParam) { - try { - // URL 객체로 파싱 - const callbackUrl = new URL(callbackUrlParam); - - // pathname + search만 사용 (호스트 제거) - const relativeUrl = callbackUrl.pathname + callbackUrl.search; - router.push(relativeUrl); - } catch (e) { - // 유효하지 않은 URL이면 그대로 사용 (이미 상대 경로일 수 있음) - router.push(callbackUrlParam); - } + try { + const callbackUrl = new URL(callbackUrlParam); + const relativeUrl = callbackUrl.pathname + callbackUrl.search; + router.push(relativeUrl); + } catch (e) { + router.push(callbackUrlParam); + } } else { - // callbackUrl이 없으면 기본 대시보드로 리다이렉트 - router.push(`/${lng}/partners/dashboard`); + router.push(`/${lng}/partners/dashboard`); } - } else { + const errorData = await response.json(); toast({ title: t('errorTitle'), - description: t('defaultErrorMessage'), + description: errorData.message || '인증번호가 올바르지 않습니다.', variant: 'destructive', }); } } catch (error) { - console.error('Login error:', error); + console.error('MFA verification error:', error); toast({ title: t('errorTitle'), - description: t('defaultErrorMessage'), + description: 'MFA 인증 중 오류가 발생했습니다.', variant: 'destructive', }); } finally { setIsLoading(false); } - } + }; + + // 일반 사용자명/패스워드 로그인 처리 (간소화된 버전) + const handleUsernameLogin = async (e: React.FormEvent) => { + e.preventDefault(); - // 새로운 로그인 처리 함수 추가 - const handleCredentialsLogin = async () => { if (!username || !password) { toast({ title: t('errorTitle'), @@ -170,24 +219,55 @@ export function LoginForm({ setIsLoading(true); try { - // next-auth의 다른 credentials provider로 로그인 시도 + // NextAuth credentials-password provider로 로그인 const result = await signIn('credentials-password', { - username, - password, + username: username, + password: password, redirect: false, }); if (result?.ok) { + // 로그인 1차 성공 - 바로 MFA 화면으로 전환 toast({ title: t('loginSuccess'), - description: t('youAreLoggedIn'), + description: '1차 인증이 완료되었습니다.', + }); + + // 모든 사용자는 MFA 필수이므로 바로 MFA 폼으로 전환 + setMfaUserId(username); // 입력받은 username 사용 + setMfaUserEmail(username); // 입력받은 username 사용 (보통 이메일) + setShowMfaForm(true); + + // 자동으로 SMS 전송 + setTimeout(() => { + handleSendSms(); + }, 500); + + toast({ + title: 'SMS 인증 필요', + description: '등록된 전화번호로 인증번호를 전송합니다.', }); - router.push(`/${lng}/partners/dashboard`); } else { + // 로그인 실패 처리 + let errorMessage = t('invalidCredentials'); + + if (result?.error) { + switch (result.error) { + case 'CredentialsSignin': + errorMessage = t('invalidCredentials'); + break; + case 'AccessDenied': + errorMessage = t('accessDenied'); + break; + default: + errorMessage = t('defaultErrorMessage'); + } + } + toast({ title: t('errorTitle'), - description: t('invalidCredentials'), + description: errorMessage, variant: 'destructive', }); } @@ -203,36 +283,87 @@ export function LoginForm({ } }; - useEffect(() => { - const verifyToken = async () => { - if (!token) return; - setIsLoading(true); - try { - const data = await verifyTokenAction(token); + // S-Gips 로그인 처리 + // S-Gips 로그인 처리 (간소화된 버전) + const handleSgipsLogin = async (e: React.FormEvent) => { + e.preventDefault(); - if (data.valid) { - setOtpSent(true); - setEmail(data.email ?? ''); - } else { - toast({ - title: t('errorTitle'), - description: t('invalidToken'), - variant: 'destructive', - }); + if (!sgipsUsername || !sgipsPassword) { + toast({ + title: t('errorTitle'), + description: t('credentialsRequired'), + variant: 'destructive', + }); + return; + } + + setIsLoading(true); + + try { + // NextAuth credentials-password provider로 로그인 (S-Gips 구분) + const result = await signIn('credentials-password', { + username: sgipsUsername, + password: sgipsPassword, + provider: 'sgips', // S-Gips 구분을 위한 추가 파라미터 + redirect: false, + }); + + if (result?.ok) { + // S-Gips 1차 인증 성공 - 바로 MFA 화면으로 전환 + toast({ + title: t('loginSuccess'), + description: 'S-Gips 인증이 완료되었습니다.', + }); + + // S-Gips도 MFA 필수이므로 바로 MFA 폼으로 전환 + setMfaUserId(sgipsUsername); + setMfaUserEmail(sgipsUsername); + setShowMfaForm(true); + + // 자동으로 SMS 전송 + setTimeout(() => { + handleSendSms(); + }, 500); + + toast({ + title: 'SMS 인증 시작', + description: 'S-Gips 등록 전화번호로 인증번호를 전송합니다.', + }); + + } else { + let errorMessage = t('sgipsLoginFailed'); + + if (result?.error) { + switch (result.error) { + case 'CredentialsSignin': + errorMessage = t('invalidSgipsCredentials'); + break; + case 'AccessDenied': + errorMessage = t('sgipsAccessDenied'); + break; + default: + errorMessage = t('sgipsSystemError'); + } } - } catch (error) { + toast({ title: t('errorTitle'), - description: t('defaultErrorMessage'), + description: errorMessage, variant: 'destructive', }); - } finally { - setIsLoading(false); } - }; - verifyToken(); - }, [token, toast, t]); + } catch (error) { + console.error('S-Gips login error:', error); + toast({ + title: t('errorTitle'), + description: t('sgipsSystemError'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; return (
@@ -241,11 +372,6 @@ export function LoginForm({ {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */}
- {/* logo */} eVCP
@@ -253,178 +379,350 @@ export function LoginForm({ href="/partners/repository" className={cn(buttonVariants({ variant: "ghost" }))} > - - {'업체 등록 신청'} + + {'업체 등록 신청'}
{/* Content section that occupies remaining space, centered vertically */}
- {/* Your form container */}
- - {/* Here's your existing login/OTP forms: */} - {!otpSent ? ( - -
- {/* */} -
+
+
+ {/* Header */}
-

{t('loginMessage')}

- - {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */} -

- {'등록된 업체만 로그인하실 수 있습니다. 아직도 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} -

-
- - {/* S-Gips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} - {!showCredentialsForm && ( + {!showMfaForm ? ( <> -
- setEmail(e.target.value)} - /> +

{t('loginMessage')}

+

+ {'등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} +

+ + ) : ( + <> +
+ 🔐
- +

SMS 인증

+

+ {mfaUserEmail}로 로그인하셨습니다 +

+

+ 등록된 전화번호로 전송된 6자리 인증번호를 입력해주세요 +

+ + )} +
- {/* 구분선과 "Or continue with" 섹션 추가 */} -
-
- + {/* 로그인 폼 또는 MFA 폼 */} + {!showMfaForm ? ( + <> + {/* Login Method Tabs */} +
+ + +
+ + {/* Username Login Form */} + {loginMethod === 'username' && ( + +
+ setUsername(e.target.value)} + disabled={isLoading} + />
-
- - {t('orContinueWith')} - +
+ setPassword(e.target.value)} + disabled={isLoading} + />
-
- - {/* S-Gips 로그인 버튼 */} + + + )} + + {/* S-Gips Login Form */} + {loginMethod === 'sgips' && ( +
+
+ setSgipsUsername(e.target.value)} + disabled={isLoading} + /> +
+
+ setSgipsPassword(e.target.value)} + disabled={isLoading} + /> +
+ +

+ S-Gips 계정으로 로그인하면 자동으로 SMS 인증이 진행됩니다. +

+
+ )} + + {/* Additional Links */} +
- {/* 업체 등록 안내 링크 추가 */} - - - )} - - {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */} - {showCredentialsForm && ( - <> -
- setUsername(e.target.value)} - /> - setPassword(e.target.value)} - /> + {loginMethod === 'username' && ( + )} - {/* 뒤로 가기 버튼 */} + {/* 테스트용 MFA 화면 버튼 */} + {process.env.NODE_ENV === 'development' && ( + )} +
+ + ) : ( + /* MFA 입력 폼 */ +
+ {/* 뒤로 가기 버튼 */} +
+ +
+ + {/* SMS 재전송 섹션 */} +
+

+ 인증번호 재전송 +

+

+ 인증번호를 받지 못하셨나요? +

+ +
+ + {/* SMS 토큰 입력 폼 */} +
+
+
+ +
+ setMfaToken(value)} + > + + + + + + + + + +
+
- - )} -
- - - - - - handleChangeLanguage(value)} - > - - {t('languages.english')} - - - {t('languages.korean')} - - - - -
-
- - ) : ( -
-
-
-

{t('loginMessage')}

+ + + + {/* 도움말 */} +
+
+
+ ⚠️ +
+
+

+ 인증번호를 받지 못하셨나요? +

+
+
    +
  • 전화번호가 올바른지 확인해주세요
  • +
  • 스팸 메시지함을 확인해주세요
  • +
  • 잠시 후 재전송 버튼을 이용해주세요
  • +
+
+
+
+
-
- setOtp(value)} - > - - - - - - - - - + )} + + {/* 비밀번호 재설정 다이얼로그 */} + {showForgotPassword && !showMfaForm && ( +
+
+
+

비밀번호 재설정

+ +
+
+
+

+ 가입하신 이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다. +

+ +
+
+ + +
+
+
- -
+ )} + + {/* Language Selector - MFA 화면에서는 숨김 */} + {!showMfaForm && ( +
-
- - )} - -
- {t('termsMessage')} {t('termsOfService')} {t('and')} - {t('privacyPolicy')}. + )} +
+ + {/* Terms - MFA 화면에서는 숨김 */} + {!showMfaForm && ( +
+ {t("agreement")}{" "} + + {t("privacyPolicy")} + +
+ )}
- {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} + {/* Right BG 이미지 영역 */}
- {/* Image 컴포넌트로 대체 */}

“{t("blockquote")}”

- {/*
SHI
*/}
diff --git a/components/login/next-auth-reauth-modal.tsx b/components/login/next-auth-reauth-modal.tsx new file mode 100644 index 00000000..5aa61b7d --- /dev/null +++ b/components/login/next-auth-reauth-modal.tsx @@ -0,0 +1,215 @@ +// components/auth/next-auth-reauth-modal.tsx +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { z } from "zod" +import { signIn } from "next-auth/react" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { toast } from "@/hooks/use-toast" +import { AlertCircle, Shield } from "lucide-react" + +const reAuthSchema = z.object({ + password: z.string().min(1, "Password is required"), +}) + +type ReAuthFormValues = z.infer + +interface NextAuthReAuthModalProps { + isOpen: boolean + onSuccess: () => void + userEmail: string +} + +export function NextAuthReAuthModal({ + isOpen, + onSuccess, + userEmail +}: NextAuthReAuthModalProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [attemptCount, setAttemptCount] = React.useState(0) + + const form = useForm({ + resolver: zodResolver(reAuthSchema), + defaultValues: { + password: "", + }, + }) + + async function onSubmit(data: ReAuthFormValues) { + setIsLoading(true) + + try { + // Next-auth의 signIn 함수를 사용하여 재인증 + const result = await signIn("credentials", { + email: userEmail, + password: data.password, + redirect: false, // 리다이렉트 하지 않음 + callbackUrl: undefined, + }) + + if (result?.error) { + setAttemptCount(prev => prev + 1) + + // 3회 이상 실패 시 추가 보안 조치 + if (attemptCount >= 2) { + toast({ + title: "Too many failed attempts", + description: "Please wait a moment before trying again.", + variant: "destructive", + }) + // 30초 대기 + setTimeout(() => { + setAttemptCount(0) + }, 30000) + return + } + + toast({ + title: "Authentication failed", + description: `Invalid password. ${2 - attemptCount} attempts remaining.`, + variant: "destructive", + }) + + form.setError("password", { + type: "manual", + message: "Invalid password" + }) + } else { + // 재인증 성공 + setAttemptCount(0) + onSuccess() + form.reset() + + toast({ + title: "Authentication successful", + description: "You can now access account settings.", + }) + } + } catch (error) { + console.error("Re-authentication error:", error) + toast({ + title: "Error", + description: "An unexpected error occurred. Please try again.", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + // 모달이 닫힐 때 폼 리셋 + React.useEffect(() => { + if (!isOpen) { + form.reset() + setAttemptCount(0) + } + }, [isOpen, form]) + + return ( + {}}> + + + +
+ +
+ Security Verification +
+ + For your security, please confirm your password to access sensitive account settings. + This verification is valid for 5 minutes. + +
+ +
+ + {/* 사용자 정보 표시 */} +
+
+
+ + Signed in as: {userEmail} + +
+
+ + {/* 경고 메시지 (실패 횟수가 많을 때) */} + {attemptCount >= 2 && ( +
+
+ +
+

Security Alert

+

Multiple failed attempts detected. Please wait 30 seconds before trying again.

+
+
+
+ )} + + ( + + Current Password + + = 3 || isLoading} + {...field} + autoFocus + autoComplete="current-password" + /> + + + + )} + /> + + + + + +
+

This helps protect your account from unauthorized changes.

+

Your session will remain active during verification.

+
+
+
+ ) +} \ No newline at end of file diff --git a/components/login/partner-auth-form.tsx b/components/login/partner-auth-form.tsx index ada64d96..5fed19cf 100644 --- a/components/login/partner-auth-form.tsx +++ b/components/login/partner-auth-form.tsx @@ -47,7 +47,7 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) { const params = useParams() || {}; const pathname = usePathname() || ''; - + const lng = params.lng as string const { t, i18n } = useTranslation(lng, "login") @@ -110,7 +110,7 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) { title: "가입이 진행 중이거나 완료된 회사", description: `${result.data} 에 연락하여 계정 생성 요청을 하시기 바랍니다.`, }) - + // 로그인 액션 버튼이 있는 알림 표시 setTimeout(() => { toast({ @@ -244,6 +244,7 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) { variant="link" className="text-blue-600 hover:text-blue-800 text-sm" onClick={goToLogin} + type="button" > {t("alreadyRegistered") || "이미 등록된 업체이신가요? 로그인하기"} @@ -279,19 +280,12 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) {

{t("agreement")}{" "} - {t("termsOfService")} - {" "} - {t("and")}{" "} - {t("privacyPolicy")} - . + {/* {t("privacyAgreement")}. */}

diff --git a/components/login/privacy-policy-page.tsx b/components/login/privacy-policy-page.tsx new file mode 100644 index 00000000..e3eccdcb --- /dev/null +++ b/components/login/privacy-policy-page.tsx @@ -0,0 +1,733 @@ +"use client" + +import * as React from "react" +import { useRouter, useParams } from "next/navigation" +import Link from "next/link" +import { Ship, ArrowLeft } from "lucide-react" +import { Button } from "@/components/ui/button" + +// 한국어 개인정보처리방침 컴포넌트 +function PrivacyPolicyPageKo() { + const router = useRouter() + + return ( +
+ {/* Header */} +
+
+
+
+ + eVCP +
+ +
+
+
+ + {/* Content */} +
+
+
+

+ 개인정보처리방침 +

+

+ 시행일자: 2025년 1월 1일 +

+
+ +
+
+

+ eVCP는 개인정보보호법, 정보통신망 이용촉진 및 정보보호 등에 관한 법률 등 + 개인정보보호 관련 법령을 준수하며, 이용자의 개인정보를 안전하게 처리하고 있습니다. +

+
+ + {/* 목차 */} + + + {/* 각 섹션 */} +
+

+ 1. 개인정보의 수집 및 이용목적 +

+

회사는 다음의 목적을 위하여 개인정보를 수집 및 이용합니다:

+ +
+
+

1.1 회원가입 및 계정관리

+
    +
  • 회원 식별 및 본인인증
  • +
  • 회원자격 유지·관리
  • +
  • 서비스 부정이용 방지
  • +
+
+ +
+

1.2 서비스 제공

+
    +
  • 업체 등록 및 인증 서비스 제공
  • +
  • 고객상담 및 문의사항 처리
  • +
  • 공지사항 전달
  • +
+
+ +
+

1.3 법정의무 이행

+
    +
  • 관련 법령에 따른 의무사항 이행
  • +
+
+
+
+ +
+

+ 2. 수집하는 개인정보의 항목 +

+ +
+
+

2.1 필수정보

+
    +
  • 이메일 주소: 계정 생성, 로그인, 중요 알림 발송
  • +
  • 전화번호: 본인인증, 중요 연락사항 전달
  • +
+
+ +
+

2.2 자동 수집정보

+
    +
  • 접속 IP주소, 접속 시간, 이용기록
  • +
  • 쿠키, 서비스 이용기록
  • +
+
+
+
+ +
+

+ 3. 개인정보의 보유 및 이용기간 +

+ +
+
+

3.1 회원정보

+
    +
  • 보유기간: 회원탈퇴 시까지
  • +
  • 예외: 관련 법령에 따라 보존할 필요가 있는 경우 해당 기간 동안 보관
  • +
+
+ +
+

3.2 법령에 따른 보관

+
    +
  • 계약 또는 청약철회 등에 관한 기록: 5년 (전자상거래법)
  • +
  • 대금결제 및 재화 등의 공급에 관한 기록: 5년 (전자상거래법)
  • +
  • 소비자 불만 또는 분쟁처리에 관한 기록: 3년 (전자상거래법)
  • +
  • 웹사이트 방문기록: 3개월 (통신비밀보호법)
  • +
+
+
+
+ +
+

+ 4. 개인정보의 제3자 제공 +

+

+ 회사는 원칙적으로 이용자의 개인정보를 제3자에게 제공하지 않습니다. +

+

다만, 다음의 경우에는 예외로 합니다:

+
    +
  • 이용자가 사전에 동의한 경우
  • +
  • 법령의 규정에 의거하거나, 수사 목적으로 법령에 정해진 절차와 방법에 따라 수사기관의 요구가 있는 경우
  • +
+
+ +
+

+ 5. 개인정보 처리의 위탁 +

+

+ 현재 회사는 개인정보 처리업무를 외부에 위탁하고 있지 않습니다. +

+

향후 개인정보 처리업무를 위탁하는 경우, 다음 사항을 준수하겠습니다:

+
    +
  • 위탁계약 체결 시 개인정보보호 관련 법령 준수, 개인정보에 관한 비밀유지, 제3자 제공 금지 등을 계약서에 명시
  • +
  • 위탁업체가 개인정보를 안전하게 처리하는지 감독
  • +
+
+ + {/* 권리 행사 섹션 - 특별히 강조 */} +
+

+ 6. 개인정보 주체의 권리 +

+ +
+

+ 💡 이용자님의 권리를 알려드립니다 +

+

+ 언제든지 본인의 개인정보에 대해 열람, 수정, 삭제를 요청하실 수 있습니다. +

+
+ +
+
+

6.1 정보주체의 권리

+
    +
  • 열람권: 본인의 개인정보 처리현황을 확인할 권리
  • +
  • 정정·삭제권: 잘못된 정보의 수정이나 삭제를 요구할 권리
  • +
  • 처리정지권: 개인정보 처리 중단을 요구할 권리
  • +
+
+ +
+

6.2 권리행사 방법

+
    +
  • 연락처: privacy@evcp.com
  • +
  • 처리기간: 요청 접수 후 10일 이내
  • +
+
+
+
+ +
+

+ 7. 개인정보의 파기절차 및 방법 +

+ +
+
+

7.1 파기절차

+
    +
  • 보유기간 만료 또는 처리목적 달성 시 지체없이 파기
  • +
  • 다른 법령에 따라 보관하여야 하는 경우에는 해당 기간 동안 보관
  • +
+
+ +
+

7.2 파기방법

+
    +
  • 전자적 파일: 복구 및 재생되지 않도록 안전하게 삭제
  • +
  • 서면: 분쇄기로 분쇄하거나 소각
  • +
+
+
+
+ + {/* 연락처 정보 */} +
+

+ 8. 개인정보 보호책임자 +

+ +
+
+

개인정보 보호책임자

+
    +
  • 성명: [담당자명]
  • +
  • 직책: [직책명]
  • +
  • 연락처: privacy@evcp.com
  • +
+
+ +
+

개인정보 보호담당자

+
    +
  • 성명: [담당자명]
  • +
  • 부서: [부서명]
  • +
  • 연락처: privacy@evcp.com, 02-0000-0000
  • +
+
+
+
+ +
+

+ 9. 개인정보의 안전성 확보조치 +

+ +
+
+

9.1 기술적 조치

+
    +
  • 개인정보 암호화
  • +
  • 해킹 등에 대비한 기술적 대책
  • +
  • 백신 소프트웨어 등의 설치·갱신
  • +
+
+ +
+

9.2 관리적 조치

+
    +
  • 개인정보 취급자의 최소한 지정 및 교육
  • +
  • 개인정보 취급자에 대한 정기적 교육
  • +
+
+ +
+

9.3 물리적 조치

+
    +
  • 전산실, 자료보관실 등의 접근통제
  • +
+
+
+
+ +
+

+ 10. 쿠키의 설치·운영 및 거부 +

+ +
+
+

10.1 쿠키의 사용목적

+
    +
  • 이용자에게 최적화된 서비스 제공
  • +
  • 웹사이트 방문 및 이용형태 파악
  • +
+
+ +
+

10.2 쿠키 거부 방법

+

웹브라우저 설정을 통해 쿠키 허용, 차단 등의 설정을 변경할 수 있습니다.

+
    +
  • Chrome: 설정 → 개인정보 및 보안 → 쿠키 및 기타 사이트 데이터
  • +
  • Safari: 환경설정 → 개인정보 보호 → 쿠키 및 웹사이트 데이터
  • +
+
+
+
+ +
+

+ 11. 개인정보 처리방침의 변경 +

+

+ 본 개인정보처리방침은 법령·정책 또는 보안기술의 변경에 따라 내용의 추가·삭제 및 수정이 있을 시 + 변경 최소 7일 전부터 웹사이트를 통해 변경이유 및 내용 등을 공지하겠습니다. +

+
+

공고일자: 2025년 1월 1일

+

시행일자: 2025년 1월 1일

+
+
+ + {/* 문의처 */} +
+

문의처

+

+ 개인정보와 관련한 문의사항이 있으시면 아래 연락처로 문의해 주시기 바랍니다. +

+
+

이메일: privacy@evcp.com

+

전화: 02-0000-0000

+

주소: [회사 주소]

+
+
+ +
+

본 방침은 2025년 1월 1일부터 시행됩니다.

+
+
+
+
+
+ ) +} + +// 영문 개인정보처리방침 컴포넌트 +function PrivacyPolicyPageEn() { + const router = useRouter() + + return ( +
+ {/* Header */} +
+
+
+
+ + eVCP +
+ +
+
+
+ + {/* Content */} +
+
+
+

+ Privacy Policy +

+

+ Effective Date: January 1, 2025 +

+
+ +
+
+

+ eVCP complies with applicable privacy and data protection laws and regulations, + and is committed to protecting and securely processing your personal information. +

+
+ + {/* Table of Contents */} + + + {/* Sections */} +
+

+ 1. Purpose of Personal Information Collection and Use +

+

We collect and use personal information for the following purposes:

+ +
+
+

1.1 Account Registration and Management

+
    +
  • User identification and authentication
  • +
  • Account maintenance and management
  • +
  • Prevention of service misuse
  • +
+
+ +
+

1.2 Service Provision

+
    +
  • Company registration and verification services
  • +
  • Customer support and inquiry handling
  • +
  • Important notifications and announcements
  • +
+
+ +
+

1.3 Legal Compliance

+
    +
  • Compliance with applicable laws and regulations
  • +
+
+
+
+ +
+

+ 2. Personal Information We Collect +

+ +
+
+

2.1 Required Information

+
    +
  • Email Address: Account creation, login, important notifications
  • +
  • Phone Number: Identity verification, important communications
  • +
+
+ +
+

2.2 Automatically Collected Information

+
    +
  • IP address, access time, usage records
  • +
  • Cookies and service usage records
  • +
+
+
+
+ +
+

+ 3. Retention and Use Period +

+ +
+
+

3.1 Member Information

+
    +
  • Retention Period: Until account deletion
  • +
  • Exception: Where required by law, retained for the required period
  • +
+
+ +
+

3.2 Legal Retention Requirements

+
    +
  • Contract and transaction records: 5 years
  • +
  • Payment and service delivery records: 5 years
  • +
  • Consumer complaint or dispute records: 3 years
  • +
  • Website visit records: 3 months
  • +
+
+
+
+ +
+

+ 4. Third Party Disclosure +

+

+ We do not disclose your personal information to third parties, except in the following cases: +

+
    +
  • With your prior consent
  • +
  • When required by law or legal authorities following proper procedures
  • +
+
+ +
+

+ 5. Processing Outsourcing +

+

+ We currently do not outsource personal information processing to external parties. +

+

If we outsource personal information processing in the future, we will:

+
    +
  • Include privacy protection clauses in outsourcing contracts
  • +
  • Supervise outsourced parties to ensure secure processing of personal information
  • +
+
+ + {/* Rights section - emphasized */} +
+

+ 6. Your Rights +

+ +
+

+ 💡 Know Your Rights +

+

+ You can request access, correction, or deletion of your personal information at any time. +

+
+ +
+
+

6.1 Data Subject Rights

+
    +
  • Right of Access: Request information about how your data is processed
  • +
  • Right of Rectification: Request correction or deletion of incorrect information
  • +
  • Right to Restriction: Request suspension of personal information processing
  • +
+
+ +
+

6.2 How to Exercise Your Rights

+
    +
  • Contact: privacy@evcp.com
  • +
  • Response Time: Within 10 days of request
  • +
+
+
+
+ +
+

+ 7. Data Deletion Procedures +

+ +
+
+

7.1 Deletion Procedure

+
    +
  • Deletion without delay when retention period expires or purpose is achieved
  • +
  • Retention for required period when required by other laws
  • +
+
+ +
+

7.2 Deletion Method

+
    +
  • Electronic files: Secure deletion to prevent recovery
  • +
  • Paper documents: Shredding or incineration
  • +
+
+
+
+ + {/* Contact information */} +
+

+ 8. Privacy Officer +

+ +
+
+

Chief Privacy Officer

+
    +
  • Name: [Officer Name]
  • +
  • Title: [Title]
  • +
  • Contact: privacy@evcp.com
  • +
+
+ +
+

Privacy Manager

+
    +
  • Name: [Manager Name]
  • +
  • Department: [Department]
  • +
  • Contact: privacy@evcp.com, +82-2-0000-0000
  • +
+
+
+
+ +
+

+ 9. Security Measures +

+ +
+
+

9.1 Technical Measures

+
    +
  • Personal information encryption
  • +
  • Technical safeguards against hacking
  • +
  • Installation and updating of antivirus software
  • +
+
+ +
+

9.2 Administrative Measures

+
    +
  • Minimizing and training personal information handlers
  • +
  • Regular training for personal information handlers
  • +
+
+ +
+

9.3 Physical Measures

+
    +
  • Access control to computer rooms and data storage areas
  • +
+
+
+
+ +
+

+ 10. Cookies +

+ +
+
+

10.1 Purpose of Cookie Use

+
    +
  • Providing optimized services to users
  • +
  • Understanding website visit and usage patterns
  • +
+
+ +
+

10.2 Cookie Management

+

You can control cookie settings through your web browser:

+
    +
  • Chrome: Settings → Privacy and security → Cookies and other site data
  • +
  • Safari: Preferences → Privacy → Cookies and website data
  • +
+
+
+
+ +
+

+ 11. Policy Changes +

+

+ This Privacy Policy may be updated due to changes in laws, policies, or security technology. + We will notify users of changes at least 7 days in advance through our website. +

+
+

Publication Date: January 1, 2025

+

Effective Date: January 1, 2025

+
+
+ + {/* Contact section */} +
+

Contact Us

+

+ If you have any questions about this Privacy Policy, please contact us: +

+
+

Email: privacy@evcp.com

+

Phone: +82-2-0000-0000

+

Address: [Company Address]

+
+
+ +
+

This policy is effective from January 1, 2025.

+
+
+
+
+
+ ) +} + +// 메인 컴포넌트 - 언어에 따라 조건부 렌더링 +export function PrivacyPolicyPage() { + const params = useParams() || {}; + const lng = params.lng as string + + // 한국어면 한국어 버전, 그 외는 영문 버전 + if (lng === 'ko') { + return + } else { + return + } +} \ No newline at end of file diff --git a/components/login/reset-password.tsx b/components/login/reset-password.tsx new file mode 100644 index 00000000..f68018d9 --- /dev/null +++ b/components/login/reset-password.tsx @@ -0,0 +1,351 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useFormState } from 'react-dom'; +import { useToast } from '@/hooks/use-toast'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Ship, Eye, EyeOff, CheckCircle, XCircle, AlertCircle, Shield } from 'lucide-react'; +import Link from 'next/link'; +import SuccessPage from './SuccessPage'; +import { PasswordPolicy } from '@/lib/users/auth/passwordUtil'; +import { PasswordValidationResult, resetPasswordAction, validatePasswordAction } from '@/lib/users/auth/partners-auth'; + +interface PasswordRequirement { + text: string; + met: boolean; + type: 'length' | 'uppercase' | 'lowercase' | 'number' | 'symbol' | 'pattern'; +} + +interface Props { + token: string; + userId: number; + passwordPolicy: PasswordPolicy; +} + +export default function ResetPasswordForm({ token, userId, passwordPolicy }: Props) { + const router = useRouter(); + const { toast } = useToast(); + + // 상태 관리 + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordValidation, setPasswordValidation] = useState(null); + const [isValidatingPassword, setIsValidatingPassword] = useState(false); + + // 서버 액션 상태 + const [resetState, resetAction] = useFormState(resetPasswordAction, { + success: false, + error: undefined, + message: undefined, + }); + + // 패스워드 검증 (디바운싱 적용) + useEffect(() => { + const validatePassword = async () => { + if (!newPassword) { + setPasswordValidation(null); + return; + } + + setIsValidatingPassword(true); + + try { + // 사용자 ID를 포함한 검증 (히스토리 체크 포함) + const validation = await validatePasswordAction(newPassword, userId); + setPasswordValidation(validation); + } catch (error) { + console.error('Password validation error:', error); + setPasswordValidation(null); + } finally { + setIsValidatingPassword(false); + } + }; + + // 디바운싱: 500ms 후에 검증 실행 + const timeoutId = setTimeout(validatePassword, 500); + return () => clearTimeout(timeoutId); + }, [newPassword, userId]); + + // 서버 액션 결과 처리 + useEffect(() => { + if (resetState.error) { + toast({ + title: '오류', + description: resetState.error, + variant: 'destructive', + }); + } + }, [resetState, toast]); + + // 패스워드 요구사항 생성 + const getPasswordRequirements = (): PasswordRequirement[] => { + if (!passwordValidation) return []; + + const { strength } = passwordValidation; + const requirements: PasswordRequirement[] = [ + { + text: `${passwordPolicy.minLength}자 이상`, + met: strength.length >= passwordPolicy.minLength, + type: 'length' + } + ]; + + if (passwordPolicy.requireUppercase) { + requirements.push({ + text: '대문자 포함', + met: strength.hasUppercase, + type: 'uppercase' + }); + } + + if (passwordPolicy.requireLowercase) { + requirements.push({ + text: '소문자 포함', + met: strength.hasLowercase, + type: 'lowercase' + }); + } + + if (passwordPolicy.requireNumbers) { + requirements.push({ + text: '숫자 포함', + met: strength.hasNumbers, + type: 'number' + }); + } + + if (passwordPolicy.requireSymbols) { + requirements.push({ + text: '특수문자 포함', + met: strength.hasSymbols, + type: 'symbol' + }); + } + + return requirements; + }; + + // 패스워드 강도 색상 + const getStrengthColor = (score: number) => { + switch (score) { + case 1: return 'text-red-600'; + case 2: return 'text-orange-600'; + case 3: return 'text-yellow-600'; + case 4: return 'text-blue-600'; + case 5: return 'text-green-600'; + default: return 'text-gray-600'; + } + }; + + const getStrengthText = (score: number) => { + switch (score) { + case 1: return '매우 약함'; + case 2: return '약함'; + case 3: return '보통'; + case 4: return '강함'; + case 5: return '매우 강함'; + default: return ''; + } + }; + + const passwordRequirements = getPasswordRequirements(); + const allRequirementsMet = passwordValidation?.policyValid && passwordValidation?.historyValid !== false; + const passwordsMatch = newPassword === confirmPassword && confirmPassword.length > 0; + const canSubmit = allRequirementsMet && passwordsMatch && !isValidatingPassword; + + // 성공 화면 + if (resetState.success) { + return ; + } + + return ( + + +
+ + eVCP +
+ 새 비밀번호 설정 + + 계정 보안을 위해 강력한 비밀번호를 설정해주세요. + +
+ + +
+ + + {/* 새 비밀번호 */} +
+ +
+ setNewPassword(e.target.value)} + placeholder="새 비밀번호를 입력하세요" + required + /> + +
+ + {/* 패스워드 강도 표시 */} + {passwordValidation && ( +
+
+ + 강도: + + {getStrengthText(passwordValidation.strength.score)} + + {isValidatingPassword && ( +
+ )} +
+ + {/* 강도 진행바 */} +
+
+
+
+ )} + + {/* 패스워드 요구사항 */} + {passwordRequirements.length > 0 && ( +
+ {passwordRequirements.map((req, index) => ( +
+ {req.met ? ( + + ) : ( + + )} + + {req.text} + +
+ ))} +
+ )} + + {/* 히스토리 검증 결과 */} + {passwordValidation?.historyValid === false && ( +
+
+ + + 최근 {passwordPolicy.historyCount}개 비밀번호와 달라야 합니다 + +
+
+ )} + + {/* 추가 피드백 */} + {passwordValidation?.strength.feedback && passwordValidation.strength.feedback.length > 0 && ( +
+ {passwordValidation.strength.feedback.map((feedback, index) => ( +
+ + {feedback} +
+ ))} +
+ )} + + {/* 정책 오류 */} + {passwordValidation && !passwordValidation.policyValid && passwordValidation.policyErrors.length > 0 && ( +
+ {passwordValidation.policyErrors.map((error, index) => ( +
+ + {error} +
+ ))} +
+ )} +
+ + {/* 비밀번호 확인 */} +
+ +
+ setConfirmPassword(e.target.value)} + placeholder="비밀번호를 다시 입력하세요" + required + /> + +
+ + {/* 비밀번호 일치 확인 */} + {confirmPassword && ( +
+ {passwordsMatch ? ( + <> + + 비밀번호가 일치합니다 + + ) : ( + <> + + 비밀번호가 일치하지 않습니다 + + )} +
+ )} +
+ + + + +
+ + 로그인 페이지로 돌아가기 + +
+ + + ); +} \ No newline at end of file diff --git a/components/mail/mail-template-editor-client.tsx b/components/mail/mail-template-editor-client.tsx new file mode 100644 index 00000000..dfbeb4e0 --- /dev/null +++ b/components/mail/mail-template-editor-client.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useState, useEffect, useTransition } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Save, Eye } from 'lucide-react'; +import { toast } from 'sonner'; +import Link from 'next/link'; +import { getTemplateAction, updateTemplateAction, previewTemplateAction, TemplateFile } from '@/lib/mail/service'; + +type Template = TemplateFile; + +interface MailTemplateEditorClientProps { + templateName: string; + initialTemplate?: Template | null; +} + +export default function MailTemplateEditorClient({ + templateName, + initialTemplate +}: MailTemplateEditorClientProps) { + const router = useRouter(); + const params = useParams(); + + const lng = (params?.lng as string) || 'ko'; + + const [template, setTemplate] = useState