summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-27 01:16:20 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-27 01:16:20 +0000
commite9897d416b3e7327bbd4d4aef887eee37751ae82 (patch)
treebd20ce6eadf9b21755bd7425492d2d31c7700a0e /components
parent3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff)
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'components')
-rw-r--r--components/auth/simple-reauth-modal.tsx193
-rw-r--r--components/data-table/data-table-grobal-filter.tsx1
-rw-r--r--components/form-data/form-data-table.tsx1
-rw-r--r--components/form-data/spreadJS-dialog.tsx317
-rw-r--r--components/layout/SessionManager.tsx361
-rw-r--r--components/layout/providers.tsx31
-rw-r--r--components/login/InvalidTokenPage.tsx45
-rw-r--r--components/login/SuccessPage.tsx53
-rw-r--r--components/login/login-form copy.tsx485
-rw-r--r--components/login/login-form.tsx814
-rw-r--r--components/login/next-auth-reauth-modal.tsx215
-rw-r--r--components/login/partner-auth-form.tsx16
-rw-r--r--components/login/privacy-policy-page.tsx733
-rw-r--r--components/login/reset-password.tsx351
-rw-r--r--components/mail/mail-template-editor-client.tsx255
-rw-r--r--components/mail/mail-templates-client.tsx218
-rw-r--r--components/signup/join-form.tsx56
-rw-r--r--components/system/passwordPolicy.tsx530
-rw-r--r--components/system/permissionsTreeVendor.tsx167
-rw-r--r--components/ui/badge.tsx2
-rw-r--r--components/ui/button.tsx10
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">&ldquo;{t("blockquote")}&rdquo;</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">&ldquo;{t("blockquote")}&rdquo;</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}
/>