summaryrefslogtreecommitdiff
path: root/components/system
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/system
parent3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff)
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'components/system')
-rw-r--r--components/system/passwordPolicy.tsx530
-rw-r--r--components/system/permissionsTreeVendor.tsx167
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