summaryrefslogtreecommitdiff
path: root/components/permissions/user-permission-manager.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/permissions/user-permission-manager.tsx')
-rw-r--r--components/permissions/user-permission-manager.tsx573
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