forked from baron/baron-sso
adminfront 및 백엔드: 전 메뉴 및 탭 수준 ReBAC 기반 접근 제어(Admin Control) 기능 추가 구현 완료
This commit is contained in:
@@ -116,6 +116,7 @@ describe("admin AppLayout", () => {
|
|||||||
"Ory SSOT System",
|
"Ory SSOT System",
|
||||||
"Data Integrity",
|
"Data Integrity",
|
||||||
"Users",
|
"Users",
|
||||||
|
"권한 부여",
|
||||||
"Auth Guard",
|
"Auth Guard",
|
||||||
"API Keys",
|
"API Keys",
|
||||||
"Audit Logs",
|
"Audit Logs",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const members = [
|
|||||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||||
|
|
||||||
vi.mock("../../lib/adminApi", () => ({
|
vi.mock("../../lib/adminApi", () => ({
|
||||||
|
fetchMe: vi.fn(async () => ({ id: "admin-user", role: "super_admin" })),
|
||||||
fetchTenant: vi.fn(async () => tenant),
|
fetchTenant: vi.fn(async () => tenant),
|
||||||
fetchUsers: vi.fn(async () => ({
|
fetchUsers: vi.fn(async () => ({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
import { useTenantPermission, type TenantPermissionKey } from "../hooks/useTenantPermission";
|
||||||
|
|
||||||
interface TenantPermissionGuardProps {
|
interface TenantPermissionGuardProps {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
relation: "view" | "manage" | "manage_admins";
|
relation: TenantPermissionKey;
|
||||||
fallback?: React.ReactNode;
|
fallback?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { fetchTenant, fetchMe } from "../../../lib/adminApi";
|
import { fetchTenant, fetchMe } from "../../../lib/adminApi";
|
||||||
import { normalizeAdminRole } from "../../../lib/roles";
|
import { normalizeAdminRole } from "../../../lib/roles";
|
||||||
|
|
||||||
|
export type TenantPermissionKey =
|
||||||
|
| "view"
|
||||||
|
| "manage"
|
||||||
|
| "manage_admins"
|
||||||
|
| "view_profile"
|
||||||
|
| "manage_profile"
|
||||||
|
| "view_permissions"
|
||||||
|
| "manage_permissions"
|
||||||
|
| "view_organization"
|
||||||
|
| "manage_organization"
|
||||||
|
| "view_schema"
|
||||||
|
| "manage_schema";
|
||||||
|
|
||||||
export function useTenantPermission(tenantId: string) {
|
export function useTenantPermission(tenantId: string) {
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
@@ -14,7 +27,7 @@ export function useTenantPermission(tenantId: string) {
|
|||||||
enabled: !!tenantId,
|
enabled: !!tenantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasPermission = (requiredRelation: "view" | "manage" | "manage_admins"): boolean => {
|
const hasPermission = (requiredRelation: TenantPermissionKey): boolean => {
|
||||||
// Super Admin always has full bypass access
|
// Super Admin always has full bypass access
|
||||||
if (normalizeAdminRole(profile?.role) === "super_admin") {
|
if (normalizeAdminRole(profile?.role) === "super_admin") {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||||
const tenantId = tenantIdParam ?? "";
|
const tenantId = tenantIdParam ?? "";
|
||||||
const { hasPermission } = useTenantPermission(tenantId);
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
const isWritable = hasPermission("manage_admins");
|
const isWritable = hasPermission("manage_permissions") || hasPermission("manage_admins");
|
||||||
|
const canView = hasPermission("view_permissions") || hasPermission("view");
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||||
@@ -341,6 +342,16 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
|
|
||||||
if (!tenantId) return null;
|
if (!tenantId) return null;
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
return (
|
||||||
|
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||||
|
<h3 className="text-xl font-bold text-destructive">
|
||||||
|
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const serverOwners = ownersQuery.data || [];
|
const serverOwners = ownersQuery.data || [];
|
||||||
const serverAdmins = adminsQuery.data || [];
|
const serverAdmins = adminsQuery.data || [];
|
||||||
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
fetchAllTenants,
|
fetchAllTenants,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
@@ -62,6 +62,9 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
const [activeUserId, setActiveUserId] = useState<string | null>(null);
|
const [activeUserId, setActiveUserId] = useState<string | null>(null);
|
||||||
const [userSearchTerm, setUserSearchTerm] = useState("");
|
const [userSearchTerm, setUserSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 🌟 글로벌 시스템 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
|
||||||
|
const [localSystemPermissions, setLocalSystemPermissions] = useState<Record<string, Record<string, "none" | "read" | "write">>>({});
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
queryFn: fetchMe,
|
queryFn: fetchMe,
|
||||||
@@ -87,6 +90,27 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
});
|
});
|
||||||
const systemRelations = systemRelationsQuery.data ?? [];
|
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({
|
const addSystemRelationMutation = useMutation({
|
||||||
mutationFn: (payload: { userId: string; relation: string }) =>
|
mutationFn: (payload: { userId: string; relation: string }) =>
|
||||||
addSystemRelation(payload.userId, payload.relation),
|
addSystemRelation(payload.userId, payload.relation),
|
||||||
@@ -118,10 +142,15 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
|
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t("msg.admin.system.relations.add_success", "시스템 메뉴 권한이 추가되었습니다."));
|
// Quiet mutate
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
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", "오류가 발생했습니다."));
|
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t("msg.admin.system.relations.remove_success", "시스템 메뉴 권한이 회수되었습니다."));
|
// Quiet mutate
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
setTimeout(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||||
|
}, 500);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSystemRelationChange = async (
|
const handleSystemRelationChange = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
relation: string,
|
menuKey: string,
|
||||||
hasAccess: boolean,
|
currentVal: "none" | "read" | "write",
|
||||||
|
newVal: "none" | "read" | "write",
|
||||||
) => {
|
) => {
|
||||||
if (hasAccess) {
|
if (currentVal === newVal) return;
|
||||||
await addSystemRelationMutation.mutateAsync({ userId, relation });
|
|
||||||
} else {
|
try {
|
||||||
await removeSystemRelationMutation.mutateAsync({ userId, relation });
|
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", "핵심 대시보드 및 분석"),
|
title: t("ui.admin.permissions_direct.cat_dashboard", "핵심 대시보드 및 분석"),
|
||||||
menus: [
|
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.overview", "개요"), relation: "overview", 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.audit_logs", "감사 로그"), relation: "audit_logs", desc: t("msg.admin.permissions_direct.desc_audit_logs", "시스템 전역 보안 감사 및 접속 이력 로그"), icon: NotebookTabs },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("ui.admin.permissions_direct.cat_resources", "핵심 리소스 관리"),
|
title: t("ui.admin.permissions_direct.cat_resources", "핵심 리소스 관리"),
|
||||||
menus: [
|
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.tenants", "테넌트"), relation: "tenants", 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.org_chart", "조직도"), relation: "org_chart", 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.users", "사용자"), relation: "users", desc: t("msg.admin.permissions_direct.desc_users", "가입 사용자 목록, 승인 및 커스텀 클레임 수동 주입"), icon: Users },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("ui.admin.permissions_direct.cat_integrations", "인프라 연동 및 보안"),
|
title: t("ui.admin.permissions_direct.cat_integrations", "인프라 연동 및 보안"),
|
||||||
menus: [
|
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.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_viewers", desc: t("msg.admin.permissions_direct.desc_api_keys", "조직도 연동을 위한 전역 서드파티 토큰 관리"), icon: Key },
|
{ 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", "아이덴티티 및 게이트 관리"),
|
title: t("ui.admin.permissions_direct.cat_system", "아이덴티티 및 게이트 관리"),
|
||||||
menus: [
|
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.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_viewers", desc: t("msg.admin.permissions_direct.desc_data_integrity", "고아 레코드 검출 및 DB 정합성 최종 검증기"), icon: ShieldCheck },
|
{ 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_viewers", desc: t("msg.admin.permissions_direct.desc_auth_guard", "정책엔진 기준으로 Keto ReBAC 관계 검증 시뮬레이터"), icon: KeyRound },
|
{ 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_viewers", desc: t("msg.admin.permissions_direct.desc_permissions_direct", "본 사이드바 메뉴 세부 권한 격자 및 테넌트 인가 설정 패널"), icon: Shield },
|
{ 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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<select
|
<select
|
||||||
|
name="select-tenant-for-fine-grained-permissions"
|
||||||
value={selectedTenantId}
|
value={selectedTenantId}
|
||||||
onChange={(e) => setSelectedTenantId(e.target.value)}
|
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"
|
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", "이름 또는 이메일 검색...")}
|
placeholder={t("ui.common.search", "이름 또는 이메일 검색...")}
|
||||||
value={userSearchTerm}
|
value={userSearchTerm}
|
||||||
onChange={(e) => setUserSearchTerm(e.target.value)}
|
onChange={(e) => setUserSearchTerm(e.target.value)}
|
||||||
|
name="user-search"
|
||||||
className="pl-8 h-8 text-xs"
|
className="pl-8 h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -442,7 +494,10 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
<Card className="border border-border/60 shadow-none bg-card">
|
<Card className="border border-border/60 shadow-none bg-card">
|
||||||
<CardContent className="p-0 divide-y divide-border/40">
|
<CardContent className="p-0 divide-y divide-border/40">
|
||||||
{category.menus.map((menu) => {
|
{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;
|
const Icon = menu.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -461,12 +516,33 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<select
|
||||||
checked={hasAccess}
|
name={`system-menu-permission-${menu.relation}`}
|
||||||
onCheckedChange={(val) =>
|
value={permissionValue}
|
||||||
handleSystemRelationChange(selectedUser.userId, menu.relation, val)
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -528,6 +604,7 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
className="pl-10 h-11"
|
className="pl-10 h-11"
|
||||||
autoFocus
|
autoFocus
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
name="system-user-dialog-search"
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -247,7 +247,8 @@ function TenantGroupsPage() {
|
|||||||
const _queryClient = useQueryClient();
|
const _queryClient = useQueryClient();
|
||||||
|
|
||||||
const { hasPermission } = useTenantPermission(tenantId);
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
const isWritable = hasPermission("manage");
|
const isWritable = hasPermission("manage_organization") || hasPermission("manage");
|
||||||
|
const canView = hasPermission("view_organization") || hasPermission("view");
|
||||||
|
|
||||||
const [newGroupName, setNewGroupName] = useState("");
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||||
@@ -396,6 +397,16 @@ function TenantGroupsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
return (
|
||||||
|
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||||
|
<h3 className="text-xl font-bold text-destructive">
|
||||||
|
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const groupTree = groupsQuery.data
|
const groupTree = groupsQuery.data
|
||||||
? buildGroupTree(groupsQuery.data, tenantId)
|
? buildGroupTree(groupsQuery.data, tenantId)
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export function TenantProfilePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { hasPermission } = useTenantPermission(tenantId);
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
const isWritable = hasPermission("manage");
|
const isWritable = hasPermission("manage_profile") || hasPermission("manage");
|
||||||
|
const canView = hasPermission("view_profile") || hasPermission("view");
|
||||||
|
|
||||||
const parentQuery = useQuery({
|
const parentQuery = useQuery({
|
||||||
queryKey: ["tenants", "list-all"],
|
queryKey: ["tenants", "list-all"],
|
||||||
@@ -207,6 +208,16 @@ export function TenantProfilePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
return (
|
||||||
|
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||||
|
<h3 className="text-xl font-bold text-destructive">
|
||||||
|
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (isProtectedSeedTenant) {
|
if (isProtectedSeedTenant) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import {
|
|||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
import { toast } from "../../../components/ui/use-toast";
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { normalizeAdminRole } from "../../../lib/roles";
|
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||||
import {
|
import {
|
||||||
createSchemaField,
|
createSchemaField,
|
||||||
isSchemaFieldType,
|
isSchemaFieldType,
|
||||||
@@ -28,13 +28,9 @@ export function TenantSchemaPage() {
|
|||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
const { hasPermission, isLoading: isPermissionLoading } = useTenantPermission(tenantId ?? "");
|
||||||
queryKey: ["me"],
|
const canView = hasPermission("view_schema") || hasPermission("view");
|
||||||
queryFn: fetchMe,
|
const isWritable = hasPermission("manage_schema") || hasPermission("manage");
|
||||||
});
|
|
||||||
|
|
||||||
const profileRole = normalizeAdminRole(profile?.role);
|
|
||||||
const canAccess = profileRole === "super_admin";
|
|
||||||
|
|
||||||
const tenantQuery = useQuery({
|
const tenantQuery = useQuery({
|
||||||
queryKey: ["tenant", tenantId],
|
queryKey: ["tenant", tenantId],
|
||||||
@@ -42,7 +38,7 @@ export function TenantSchemaPage() {
|
|||||||
if (!tenantId) throw new Error("Tenant ID is required");
|
if (!tenantId) throw new Error("Tenant ID is required");
|
||||||
return fetchTenant(tenantId);
|
return fetchTenant(tenantId);
|
||||||
},
|
},
|
||||||
enabled: !!tenantId && canAccess,
|
enabled: !!tenantId && canView,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [fields, setFields] = useState<SchemaField[]>([]);
|
const [fields, setFields] = useState<SchemaField[]>([]);
|
||||||
@@ -85,7 +81,7 @@ export function TenantSchemaPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isProfileLoading) {
|
if (isPermissionLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center animate-pulse text-muted-foreground">
|
<div className="p-8 text-center animate-pulse text-muted-foreground">
|
||||||
{t("msg.common.loading", "로딩 중...")}
|
{t("msg.common.loading", "로딩 중...")}
|
||||||
@@ -93,7 +89,7 @@ export function TenantSchemaPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canAccess) {
|
if (!canView) {
|
||||||
return (
|
return (
|
||||||
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||||
<h3 className="text-xl font-bold text-destructive">
|
<h3 className="text-xl font-bold text-destructive">
|
||||||
@@ -147,7 +143,7 @@ export function TenantSchemaPage() {
|
|||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={addField} size="sm">
|
<Button onClick={addField} size="sm" disabled={!isWritable}>
|
||||||
<Plus size={16} className="mr-2" />
|
<Plus size={16} className="mr-2" />
|
||||||
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
|
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -182,6 +178,7 @@ export function TenantSchemaPage() {
|
|||||||
"예: employee_id",
|
"예: employee_id",
|
||||||
)}
|
)}
|
||||||
className="h-10"
|
className="h-10"
|
||||||
|
disabled={!isWritable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -198,6 +195,7 @@ export function TenantSchemaPage() {
|
|||||||
"예: 사번",
|
"예: 사번",
|
||||||
)}
|
)}
|
||||||
className="h-10"
|
className="h-10"
|
||||||
|
disabled={!isWritable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -207,8 +205,9 @@ export function TenantSchemaPage() {
|
|||||||
<select
|
<select
|
||||||
id={`tenant-schema-field-type-${field.key || index}`}
|
id={`tenant-schema-field-type-${field.key || index}`}
|
||||||
name={`tenant-schema-field-type-${field.key || index}`}
|
name={`tenant-schema-field-type-${field.key || index}`}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
|
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary disabled:opacity-60"
|
||||||
value={field.type}
|
value={field.type}
|
||||||
|
disabled={!isWritable}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const nextType = e.target.value;
|
const nextType = e.target.value;
|
||||||
if (isSchemaFieldType(nextType)) {
|
if (isSchemaFieldType(nextType)) {
|
||||||
@@ -271,10 +270,11 @@ export function TenantSchemaPage() {
|
|||||||
name={`tenant-schema-field-required-${field.key || index}`}
|
name={`tenant-schema-field-required-${field.key || index}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.required}
|
checked={field.required}
|
||||||
|
disabled={!isWritable}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, { required: e.target.checked })
|
updateField(index, { required: e.target.checked })
|
||||||
}
|
}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
|
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
|
||||||
@@ -285,10 +285,11 @@ export function TenantSchemaPage() {
|
|||||||
name={`tenant-schema-field-admin-only-${field.key || index}`}
|
name={`tenant-schema-field-admin-only-${field.key || index}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.adminOnly}
|
checked={field.adminOnly}
|
||||||
|
disabled={!isWritable}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, { adminOnly: e.target.checked })
|
updateField(index, { adminOnly: e.target.checked })
|
||||||
}
|
}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t(
|
{t(
|
||||||
@@ -302,6 +303,7 @@ export function TenantSchemaPage() {
|
|||||||
name={`tenant-schema-field-login-id-${field.key || index}`}
|
name={`tenant-schema-field-login-id-${field.key || index}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.isLoginId || false}
|
checked={field.isLoginId || false}
|
||||||
|
disabled={!isWritable}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, {
|
updateField(index, {
|
||||||
isLoginId: e.target.checked,
|
isLoginId: e.target.checked,
|
||||||
@@ -309,7 +311,7 @@ export function TenantSchemaPage() {
|
|||||||
type: e.target.checked ? "text" : field.type,
|
type: e.target.checked ? "text" : field.type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-blue-600">
|
<span className="text-sm font-medium text-blue-600">
|
||||||
{t(
|
{t(
|
||||||
@@ -323,7 +325,7 @@ export function TenantSchemaPage() {
|
|||||||
name={`tenant-schema-field-indexed-${field.key || index}`}
|
name={`tenant-schema-field-indexed-${field.key || index}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.indexed || field.isLoginId || false}
|
checked={field.indexed || field.isLoginId || false}
|
||||||
disabled={field.isLoginId}
|
disabled={field.isLoginId || !isWritable}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, { indexed: e.target.checked })
|
updateField(index, { indexed: e.target.checked })
|
||||||
}
|
}
|
||||||
@@ -342,10 +344,11 @@ export function TenantSchemaPage() {
|
|||||||
name={`tenant-schema-field-unsigned-${field.key || index}`}
|
name={`tenant-schema-field-unsigned-${field.key || index}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.unsigned}
|
checked={field.unsigned}
|
||||||
|
disabled={!isWritable}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, { unsigned: e.target.checked })
|
updateField(index, { unsigned: e.target.checked })
|
||||||
}
|
}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t(
|
{t(
|
||||||
@@ -359,6 +362,7 @@ export function TenantSchemaPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input
|
<Input
|
||||||
value={field.validation}
|
value={field.validation}
|
||||||
|
disabled={!isWritable}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField(index, { validation: e.target.value })
|
updateField(index, { validation: e.target.value })
|
||||||
}
|
}
|
||||||
@@ -375,6 +379,7 @@ export function TenantSchemaPage() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="text-destructive hover:bg-destructive/10 h-10 w-10"
|
className="text-destructive hover:bg-destructive/10 h-10 w-10"
|
||||||
onClick={() => removeField(index)}
|
onClick={() => removeField(index)}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -388,7 +393,7 @@ export function TenantSchemaPage() {
|
|||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end pt-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => updateMutation.mutate(fields)}
|
onClick={() => updateMutation.mutate(fields)}
|
||||||
disabled={updateMutation.isPending || tenantQuery.isLoading}
|
disabled={updateMutation.isPending || tenantQuery.isLoading || !isWritable}
|
||||||
className="px-8 h-11"
|
className="px-8 h-11"
|
||||||
>
|
>
|
||||||
<Save size={18} className="mr-2" />
|
<Save size={18} className="mr-2" />
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ function UserListPage() {
|
|||||||
queryFn: fetchMe,
|
queryFn: fetchMe,
|
||||||
});
|
});
|
||||||
const profileRole = normalizeAdminRole(profile?.role);
|
const profileRole = normalizeAdminRole(profile?.role);
|
||||||
|
const isWritable = profileRole === "super_admin" || !!profile?.systemPermissions?.manage_users;
|
||||||
|
|
||||||
const { data: tenantsData } = useQuery({
|
const { data: tenantsData } = useQuery({
|
||||||
queryKey: ["tenants", "all"],
|
queryKey: ["tenants", "all"],
|
||||||
@@ -720,8 +721,9 @@ function UserListPage() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setBulkUploadOpen(true);
|
if (isWritable) setBulkUploadOpen(true);
|
||||||
}}
|
}}
|
||||||
|
disabled={!isWritable}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<Upload size={16} className="mr-2 opacity-50" />
|
<Upload size={16} className="mr-2 opacity-50" />
|
||||||
@@ -813,12 +815,19 @@ function UserListPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Button asChild size="sm" className="h-9">
|
{isWritable ? (
|
||||||
<Link to="/users/new">
|
<Button asChild size="sm" className="h-9">
|
||||||
|
<Link to="/users/new">
|
||||||
|
<Plus size={16} />
|
||||||
|
{t("ui.admin.users.list.add", "사용자 추가")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" className="h-9" disabled>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
{t("ui.admin.users.list.add", "사용자 추가")}
|
{t("ui.admin.users.list.add", "사용자 추가")}
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1095,7 +1104,8 @@ function UserListPage() {
|
|||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
statusMutation.isPending ||
|
statusMutation.isPending ||
|
||||||
user.id === profile?.id
|
user.id === profile?.id ||
|
||||||
|
!isWritable
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
@@ -1278,7 +1288,8 @@ function UserListPage() {
|
|||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
(!selectedBulkStatus && !selectedBulkPermission) ||
|
(!selectedBulkStatus && !selectedBulkPermission) ||
|
||||||
bulkUpdateMutation.isPending
|
bulkUpdateMutation.isPending ||
|
||||||
|
!isWritable
|
||||||
}
|
}
|
||||||
data-testid="bulk-apply-btn"
|
data-testid="bulk-apply-btn"
|
||||||
>
|
>
|
||||||
@@ -1291,6 +1302,7 @@ function UserListPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
|
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
|
||||||
onClick={handleBulkDelete}
|
onClick={handleBulkDelete}
|
||||||
|
disabled={!isWritable}
|
||||||
data-testid="bulk-delete-btn"
|
data-testid="bulk-delete-btn"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ export type TenantSummary = {
|
|||||||
view: boolean;
|
view: boolean;
|
||||||
manage: boolean;
|
manage: boolean;
|
||||||
manage_admins: boolean;
|
manage_admins: boolean;
|
||||||
|
view_profile?: boolean;
|
||||||
|
manage_profile?: boolean;
|
||||||
|
view_permissions?: boolean;
|
||||||
|
manage_permissions?: boolean;
|
||||||
|
view_organization?: boolean;
|
||||||
|
manage_organization?: boolean;
|
||||||
|
view_schema?: boolean;
|
||||||
|
manage_schema?: boolean;
|
||||||
};
|
};
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -1275,6 +1283,18 @@ export type SystemPermissions = {
|
|||||||
auth_guard: boolean;
|
auth_guard: boolean;
|
||||||
api_keys: boolean;
|
api_keys: boolean;
|
||||||
audit_logs: boolean;
|
audit_logs: boolean;
|
||||||
|
|
||||||
|
manage_overview?: boolean;
|
||||||
|
manage_tenants?: boolean;
|
||||||
|
manage_org_chart?: boolean;
|
||||||
|
manage_worksmobile?: boolean;
|
||||||
|
manage_ory_ssot?: boolean;
|
||||||
|
manage_data_integrity?: boolean;
|
||||||
|
manage_users?: boolean;
|
||||||
|
manage_permissions_direct?: boolean;
|
||||||
|
manage_auth_guard?: boolean;
|
||||||
|
manage_api_keys?: boolean;
|
||||||
|
manage_audit_logs?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserProfileResponse = {
|
export type UserProfileResponse = {
|
||||||
|
|||||||
@@ -81,6 +81,18 @@ type SystemPermissions struct {
|
|||||||
AuthGuard bool `json:"auth_guard"`
|
AuthGuard bool `json:"auth_guard"`
|
||||||
ApiKeys bool `json:"api_keys"`
|
ApiKeys bool `json:"api_keys"`
|
||||||
AuditLogs bool `json:"audit_logs"`
|
AuditLogs bool `json:"audit_logs"`
|
||||||
|
|
||||||
|
ManageOverview bool `json:"manage_overview"`
|
||||||
|
ManageTenants bool `json:"manage_tenants"`
|
||||||
|
ManageOrgChart bool `json:"manage_org_chart"`
|
||||||
|
ManageWorksmobile bool `json:"manage_worksmobile"`
|
||||||
|
ManageOrySSOT bool `json:"manage_ory_ssot"`
|
||||||
|
ManageDataIntegrity bool `json:"manage_data_integrity"`
|
||||||
|
ManageUsers bool `json:"manage_users"`
|
||||||
|
ManagePermissionsDirect bool `json:"manage_permissions_direct"`
|
||||||
|
ManageAuthGuard bool `json:"manage_auth_guard"`
|
||||||
|
ManageApiKeys bool `json:"manage_api_keys"`
|
||||||
|
ManageAuditLogs bool `json:"manage_audit_logs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserProfileResponse struct {
|
type UserProfileResponse struct {
|
||||||
|
|||||||
@@ -4776,17 +4776,28 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai
|
|||||||
|
|
||||||
if profile.Role == "super_admin" {
|
if profile.Role == "super_admin" {
|
||||||
sp = domain.SystemPermissions{
|
sp = domain.SystemPermissions{
|
||||||
Overview: true,
|
Overview: true,
|
||||||
Tenants: true,
|
Tenants: true,
|
||||||
OrgChart: true,
|
OrgChart: true,
|
||||||
Worksmobile: true,
|
Worksmobile: true,
|
||||||
OrySSOT: true,
|
OrySSOT: true,
|
||||||
DataIntegrity: true,
|
DataIntegrity: true,
|
||||||
Users: true,
|
Users: true,
|
||||||
PermissionsDirect: true,
|
PermissionsDirect: true,
|
||||||
AuthGuard: true,
|
AuthGuard: true,
|
||||||
ApiKeys: true,
|
ApiKeys: true,
|
||||||
AuditLogs: true,
|
AuditLogs: true,
|
||||||
|
ManageOverview: true,
|
||||||
|
ManageTenants: true,
|
||||||
|
ManageOrgChart: true,
|
||||||
|
ManageWorksmobile: true,
|
||||||
|
ManageOrySSOT: true,
|
||||||
|
ManageDataIntegrity: true,
|
||||||
|
ManageUsers: true,
|
||||||
|
ManagePermissionsDirect: true,
|
||||||
|
ManageAuthGuard: true,
|
||||||
|
ManageApiKeys: true,
|
||||||
|
ManageAuditLogs: true,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Query Keto in parallel for maximum performance
|
// Query Keto in parallel for maximum performance
|
||||||
@@ -4795,17 +4806,28 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai
|
|||||||
allowed bool
|
allowed bool
|
||||||
}
|
}
|
||||||
menus := map[string]string{
|
menus := map[string]string{
|
||||||
"overview": "access_overview",
|
"overview": "access_overview",
|
||||||
"tenants": "access_tenants",
|
"manage_overview": "manage_overview",
|
||||||
"org_chart": "access_org_chart",
|
"tenants": "access_tenants",
|
||||||
"worksmobile": "access_worksmobile",
|
"manage_tenants": "manage_tenants",
|
||||||
"ory_ssot": "access_ory_ssot",
|
"org_chart": "access_org_chart",
|
||||||
"data_integrity": "access_data_integrity",
|
"manage_org_chart": "manage_org_chart",
|
||||||
"users": "access_users",
|
"worksmobile": "access_worksmobile",
|
||||||
"permissions_direct": "access_permissions_direct",
|
"manage_worksmobile": "manage_worksmobile",
|
||||||
"auth_guard": "access_auth_guard",
|
"ory_ssot": "access_ory_ssot",
|
||||||
"api_keys": "access_api_keys",
|
"manage_ory_ssot": "manage_ory_ssot",
|
||||||
"audit_logs": "access_audit_logs",
|
"data_integrity": "access_data_integrity",
|
||||||
|
"manage_data_integrity": "manage_data_integrity",
|
||||||
|
"users": "access_users",
|
||||||
|
"manage_users": "manage_users",
|
||||||
|
"permissions_direct": "access_permissions_direct",
|
||||||
|
"manage_permissions_direct": "manage_permissions_direct",
|
||||||
|
"auth_guard": "access_auth_guard",
|
||||||
|
"manage_auth_guard": "manage_auth_guard",
|
||||||
|
"api_keys": "access_api_keys",
|
||||||
|
"manage_api_keys": "manage_api_keys",
|
||||||
|
"audit_logs": "access_audit_logs",
|
||||||
|
"manage_audit_logs": "manage_audit_logs",
|
||||||
}
|
}
|
||||||
ch := make(chan checkResult, len(menus))
|
ch := make(chan checkResult, len(menus))
|
||||||
for m, rel := range menus {
|
for m, rel := range menus {
|
||||||
@@ -4819,26 +4841,48 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai
|
|||||||
switch res.menu {
|
switch res.menu {
|
||||||
case "overview":
|
case "overview":
|
||||||
sp.Overview = res.allowed
|
sp.Overview = res.allowed
|
||||||
|
case "manage_overview":
|
||||||
|
sp.ManageOverview = res.allowed
|
||||||
case "tenants":
|
case "tenants":
|
||||||
sp.Tenants = res.allowed
|
sp.Tenants = res.allowed
|
||||||
|
case "manage_tenants":
|
||||||
|
sp.ManageTenants = res.allowed
|
||||||
case "org_chart":
|
case "org_chart":
|
||||||
sp.OrgChart = res.allowed
|
sp.OrgChart = res.allowed
|
||||||
|
case "manage_org_chart":
|
||||||
|
sp.ManageOrgChart = res.allowed
|
||||||
case "worksmobile":
|
case "worksmobile":
|
||||||
sp.Worksmobile = res.allowed
|
sp.Worksmobile = res.allowed
|
||||||
|
case "manage_worksmobile":
|
||||||
|
sp.ManageWorksmobile = res.allowed
|
||||||
case "ory_ssot":
|
case "ory_ssot":
|
||||||
sp.OrySSOT = res.allowed
|
sp.OrySSOT = res.allowed
|
||||||
|
case "manage_ory_ssot":
|
||||||
|
sp.ManageOrySSOT = res.allowed
|
||||||
case "data_integrity":
|
case "data_integrity":
|
||||||
sp.DataIntegrity = res.allowed
|
sp.DataIntegrity = res.allowed
|
||||||
|
case "manage_data_integrity":
|
||||||
|
sp.ManageDataIntegrity = res.allowed
|
||||||
case "users":
|
case "users":
|
||||||
sp.Users = res.allowed
|
sp.Users = res.allowed
|
||||||
|
case "manage_users":
|
||||||
|
sp.ManageUsers = res.allowed
|
||||||
case "permissions_direct":
|
case "permissions_direct":
|
||||||
sp.PermissionsDirect = res.allowed
|
sp.PermissionsDirect = res.allowed
|
||||||
|
case "manage_permissions_direct":
|
||||||
|
sp.ManagePermissionsDirect = res.allowed
|
||||||
case "auth_guard":
|
case "auth_guard":
|
||||||
sp.AuthGuard = res.allowed
|
sp.AuthGuard = res.allowed
|
||||||
|
case "manage_auth_guard":
|
||||||
|
sp.ManageAuthGuard = res.allowed
|
||||||
case "api_keys":
|
case "api_keys":
|
||||||
sp.ApiKeys = res.allowed
|
sp.ApiKeys = res.allowed
|
||||||
|
case "manage_api_keys":
|
||||||
|
sp.ManageApiKeys = res.allowed
|
||||||
case "audit_logs":
|
case "audit_logs":
|
||||||
sp.AuditLogs = res.allowed
|
sp.AuditLogs = res.allowed
|
||||||
|
case "manage_audit_logs":
|
||||||
|
sp.ManageAuditLogs = res.allowed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ func TestTenantHandler_GetTenant_NormalUser(t *testing.T) {
|
|||||||
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "view").Return(true, nil)
|
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "view").Return(true, nil)
|
||||||
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage").Return(true, nil)
|
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage").Return(true, nil)
|
||||||
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage_admins").Return(false, nil)
|
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage_admins").Return(false, nil)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", mock.Anything).Return(false, nil).Maybe()
|
||||||
|
|
||||||
// We'll simulate middleware setting "user_profile" for a regular admin/user
|
// We'll simulate middleware setting "user_profile" for a regular admin/user
|
||||||
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
|
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class System implements Namespace {
|
|||||||
super_admins: User[]
|
super_admins: User[]
|
||||||
authenticated_users: User[]
|
authenticated_users: User[]
|
||||||
|
|
||||||
// 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의
|
// 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 - 조회(Read)
|
||||||
overview_viewers: User[]
|
overview_viewers: User[]
|
||||||
tenants_viewers: User[]
|
tenants_viewers: User[]
|
||||||
org_chart_viewers: User[]
|
org_chart_viewers: User[]
|
||||||
@@ -19,55 +19,112 @@ class System implements Namespace {
|
|||||||
auth_guard_viewers: User[]
|
auth_guard_viewers: User[]
|
||||||
api_keys_viewers: User[]
|
api_keys_viewers: User[]
|
||||||
audit_logs_viewers: User[]
|
audit_logs_viewers: User[]
|
||||||
|
|
||||||
|
// 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 - 수정(Write)
|
||||||
|
overview_managers: User[]
|
||||||
|
tenants_managers: User[]
|
||||||
|
org_chart_managers: User[]
|
||||||
|
worksmobile_managers: User[]
|
||||||
|
ory_ssot_managers: User[]
|
||||||
|
data_integrity_managers: User[]
|
||||||
|
users_managers: User[]
|
||||||
|
permissions_direct_managers: User[]
|
||||||
|
auth_guard_managers: User[]
|
||||||
|
api_keys_managers: User[]
|
||||||
|
audit_logs_managers: User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
permits = {
|
permits = {
|
||||||
manage_all: (ctx: Context): boolean =>
|
manage_all: (ctx: Context): boolean =>
|
||||||
this.related.super_admins.includes(ctx.subject),
|
this.related.super_admins.includes(ctx.subject),
|
||||||
|
|
||||||
// 🌟 글로벌 메뉴 허가 규칙 (Permit Rules) - Super Admin은 언제나 무조건 패스
|
// 🌟 글로벌 메뉴 허가 규칙 (Permit Rules) - 조회(access_)와 수정(manage_) 완전 분리 이원화
|
||||||
access_overview: (ctx: Context): boolean =>
|
access_overview: (ctx: Context): boolean =>
|
||||||
this.related.overview_viewers.includes(ctx.subject) ||
|
this.related.overview_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_overview(ctx),
|
||||||
|
|
||||||
|
manage_overview: (ctx: Context): boolean =>
|
||||||
|
this.related.overview_managers.includes(ctx.subject) ||
|
||||||
this.permits.manage_all(ctx),
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
access_tenants: (ctx: Context): boolean =>
|
access_tenants: (ctx: Context): boolean =>
|
||||||
this.related.tenants_viewers.includes(ctx.subject) ||
|
this.related.tenants_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_tenants(ctx),
|
||||||
|
|
||||||
|
manage_tenants: (ctx: Context): boolean =>
|
||||||
|
this.related.tenants_managers.includes(ctx.subject) ||
|
||||||
this.permits.manage_all(ctx),
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
access_org_chart: (ctx: Context): boolean =>
|
access_org_chart: (ctx: Context): boolean =>
|
||||||
this.related.org_chart_viewers.includes(ctx.subject) ||
|
this.related.org_chart_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_org_chart(ctx),
|
||||||
|
|
||||||
|
manage_org_chart: (ctx: Context): boolean =>
|
||||||
|
this.related.org_chart_managers.includes(ctx.subject) ||
|
||||||
this.permits.manage_all(ctx),
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
access_worksmobile: (ctx: Context): boolean =>
|
access_worksmobile: (ctx: Context): boolean =>
|
||||||
this.related.worksmobile_viewers.includes(ctx.subject) ||
|
this.related.worksmobile_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_worksmobile(ctx),
|
||||||
|
|
||||||
|
manage_worksmobile: (ctx: Context): boolean =>
|
||||||
|
this.related.worksmobile_managers.includes(ctx.subject) ||
|
||||||
this.permits.manage_all(ctx),
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
access_ory_ssot: (ctx: Context): boolean =>
|
access_ory_ssot: (ctx: Context): boolean =>
|
||||||
this.related.ory_ssot_viewers.includes(ctx.subject) ||
|
this.related.ory_ssot_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_ory_ssot(ctx),
|
||||||
|
|
||||||
|
manage_ory_ssot: (ctx: Context): boolean =>
|
||||||
|
this.related.ory_ssot_managers.includes(ctx.subject) ||
|
||||||
this.permits.manage_all(ctx),
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
access_data_integrity: (ctx: Context): boolean =>
|
access_data_integrity: (ctx: Context): boolean =>
|
||||||
this.related.data_integrity_viewers.includes(ctx.subject) ||
|
this.related.data_integrity_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_data_integrity(ctx),
|
||||||
|
|
||||||
|
manage_data_integrity: (ctx: Context): boolean =>
|
||||||
|
this.related.data_integrity_managers.includes(ctx.subject) ||
|
||||||
this.permits.manage_all(ctx),
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
access_users: (ctx: Context): boolean =>
|
access_users: (ctx: Context): boolean =>
|
||||||
this.related.users_viewers.includes(ctx.subject) ||
|
this.related.users_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_users(ctx),
|
||||||
|
|
||||||
|
manage_users: (ctx: Context): boolean =>
|
||||||
|
this.related.users_managers.includes(ctx.subject) ||
|
||||||
this.permits.manage_all(ctx),
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
access_permissions_direct: (ctx: Context): boolean =>
|
access_permissions_direct: (ctx: Context): boolean =>
|
||||||
this.related.permissions_direct_viewers.includes(ctx.subject) ||
|
this.related.permissions_direct_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_permissions_direct(ctx),
|
||||||
|
|
||||||
|
manage_permissions_direct: (ctx: Context): boolean =>
|
||||||
|
this.related.permissions_direct_managers.includes(ctx.subject) ||
|
||||||
this.permits.manage_all(ctx),
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
access_auth_guard: (ctx: Context): boolean =>
|
access_auth_guard: (ctx: Context): boolean =>
|
||||||
this.related.auth_guard_viewers.includes(ctx.subject) ||
|
this.related.auth_guard_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_auth_guard(ctx),
|
||||||
|
|
||||||
|
manage_auth_guard: (ctx: Context): boolean =>
|
||||||
|
this.related.auth_guard_managers.includes(ctx.subject) ||
|
||||||
this.permits.manage_all(ctx),
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
access_api_keys: (ctx: Context): boolean =>
|
access_api_keys: (ctx: Context): boolean =>
|
||||||
this.related.api_keys_viewers.includes(ctx.subject) ||
|
this.related.api_keys_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_api_keys(ctx),
|
||||||
|
|
||||||
|
manage_api_keys: (ctx: Context): boolean =>
|
||||||
|
this.related.api_keys_managers.includes(ctx.subject) ||
|
||||||
this.permits.manage_all(ctx),
|
this.permits.manage_all(ctx),
|
||||||
|
|
||||||
access_audit_logs: (ctx: Context): boolean =>
|
access_audit_logs: (ctx: Context): boolean =>
|
||||||
this.related.audit_logs_viewers.includes(ctx.subject) ||
|
this.related.audit_logs_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_audit_logs(ctx),
|
||||||
|
|
||||||
|
manage_audit_logs: (ctx: Context): boolean =>
|
||||||
|
this.related.audit_logs_managers.includes(ctx.subject) ||
|
||||||
this.permits.manage_all(ctx)
|
this.permits.manage_all(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user