From e9897d416b3e7327bbd4d4aef887eee37751ae82 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 27 Jun 2025 01:16:20 +0000 Subject: (대표님) 20250627 오전 10시 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/system/passwordPolicy.tsx | 530 ++++++++++++++++++++++++++++ components/system/permissionsTreeVendor.tsx | 167 +++++++++ 2 files changed, 697 insertions(+) create mode 100644 components/system/passwordPolicy.tsx create mode 100644 components/system/permissionsTreeVendor.tsx (limited to 'components/system') 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(initialSettings) + const [editingItems, setEditingItems] = useState>(new Set()) + const [tempValues, setTempValues] = useState>({}) + 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 ( +
+ handleValueChange(key, checked)} + /> + {currentValue ? '사용' : '사용 안함'} +
+ ) + } else { + // nullable-number 타입을 위한 특별 처리 + if (item.type === 'nullable-number') { + return ( + { + 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 ( + { + 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 ( + + {currentValue ? '사용' : '사용 안함'} + + ) + } 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 ( + {displayValue} + ) + } + } + + const renderActions = (key: string) => { + const isEditing = editingItems.has(key) + + if (isEditing) { + return ( +
+ + +
+ ) + } + + return ( + + ) + } + + return ( +
+ {/* 액션 헤더 */} +
+
+

+ 각 항목을 클릭하여 수정할 수 있습니다. +

+
+ + + + + + + + + 설정 초기화 확인 + + + 모든 보안 설정을 기본값으로 초기화하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + 초기화 + + +
+
+
+ + {settingCategories.map((category) => ( + + + + + {category.title} + + {category.description} + + + + + + 설정 항목 + 설명 + 현재 값 + 작업 + + + + {category.items.map((item) => ( + + {item.label} + + {item.description} + + {renderValue(item)} + {renderActions(item.key)} + + ))} + +
+
+
+ ))} + +
+ 마지막 업데이트: {settings.updatedAt.toLocaleString('ko-KR')} + {editingItems.size > 0 && ( + + {editingItems.size}개 항목 편집 중 + + )} +
+
+ ) +} \ 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 ( + + {/* tslint:disable-next-line: max-line-length */} + + + ); +} + + +interface SelectedKey { + key: string; + title: string; +} + +export default function PermissionsTreeVendor() { + const [expandedItems, setExpandedItems] = React.useState([]); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [selectedKey, setSelectedKey] = React.useState(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 ( +
+ +
+ +
+ + + + {/* (A) mainNav를 트리로 렌더 */} + {mainNav.map((section) => ( + + {section.items.map((itm) => { + const lastSegment = itm.href.split("/").pop() || itm.title; + const key = { key: lastSegment, title: itm.title } + return ( + handleItemClick(key)} + /> + ); + })} + + ))} + + + {additionalNav.map((itm) => { + const lastSegment = itm.href.split("/").pop() || itm.title; + const key = { key: lastSegment, title: itm.title } + return ( + handleItemClick(key)} + /> + ); + })} + + +
+ + +
+ ); +} \ No newline at end of file -- cgit v1.2.3