diff options
Diffstat (limited to 'components/permissions/user-permission-manager.tsx')
| -rw-r--r-- | components/permissions/user-permission-manager.tsx | 573 |
1 files changed, 573 insertions, 0 deletions
diff --git a/components/permissions/user-permission-manager.tsx b/components/permissions/user-permission-manager.tsx new file mode 100644 index 00000000..9c23b122 --- /dev/null +++ b/components/permissions/user-permission-manager.tsx @@ -0,0 +1,573 @@ +// components/permissions/user-permission-manager.tsx + +"use client"; + +import { useState, useEffect, useTransition } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Calendar } from "@/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Search, + UserPlus, + Shield, + Clock, + AlertTriangle, + CheckCircle, + XCircle, + CalendarIcon, + Plus, + Minus, + Settings, + Key, + Users, + Building, + ChevronRight +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getUserPermissionDetails, + grantPermissionToUser, + revokePermissionFromUser, + searchUsers, + getUserRoles, +} from "@/lib/permissions/service"; + +interface User { + id: number; + name: string; + email: string; + imageUrl?: string; + domain: string; + companyName?: string; + roles: { id: number; name: string }[]; +} + +interface Permission { + id: number; + permissionKey: string; + name: string; + description?: string; + permissionType: string; + resource: string; + action: string; + scope: string; + menuPath?: string; +} + +interface UserPermission extends Permission { + source: "role" | "direct"; + roleName?: string; + grantedBy?: string; + grantedAt?: Date; + expiresAt?: Date; + reason?: string; + isGrant: boolean; +} + +export function UserPermissionManager() { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedUser, setSelectedUser] = useState<User | null>(null); + const [users, setUsers] = useState<User[]>([]); + const [userPermissions, setUserPermissions] = useState<UserPermission[]>([]); + const [availablePermissions, setAvailablePermissions] = useState<Permission[]>([]); + const [loading, setLoading] = useState(false); + const [isPending, startTransition] = useTransition(); + const [addPermissionDialogOpen, setAddPermissionDialogOpen] = useState(false); + + // 사용자 검색 + useEffect(() => { + const timer = setTimeout(() => { + if (searchQuery) { + searchUsersData(searchQuery); + } + }, 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + // 선택된 사용자의 권한 로드 + useEffect(() => { + if (selectedUser) { + loadUserPermissions(selectedUser.id); + } + }, [selectedUser]); + + const searchUsersData = async (query: string) => { + try { + const data = await searchUsers(query); + setUsers(data); + } catch (error) { + toast.error("사용자 검색에 실패했습니다."); + } + }; + + const loadUserPermissions = async (userId: number) => { + setLoading(true); + try { + const data = await getUserPermissionDetails(userId); + setUserPermissions(data.permissions); + setAvailablePermissions(data.availablePermissions); + } catch (error) { + toast.error("권한 정보를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + // 역할 기반 권한과 직접 부여 권한 분리 + const rolePermissions = userPermissions.filter(p => p.source === "role"); + const directPermissions = userPermissions.filter(p => p.source === "direct"); + + return ( + <div className="grid grid-cols-3 gap-6"> + {/* 사용자 검색 및 선택 */} + <Card className="col-span-1"> + <CardHeader> + <CardTitle>사용자 선택</CardTitle> + <CardDescription>권한을 관리할 사용자를 검색하세요.</CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* 검색 입력 */} + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="이름, 이메일로 검색..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + {/* 사용자 목록 */} + <ScrollArea className="h-[500px]"> + <div className="space-y-2"> + {users.map((user) => ( + <button + key={user.id} + onClick={() => setSelectedUser(user)} + className={cn( + "w-full p-3 rounded-lg border text-left transition-colors", + selectedUser?.id === user.id + ? "bg-primary/10 border-primary" + : "hover:bg-muted" + )} + > + <div className="flex items-center gap-3"> + <Avatar> + <AvatarImage src={user.imageUrl} /> + <AvatarFallback>{user.name[0]}</AvatarFallback> + </Avatar> + <div className="flex-1 min-w-0"> + <div className="font-medium truncate">{user.name}</div> + <div className="text-sm text-muted-foreground truncate"> + {user.email} + </div> + <div className="flex items-center gap-2 mt-1"> + <Badge variant="outline" className="text-xs"> + {user.domain} + </Badge> + {user.companyName && ( + <span className="text-xs text-muted-foreground"> + {user.companyName} + </span> + )} + </div> + </div> + </div> + </button> + ))} + </div> + </ScrollArea> + </div> + </CardContent> + </Card> + + {/* 권한 상세 */} + {selectedUser ? ( + <Card className="col-span-2"> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>{selectedUser.name}의 권한</CardTitle> + <CardDescription> + {selectedUser.email} • {selectedUser.domain} + </CardDescription> + </div> + <AddPermissionDialog + userId={selectedUser.id} + availablePermissions={availablePermissions.filter( + p => !userPermissions.some(up => up.id === p.id) + )} + onSuccess={() => loadUserPermissions(selectedUser.id)} + /> + </div> + </CardHeader> + <CardContent> + <Tabs defaultValue="all" className="w-full"> + <TabsList> + <TabsTrigger value="all"> + 전체 권한 ({userPermissions.length}) + </TabsTrigger> + <TabsTrigger value="role"> + <Users className="mr-2 h-4 w-4" /> + 역할 기반 ({rolePermissions.length}) + </TabsTrigger> + <TabsTrigger value="direct"> + <Shield className="mr-2 h-4 w-4" /> + 직접 부여 ({directPermissions.length}) + </TabsTrigger> + </TabsList> + + <TabsContent value="all" className="mt-4"> + <PermissionList + permissions={userPermissions} + userId={selectedUser.id} + onRevoke={() => loadUserPermissions(selectedUser.id)} + /> + </TabsContent> + + <TabsContent value="role" className="mt-4"> + <div className="space-y-4"> + {/* 역할 표시 */} + <div className="p-4 bg-muted/50 rounded-lg"> + <h4 className="text-sm font-medium mb-2">보유 역할</h4> + <div className="flex flex-wrap gap-2"> + {selectedUser.roles.map(role => ( + <Badge key={role.id} variant="secondary"> + {role.name} + </Badge> + ))} + </div> + </div> + <PermissionList + permissions={rolePermissions} + userId={selectedUser.id} + readOnly + /> + </div> + </TabsContent> + + <TabsContent value="direct" className="mt-4"> + <PermissionList + permissions={directPermissions} + userId={selectedUser.id} + onRevoke={() => loadUserPermissions(selectedUser.id)} + /> + </TabsContent> + </Tabs> + </CardContent> + </Card> + ) : ( + <Card className="col-span-2 flex items-center justify-center h-[600px]"> + <div className="text-center text-muted-foreground"> + <Shield className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>사용자를 선택하면 권한 정보가 표시됩니다.</p> + </div> + </Card> + )} + </div> + ); +} + +// 권한 목록 컴포넌트 +function PermissionList({ + permissions, + userId, + readOnly = false, + onRevoke +}: { + permissions: UserPermission[]; + userId: number; + readOnly?: boolean; + onRevoke?: () => void; +}) { + const [revoking, setRevoking] = useState<number | null>(null); + + const handleRevoke = async (permissionId: number) => { + if (!confirm("이 권한을 제거하시겠습니까?")) return; + + setRevoking(permissionId); + try { + await revokePermissionFromUser(userId, permissionId); + toast.success("권한이 제거되었습니다."); + onRevoke?.(); + } catch (error) { + toast.error("권한 제거에 실패했습니다."); + } finally { + setRevoking(null); + } + }; + + // 권한을 리소스별로 그룹화 + const groupedPermissions = permissions.reduce((acc, perm) => { + const group = perm.resource; + if (!acc[group]) acc[group] = []; + acc[group].push(perm); + return acc; + }, {} as Record<string, UserPermission[]>); + + return ( + <div className="space-y-4"> + {Object.entries(groupedPermissions).map(([resource, perms]) => ( + <div key={resource} className="border rounded-lg"> + <div className="px-4 py-2 bg-muted/30 font-medium text-sm"> + {resource} + </div> + <div className="divide-y"> + {perms.map((permission) => ( + <div key={permission.id} className="p-4"> + <div className="flex items-start justify-between"> + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <span className="font-medium">{permission.name}</span> + {permission.source === "role" && ( + <Badge variant="outline" className="text-xs"> + 역할: {permission.roleName} + </Badge> + )} + {permission.isGrant === false && ( + <Badge variant="destructive" className="text-xs"> + 제한 + </Badge> + )} + </div> + <div className="text-sm text-muted-foreground"> + {permission.description} + </div> + <div className="flex items-center gap-4 text-xs text-muted-foreground"> + <span>타입: {permission.permissionType}</span> + <span>범위: {permission.scope}</span> + {permission.expiresAt && ( + <span className="text-orange-600"> + <Clock className="inline h-3 w-3 mr-1" /> + {format(new Date(permission.expiresAt), "yyyy-MM-dd")} 만료 + </span> + )} + </div> + {permission.reason && ( + <div className="text-xs text-muted-foreground mt-2"> + 사유: {permission.reason} + </div> + )} + </div> + + {!readOnly && permission.source === "direct" && ( + <Button + variant="ghost" + size="sm" + onClick={() => handleRevoke(permission.id)} + disabled={revoking === permission.id} + > + <XCircle className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ))} + </div> + </div> + ))} + </div> + ); +} + +// 권한 추가 다이얼로그 +function AddPermissionDialog({ + userId, + availablePermissions, + onSuccess +}: { + userId: number; + availablePermissions: Permission[]; + onSuccess: () => void; +}) { + const [open, setOpen] = useState(false); + const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]); + const [reason, setReason] = useState(""); + const [expiresAt, setExpiresAt] = useState<Date | undefined>(); + const [isGrant, setIsGrant] = useState(true); + const [saving, setSaving] = useState(false); + + const handleSubmit = async () => { + if (selectedPermissions.length === 0) { + toast.error("권한을 선택해주세요."); + return; + } + + setSaving(true); + try { + await grantPermissionToUser({ + userId, + permissionIds: selectedPermissions, + isGrant, + reason, + expiresAt, + }); + toast.success("권한이 추가되었습니다."); + setOpen(false); + onSuccess(); + // Reset form + setSelectedPermissions([]); + setReason(""); + setExpiresAt(undefined); + setIsGrant(true); + } catch (error) { + toast.error("권한 추가에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button> + <Plus className="mr-2 h-4 w-4" /> + 권한 추가 + </Button> + </DialogTrigger> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>권한 추가</DialogTitle> + <DialogDescription> + 사용자에게 직접 권한을 부여하거나 제한합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + {/* 권한 타입 선택 */} + <div className="flex items-center space-x-4"> + <Label>권한 타입</Label> + <div className="flex gap-4"> + <label className="flex items-center gap-2"> + <input + type="radio" + checked={isGrant} + onChange={() => setIsGrant(true)} + /> + <span className="text-sm">부여</span> + </label> + <label className="flex items-center gap-2"> + <input + type="radio" + checked={!isGrant} + onChange={() => setIsGrant(false)} + /> + <span className="text-sm text-destructive">제한</span> + </label> + </div> + </div> + + {/* 권한 선택 */} + <div> + <Label>권한 선택</Label> + <ScrollArea className="h-[200px] border rounded-md p-4 mt-2"> + <div className="space-y-2"> + {availablePermissions.map(permission => ( + <label + key={permission.id} + className="flex items-start gap-2 cursor-pointer" + > + <Checkbox + checked={selectedPermissions.includes(permission.id)} + onCheckedChange={(checked) => { + if (checked) { + setSelectedPermissions([...selectedPermissions, permission.id]); + } else { + setSelectedPermissions( + selectedPermissions.filter(id => id !== permission.id) + ); + } + }} + /> + <div className="flex-1"> + <div className="text-sm font-medium">{permission.name}</div> + <div className="text-xs text-muted-foreground"> + {permission.description} + </div> + </div> + </label> + ))} + </div> + </ScrollArea> + </div> + + {/* 사유 */} + <div> + <Label>사유</Label> + <Textarea + placeholder="권한 부여/제한 사유를 입력하세요." + value={reason} + onChange={(e) => setReason(e.target.value)} + className="mt-2" + /> + </div> + + {/* 만료일 */} + <div> + <Label>만료일 (선택)</Label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + className={cn( + "w-full justify-start text-left font-normal mt-2", + !expiresAt && "text-muted-foreground" + )} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + {expiresAt ? format(expiresAt, "PPP", { locale: ko }) : "만료일 선택"} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={expiresAt} + onSelect={setExpiresAt} + initialFocus + /> + </PopoverContent> + </Popover> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => setOpen(false)}> + 취소 + </Button> + <Button onClick={handleSubmit} disabled={saving}> + {saving ? "저장 중..." : "권한 추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
