diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-27 01:16:20 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-27 01:16:20 +0000 |
| commit | e9897d416b3e7327bbd4d4aef887eee37751ae82 (patch) | |
| tree | bd20ce6eadf9b21755bd7425492d2d31c7700a0e /components/system/passwordPolicy.tsx | |
| parent | 3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff) | |
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'components/system/passwordPolicy.tsx')
| -rw-r--r-- | components/system/passwordPolicy.tsx | 530 |
1 files changed, 530 insertions, 0 deletions
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 |
