diff options
Diffstat (limited to 'components')
21 files changed, 4509 insertions, 345 deletions
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<typeof reAuthSchema> + +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<ReAuthFormValues>({ + 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 ( + <Dialog open={isOpen} onOpenChange={() => {}}> + <DialogContent className="sm:max-w-[400px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Shield className="h-5 w-5 text-amber-600" /> + Verify Your Password + </DialogTitle> + <DialogDescription> + Please enter your password to access account settings. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <div className="rounded-lg bg-blue-50 border border-blue-200 p-3"> + <p className="text-sm text-blue-800"> + <strong>Email:</strong> {userEmail} + </p> + </div> + + {attemptCount >= 2 && ( + <div className="rounded-lg bg-red-50 border border-red-200 p-3"> + <div className="flex items-center gap-2"> + <AlertCircle className="h-4 w-4 text-red-500" /> + <p className="text-sm text-red-800"> + Too many failed attempts. Please wait 30 seconds. + </p> + </div> + </div> + )} + + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Enter your password" + disabled={attemptCount >= 3 || isLoading} + {...field} + autoFocus + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Button + type="submit" + className="w-full" + disabled={isLoading || attemptCount >= 3} + > + {isLoading ? ( + <> + <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> + Verifying... + </> + ) : attemptCount >= 3 ? ( + "Please wait..." + ) : ( + "Verify" + )} + </Button> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ 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: () => ( + <div className="flex items-center justify-center h-full"> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading SpreadSheets... + </div> + ) + } +); + +// 라이센스 키 설정을 클라이언트에서만 실행 +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<string>; - 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<string>; - 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<string, string[]>; // 편집 가능 필드 정보 onUpdateSuccess?: (updatedValues: Record<string, any>) => 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<any>(null); const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); + const [cellMappings, setCellMappings] = React.useState<Array<{attId: string, cellAddress: string, isEditable: boolean}>>([]); + 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({ </span> )} <br /> - <span className="text-xs text-muted-foreground"> - Template content will be loaded directly. Manual data entry may be required. - </span> + <div className="flex items-center gap-4 mt-2"> + <span className="text-xs text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span> + Editable fields + </span> + <span className="text-xs text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span> + Read-only fields + </span> + {cellMappings.length > 0 && ( + <span className="text-xs text-blue-600"> + {cellMappings.filter(m => m.isEditable).length} of {cellMappings.length} fields editable + </span> + )} + </div> </DialogDescription> </DialogHeader> @@ -295,15 +487,22 @@ export function TemplateViewDialog({ {/* SpreadSheets 컴포넌트 영역 */} <div className="flex-1 overflow-hidden"> - {selectedTemplate ? ( + {selectedTemplate && isClient ? ( <SpreadSheets - key={selectedTemplateId} // 템플릿 변경 시 컴포넌트 재생성 + key={selectedTemplateId} workbookInitialized={initSpread} hostStyle={hostStyle} /> ) : ( <div className="flex items-center justify-center h-full text-muted-foreground"> - No template available + {!isClient ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading... + </> + ) : ( + "No template available" + )} </div> )} </div> 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<number | null>(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 ( + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100]"> + <Card className="w-full max-w-md mx-4"> + <CardContent className="p-6"> + <div className="flex items-center space-x-3 mb-4"> + <AlertCircle className="h-6 w-6 text-destructive" /> + <h3 className="text-lg font-semibold">{t.sessionExpired}</h3> + </div> + + <p className="text-muted-foreground mb-4"> + {t.pleaseRelogin} + </p> + + {autoLogoutCountdown > 0 && ( + <div className="mb-4"> + <p className="text-sm text-muted-foreground mb-2"> + {t.autoLogoutIn.replace('{seconds}', autoLogoutCountdown.toString())} + </p> + <Progress + value={(10 - autoLogoutCountdown) * 10} + className="h-2" + /> + </div> + )} + + <div className="flex space-x-2"> + <Button + onClick={() => { + setAutoLogoutCountdown(0) + extendSession() + setShowExpiredModal(false) + }} + className="flex-1" + disabled={isExtending} + > + {isExtending ? ( + <> + <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> + {t.extending} + </> + ) : ( + t.staySignedIn + )} + </Button> + <Button + variant="outline" + onClick={handleAutoLogout} + className="flex-1" + > + {t.logout} + </Button> + </div> + </CardContent> + </Card> + </div> + ) + } + + // 세션 경고 알림 + 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 ( + <div className={cn( + "fixed top-4 right-4 z-50 w-full max-w-sm", + "animate-in slide-in-from-right-full duration-300" + )}> + <Alert className={cn( + "border-2 shadow-lg", + isCritical + ? "border-destructive bg-destructive/5" + : "border-warning bg-warning/5" + )}> + <Clock className={cn( + "h-4 w-4", + isCritical ? "text-destructive" : "text-warning" + )} /> + + <div className="flex-1"> + <div className="flex items-center justify-between mb-2"> + <h4 className="font-medium text-sm"> + {t.sessionExpiring} + </h4> + <Button + variant="ghost" + size="sm" + className="h-auto p-1 hover:bg-transparent" + onClick={() => setShowWarning(false)} + > + <X className="h-3 w-3" /> + </Button> + </div> + + <AlertDescription className="text-xs mb-3"> + {minutes > 0 + ? t.sessionWillExpire.replace('{minutes}', minutes.toString()) + : `${seconds}초 후 세션이 만료됩니다.` + } + </AlertDescription> + + <div className="space-y-2"> + <Progress + value={progressValue} + className={cn( + "h-1.5", + isCritical && "bg-destructive/20" + )} + /> + + <div className="flex space-x-2"> + <Button + size="sm" + onClick={extendSession} + disabled={isExtending} + className="flex-1 h-7 text-xs" + > + {isExtending ? ( + <> + <RefreshCw className="h-3 w-3 mr-1 animate-spin" /> + {t.extending} + </> + ) : ( + t.extend + )} + </Button> + <Button + variant="outline" + size="sm" + onClick={() => setShowWarning(false)} + className="h-7 text-xs px-2" + > + {t.close} + </Button> + </div> + </div> + </div> + </Alert> + </div> + ) + } + + 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<typeof NextThemesProvider> { + lng?: string; // ✅ lng prop 추가 +} + export function ThemeProvider({ children, + lng = 'ko', // ✅ 기본값 설정 ...props -}: React.ComponentProps<typeof NextThemesProvider>) { +}: ThemeProviderProps) { return ( <JotaiProvider> <CacheProvider value={cache}> @@ -60,6 +65,8 @@ export function ThemeProvider({ {/* ✅ 간소화된 SWR 설정 적용 */} <SWRConfig value={swrConfig}> {children} + {/* ✅ SessionManager 추가 - 모든 프로바이더 내부에 위치 */} + <SessionManager lng={lng} /> </SWRConfig> </SessionProvider> </NuqsAdapter> @@ -68,4 +75,4 @@ export function ThemeProvider({ </CacheProvider> </JotaiProvider> ) -} +}
\ 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 ( + <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> + <Card className="w-full max-w-md"> + <CardHeader className="text-center"> + <div className="mx-auto flex items-center justify-center w-12 h-12 rounded-full bg-red-100 mb-4"> + <XCircle className="w-6 h-6 text-red-600" /> + </div> + <CardTitle className="text-2xl">링크 오류</CardTitle> + <CardDescription> + {expired + ? '재설정 링크가 만료되었습니다. 새로운 재설정 요청을 해주세요.' + : error || '유효하지 않은 재설정 링크입니다.'} + </CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + <Link href="/auth/forgot-password"> + <Button className="w-full"> + 새로운 재설정 링크 요청 + </Button> + </Link> + <Link href="/auth/login"> + <Button variant="outline" className="w-full"> + 로그인 페이지로 돌아가기 + </Button> + </Link> + </div> + </CardContent> + </Card> + </div> + ); +}
\ 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 ( + <Card className="w-full max-w-md"> + <CardHeader className="text-center"> + <div className="mx-auto flex items-center justify-center w-12 h-12 rounded-full bg-green-100 mb-4"> + <CheckCircle className="w-6 h-6 text-green-600" /> + </div> + <CardTitle className="text-2xl">재설정 완료</CardTitle> + <CardDescription> + {message || '비밀번호가 성공적으로 변경되었습니다.'} + </CardDescription> + </CardHeader> + <CardContent> + <div className="text-center space-y-4"> + <p className="text-sm text-gray-600"> + 잠시 후 로그인 페이지로 자동 이동합니다... + </p> + <Link href="/auth/login"> + <Button className="w-full"> + 지금 로그인하기 + </Button> + </Link> + </div> + </CardContent> + </Card> + ); +}
\ 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 ( + <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/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<LoginMethod>('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 ( <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"> @@ -241,11 +372,6 @@ export function LoginForm({ {/* 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> @@ -253,178 +379,350 @@ export function LoginForm({ href="/partners/repository" className={cn(buttonVariants({ variant: "ghost" }))} > - <InfoIcon className="w-4 h-4 mr-1" /> - {'업체 등록 신청'} + <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="p-6 md:p-8"> + <div className="flex flex-col gap-6"> + {/* Header */} <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 && ( + {!showMfaForm ? ( <> - <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)} - /> + <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> + <p className="text-xs text-muted-foreground mt-2"> + {'등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} + </p> + </> + ) : ( + <> + <div className="flex items-center justify-center w-12 h-12 rounded-full bg-blue-100 mb-4"> + 🔐 </div> - <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}> - {isLoading ? t('sending') : t('ContinueWithEmail')} - </Button> + <h1 className="text-2xl font-bold">SMS 인증</h1> + <p className="text-sm text-muted-foreground mt-2"> + {mfaUserEmail}로 로그인하셨습니다 + </p> + <p className="text-xs text-muted-foreground mt-1"> + 등록된 전화번호로 전송된 6자리 인증번호를 입력해주세요 + </p> + </> + )} + </div> - {/* 구분선과 "Or continue with" 섹션 추가 */} - <div className="relative"> - <div className="absolute inset-0 flex items-center"> - <span className="w-full border-t"></span> + {/* 로그인 폼 또는 MFA 폼 */} + {!showMfaForm ? ( + <> + {/* Login Method Tabs */} + <div className="flex rounded-lg bg-muted p-1"> + <button + type="button" + onClick={() => setLoginMethod('username')} + className={cn( + "flex-1 rounded-md px-3 py-2 text-sm font-medium transition-all", + loginMethod === 'username' + ? "bg-background text-foreground shadow-sm" + : "text-muted-foreground hover:text-foreground" + )} + > + 일반 로그인 + </button> + <button + type="button" + onClick={() => setLoginMethod('sgips')} + className={cn( + "flex-1 rounded-md px-3 py-2 text-sm font-medium transition-all", + loginMethod === 'sgips' + ? "bg-background text-foreground shadow-sm" + : "text-muted-foreground hover:text-foreground" + )} + > + S-Gips 로그인 + </button> + </div> + + {/* Username Login Form */} + {loginMethod === 'username' && ( + <form onSubmit={handleUsernameLogin} className="grid gap-4"> + <div className="grid gap-2"> + <Input + id="username" + type="text" + placeholder="이메일을 넣으세요" + required + className="h-10" + value={username} + onChange={(e) => setUsername(e.target.value)} + disabled={isLoading} + /> </div> - <div className="relative flex justify-center text-xs uppercase"> - <span className="bg-background px-2 text-muted-foreground"> - {t('orContinueWith')} - </span> + <div className="grid gap-2"> + <Input + id="password" + type="password" + placeholder={t('password')} + required + className="h-10" + value={password} + onChange={(e) => setPassword(e.target.value)} + disabled={isLoading} + /> </div> - </div> - - {/* S-Gips 로그인 버튼 */} + <Button + type="submit" + className="w-full" + variant="samsung" + disabled={isLoading || !username || !password} + > + {isLoading ? '로그인 중...' : t('login')} + </Button> + </form> + )} + + {/* S-Gips Login Form */} + {loginMethod === 'sgips' && ( + <form onSubmit={handleSgipsLogin} className="grid gap-4"> + <div className="grid gap-2"> + <Input + id="sgipsUsername" + type="text" + placeholder="S-Gips ID" + required + className="h-10" + value={sgipsUsername} + onChange={(e) => setSgipsUsername(e.target.value)} + disabled={isLoading} + /> + </div> + <div className="grid gap-2"> + <Input + id="sgipsPassword" + type="password" + placeholder="S-Gips 비밀번호" + required + className="h-10" + value={sgipsPassword} + onChange={(e) => setSgipsPassword(e.target.value)} + disabled={isLoading} + /> + </div> + <Button + type="submit" + className="w-full" + variant="default" + disabled={isLoading || !sgipsUsername || !sgipsPassword} + > + {isLoading ? 'S-Gips 인증 중...' : 'S-Gips 로그인'} + </Button> + <p className="text-xs text-muted-foreground text-center"> + S-Gips 계정으로 로그인하면 자동으로 SMS 인증이 진행됩니다. + </p> + </form> + )} + + {/* Additional Links */} + <div className="flex flex-col gap-2 text-center"> <Button type="button" - className="w-full" - // variant="" - onClick={() => setShowCredentialsForm(true)} + variant="link" + className="text-blue-600 hover:text-blue-800 text-sm" + onClick={goToVendorRegistration} > - 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)} - /> + {loginMethod === 'username' && ( <Button type="button" - className="w-full" - variant="samsung" - onClick={handleCredentialsLogin} - disabled={isLoading} + variant="link" + className="text-blue-600 hover:text-blue-800 text-sm" + onClick={() => setShowForgotPassword(true)} > - {isLoading ? "로그인 중..." : "로그인"} + 비밀번호를 잊으셨나요? </Button> + )} - {/* 뒤로 가기 버튼 */} + {/* 테스트용 MFA 화면 버튼 */} + {process.env.NODE_ENV === 'development' && ( <Button type="button" - variant="ghost" - className="w-full text-sm" - onClick={() => setShowCredentialsForm(false)} + variant="link" + className="text-green-600 hover:text-green-800 text-sm" + onClick={() => { + setMfaUserId('test-user'); + setMfaUserEmail('test@example.com'); + setShowMfaForm(true); + }} > - 이메일로 로그인하기 + [개발용] MFA 화면 테스트 </Button> + )} + </div> + </> + ) : ( + /* MFA 입력 폼 */ + <div className="space-y-6"> + {/* 뒤로 가기 버튼 */} + <div className="flex items-center justify-start"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => { + setShowMfaForm(false); + setMfaToken(''); + setMfaUserId(''); + setMfaUserEmail(''); + setMfaCountdown(0); + }} + className="text-blue-600 hover:text-blue-800" + > + <ArrowLeft className="w-4 h-4 mr-1" /> + 다시 로그인하기 + </Button> + </div> + + {/* SMS 재전송 섹션 */} + <div className="bg-gray-50 p-4 rounded-lg"> + <h3 className="text-sm font-medium text-gray-900 mb-2"> + 인증번호 재전송 + </h3> + <p className="text-xs text-gray-600 mb-3"> + 인증번호를 받지 못하셨나요? + </p> + <Button + onClick={handleSendSms} + disabled={isLoading || mfaCountdown > 0} + variant="outline" + size="sm" + className="w-full" + > + {isLoading ? ( + '전송 중...' + ) : mfaCountdown > 0 ? ( + `재전송 가능 (${mfaCountdown}초)` + ) : ( + '인증번호 재전송' + )} + </Button> + </div> + + {/* SMS 토큰 입력 폼 */} + <form onSubmit={handleMfaSubmit} className="space-y-6"> + <div className="space-y-4"> + <div className="text-center"> + <label className="block text-sm font-medium text-gray-700 mb-3"> + 6자리 인증번호를 입력해주세요 + </label> + <div className="flex justify-center"> + <InputOTP + maxLength={6} + value={mfaToken} + onChange={(value) => setMfaToken(value)} + > + <InputOTPGroup> + <InputOTPSlot index={0} /> + <InputOTPSlot index={1} /> + <InputOTPSlot index={2} /> + <InputOTPSlot index={3} /> + <InputOTPSlot index={4} /> + <InputOTPSlot index={5} /> + </InputOTPGroup> + </InputOTP> + </div> + </div> </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> + <Button + type="submit" + className="w-full" + variant="samsung" + disabled={isLoading || mfaToken.length !== 6} + > + {isLoading ? '인증 중...' : '인증 완료'} + </Button> + </form> + + {/* 도움말 */} + <div className="bg-yellow-50 p-3 rounded-lg border border-yellow-200"> + <div className="flex"> + <div className="flex-shrink-0"> + ⚠️ + </div> + <div className="ml-2"> + <h4 className="text-xs font-medium text-yellow-800"> + 인증번호를 받지 못하셨나요? + </h4> + <div className="mt-1 text-xs text-yellow-700"> + <ul className="list-disc list-inside space-y-1"> + <li>전화번호가 올바른지 확인해주세요</li> + <li>스팸 메시지함을 확인해주세요</li> + <li>잠시 후 재전송 버튼을 이용해주세요</li> + </ul> + </div> + </div> + </div> + </div> </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> + )} + + {/* 비밀번호 재설정 다이얼로그 */} + {showForgotPassword && !showMfaForm && ( + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> + <div className="bg-white rounded-lg p-6 w-full max-w-md mx-4"> + <div className="flex justify-between items-center mb-4"> + <h3 className="text-lg font-semibold">비밀번호 재설정</h3> + <button + onClick={() => { + setShowForgotPassword(false); + }} + className="text-gray-400 hover:text-gray-600" + > + ✕ + </button> + </div> + <form action={passwordResetAction} className="space-y-4"> + <div> + <p className="text-sm text-gray-600 mb-3"> + 가입하신 이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다. + </p> + <Input + name="email" + type="email" + placeholder="이메일 주소" + required + /> + </div> + <div className="flex gap-2"> + <Button + type="button" + variant="outline" + className="flex-1" + onClick={() => { + setShowForgotPassword(false); + }} + > + 취소 + </Button> + <Button + type="submit" + className="flex-1" + > + 재설정 링크 전송 + </Button> + </div> + </form> + </div> </div> - <Button type="submit" className="w-full" disabled={isLoading}> - {isLoading ? t('verifying') : t('verifyOtp')} - </Button> - <div className="mx-auto"> + )} + + {/* Language Selector - MFA 화면에서는 숨김 */} + {!showMfaForm && ( + <div className="text-center text-sm mx-auto"> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="flex items-center gap-2"> @@ -448,21 +746,28 @@ export function LoginForm({ </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> + + {/* Terms - MFA 화면에서는 숨김 */} + {!showMfaForm && ( + <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> + {t("agreement")}{" "} + <Link + href={`/${lng}/privacy`} // 개인정보처리방침만 남김 + className="underline underline-offset-4 hover:text-primary" + > + {t("privacyPolicy")} + </Link> + </div> + )} </div> </div> </div> - {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} + {/* Right BG 이미지 영역 */} <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" @@ -476,7 +781,6 @@ export function LoginForm({ <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> 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<typeof reAuthSchema> + +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<ReAuthFormValues>({ + 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 ( + <Dialog open={isOpen} onOpenChange={() => {}}> + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-3"> + <div className="h-8 w-8 rounded-full bg-amber-100 flex items-center justify-center"> + <Shield className="h-5 w-5 text-amber-600" /> + </div> + Security Verification + </DialogTitle> + <DialogDescription className="text-left"> + For your security, please confirm your password to access sensitive account settings. + This verification is valid for 5 minutes. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + {/* 사용자 정보 표시 */} + <div className="rounded-lg bg-blue-50 border border-blue-200 p-3"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-blue-500 rounded-full"></div> + <span className="text-sm font-medium text-blue-900"> + Signed in as: {userEmail} + </span> + </div> + </div> + + {/* 경고 메시지 (실패 횟수가 많을 때) */} + {attemptCount >= 2 && ( + <div className="rounded-lg bg-red-50 border border-red-200 p-3"> + <div className="flex items-start gap-2"> + <AlertCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" /> + <div className="text-sm text-red-800"> + <p className="font-medium">Security Alert</p> + <p>Multiple failed attempts detected. Please wait 30 seconds before trying again.</p> + </div> + </div> + </div> + )} + + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Current Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Enter your password" + disabled={attemptCount >= 3 || isLoading} + {...field} + autoFocus + autoComplete="current-password" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Button + type="submit" + className="w-full" + disabled={isLoading || attemptCount >= 3} + > + {isLoading ? ( + <> + <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> + Verifying... + </> + ) : attemptCount >= 3 ? ( + "Please wait..." + ) : ( + "Verify Identity" + )} + </Button> + </form> + </Form> + + <div className="text-xs text-muted-foreground text-center space-y-1"> + <p>This helps protect your account from unauthorized changes.</p> + <p>Your session will remain active during verification.</p> + </div> + </DialogContent> + </Dialog> + ) +}
\ 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") || "이미 등록된 업체이신가요? 로그인하기"} </Button> @@ -279,19 +280,12 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) { <p className="px-8 text-center text-sm text-muted-foreground"> {t("agreement")}{" "} <Link - href="/terms" - className="underline underline-offset-4 hover:text-primary" - > - {t("termsOfService")} - </Link>{" "} - {t("and")}{" "} - <Link - href="/privacy" + href={`/${lng}/privacy`} // 개인정보처리방침만 남김 className="underline underline-offset-4 hover:text-primary" > {t("privacyPolicy")} </Link> - . + {/* {t("privacyAgreement")}. */} </p> </div> </div> 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 ( + <div className="min-h-screen bg-gray-50"> + {/* Header */} + <div className="bg-white border-b"> + <div className="container mx-auto px-4 py-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + <Ship className="w-6 h-6 text-blue-600" /> + <span className="text-xl font-bold">eVCP</span> + </div> + <Button + variant="ghost" + onClick={() => router.back()} + className="flex items-center space-x-2" + > + <ArrowLeft className="w-4 h-4" /> + <span>뒤로가기</span> + </Button> + </div> + </div> + </div> + + {/* Content */} + <div className="container mx-auto px-4 py-8 max-w-4xl"> + <div className="bg-white rounded-lg shadow-sm p-8"> + <header className="mb-8"> + <h1 className="text-3xl font-bold text-gray-900 mb-2"> + 개인정보처리방침 + </h1> + <p className="text-gray-600"> + 시행일자: 2025년 1월 1일 + </p> + </header> + + <div className="prose prose-lg max-w-none"> + <div className="mb-6 p-4 bg-blue-50 rounded-lg border-l-4 border-blue-500"> + <p className="text-blue-800 font-medium mb-2"> + eVCP는 개인정보보호법, 정보통신망 이용촉진 및 정보보호 등에 관한 법률 등 + 개인정보보호 관련 법령을 준수하며, 이용자의 개인정보를 안전하게 처리하고 있습니다. + </p> + </div> + + {/* 목차 */} + <div className="mb-8 p-6 bg-gray-50 rounded-lg"> + <h3 className="text-lg font-semibold mb-4">목차</h3> + <ul className="space-y-2 text-sm"> + <li><a href="#section1" className="text-blue-600 hover:underline">1. 개인정보의 수집 및 이용목적</a></li> + <li><a href="#section2" className="text-blue-600 hover:underline">2. 수집하는 개인정보의 항목</a></li> + <li><a href="#section3" className="text-blue-600 hover:underline">3. 개인정보의 보유 및 이용기간</a></li> + <li><a href="#section4" className="text-blue-600 hover:underline">4. 개인정보의 제3자 제공</a></li> + <li><a href="#section5" className="text-blue-600 hover:underline">5. 개인정보 처리의 위탁</a></li> + <li><a href="#section6" className="text-blue-600 hover:underline">6. 개인정보 주체의 권리</a></li> + <li><a href="#section7" className="text-blue-600 hover:underline">7. 개인정보의 파기절차 및 방법</a></li> + <li><a href="#section8" className="text-blue-600 hover:underline">8. 개인정보 보호책임자</a></li> + <li><a href="#section9" className="text-blue-600 hover:underline">9. 개인정보의 안전성 확보조치</a></li> + <li><a href="#section10" className="text-blue-600 hover:underline">10. 쿠키의 설치·운영 및 거부</a></li> + <li><a href="#section11" className="text-blue-600 hover:underline">11. 개인정보 처리방침의 변경</a></li> + </ul> + </div> + + {/* 각 섹션 */} + <section id="section1" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 1. 개인정보의 수집 및 이용목적 + </h2> + <p className="mb-4">회사는 다음의 목적을 위하여 개인정보를 수집 및 이용합니다:</p> + + <div className="space-y-4"> + <div> + <h3 className="text-lg font-medium mb-2">1.1 회원가입 및 계정관리</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>회원 식별 및 본인인증</li> + <li>회원자격 유지·관리</li> + <li>서비스 부정이용 방지</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">1.2 서비스 제공</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>업체 등록 및 인증 서비스 제공</li> + <li>고객상담 및 문의사항 처리</li> + <li>공지사항 전달</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">1.3 법정의무 이행</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>관련 법령에 따른 의무사항 이행</li> + </ul> + </div> + </div> + </section> + + <section id="section2" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 2. 수집하는 개인정보의 항목 + </h2> + + <div className="space-y-4"> + <div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200"> + <h3 className="text-lg font-medium mb-2 text-yellow-800">2.1 필수정보</h3> + <ul className="list-disc pl-6 space-y-1 text-yellow-700"> + <li><strong>이메일 주소</strong>: 계정 생성, 로그인, 중요 알림 발송</li> + <li><strong>전화번호</strong>: 본인인증, 중요 연락사항 전달</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">2.2 자동 수집정보</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>접속 IP주소, 접속 시간, 이용기록</li> + <li>쿠키, 서비스 이용기록</li> + </ul> + </div> + </div> + </section> + + <section id="section3" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 3. 개인정보의 보유 및 이용기간 + </h2> + + <div className="space-y-4"> + <div> + <h3 className="text-lg font-medium mb-2">3.1 회원정보</h3> + <ul className="list-disc pl-6 space-y-1"> + <li><strong>보유기간</strong>: 회원탈퇴 시까지</li> + <li><strong>예외</strong>: 관련 법령에 따라 보존할 필요가 있는 경우 해당 기간 동안 보관</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">3.2 법령에 따른 보관</h3> + <ul className="list-disc pl-6 space-y-1"> + <li><strong>계약 또는 청약철회 등에 관한 기록</strong>: 5년 (전자상거래법)</li> + <li><strong>대금결제 및 재화 등의 공급에 관한 기록</strong>: 5년 (전자상거래법)</li> + <li><strong>소비자 불만 또는 분쟁처리에 관한 기록</strong>: 3년 (전자상거래법)</li> + <li><strong>웹사이트 방문기록</strong>: 3개월 (통신비밀보호법)</li> + </ul> + </div> + </div> + </section> + + <section id="section4" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 4. 개인정보의 제3자 제공 + </h2> + <p className="mb-4"> + 회사는 원칙적으로 이용자의 개인정보를 제3자에게 제공하지 않습니다. + </p> + <p className="mb-2">다만, 다음의 경우에는 예외로 합니다:</p> + <ul className="list-disc pl-6 space-y-1"> + <li>이용자가 사전에 동의한 경우</li> + <li>법령의 규정에 의거하거나, 수사 목적으로 법령에 정해진 절차와 방법에 따라 수사기관의 요구가 있는 경우</li> + </ul> + </section> + + <section id="section5" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 5. 개인정보 처리의 위탁 + </h2> + <p className="mb-4"> + 현재 회사는 개인정보 처리업무를 외부에 위탁하고 있지 않습니다. + </p> + <p className="mb-2">향후 개인정보 처리업무를 위탁하는 경우, 다음 사항을 준수하겠습니다:</p> + <ul className="list-disc pl-6 space-y-1"> + <li>위탁계약 체결 시 개인정보보호 관련 법령 준수, 개인정보에 관한 비밀유지, 제3자 제공 금지 등을 계약서에 명시</li> + <li>위탁업체가 개인정보를 안전하게 처리하는지 감독</li> + </ul> + </section> + + {/* 권리 행사 섹션 - 특별히 강조 */} + <section id="section6" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 6. 개인정보 주체의 권리 + </h2> + + <div className="p-6 bg-blue-50 rounded-lg border border-blue-200 mb-4"> + <p className="text-blue-800 font-medium mb-2"> + 💡 이용자님의 권리를 알려드립니다 + </p> + <p className="text-blue-700 text-sm"> + 언제든지 본인의 개인정보에 대해 열람, 수정, 삭제를 요청하실 수 있습니다. + </p> + </div> + + <div className="space-y-4"> + <div> + <h3 className="text-lg font-medium mb-2">6.1 정보주체의 권리</h3> + <ul className="list-disc pl-6 space-y-1"> + <li><strong>열람권</strong>: 본인의 개인정보 처리현황을 확인할 권리</li> + <li><strong>정정·삭제권</strong>: 잘못된 정보의 수정이나 삭제를 요구할 권리</li> + <li><strong>처리정지권</strong>: 개인정보 처리 중단을 요구할 권리</li> + </ul> + </div> + + <div className="p-4 bg-gray-50 rounded-lg"> + <h3 className="text-lg font-medium mb-2">6.2 권리행사 방법</h3> + <ul className="list-disc pl-6 space-y-1"> + <li><strong>연락처</strong>: privacy@evcp.com</li> + <li><strong>처리기간</strong>: 요청 접수 후 10일 이내</li> + </ul> + </div> + </div> + </section> + + <section id="section7" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 7. 개인정보의 파기절차 및 방법 + </h2> + + <div className="space-y-4"> + <div> + <h3 className="text-lg font-medium mb-2">7.1 파기절차</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>보유기간 만료 또는 처리목적 달성 시 지체없이 파기</li> + <li>다른 법령에 따라 보관하여야 하는 경우에는 해당 기간 동안 보관</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">7.2 파기방법</h3> + <ul className="list-disc pl-6 space-y-1"> + <li><strong>전자적 파일</strong>: 복구 및 재생되지 않도록 안전하게 삭제</li> + <li><strong>서면</strong>: 분쇄기로 분쇄하거나 소각</li> + </ul> + </div> + </div> + </section> + + {/* 연락처 정보 */} + <section id="section8" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 8. 개인정보 보호책임자 + </h2> + + <div className="grid md:grid-cols-2 gap-6"> + <div className="p-4 border rounded-lg"> + <h3 className="text-lg font-medium mb-2">개인정보 보호책임자</h3> + <ul className="space-y-1 text-sm"> + <li><strong>성명</strong>: [담당자명]</li> + <li><strong>직책</strong>: [직책명]</li> + <li><strong>연락처</strong>: privacy@evcp.com</li> + </ul> + </div> + + <div className="p-4 border rounded-lg"> + <h3 className="text-lg font-medium mb-2">개인정보 보호담당자</h3> + <ul className="space-y-1 text-sm"> + <li><strong>성명</strong>: [담당자명]</li> + <li><strong>부서</strong>: [부서명]</li> + <li><strong>연락처</strong>: privacy@evcp.com, 02-0000-0000</li> + </ul> + </div> + </div> + </section> + + <section id="section9" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 9. 개인정보의 안전성 확보조치 + </h2> + + <div className="space-y-4"> + <div> + <h3 className="text-lg font-medium mb-2">9.1 기술적 조치</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>개인정보 암호화</li> + <li>해킹 등에 대비한 기술적 대책</li> + <li>백신 소프트웨어 등의 설치·갱신</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">9.2 관리적 조치</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>개인정보 취급자의 최소한 지정 및 교육</li> + <li>개인정보 취급자에 대한 정기적 교육</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">9.3 물리적 조치</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>전산실, 자료보관실 등의 접근통제</li> + </ul> + </div> + </div> + </section> + + <section id="section10" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 10. 쿠키의 설치·운영 및 거부 + </h2> + + <div className="space-y-4"> + <div> + <h3 className="text-lg font-medium mb-2">10.1 쿠키의 사용목적</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>이용자에게 최적화된 서비스 제공</li> + <li>웹사이트 방문 및 이용형태 파악</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">10.2 쿠키 거부 방법</h3> + <p className="mb-2">웹브라우저 설정을 통해 쿠키 허용, 차단 등의 설정을 변경할 수 있습니다.</p> + <ul className="list-disc pl-6 space-y-1"> + <li>Chrome: 설정 → 개인정보 및 보안 → 쿠키 및 기타 사이트 데이터</li> + <li>Safari: 환경설정 → 개인정보 보호 → 쿠키 및 웹사이트 데이터</li> + </ul> + </div> + </div> + </section> + + <section id="section11" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 11. 개인정보 처리방침의 변경 + </h2> + <p> + 본 개인정보처리방침은 법령·정책 또는 보안기술의 변경에 따라 내용의 추가·삭제 및 수정이 있을 시 + 변경 최소 7일 전부터 웹사이트를 통해 변경이유 및 내용 등을 공지하겠습니다. + </p> + <div className="mt-4 p-4 bg-gray-50 rounded-lg"> + <p><strong>공고일자</strong>: 2025년 1월 1일</p> + <p><strong>시행일자</strong>: 2025년 1월 1일</p> + </div> + </section> + + {/* 문의처 */} + <div className="mt-12 p-6 bg-gradient-to-r from-blue-50 to-blue-100 rounded-lg border border-blue-200"> + <h3 className="text-xl font-semibold mb-4 text-blue-900">문의처</h3> + <p className="text-blue-800 mb-4"> + 개인정보와 관련한 문의사항이 있으시면 아래 연락처로 문의해 주시기 바랍니다. + </p> + <div className="space-y-2 text-blue-700"> + <p><strong>이메일</strong>: privacy@evcp.com</p> + <p><strong>전화</strong>: 02-0000-0000</p> + <p><strong>주소</strong>: [회사 주소]</p> + </div> + </div> + + <div className="mt-8 text-center text-sm text-gray-500"> + <p>본 방침은 2025년 1월 1일부터 시행됩니다.</p> + </div> + </div> + </div> + </div> + </div> + ) +} + +// 영문 개인정보처리방침 컴포넌트 +function PrivacyPolicyPageEn() { + const router = useRouter() + + return ( + <div className="min-h-screen bg-gray-50"> + {/* Header */} + <div className="bg-white border-b"> + <div className="container mx-auto px-4 py-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + <Ship className="w-6 h-6 text-blue-600" /> + <span className="text-xl font-bold">eVCP</span> + </div> + <Button + variant="ghost" + onClick={() => router.back()} + className="flex items-center space-x-2" + > + <ArrowLeft className="w-4 h-4" /> + <span>Back</span> + </Button> + </div> + </div> + </div> + + {/* Content */} + <div className="container mx-auto px-4 py-8 max-w-4xl"> + <div className="bg-white rounded-lg shadow-sm p-8"> + <header className="mb-8"> + <h1 className="text-3xl font-bold text-gray-900 mb-2"> + Privacy Policy + </h1> + <p className="text-gray-600"> + Effective Date: January 1, 2025 + </p> + </header> + + <div className="prose prose-lg max-w-none"> + <div className="mb-6 p-4 bg-blue-50 rounded-lg border-l-4 border-blue-500"> + <p className="text-blue-800 font-medium mb-2"> + eVCP complies with applicable privacy and data protection laws and regulations, + and is committed to protecting and securely processing your personal information. + </p> + </div> + + {/* Table of Contents */} + <div className="mb-8 p-6 bg-gray-50 rounded-lg"> + <h3 className="text-lg font-semibold mb-4">Table of Contents</h3> + <ul className="space-y-2 text-sm"> + <li><a href="#section1" className="text-blue-600 hover:underline">1. Purpose of Personal Information Collection and Use</a></li> + <li><a href="#section2" className="text-blue-600 hover:underline">2. Personal Information We Collect</a></li> + <li><a href="#section3" className="text-blue-600 hover:underline">3. Retention and Use Period</a></li> + <li><a href="#section4" className="text-blue-600 hover:underline">4. Third Party Disclosure</a></li> + <li><a href="#section5" className="text-blue-600 hover:underline">5. Processing Outsourcing</a></li> + <li><a href="#section6" className="text-blue-600 hover:underline">6. Your Rights</a></li> + <li><a href="#section7" className="text-blue-600 hover:underline">7. Data Deletion Procedures</a></li> + <li><a href="#section8" className="text-blue-600 hover:underline">8. Privacy Officer</a></li> + <li><a href="#section9" className="text-blue-600 hover:underline">9. Security Measures</a></li> + <li><a href="#section10" className="text-blue-600 hover:underline">10. Cookies</a></li> + <li><a href="#section11" className="text-blue-600 hover:underline">11. Policy Changes</a></li> + </ul> + </div> + + {/* Sections */} + <section id="section1" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 1. Purpose of Personal Information Collection and Use + </h2> + <p className="mb-4">We collect and use personal information for the following purposes:</p> + + <div className="space-y-4"> + <div> + <h3 className="text-lg font-medium mb-2">1.1 Account Registration and Management</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>User identification and authentication</li> + <li>Account maintenance and management</li> + <li>Prevention of service misuse</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">1.2 Service Provision</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>Company registration and verification services</li> + <li>Customer support and inquiry handling</li> + <li>Important notifications and announcements</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">1.3 Legal Compliance</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>Compliance with applicable laws and regulations</li> + </ul> + </div> + </div> + </section> + + <section id="section2" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 2. Personal Information We Collect + </h2> + + <div className="space-y-4"> + <div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200"> + <h3 className="text-lg font-medium mb-2 text-yellow-800">2.1 Required Information</h3> + <ul className="list-disc pl-6 space-y-1 text-yellow-700"> + <li><strong>Email Address</strong>: Account creation, login, important notifications</li> + <li><strong>Phone Number</strong>: Identity verification, important communications</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">2.2 Automatically Collected Information</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>IP address, access time, usage records</li> + <li>Cookies and service usage records</li> + </ul> + </div> + </div> + </section> + + <section id="section3" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 3. Retention and Use Period + </h2> + + <div className="space-y-4"> + <div> + <h3 className="text-lg font-medium mb-2">3.1 Member Information</h3> + <ul className="list-disc pl-6 space-y-1"> + <li><strong>Retention Period</strong>: Until account deletion</li> + <li><strong>Exception</strong>: Where required by law, retained for the required period</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">3.2 Legal Retention Requirements</h3> + <ul className="list-disc pl-6 space-y-1"> + <li><strong>Contract and transaction records</strong>: 5 years</li> + <li><strong>Payment and service delivery records</strong>: 5 years</li> + <li><strong>Consumer complaint or dispute records</strong>: 3 years</li> + <li><strong>Website visit records</strong>: 3 months</li> + </ul> + </div> + </div> + </section> + + <section id="section4" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 4. Third Party Disclosure + </h2> + <p className="mb-4"> + We do not disclose your personal information to third parties, except in the following cases: + </p> + <ul className="list-disc pl-6 space-y-1"> + <li>With your prior consent</li> + <li>When required by law or legal authorities following proper procedures</li> + </ul> + </section> + + <section id="section5" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 5. Processing Outsourcing + </h2> + <p className="mb-4"> + We currently do not outsource personal information processing to external parties. + </p> + <p className="mb-2">If we outsource personal information processing in the future, we will:</p> + <ul className="list-disc pl-6 space-y-1"> + <li>Include privacy protection clauses in outsourcing contracts</li> + <li>Supervise outsourced parties to ensure secure processing of personal information</li> + </ul> + </section> + + {/* Rights section - emphasized */} + <section id="section6" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 6. Your Rights + </h2> + + <div className="p-6 bg-blue-50 rounded-lg border border-blue-200 mb-4"> + <p className="text-blue-800 font-medium mb-2"> + 💡 Know Your Rights + </p> + <p className="text-blue-700 text-sm"> + You can request access, correction, or deletion of your personal information at any time. + </p> + </div> + + <div className="space-y-4"> + <div> + <h3 className="text-lg font-medium mb-2">6.1 Data Subject Rights</h3> + <ul className="list-disc pl-6 space-y-1"> + <li><strong>Right of Access</strong>: Request information about how your data is processed</li> + <li><strong>Right of Rectification</strong>: Request correction or deletion of incorrect information</li> + <li><strong>Right to Restriction</strong>: Request suspension of personal information processing</li> + </ul> + </div> + + <div className="p-4 bg-gray-50 rounded-lg"> + <h3 className="text-lg font-medium mb-2">6.2 How to Exercise Your Rights</h3> + <ul className="list-disc pl-6 space-y-1"> + <li><strong>Contact</strong>: privacy@evcp.com</li> + <li><strong>Response Time</strong>: Within 10 days of request</li> + </ul> + </div> + </div> + </section> + + <section id="section7" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 7. Data Deletion Procedures + </h2> + + <div className="space-y-4"> + <div> + <h3 className="text-lg font-medium mb-2">7.1 Deletion Procedure</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>Deletion without delay when retention period expires or purpose is achieved</li> + <li>Retention for required period when required by other laws</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">7.2 Deletion Method</h3> + <ul className="list-disc pl-6 space-y-1"> + <li><strong>Electronic files</strong>: Secure deletion to prevent recovery</li> + <li><strong>Paper documents</strong>: Shredding or incineration</li> + </ul> + </div> + </div> + </section> + + {/* Contact information */} + <section id="section8" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 8. Privacy Officer + </h2> + + <div className="grid md:grid-cols-2 gap-6"> + <div className="p-4 border rounded-lg"> + <h3 className="text-lg font-medium mb-2">Chief Privacy Officer</h3> + <ul className="space-y-1 text-sm"> + <li><strong>Name</strong>: [Officer Name]</li> + <li><strong>Title</strong>: [Title]</li> + <li><strong>Contact</strong>: privacy@evcp.com</li> + </ul> + </div> + + <div className="p-4 border rounded-lg"> + <h3 className="text-lg font-medium mb-2">Privacy Manager</h3> + <ul className="space-y-1 text-sm"> + <li><strong>Name</strong>: [Manager Name]</li> + <li><strong>Department</strong>: [Department]</li> + <li><strong>Contact</strong>: privacy@evcp.com, +82-2-0000-0000</li> + </ul> + </div> + </div> + </section> + + <section id="section9" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 9. Security Measures + </h2> + + <div className="space-y-4"> + <div> + <h3 className="text-lg font-medium mb-2">9.1 Technical Measures</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>Personal information encryption</li> + <li>Technical safeguards against hacking</li> + <li>Installation and updating of antivirus software</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">9.2 Administrative Measures</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>Minimizing and training personal information handlers</li> + <li>Regular training for personal information handlers</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">9.3 Physical Measures</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>Access control to computer rooms and data storage areas</li> + </ul> + </div> + </div> + </section> + + <section id="section10" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 10. Cookies + </h2> + + <div className="space-y-4"> + <div> + <h3 className="text-lg font-medium mb-2">10.1 Purpose of Cookie Use</h3> + <ul className="list-disc pl-6 space-y-1"> + <li>Providing optimized services to users</li> + <li>Understanding website visit and usage patterns</li> + </ul> + </div> + + <div> + <h3 className="text-lg font-medium mb-2">10.2 Cookie Management</h3> + <p className="mb-2">You can control cookie settings through your web browser:</p> + <ul className="list-disc pl-6 space-y-1"> + <li>Chrome: Settings → Privacy and security → Cookies and other site data</li> + <li>Safari: Preferences → Privacy → Cookies and website data</li> + </ul> + </div> + </div> + </section> + + <section id="section11" className="mb-8"> + <h2 className="text-2xl font-semibold mb-4 text-gray-900"> + 11. Policy Changes + </h2> + <p> + 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. + </p> + <div className="mt-4 p-4 bg-gray-50 rounded-lg"> + <p><strong>Publication Date</strong>: January 1, 2025</p> + <p><strong>Effective Date</strong>: January 1, 2025</p> + </div> + </section> + + {/* Contact section */} + <div className="mt-12 p-6 bg-gradient-to-r from-blue-50 to-blue-100 rounded-lg border border-blue-200"> + <h3 className="text-xl font-semibold mb-4 text-blue-900">Contact Us</h3> + <p className="text-blue-800 mb-4"> + If you have any questions about this Privacy Policy, please contact us: + </p> + <div className="space-y-2 text-blue-700"> + <p><strong>Email</strong>: privacy@evcp.com</p> + <p><strong>Phone</strong>: +82-2-0000-0000</p> + <p><strong>Address</strong>: [Company Address]</p> + </div> + </div> + + <div className="mt-8 text-center text-sm text-gray-500"> + <p>This policy is effective from January 1, 2025.</p> + </div> + </div> + </div> + </div> + </div> + ) +} + +// 메인 컴포넌트 - 언어에 따라 조건부 렌더링 +export function PrivacyPolicyPage() { + const params = useParams() || {}; + const lng = params.lng as string + + // 한국어면 한국어 버전, 그 외는 영문 버전 + if (lng === 'ko') { + return <PrivacyPolicyPageKo /> + } else { + return <PrivacyPolicyPageEn /> + } +}
\ 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<PasswordValidationResult | null>(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 <SuccessPage message={resetState.message} />; + } + + return ( + <Card className="w-full max-w-md"> + <CardHeader className="text-center"> + <div className="mx-auto flex items-center justify-center space-x-2 mb-4"> + <Ship className="w-6 h-6 text-blue-600" /> + <span className="text-xl font-bold">eVCP</span> + </div> + <CardTitle className="text-2xl">새 비밀번호 설정</CardTitle> + <CardDescription> + 계정 보안을 위해 강력한 비밀번호를 설정해주세요. + </CardDescription> + </CardHeader> + + <CardContent> + <form action={resetAction} className="space-y-6"> + <input type="hidden" name="token" value={token} /> + + {/* 새 비밀번호 */} + <div className="space-y-2"> + <label htmlFor="newPassword" className="text-sm font-medium text-gray-700"> + 새 비밀번호 + </label> + <div className="relative"> + <Input + id="newPassword" + name="newPassword" + type={showPassword ? "text" : "password"} + value={newPassword} + onChange={(e) => setNewPassword(e.target.value)} + placeholder="새 비밀번호를 입력하세요" + required + /> + <button + type="button" + className="absolute inset-y-0 right-0 pr-3 flex items-center" + onClick={() => setShowPassword(!showPassword)} + > + {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} + </button> + </div> + + {/* 패스워드 강도 표시 */} + {passwordValidation && ( + <div className="mt-2 space-y-2"> + <div className="flex items-center space-x-2"> + <Shield className="h-4 w-4 text-gray-500" /> + <span className="text-xs text-gray-600">강도:</span> + <span className={`text-xs font-medium ${getStrengthColor(passwordValidation.strength.score)}`}> + {getStrengthText(passwordValidation.strength.score)} + </span> + {isValidatingPassword && ( + <div className="ml-2 animate-spin rounded-full h-3 w-3 border-b border-blue-600"></div> + )} + </div> + + {/* 강도 진행바 */} + <div className="w-full bg-gray-200 rounded-full h-2"> + <div + className={`h-2 rounded-full transition-all duration-300 ${ + passwordValidation.strength.score === 1 ? 'bg-red-500' : + passwordValidation.strength.score === 2 ? 'bg-orange-500' : + passwordValidation.strength.score === 3 ? 'bg-yellow-500' : + passwordValidation.strength.score === 4 ? 'bg-blue-500' : + 'bg-green-500' + }`} + style={{ width: `${(passwordValidation.strength.score / 5) * 100}%` }} + /> + </div> + </div> + )} + + {/* 패스워드 요구사항 */} + {passwordRequirements.length > 0 && ( + <div className="mt-2 space-y-1"> + {passwordRequirements.map((req, index) => ( + <div key={index} className="flex items-center space-x-2 text-xs"> + {req.met ? ( + <CheckCircle className="h-3 w-3 text-green-500" /> + ) : ( + <XCircle className="h-3 w-3 text-red-500" /> + )} + <span className={req.met ? 'text-green-700' : 'text-red-700'}> + {req.text} + </span> + </div> + ))} + </div> + )} + + {/* 히스토리 검증 결과 */} + {passwordValidation?.historyValid === false && ( + <div className="mt-2"> + <div className="flex items-center space-x-2 text-xs"> + <XCircle className="h-3 w-3 text-red-500" /> + <span className="text-red-700"> + 최근 {passwordPolicy.historyCount}개 비밀번호와 달라야 합니다 + </span> + </div> + </div> + )} + + {/* 추가 피드백 */} + {passwordValidation?.strength.feedback && passwordValidation.strength.feedback.length > 0 && ( + <div className="mt-2 space-y-1"> + {passwordValidation.strength.feedback.map((feedback, index) => ( + <div key={index} className="flex items-center space-x-2 text-xs"> + <AlertCircle className="h-3 w-3 text-orange-500" /> + <span className="text-orange-700">{feedback}</span> + </div> + ))} + </div> + )} + + {/* 정책 오류 */} + {passwordValidation && !passwordValidation.policyValid && passwordValidation.policyErrors.length > 0 && ( + <div className="mt-2 space-y-1"> + {passwordValidation.policyErrors.map((error, index) => ( + <div key={index} className="flex items-center space-x-2 text-xs"> + <XCircle className="h-3 w-3 text-red-500" /> + <span className="text-red-700">{error}</span> + </div> + ))} + </div> + )} + </div> + + {/* 비밀번호 확인 */} + <div className="space-y-2"> + <label htmlFor="confirmPassword" className="text-sm font-medium text-gray-700"> + 비밀번호 확인 + </label> + <div className="relative"> + <Input + id="confirmPassword" + name="confirmPassword" + type={showConfirmPassword ? "text" : "password"} + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} + placeholder="비밀번호를 다시 입력하세요" + required + /> + <button + type="button" + className="absolute inset-y-0 right-0 pr-3 flex items-center" + onClick={() => setShowConfirmPassword(!showConfirmPassword)} + > + {showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} + </button> + </div> + + {/* 비밀번호 일치 확인 */} + {confirmPassword && ( + <div className="flex items-center space-x-2 text-xs"> + {passwordsMatch ? ( + <> + <CheckCircle className="h-3 w-3 text-green-500" /> + <span className="text-green-700">비밀번호가 일치합니다</span> + </> + ) : ( + <> + <XCircle className="h-3 w-3 text-red-500" /> + <span className="text-red-700">비밀번호가 일치하지 않습니다</span> + </> + )} + </div> + )} + </div> + + <Button + type="submit" + className="w-full" + disabled={!canSubmit} + > + {isValidatingPassword ? '검증 중...' : '비밀번호 변경하기'} + </Button> + </form> + + <div className="mt-6 text-center"> + <Link href="/partners" className="text-sm text-blue-600 hover:text-blue-500"> + 로그인 페이지로 돌아가기 + </Link> + </div> + </CardContent> + </Card> + ); +}
\ 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<Template | null>(initialTemplate || null);
+ const [content, setContent] = useState(initialTemplate?.content || '');
+ const [loading, setLoading] = useState(!initialTemplate);
+ const [saving, setSaving] = useState(false);
+ const [previewLoading, setPreviewLoading] = useState(false);
+ const [previewHtml, setPreviewHtml] = useState<string | null>(null);
+ const [, startTransition] = useTransition();
+
+ // 템플릿 조회
+ const fetchTemplate = async () => {
+ if (!templateName) {
+ toast.error('잘못된 접근입니다.');
+ router.push(`/${lng}/evcp/email-template`);
+ return;
+ }
+
+ try {
+ setLoading(true);
+ startTransition(async () => {
+ const result = await getTemplateAction(templateName);
+
+ if (result.success && result.data) {
+ setTemplate(result.data);
+ setContent(result.data.content);
+ } else {
+ toast.error(result.error || '템플릿을 찾을 수 없습니다.');
+ router.push(`/${lng}/evcp/email-template`);
+ }
+ setLoading(false);
+ });
+ } catch (error) {
+ console.error('Error fetching template:', error);
+ toast.error('템플릿을 불러오는데 실패했습니다.');
+ router.push(`/${lng}/evcp/email-template`);
+ setLoading(false);
+ }
+ };
+
+ // 템플릿 저장
+ const handleSave = async () => {
+ if (!content.trim()) {
+ toast.error('템플릿 내용을 입력해주세요.');
+ return;
+ }
+
+ try {
+ setSaving(true);
+ startTransition(async () => {
+ const result = await updateTemplateAction(templateName, content);
+
+ if (result.success && result.data) {
+ toast.success('템플릿이 성공적으로 저장되었습니다.');
+ setTemplate(result.data);
+ } else {
+ toast.error(result.error || '템플릿 저장에 실패했습니다.');
+ }
+ setSaving(false);
+ });
+ } catch (error) {
+ console.error('Error saving template:', error);
+ toast.error('템플릿 저장에 실패했습니다.');
+ setSaving(false);
+ }
+ };
+
+ // 미리보기 생성
+ const handlePreview = async () => {
+ try {
+ setPreviewLoading(true);
+ startTransition(async () => {
+ const result = await previewTemplateAction(
+ templateName,
+ {
+ userName: '홍길동',
+ companyName: 'EVCP',
+ email: 'user@example.com',
+ date: new Date().toLocaleDateString('ko-KR'),
+ projectName: '샘플 프로젝트',
+ message: '이것은 샘플 메시지입니다.',
+ currentYear: new Date().getFullYear(),
+ language: 'ko',
+ name: '홍길동',
+ loginUrl: 'https://example.com/login'
+ }
+ );
+
+ if (result.success && result.data) {
+ setPreviewHtml(result.data.html);
+ } else {
+ toast.error(result.error || '미리보기 생성에 실패했습니다.');
+ }
+ setPreviewLoading(false);
+ });
+ } catch (error) {
+ console.error('Error generating preview:', error);
+ toast.error('미리보기 생성에 실패했습니다.');
+ setPreviewLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!initialTemplate) {
+ fetchTemplate();
+ }
+ }, [templateName, initialTemplate]);
+
+ if (loading) {
+ return (
+ <div className="text-center py-20">
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
+ <p className="mt-4 text-gray-600">템플릿을 불러오는 중...</p>
+ </div>
+ );
+ }
+
+ if (!template) {
+ return (
+ <div className="text-center py-20">
+ <p className="text-gray-600">템플릿을 찾을 수 없습니다.</p>
+ <Link href={`/${lng}/evcp/email-template`}>
+ <Button className="mt-4">목록으로 돌아가기</Button>
+ </Link>
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-8">
+ {/* 헤더 */}
+ <div>
+ <div className="flex items-center gap-4 mb-4">
+ <h1 className="text-3xl font-bold text-gray-900">템플릿 편집</h1>
+ </div>
+ <div>
+ <p className="text-gray-600">
+ <span className="font-medium">{template.name}</span> 템플릿을 편집합니다.
+ </p>
+ <p className="text-sm text-gray-500">
+ 마지막 수정: {new Date(template.lastModified).toLocaleString('ko-KR')}
+ </p>
+ </div>
+ </div>
+
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
+ {/* 편집 영역 */}
+ <div className="space-y-6">
+ <div className="bg-white p-6 rounded-lg shadow">
+ <h2 className="text-xl font-semibold mb-4">템플릿 내용</h2>
+
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="content">Handlebars 템플릿</Label>
+ <Textarea
+ id="content"
+ value={content}
+ onChange={(e) => setContent(e.target.value)}
+ className="min-h-[500px] font-mono text-sm"
+ />
+ </div>
+
+ <div className="flex gap-2">
+ <Button onClick={handleSave} disabled={saving}>
+ <Save className="h-4 w-4 mr-2" />
+ {saving ? '저장 중...' : '저장'}
+ </Button>
+ <Button variant="outline" onClick={handlePreview} disabled={previewLoading}>
+ <Eye className="h-4 w-4 mr-2" />
+ {previewLoading ? '생성 중...' : '미리보기'}
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 미리보기 영역 */}
+ <div className="space-y-6">
+ <div className="bg-white p-6 rounded-lg shadow">
+ <h2 className="text-xl font-semibold mb-4">빠른 미리보기</h2>
+
+ <div className="border rounded-lg p-4 min-h-[500px] bg-gray-50 overflow-auto">
+ {previewHtml ? (
+ <div
+ className="preview-content"
+ dangerouslySetInnerHTML={{ __html: previewHtml }}
+ />
+ ) : (
+ <div className="text-center text-gray-500 py-20">
+ 미리보기 버튼을 클릭하여 결과를 확인하세요.
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 도움말 */}
+ <div className="bg-blue-50 p-4 rounded-lg">
+ <h3 className="font-semibold text-blue-900 mb-2">Handlebars 문법 도움말</h3>
+ <div className="text-sm text-blue-800 space-y-1">
+ <p><code>{`{{variable}}`}</code> - 변수 출력</p>
+ <p><code>{`{{{html}}}`}</code> - HTML 출력 (이스케이프 없음)</p>
+ <p><code>{`{{#if condition}}`}</code> - 조건문</p>
+ <p><code>{`{{#each items}}`}</code> - 반복문</p>
+ </div>
+ </div>
+
+ {/* 샘플 데이터 */}
+ <div className="bg-gray-50 p-4 rounded-lg">
+ <h3 className="font-semibold text-gray-900 mb-2">미리보기 샘플 데이터</h3>
+ <pre className="text-xs text-gray-600 overflow-auto">
+{JSON.stringify({
+ userName: '홍길동',
+ companyName: 'EVCP',
+ email: 'user@example.com',
+ date: new Date().toLocaleDateString('ko-KR'),
+ projectName: '샘플 프로젝트',
+ message: '이것은 샘플 메시지입니다.',
+ currentYear: new Date().getFullYear(),
+ language: 'ko',
+ name: '홍길동',
+ loginUrl: 'https://example.com/login'
+}, null, 2)}
+ </pre>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
\ No newline at end of file diff --git a/components/mail/mail-templates-client.tsx b/components/mail/mail-templates-client.tsx new file mode 100644 index 00000000..7c4dafdf --- /dev/null +++ b/components/mail/mail-templates-client.tsx @@ -0,0 +1,218 @@ +'use client';
+
+import { useState, useEffect, useTransition } from 'react';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { Search, Edit, FileText, ChevronUp, ChevronDown } from 'lucide-react';
+import { toast } from 'sonner';
+import { getTemplatesAction, TemplateFile } from '@/lib/mail/service';
+
+type Template = TemplateFile;
+
+interface MailTemplatesClientProps {
+ initialData?: Template[];
+}
+
+type SortField = 'name' | 'lastModified';
+type SortDirection = 'asc' | 'desc';
+
+export default function MailTemplatesClient({ initialData = [] }: MailTemplatesClientProps) {
+ const params = useParams();
+ const lng = (params?.lng as string) || 'ko';
+
+ const [templates, setTemplates] = useState<Template[]>(initialData);
+ const [loading, setLoading] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [sortField, setSortField] = useState<SortField>('name');
+ const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
+ const [, startTransition] = useTransition();
+
+ // 템플릿 목록 조회
+ const fetchTemplates = async () => {
+ try {
+ setLoading(true);
+ const search = searchQuery || undefined;
+
+ startTransition(async () => {
+ const result = await getTemplatesAction(search);
+
+ if (result.success && result.data) {
+ setTemplates(result.data);
+ } else {
+ toast.error(result.error || '템플릿 목록을 가져오는데 실패했습니다.');
+ }
+ setLoading(false);
+ });
+ } catch (error) {
+ console.error('Error fetching templates:', error);
+ toast.error('템플릿 목록을 가져오는데 실패했습니다.');
+ setLoading(false);
+ }
+ };
+
+ // 검색 핸들러
+ const handleSearch = () => {
+ fetchTemplates();
+ };
+
+ // 정렬 함수
+ const sortTemplates = (templates: Template[]) => {
+ return [...templates].sort((a, b) => {
+ let aValue: string | Date;
+ let bValue: string | Date;
+
+ if (sortField === 'name') {
+ aValue = a.name;
+ bValue = b.name;
+ } else {
+ aValue = new Date(a.lastModified);
+ bValue = new Date(b.lastModified);
+ }
+
+ if (aValue < bValue) {
+ return sortDirection === 'asc' ? -1 : 1;
+ }
+ if (aValue > bValue) {
+ return sortDirection === 'asc' ? 1 : -1;
+ }
+ return 0;
+ });
+ };
+
+ // 정렬 핸들러
+ const handleSort = (field: SortField) => {
+ if (sortField === field) {
+ setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
+ } else {
+ setSortField(field);
+ setSortDirection('asc');
+ }
+ };
+
+ // 정렬된 템플릿 목록
+ const sortedTemplates = sortTemplates(templates);
+
+ useEffect(() => {
+ if (searchQuery !== '') {
+ fetchTemplates();
+ } else {
+ setTemplates(initialData);
+ }
+ }, [searchQuery, initialData]);
+
+ return (
+ <div className="space-y-6">
+ {/* 검색 */}
+ <div className="flex items-center gap-4">
+ <div className="relative flex-1 max-w-md">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
+ <Input
+ placeholder="템플릿 이름 또는 내용으로 검색..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ className="pl-10"
+ onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
+ />
+ </div>
+ <Button onClick={handleSearch} variant="outline">
+ 검색
+ </Button>
+ <Button
+ variant="outline"
+ onClick={() => window.location.reload()}
+ >
+ 새로고침
+ </Button>
+ </div>
+
+ {/* 템플릿 테이블 */}
+ <div className="bg-white rounded-lg shadow">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>
+ <button
+ className="flex items-center gap-1 hover:text-foreground"
+ onClick={() => handleSort('name')}
+ >
+ 이름
+ {sortField === 'name' && (
+ sortDirection === 'asc' ? (
+ <ChevronUp className="h-4 w-4" />
+ ) : (
+ <ChevronDown className="h-4 w-4" />
+ )
+ )}
+ </button>
+ </TableHead>
+ <TableHead>
+ <button
+ className="flex items-center gap-1 hover:text-foreground"
+ onClick={() => handleSort('lastModified')}
+ >
+ 수정일
+ {sortField === 'lastModified' && (
+ sortDirection === 'asc' ? (
+ <ChevronUp className="h-4 w-4" />
+ ) : (
+ <ChevronDown className="h-4 w-4" />
+ )
+ )}
+ </button>
+ </TableHead>
+ <TableHead className="text-right">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {loading ? (
+ <TableRow>
+ <TableCell colSpan={3} className="text-center py-8">
+ 로딩 중...
+ </TableCell>
+ </TableRow>
+ ) : templates.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={3} className="text-center py-8 text-gray-500">
+ 템플릿이 없습니다.
+ </TableCell>
+ </TableRow>
+ ) : (
+ sortedTemplates.map((template) => (
+ <TableRow key={template.name}>
+ <TableCell className="font-medium">
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4" />
+ {template.name}
+ </div>
+ </TableCell>
+ <TableCell>
+ {new Date(template.lastModified).toLocaleString('ko-KR')}
+ </TableCell>
+ <TableCell className="text-right">
+ <div className="flex justify-end gap-2">
+ <Link href={`/${lng}/evcp/email-template/${template.name}`}>
+ <Button variant="outline" size="sm">
+ <Edit className="h-4 w-4" />
+ </Button>
+ </Link>
+ </div>
+ </TableCell>
+ </TableRow>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ );
+}
\ No newline at end of file diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx index e53e779f..30449a63 100644 --- a/components/signup/join-form.tsx +++ b/components/signup/join-form.tsx @@ -100,7 +100,7 @@ const enhancedCountryArray = sortedCountryArray.map(country => ({ })); // Comprehensive list of country dial codes -const countryDialCodes: { [key: string]: string } = { +export const countryDialCodes: { [key: string]: string } = { AF: "+93", AL: "+355", DZ: "+213", AS: "+1-684", AD: "+376", AO: "+244", AI: "+1-264", AG: "+1-268", AR: "+54", AM: "+374", AW: "+297", AU: "+61", AT: "+43", AZ: "+994", BS: "+1-242", BH: "+973", BD: "+880", BB: "+1-246", @@ -315,10 +315,52 @@ export function JoinForm() { // Get country code for phone number placeholder const getPhonePlaceholder = (countryCode: string) => { - if (!countryCode || !countryDialCodes[countryCode]) return "전화번호"; - return `${countryDialCodes[countryCode]} 전화번호`; + if (!countryCode || !countryDialCodes[countryCode]) return "+82 010-1234-5678"; + + const dialCode = countryDialCodes[countryCode]; + + switch (countryCode) { + case 'KR': + return `${dialCode} 010-1234-5678`; + case 'US': + case 'CA': + return `${dialCode} 555-123-4567`; + case 'JP': + return `${dialCode} 90-1234-5678`; + case 'CN': + return `${dialCode} 138-0013-8000`; + case 'GB': + return `${dialCode} 20-7946-0958`; + case 'DE': + return `${dialCode} 30-12345678`; + case 'FR': + return `${dialCode} 1-42-86-83-16`; + default: + return `${dialCode} 전화번호`; + } }; + const getPhoneDescription = (countryCode: string) => { + if (!countryCode) return "국가를 먼저 선택해주세요."; + + const dialCode = countryDialCodes[countryCode]; + + switch (countryCode) { + case 'KR': + return `${dialCode}로 시작하는 국제번호 또는 010으로 시작하는 국내번호를 입력하세요.`; + case 'US': + case 'CA': + return `${dialCode}로 시작하는 10자리 번호를 입력하세요.`; + case 'JP': + return `${dialCode}로 시작하는 일본 전화번호를 입력하세요.`; + case 'CN': + return `${dialCode}로 시작하는 중국 전화번호를 입력하세요.`; + default: + return `${dialCode}로 시작하는 국제 전화번호를 입력하세요.`; + } + }; + + // Render return ( <div className="container py-6"> @@ -555,9 +597,15 @@ export function JoinForm() { <Input {...field} placeholder={getPhonePlaceholder(form.watch("country"))} - disabled={isSubmitting} + disabled={isSubmitting} + className={cn( + form.formState.errors.phone && "border-red-500" + )} /> </FormControl> + <FormDescription className="text-xs text-muted-foreground"> + {getPhoneDescription(form.watch("country"))} + </FormDescription> <FormMessage /> </FormItem> )} diff --git a/components/system/passwordPolicy.tsx b/components/system/passwordPolicy.tsx new file mode 100644 index 00000000..7939cebe --- /dev/null +++ b/components/system/passwordPolicy.tsx @@ -0,0 +1,530 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useToast } from '@/hooks/use-toast' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Save, + Edit3, + X, + Check, + Lock, + Shield, + Clock, + Smartphone, + RotateCcw, + AlertTriangle +} from 'lucide-react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { resetSecuritySettings, updateSecuritySettings } from '@/lib/password-policy/service' + +export interface SecuritySettings { + id: number + // 패스워드 정책 + minPasswordLength: number + requireUppercase: boolean + requireLowercase: boolean + requireNumbers: boolean + requireSymbols: boolean + passwordExpiryDays: number | null + passwordHistoryCount: number + // 계정 잠금 정책 + maxFailedAttempts: number + lockoutDurationMinutes: number + // MFA 정책 + requireMfaForPartners: boolean + smsTokenExpiryMinutes: number + maxSmsAttemptsPerDay: number + // 세션 관리 + sessionTimeoutMinutes: number + // 메타데이터 + createdAt: Date + updatedAt: Date +} + +interface Props { + initialSettings: SecuritySettings +} + +interface SettingItem { + key: keyof SecuritySettings + label: string + description: string + type: 'number' | 'boolean' | 'nullable-number' + min?: number + max?: number + unit?: string +} + +const settingCategories = [ + { + title: '패스워드 정책', + description: '사용자 비밀번호 요구사항을 설정합니다', + icon: Lock, + items: [ + { + key: 'minPasswordLength' as const, + label: '최소 길이', + description: '비밀번호 최소 문자 수', + type: 'number' as const, + min: 4, + max: 128, + unit: '자' + }, + { + key: 'requireUppercase' as const, + label: '대문자 필수', + description: '대문자 포함 필수 여부', + type: 'boolean' as const + }, + { + key: 'requireLowercase' as const, + label: '소문자 필수', + description: '소문자 포함 필수 여부', + type: 'boolean' as const + }, + { + key: 'requireNumbers' as const, + label: '숫자 필수', + description: '숫자 포함 필수 여부', + type: 'boolean' as const + }, + { + key: 'requireSymbols' as const, + label: '특수문자 필수', + description: '특수문자 포함 필수 여부', + type: 'boolean' as const + }, + { + key: 'passwordExpiryDays' as const, + label: '만료 기간', + description: '비밀번호 만료일 (0이면 만료 없음)', + type: 'nullable-number' as const, + min: 0, + max: 365, + unit: '일' + }, + { + key: 'passwordHistoryCount' as const, + label: '히스토리 개수', + description: '중복 방지할 이전 비밀번호 개수', + type: 'number' as const, + min: 0, + max: 20, + unit: '개' + } + ] + }, + { + title: '계정 잠금 정책', + description: '로그인 실패 시 계정 잠금 설정', + icon: Shield, + items: [ + { + key: 'maxFailedAttempts' as const, + label: '최대 실패 횟수', + description: '계정 잠금까지 허용되는 로그인 실패 횟수', + type: 'number' as const, + min: 1, + max: 20, + unit: '회' + }, + { + key: 'lockoutDurationMinutes' as const, + label: '잠금 시간', + description: '계정 잠금 지속 시간', + type: 'number' as const, + min: 1, + max: 1440, + unit: '분' + } + ] + }, + { + title: 'MFA 정책', + description: '다단계 인증 설정', + icon: Smartphone, + items: [ + { + key: 'requireMfaForPartners' as const, + label: '협력업체 MFA 필수', + description: '협력업체 사용자 MFA 인증 필수 여부', + type: 'boolean' as const + }, + { + key: 'smsTokenExpiryMinutes' as const, + label: 'SMS 토큰 만료', + description: 'SMS 인증 토큰 만료 시간', + type: 'number' as const, + min: 1, + max: 30, + unit: '분' + }, + { + key: 'maxSmsAttemptsPerDay' as const, + label: '일일 SMS 한도', + description: '일일 SMS 전송 최대 횟수', + type: 'number' as const, + min: 1, + max: 100, + unit: '회' + } + ] + }, + { + title: '세션 관리', + description: '사용자 세션 설정', + icon: Clock, + items: [ + { + key: 'sessionTimeoutMinutes' as const, + label: '세션 타임아웃', + description: '비활성 상태에서 자동 로그아웃까지의 시간', + type: 'number' as const, + min: 5, + max: 1440, + unit: '분' + } + ] + } +] + +export default function SecuritySettingsTable({ initialSettings }: Props) { + const [settings, setSettings] = useState<SecuritySettings>(initialSettings) + const [editingItems, setEditingItems] = useState<Set<string>>(new Set()) + const [tempValues, setTempValues] = useState<Record<string, any>>({}) + const [isPending, startTransition] = useTransition() + const { toast } = useToast() + + const handleEdit = (key: string) => { + setEditingItems(prev => new Set([...prev, key])) + setTempValues(prev => ({ ...prev, [key]: settings[key as keyof SecuritySettings] })) + } + + const handleCancel = (key: string) => { + setEditingItems(prev => { + const newSet = new Set(prev) + newSet.delete(key) + return newSet + }) + setTempValues(prev => { + const newValues = { ...prev } + delete newValues[key] + return newValues + }) + } + + const handleSave = async (key: string) => { + const value = tempValues[key] + const item = settingCategories + .flatMap(cat => cat.items) + .find(item => item.key === key) + + // 클라이언트 사이드 유효성 검사 + if (item && item.type === 'number') { + if (value < (item.min || 0) || value > (item.max || Infinity)) { + toast({ + title: '유효하지 않은 값', + description: `${item.label}은(는) ${item.min || 0}${item.unit || ''} 이상 ${item.max || Infinity}${item.unit || ''} 이하여야 합니다.`, + variant: 'destructive', + }) + return + } + } + + startTransition(async () => { + try { + const result = await updateSecuritySettings({ + [key]: value + }) + + if (result.success) { + setSettings(prev => ({ ...prev, [key]: value, updatedAt: new Date() })) + setEditingItems(prev => { + const newSet = new Set(prev) + newSet.delete(key) + return newSet + }) + setTempValues(prev => { + const newValues = { ...prev } + delete newValues[key] + return newValues + }) + toast({ + title: '설정 저장됨', + description: `${item?.label || '설정'}이(가) 성공적으로 업데이트되었습니다.`, + }) + } else { + toast({ + title: '저장 실패', + description: result.error || '설정 저장 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '저장 실패', + description: '설정 저장 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } + }) + } + + const handleValueChange = (key: string, value: any) => { + setTempValues(prev => ({ ...prev, [key]: value })) + } + + const handleReset = async () => { + startTransition(async () => { + try { + const result = await resetSecuritySettings() + + if (result.success) { + // 페이지 새로고침으로 최신 데이터 로드 + window.location.reload() + } else { + toast({ + title: '초기화 실패', + description: result.error || '설정 초기화 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '초기화 실패', + description: '설정 초기화 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } + }) + } + + const renderValue = (item: SettingItem) => { + const key = item.key + const isEditing = editingItems.has(key) + const currentValue = isEditing ? tempValues[key] : settings[key] + + if (isEditing) { + if (item.type === 'boolean') { + return ( + <div className="flex items-center space-x-2"> + <Switch + checked={currentValue} + onCheckedChange={(checked) => handleValueChange(key, checked)} + /> + <span className="text-sm">{currentValue ? '사용' : '사용 안함'}</span> + </div> + ) + } else { + // nullable-number 타입을 위한 특별 처리 + if (item.type === 'nullable-number') { + return ( + <Input + type="number" + value={currentValue === null ? '' : currentValue} + onChange={(e) => { + const value = e.target.value === '' ? null : parseInt(e.target.value) + handleValueChange(key, value) + }} + min={item.min} + max={item.max} + className="w-24" + placeholder="0 (만료없음)" + /> + ) + } else { + return ( + <Input + type="number" + value={currentValue || ''} + onChange={(e) => { + const value = e.target.value === '' ? 0 : parseInt(e.target.value) + handleValueChange(key, value) + }} + min={item.min} + max={item.max} + className="w-24" + /> + ) + } + } + } + + // 읽기 모드 + if (item.type === 'boolean') { + return ( + <Badge variant={currentValue ? 'default' : 'secondary'}> + {currentValue ? '사용' : '사용 안함'} + </Badge> + ) + } else { + let displayValue: string + + if (item.type === 'nullable-number') { + if (currentValue === null || currentValue === 0) { + displayValue = item.key === 'passwordExpiryDays' ? '만료 없음' : '사용 안함' + } else { + displayValue = `${currentValue}${item.unit || ''}` + } + } else { + displayValue = `${currentValue || 0}${item.unit || ''}` + } + + return ( + <span className="font-medium">{displayValue}</span> + ) + } + } + + const renderActions = (key: string) => { + const isEditing = editingItems.has(key) + + if (isEditing) { + return ( + <div className="flex items-center space-x-1"> + <Button + size="sm" + variant="ghost" + onClick={() => handleSave(key)} + disabled={isPending} + > + <Check className="h-4 w-4" /> + </Button> + <Button + size="sm" + variant="ghost" + onClick={() => handleCancel(key)} + disabled={isPending} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + } + + return ( + <Button + size="sm" + variant="ghost" + onClick={() => handleEdit(key)} + disabled={isPending} + > + <Edit3 className="h-4 w-4" /> + </Button> + ) + } + + return ( + <div className="space-y-6"> + {/* 액션 헤더 */} + <div className="flex justify-between items-center"> + <div> + <p className="text-sm text-muted-foreground"> + 각 항목을 클릭하여 수정할 수 있습니다. + </p> + </div> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button variant="outline" size="sm" disabled={isPending}> + <RotateCcw className="h-4 w-4 mr-2" /> + 기본값으로 초기화 + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center space-x-2"> + <AlertTriangle className="h-5 w-5 text-orange-500" /> + <span>설정 초기화 확인</span> + </AlertDialogTitle> + <AlertDialogDescription> + 모든 보안 설정을 기본값으로 초기화하시겠습니까? + <br /> + <strong>이 작업은 되돌릴 수 없습니다.</strong> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction onClick={handleReset} disabled={isPending}> + 초기화 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + + {settingCategories.map((category) => ( + <Card key={category.title}> + <CardHeader> + <CardTitle className="flex items-center space-x-2"> + <category.icon className="h-5 w-5" /> + <span>{category.title}</span> + </CardTitle> + <CardDescription>{category.description}</CardDescription> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[200px]">설정 항목</TableHead> + <TableHead>설명</TableHead> + <TableHead className="w-[150px]">현재 값</TableHead> + <TableHead className="w-[100px]">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {category.items.map((item) => ( + <TableRow key={item.key}> + <TableCell className="font-medium">{item.label}</TableCell> + <TableCell className="text-muted-foreground"> + {item.description} + </TableCell> + <TableCell>{renderValue(item)}</TableCell> + <TableCell>{renderActions(item.key)}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + ))} + + <div className="flex justify-between items-center text-xs text-muted-foreground"> + <span>마지막 업데이트: {settings.updatedAt.toLocaleString('ko-KR')}</span> + {editingItems.size > 0 && ( + <Badge variant="secondary"> + {editingItems.size}개 항목 편집 중 + </Badge> + )} + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/system/permissionsTreeVendor.tsx b/components/system/permissionsTreeVendor.tsx new file mode 100644 index 00000000..8a4adb4b --- /dev/null +++ b/components/system/permissionsTreeVendor.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon'; +import { styled } from '@mui/material/styles'; +import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView'; +import { TreeItem, treeItemClasses } from '@mui/x-tree-view/TreeItem'; +import { Minus, MinusSquare, Plus, SquarePlus } from 'lucide-react'; +import { Button } from "@/components/ui/button"; +import { mainNav, additionalNav, MenuSection, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; +import { PermissionDialog } from './permissionDialog'; + +// ------------------- Custom TreeItem Style ------------------- +const CustomTreeItem = styled(TreeItem)({ + [`& .${treeItemClasses.iconContainer}`]: { + '& .close': { + opacity: 0.3, + }, + }, +}); + +function CloseSquare(props: SvgIconProps) { + return ( + <SvgIcon + className="close" + fontSize="inherit" + style={{ width: 14, height: 14 }} + {...props} + > + {/* tslint:disable-next-line: max-line-length */} + <path d="M17.485 17.512q-.281.281-.682.281t-.696-.268l-4.12-4.147-4.12 4.147q-.294.268-.696.268t-.682-.281-.281-.682.294-.669l4.12-4.147-4.12-4.147q-.294-.268-.294-.669t.281-.682.682-.281.696.268l4.12 4.147 4.12-4.147q.294-.268.696-.268t.682.281 .281.669-.294.682l-4.12 4.147 4.12 4.147q.294.268 .294.669t-.281.682zM22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0z" /> + </SvgIcon> + ); +} + + +interface SelectedKey { + key: string; + title: string; +} + +export default function PermissionsTreeVendor() { + const [expandedItems, setExpandedItems] = React.useState<string[]>([]); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [selectedKey, setSelectedKey] = React.useState<SelectedKey | null>(null); + + const handleExpandedItemsChange = ( + event: React.SyntheticEvent, + itemIds: string[], + ) => { + setExpandedItems(itemIds); + }; + + const handleExpandClick = () => { + if (expandedItems.length === 0) { + // 모든 노드를 펼치기 + // 실제로는 mainNav와 additionalNav를 순회해 itemId를 전부 수집하는 방식 + setExpandedItems([...collectAllIds()]); + } else { + setExpandedItems([]); + } + }; + + // (4) 수동으로 "모든 TreeItem의 itemId"를 수집하는 함수 + const collectAllIds = React.useCallback(() => { + const ids: string[] = []; + + // mainNav: 상위 = section.title, 하위 = item.title + mainNavVendor.forEach((section) => { + ids.push(section.title); // 상위 + section.items.forEach((itm) => ids.push(itm.title)); + }); + + // additionalNav를 "기타메뉴" 아래에 넣을 경우, "기타메뉴" 라는 itemId + each item + additionalNavVendor.forEach((itm) => ids.push(itm.title)); + return ids; + }, []); + + + function handleItemClick(key: SelectedKey) { + // 1) Dialog 열기 + setSelectedKey(key); // 이 값은 Dialog에서 어떤 메뉴인지 식별에 사용 + setDialogOpen(true); + } + + // (5) 실제 렌더 + return ( + <div className='lg:max-w-2xl'> + <Stack spacing={2}> + <div> + <Button onClick={handleExpandClick} type='button'> + {expandedItems.length === 0 ? ( + <> + <Plus /> + Expand All + </> + ) : ( + <> + <Minus /> + Collapse All + </> + )} + </Button> + </div> + + <Box sx={{ minHeight: 352, minWidth: 250 }}> + <SimpleTreeView + // 아래 props로 아이콘 지정 + slots={{ + expandIcon: SquarePlus, + collapseIcon: MinusSquare, + endIcon: CloseSquare, + }} + expansionTrigger="iconContainer" + onExpandedItemsChange={handleExpandedItemsChange} + expandedItems={expandedItems} + > + {/* (A) mainNav를 트리로 렌더 */} + {mainNav.map((section) => ( + <CustomTreeItem + key={section.title} + itemId={section.title} + label={section.title} + > + {section.items.map((itm) => { + const lastSegment = itm.href.split("/").pop() || itm.title; + const key = { key: lastSegment, title: itm.title } + return ( + <CustomTreeItem + key={lastSegment} + itemId={lastSegment} + label={itm.title} + onClick={() => handleItemClick(key)} + /> + ); + })} + </CustomTreeItem> + ))} + + + {additionalNav.map((itm) => { + const lastSegment = itm.href.split("/").pop() || itm.title; + const key = { key: lastSegment, title: itm.title } + return ( + <CustomTreeItem + key={lastSegment} + itemId={lastSegment} + label={itm.title} + onClick={() => handleItemClick(key)} + /> + ); + })} + </SimpleTreeView> + </Box> + </Stack> + + <PermissionDialog + open={dialogOpen} + onOpenChange={setDialogOpen} + itemKey={selectedKey?.key} + itemTitle={selectedKey?.title} + /> + </div> + ); +}
\ No newline at end of file diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index ddf9c287..8f3fe7d2 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -17,6 +17,8 @@ const badgeVariants = cva( outline: "text-foreground", success: "border-transparent bg-green-500 text-white shadow hover:bg-green-600", + samsung: + "border-transparent bg-blue-500 text-white shadow hover:bg-blue-600", }, }, defaultVariants: { diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 6473751a..7a1e61ff 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,7 +1,6 @@ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" - import { cn } from "@/lib/utils" const buttonVariants = cva( @@ -20,10 +19,13 @@ const buttonVariants = cva( ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", samsung: - "bg-[hsl(222,80%,40%)] text-white shadow-sm hover:bg-[hsl(222,80%,40%)]/80", + "bg-[hsl(222,80%,40%)] text-white shadow-sm hover:bg-[hsl(222,80%,40%)]/80", + /* ──────────── NEW SUCCESS VARIANT ──────────── */ + success: + "bg-green-600 text-white shadow-sm hover:bg-green-700", }, size: { - samsung:"h-9 px-4 py-2", + samsung: "h-9 px-4 py-2", default: "h-9 px-4 py-2", sm: "h-8 rounded-md px-3 text-xs", lg: "h-10 rounded-md px-8", @@ -48,7 +50,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Comp = asChild ? Slot : "button" return ( <Comp - className={cn(buttonVariants({ variant, size, className }))} + className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} /> |
