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 +++++++++++++++++++++++++++++++++++ 1 file changed, 530 insertions(+) create mode 100644 components/system/passwordPolicy.tsx (limited to 'components/system/passwordPolicy.tsx') 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 -- cgit v1.2.3