1
0
forked from baron/baron-sso

adminfront: 시스템 권한 설정 패널에 슬랙 스타일의 좌우 분할 화면(Split Screen) 및 그룹 토글 레이아웃 전격 반영

This commit is contained in:
2026-06-10 17:38:52 +09:00
parent 679c1656f4
commit fd6addfffd

View File

@@ -12,7 +12,24 @@ import {
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { TenantFineGrainedPermissionsTab } from "./TenantFineGrainedPermissionsTab"; import { TenantFineGrainedPermissionsTab } from "./TenantFineGrainedPermissionsTab";
import { ShieldCheck, Search, Plus, UserPlus, Trash2, Settings, Shield } from "lucide-react"; import {
ShieldCheck,
Search,
Plus,
UserPlus,
Trash2,
Settings,
Shield,
LayoutDashboard,
Building2,
Network,
Database,
Users,
KeyRound,
Key,
NotebookTabs,
Share2,
} from "lucide-react";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -30,14 +47,10 @@ import {
DialogTitle, DialogTitle,
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { import { Avatar, AvatarFallback } from "../../../components/ui/avatar";
Table, import { ScrollArea } from "../../../components/ui/scroll-area";
TableBody, import { Separator } from "../../../components/ui/separator";
TableCell, import { Switch } from "../../../components/ui/switch";
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast"; import { toast } from "../../../components/ui/use-toast";
export function TenantFineGrainedPermissionsPage() { export function TenantFineGrainedPermissionsPage() {
@@ -46,6 +59,8 @@ export function TenantFineGrainedPermissionsPage() {
const [selectedTenantId, setSelectedTenantId] = useState(""); const [selectedTenantId, setSelectedTenantId] = useState("");
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [activeUserId, setActiveUserId] = useState<string | null>(null);
const [userSearchTerm, setUserSearchTerm] = useState("");
const { data: profile } = useQuery({ const { data: profile } = useQuery({
queryKey: ["me"], queryKey: ["me"],
@@ -165,6 +180,9 @@ export function TenantFineGrainedPermissionsPage() {
for (const rel of userRelations) { for (const rel of userRelations) {
await removeSystemRelationMutation.mutateAsync({ userId, relation: rel }); await removeSystemRelationMutation.mutateAsync({ userId, relation: rel });
} }
if (activeUserId === userId) {
setActiveUserId(null);
}
}; };
const usersQuery = useQuery({ const usersQuery = useQuery({
@@ -175,26 +193,56 @@ export function TenantFineGrainedPermissionsPage() {
const handleAddSystemUser = (userId: string) => { const handleAddSystemUser = (userId: string) => {
addSystemRelationMutation.mutate({ userId, relation: "overview_viewers" }); addSystemRelationMutation.mutate({ userId, relation: "overview_viewers" });
setActiveUserId(userId);
setIsDialogOpen(false); setIsDialogOpen(false);
setSearchTerm(""); setSearchTerm("");
}; };
const searchResults = usersQuery.data?.items || []; const searchResults = usersQuery.data?.items || [];
const systemMenus = [ // Categorized system menus with descriptions and icons
{ label: t("ui.admin.nav.overview", "개요"), relation: "overview_viewers" }, const systemMenuCategories = [
{ label: t("ui.admin.nav.tenants", "테넌트"), relation: "tenants_viewers" }, {
{ label: t("ui.admin.nav.org_chart", "조직도"), relation: "org_chart_viewers" }, title: t("ui.admin.permissions_direct.cat_dashboard", "핵심 대시보드 및 분석"),
{ label: t("ui.admin.nav.worksmobile", "Worksmobile"), relation: "worksmobile_viewers" }, menus: [
{ label: t("ui.admin.nav.ory_ssot", "Ory SSOT"), relation: "ory_ssot_viewers" }, { label: t("ui.admin.nav.overview", "개요"), relation: "overview_viewers", desc: t("msg.admin.permissions_direct.desc_overview", "바론 전체 사양 및 시스템 상태 개요 정보"), icon: LayoutDashboard },
{ label: t("ui.admin.nav.data_integrity", "정합성"), relation: "data_integrity_viewers" }, { label: t("ui.admin.nav.audit_logs", "감사 로그"), relation: "audit_logs_viewers", desc: t("msg.admin.permissions_direct.desc_audit_logs", "시스템 전역 보안 감사 및 접속 이력 로그"), icon: NotebookTabs },
{ label: t("ui.admin.nav.users", "사용자"), relation: "users_viewers" }, ]
{ label: t("ui.admin.nav.permissions_direct", "권한 부여"), relation: "permissions_direct_viewers" }, },
{ label: t("ui.admin.nav.auth_guard", "인증 가드"), relation: "auth_guard_viewers" }, {
{ label: t("ui.admin.nav.api_keys", "API 키"), relation: "api_keys_viewers" }, title: t("ui.admin.permissions_direct.cat_resources", "핵심 리소스 관리"),
{ label: t("ui.admin.nav.audit_logs", "감사 로그"), relation: "audit_logs_viewers" }, menus: [
{ label: t("ui.admin.nav.tenants", "테넌트"), relation: "tenants_viewers", desc: t("msg.admin.permissions_direct.desc_tenants", "고객 테넌트 목록, 신규 부모-자식 테넌트 관리"), icon: Building2 },
{ label: t("ui.admin.nav.org_chart", "조직도"), relation: "org_chart_viewers", desc: t("msg.admin.permissions_direct.desc_org_chart", "조직도 가시화 및 트리 배치 확인"), icon: Network },
{ label: t("ui.admin.nav.users", "사용자"), relation: "users_viewers", desc: t("msg.admin.permissions_direct.desc_users", "가입 사용자 목록, 승인 및 커스텀 클레임 수동 주입"), icon: Users },
]
},
{
title: t("ui.admin.permissions_direct.cat_integrations", "인프라 연동 및 보안"),
menus: [
{ label: t("ui.admin.nav.worksmobile", "WORKS 연동"), relation: "worksmobile_viewers", desc: t("msg.admin.permissions_direct.desc_worksmobile", "라인웍스 연동 및 사내 임직원 패스워드 강제 동기화"), icon: Share2 },
{ label: t("ui.admin.nav.api_keys", "API 키"), relation: "api_keys_viewers", desc: t("msg.admin.permissions_direct.desc_api_keys", "조직도 연동을 위한 전역 서드파티 토큰 관리"), icon: Key },
]
},
{
title: t("ui.admin.permissions_direct.cat_system", "아이덴티티 및 게이트 관리"),
menus: [
{ label: t("ui.admin.nav.ory_ssot", "Ory SSOT 시스템"), relation: "ory_ssot_viewers", desc: t("msg.admin.permissions_direct.desc_ory_ssot", "Redis 아이덴티티 미러 캐시 및 PostgreSQL read model 정합성 갱신"), icon: Database },
{ label: t("ui.admin.nav.data_integrity", "데이터 정합성"), relation: "data_integrity_viewers", desc: t("msg.admin.permissions_direct.desc_data_integrity", "고아 레코드 검출 및 DB 정합성 최종 검증기"), icon: ShieldCheck },
{ label: t("ui.admin.nav.auth_guard", "인증 가드"), relation: "auth_guard_viewers", desc: t("msg.admin.permissions_direct.desc_auth_guard", "정책엔진 기준으로 Keto ReBAC 관계 검증 시뮬레이터"), icon: KeyRound },
{ label: t("ui.admin.nav.permissions_direct", "권한 부여"), relation: "permissions_direct_viewers", desc: t("msg.admin.permissions_direct.desc_permissions_direct", "본 사이드바 메뉴 세부 권한 격자 및 테넌트 인가 설정 패널"), icon: Shield },
]
}
]; ];
const filteredRelations = systemRelations.filter(
(r) =>
r.name.toLowerCase().includes(userSearchTerm.toLowerCase()) ||
r.email.toLowerCase().includes(userSearchTerm.toLowerCase()),
);
const selectedUser = systemRelations.find((r) => r.userId === activeUserId);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -277,99 +325,173 @@ export function TenantFineGrainedPermissionsPage() {
)} )}
</> </>
) : ( ) : (
/* 시스템 메뉴 권한 (Admin Control) Tab Panel */ /* 시스템 메뉴 권한 (Admin Control) Split Screen Panel */
<Card className="border-none shadow-sm bg-[var(--color-panel)]"> <div className="flex flex-col lg:flex-row gap-6 h-[720px] border border-border rounded-xl bg-card overflow-hidden shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0"> {/* Left Panel: User List */}
<div className="space-y-1"> <div className="w-full lg:w-80 border-r border-border flex flex-col bg-muted/10 h-full">
<CardTitle className="text-2xl font-bold flex items-center gap-2"> <div className="p-4 border-b border-border space-y-3 flex-shrink-0">
<ShieldCheck className="h-6 w-6 text-primary" /> <div className="flex items-center justify-between">
{t("ui.admin.permissions_direct.tab_system_title", "글로벌 메뉴 접근 제어 (Admin Control)")} <h3 className="font-bold text-sm text-foreground">
</CardTitle> {t("ui.admin.permissions_direct.user_list", "대상 사용자")} ({filteredRelations.length})
<CardDescription className="text-muted-foreground"> </h3>
{t( <Button
"msg.admin.permissions_direct.tab_system_desc", size="sm"
"사이드바 각 메뉴별 접근 권한을 사용자에게 직접 부여합니다. 최고 관리자(super_admin)는 기본적으로 언제나 모든 권한을 우회 통과합니다.", variant="ghost"
className="h-8 w-8 p-0"
onClick={() => setIsDialogOpen(true)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "이름 또는 이메일 검색...")}
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
className="pl-8 h-8 text-xs"
/>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{filteredRelations.length === 0 ? (
<div className="p-6 text-center text-xs text-muted-foreground">
{t("msg.admin.permissions_direct.no_users_found", "등록된 사용자가 없습니다.")}
</div>
) : (
filteredRelations.map((user) => {
const isSelected = activeUserId === user.userId;
const activeCount = user.relations.length;
return (
<button
key={user.userId}
onClick={() => setActiveUserId(user.userId)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-all ${
isSelected
? "bg-primary/10 text-primary border-l-4 border-primary shadow-sm"
: "hover:bg-muted/50 text-foreground"
}`}
>
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-8 w-8 border border-border">
<AvatarFallback className="bg-primary/10 text-primary font-bold text-xs uppercase">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className="text-sm font-semibold truncate">{user.name}</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[150px]">
{user.email}
</span>
</div>
</div>
<Badge variant={isSelected ? "default" : "secondary"} className="text-[9px] px-1.5 py-0.5">
{activeCount}
</Badge>
</button>
);
})
)} )}
</CardDescription> </div>
</div> </ScrollArea>
<Button </div>
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setIsDialogOpen(true)} {/* Right Panel: Toggle settings grid */}
> <div className="flex-1 flex flex-col h-full bg-background">
<UserPlus className="mr-2 h-4 w-4" /> {selectedUser ? (
{t("ui.admin.permissions_direct.add_system_user", "시스템 권한 사용자 추가")} <>
</Button> {/* User Detail Header */}
</CardHeader> <div className="p-5 border-b border-border flex items-center justify-between flex-shrink-0 bg-muted/5">
<CardContent className="pt-0"> <div className="flex items-center gap-4">
<div className="rounded-md border border-border overflow-hidden overflow-x-auto"> <Avatar className="h-11 w-11 border">
<Table className="min-w-[1200px]"> <AvatarFallback className="bg-primary/5 text-primary font-extrabold text-sm uppercase">
<TableHeader className="bg-secondary/40"> {selectedUser.name.charAt(0)}
<TableRow> </AvatarFallback>
<TableHead className="font-bold sticky left-0 bg-background z-20 w-[180px]">{t("ui.common.name", "이름")}</TableHead> </Avatar>
{systemMenus.map((menu) => ( <div className="flex flex-col">
<TableHead key={menu.relation} className="font-bold text-center text-xs"> <h2 className="text-lg font-bold flex items-center gap-2">
{menu.label} {selectedUser.name}
</TableHead> <Badge variant="outline" className="text-xs font-normal">
))} {selectedUser.relations.length} {t("ui.admin.permissions_direct.allowed", "개 허용됨")}
<TableHead className="font-bold text-center w-[70px]">{t("ui.common.action", "작업")}</TableHead> </Badge>
</TableRow> </h2>
</TableHeader> <span className="text-xs text-muted-foreground">{selectedUser.email}</span>
<TableBody> </div>
{systemRelations.length === 0 ? ( </div>
<TableRow> <Button
<TableCell colSpan={13} className="text-center py-12 text-muted-foreground"> variant="outline"
{t("msg.admin.permissions_direct.system_empty", "지정된 글로벌 메뉴 세부 권한자가 없습니다. 사용자를 추가해 관리해 주세요.")} size="sm"
</TableCell> className="text-destructive border-destructive/20 hover:bg-destructive/10"
</TableRow> onClick={() => handleRemoveAllSystemRelations(selectedUser.userId, selectedUser.relations)}
) : ( >
systemRelations.map((user) => { <Trash2 className="h-4 w-4 mr-2" />
return ( {t("ui.admin.permissions_direct.revoke_all", "모든 권한 회수")}
<TableRow key={user.userId} className="hover:bg-muted/10 transition-colors"> </Button>
<TableCell className="font-medium sticky left-0 bg-background z-10 shadow-[2px_0_5px_rgba(0,0,0,0.05)]"> </div>
<div className="flex flex-col">
<span className="font-semibold text-foreground">{user.name}</span> {/* Categorized Toggle Grid */}
<span className="text-[10px] text-muted-foreground italic truncate max-w-[150px]">{user.email}</span> <ScrollArea className="flex-1">
</div> <div className="p-6 space-y-6">
</TableCell> {systemMenuCategories.map((category) => (
{systemMenus.map((menu) => { <div key={category.title} className="space-y-3">
const hasAccess = user.relations.includes(menu.relation); <h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
return ( {category.title}
<TableCell key={menu.relation} className="text-center"> </h4>
<select <Card className="border border-border/60 shadow-none bg-card">
className="mx-auto flex h-8 w-[100px] rounded-md border border-input bg-background px-1.5 py-0.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" <CardContent className="p-0 divide-y divide-border/40">
value={hasAccess ? "access" : "none"} {category.menus.map((menu) => {
onChange={(e) => const hasAccess = selectedUser.relations.includes(menu.relation);
handleSystemRelationChange( const Icon = menu.icon;
user.userId,
menu.relation, return (
e.target.value === "access", <div
) key={menu.relation}
} className="flex items-center justify-between p-4 hover:bg-muted/10 transition-colors"
> >
<option value="none">{t("ui.common.none", "X")}</option> <div className="flex items-start gap-4 pr-4 min-w-0">
<option value="access">{t("ui.common.access", "허용")}</option> <div className="p-2 rounded-lg bg-secondary/50 text-foreground flex-shrink-0 mt-0.5">
</select> <Icon className="h-4 w-4 text-muted-foreground" />
</TableCell> </div>
); <div className="flex flex-col min-w-0">
})} <span className="text-sm font-semibold text-foreground">{menu.label}</span>
<TableCell className="text-center"> <span className="text-xs text-muted-foreground line-clamp-1">
<Button {menu.desc}
variant="ghost" </span>
size="icon" </div>
onClick={() => handleRemoveAllSystemRelations(user.userId, user.relations)} </div>
> <Switch
<Trash2 className="h-4 w-4 text-destructive" /> checked={hasAccess}
</Button> onCheckedChange={(val) =>
</TableCell> handleSystemRelationChange(selectedUser.userId, menu.relation, val)
</TableRow> }
); />
}) </div>
)} );
</TableBody> })}
</Table> </CardContent>
</div> </Card>
</CardContent> </div>
</Card> ))}
</div>
</ScrollArea>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center text-muted-foreground bg-muted/5 gap-3">
<ShieldCheck className="h-12 w-12 text-muted-foreground opacity-30" />
<div>
<h3 className="text-sm font-semibold text-foreground">
{t("ui.admin.permissions_direct.no_user_selected", "사용자가 선택되지 않았습니다.")}
</h3>
<p className="text-xs mt-1">
{t("msg.admin.permissions_direct.no_user_selected_desc", "왼쪽의 사용자 리스트에서 권한을 변경할 인원을 선택해 주세요.")}
</p>
</div>
</div>
)}
</div>
</div>
)} )}
{/* User Search Dialog for System relations */} {/* User Search Dialog for System relations */}