diff options
Diffstat (limited to 'components/system')
| -rw-r--r-- | components/system/passwordPolicy.tsx | 530 | ||||
| -rw-r--r-- | components/system/permissionsTreeVendor.tsx | 167 |
2 files changed, 697 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 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 |
