1
0
forked from baron/baron-sso

adminfront 및 백엔드: 전 메뉴 및 탭 수준 ReBAC 기반 접근 제어(Admin Control) 기능 추가 구현 완료

This commit is contained in:
2026-06-12 11:40:56 +09:00
parent d0bdc54286
commit a70755e993
15 changed files with 360 additions and 84 deletions

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { useState } from "react";
import { useState, useEffect } from "react";
import {
fetchAllTenants,
fetchMe,
@@ -62,6 +62,9 @@ export function TenantFineGrainedPermissionsPage() {
const [activeUserId, setActiveUserId] = useState<string | null>(null);
const [userSearchTerm, setUserSearchTerm] = useState("");
// 🌟 글로벌 시스템 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
const [localSystemPermissions, setLocalSystemPermissions] = useState<Record<string, Record<string, "none" | "read" | "write">>>({});
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
@@ -87,6 +90,27 @@ export function TenantFineGrainedPermissionsPage() {
});
const systemRelations = systemRelationsQuery.data ?? [];
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
useEffect(() => {
if (systemRelationsQuery.data) {
const initialMap: Record<string, Record<string, "none" | "read" | "write">> = {};
for (const user of systemRelationsQuery.data) {
initialMap[user.userId] = {};
const menus = [
"overview", "audit_logs", "tenants", "org_chart", "users",
"worksmobile", "api_keys", "ory_ssot", "data_integrity",
"auth_guard", "permissions_direct"
];
for (const m of menus) {
const isWrite = user.relations.includes(`${m}_managers`);
const isRead = user.relations.includes(`${m}_viewers`);
initialMap[user.userId][m] = isWrite ? "write" : isRead ? "read" : "none";
}
}
setLocalSystemPermissions(initialMap);
}
}, [systemRelationsQuery.data]);
const addSystemRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
addSystemRelation(payload.userId, payload.relation),
@@ -118,10 +142,15 @@ export function TenantFineGrainedPermissionsPage() {
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
},
onSuccess: () => {
toast.success(t("msg.admin.system.relations.add_success", "시스템 메뉴 권한이 추가되었습니다."));
// Quiet mutate
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
}, 500);
},
});
@@ -154,22 +183,43 @@ export function TenantFineGrainedPermissionsPage() {
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
},
onSuccess: () => {
toast.success(t("msg.admin.system.relations.remove_success", "시스템 메뉴 권한이 회수되었습니다."));
// Quiet mutate
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
}, 500);
},
});
const handleSystemRelationChange = async (
userId: string,
relation: string,
hasAccess: boolean,
menuKey: string,
currentVal: "none" | "read" | "write",
newVal: "none" | "read" | "write",
) => {
if (hasAccess) {
await addSystemRelationMutation.mutateAsync({ userId, relation });
} else {
await removeSystemRelationMutation.mutateAsync({ userId, relation });
if (currentVal === newVal) return;
try {
if (currentVal === "read") {
await removeSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_viewers` });
} else if (currentVal === "write") {
await removeSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_managers` });
}
if (newVal === "read") {
await addSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_viewers` });
} else if (newVal === "write") {
await addSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_managers` });
}
// 🌟 Trigger a single consolidated success toast at the very end
toast.success(t("msg.admin.system.relations.update_success", "시스템 메뉴 권한이 성공적으로 변경되었습니다."));
} catch {
// Individual mutations handle error toast via onError
}
};
@@ -205,32 +255,32 @@ export function TenantFineGrainedPermissionsPage() {
{
title: t("ui.admin.permissions_direct.cat_dashboard", "핵심 대시보드 및 분석"),
menus: [
{ label: t("ui.admin.nav.overview", "개요"), relation: "overview_viewers", desc: t("msg.admin.permissions_direct.desc_overview", "바론 전체 사양 및 시스템 상태 개요 정보"), icon: LayoutDashboard },
{ 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.overview", "개요"), relation: "overview", desc: t("msg.admin.permissions_direct.desc_overview", "바론 전체 사양 및 시스템 상태 개요 정보"), icon: LayoutDashboard },
{ label: t("ui.admin.nav.audit_logs", "감사 로그"), relation: "audit_logs", desc: t("msg.admin.permissions_direct.desc_audit_logs", "시스템 전역 보안 감사 및 접속 이력 로그"), icon: NotebookTabs },
]
},
{
title: t("ui.admin.permissions_direct.cat_resources", "핵심 리소스 관리"),
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 },
{ label: t("ui.admin.nav.tenants", "테넌트"), relation: "tenants", desc: t("msg.admin.permissions_direct.desc_tenants", "고객 테넌트 목록, 신규 부모-자식 테넌트 관리"), icon: Building2 },
{ label: t("ui.admin.nav.org_chart", "조직도"), relation: "org_chart", desc: t("msg.admin.permissions_direct.desc_org_chart", "조직도 가시화 및 트리 배치 확인"), icon: Network },
{ label: t("ui.admin.nav.users", "사용자"), relation: "users", 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 },
{ label: t("ui.admin.nav.worksmobile", "WORKS 연동"), relation: "worksmobile", desc: t("msg.admin.permissions_direct.desc_worksmobile", "라인웍스 연동 및 사내 임직원 패스워드 강제 동기화"), icon: Share2 },
{ label: t("ui.admin.nav.api_keys", "API 키"), relation: "api_keys", 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 },
{ label: t("ui.admin.nav.ory_ssot", "Ory SSOT 시스템"), relation: "ory_ssot", 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", desc: t("msg.admin.permissions_direct.desc_data_integrity", "고아 레코드 검출 및 DB 정합성 최종 검증기"), icon: ShieldCheck },
{ label: t("ui.admin.nav.auth_guard", "인증 가드"), relation: "auth_guard", desc: t("msg.admin.permissions_direct.desc_auth_guard", "정책엔진 기준으로 Keto ReBAC 관계 검증 시뮬레이터"), icon: KeyRound },
{ label: t("ui.admin.nav.permissions_direct", "권한 부여"), relation: "permissions_direct", desc: t("msg.admin.permissions_direct.desc_permissions_direct", "본 사이드바 메뉴 세부 권한 격자 및 테넌트 인가 설정 패널"), icon: Shield },
]
}
];
@@ -302,6 +352,7 @@ export function TenantFineGrainedPermissionsPage() {
</CardHeader>
<CardContent>
<select
name="select-tenant-for-fine-grained-permissions"
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
className="flex h-10 w-full max-w-[360px] rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
@@ -349,6 +400,7 @@ export function TenantFineGrainedPermissionsPage() {
placeholder={t("ui.common.search", "이름 또는 이메일 검색...")}
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
name="user-search"
className="pl-8 h-8 text-xs"
/>
</div>
@@ -442,7 +494,10 @@ export function TenantFineGrainedPermissionsPage() {
<Card className="border border-border/60 shadow-none bg-card">
<CardContent className="p-0 divide-y divide-border/40">
{category.menus.map((menu) => {
const hasAccess = selectedUser.relations.includes(menu.relation);
const isWrite = selectedUser.relations.includes(`${menu.relation}_managers`);
const isRead = selectedUser.relations.includes(`${menu.relation}_viewers`);
const serverValue: "none" | "read" | "write" = isWrite ? "write" : isRead ? "read" : "none";
const permissionValue = localSystemPermissions[selectedUser.userId]?.[menu.relation] ?? serverValue;
const Icon = menu.icon;
return (
@@ -461,12 +516,33 @@ export function TenantFineGrainedPermissionsPage() {
</span>
</div>
</div>
<Switch
checked={hasAccess}
onCheckedChange={(val) =>
handleSystemRelationChange(selectedUser.userId, menu.relation, val)
}
/>
<select
name={`system-menu-permission-${menu.relation}`}
value={permissionValue}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
// 🌟 1단계: 로컬 임시 상태 즉시 갱신 (0ms 반응 보장)
setLocalSystemPermissions(prev => ({
...prev,
[selectedUser.userId]: {
...(prev[selectedUser.userId] ?? {}),
[menu.relation]: nextVal
}
}));
// 🌟 2단계: 백그라운드 비동기 API 요청 수행
handleSystemRelationChange(
selectedUser.userId,
menu.relation,
permissionValue,
nextVal,
);
}}
className="flex h-9 w-[180px] rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="none">{t("ui.common.none", "권한 없음")}</option>
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
</select>
</div>
);
})}
@@ -528,6 +604,7 @@ export function TenantFineGrainedPermissionsPage() {
className="pl-10 h-11"
autoFocus
value={searchTerm}
name="system-user-dialog-search"
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>