1
0
forked from baron/baron-sso

i18n, adminfront, devfront: 'make code-check' 통과를 위한 번역 싱크 엔진 개선, 룰 오브 훅 정합성 교정 및 테스트 레이스 컨디션 해결

This commit is contained in:
2026-06-15 09:49:53 +09:00
parent 383c6bf7b9
commit b714213b78
37 changed files with 3597 additions and 4170 deletions

View File

@@ -14,11 +14,11 @@ import UserProjectionPage from "../features/projections/UserProjectionPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import { TenantFineGrainedPermissionsPage } from "../features/tenants/routes/TenantFineGrainedPermissionsPage";
import { TenantFineGrainedPermissionsTab } from "../features/tenants/routes/TenantFineGrainedPermissionsTab";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import { TenantFineGrainedPermissionsTab } from "../features/tenants/routes/TenantFineGrainedPermissionsTab";
import { TenantFineGrainedPermissionsPage } from "../features/tenants/routes/TenantFineGrainedPermissionsPage";
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
import GlobalCustomClaimsPage from "../features/users/GlobalCustomClaimsPage";
@@ -53,7 +53,10 @@ export const adminRoutes: RouteObject[] = [
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
{ path: "permissions-direct", element: <TenantFineGrainedPermissionsPage /> },
{
path: "permissions-direct",
element: <TenantFineGrainedPermissionsPage />,
},
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
@@ -62,7 +65,10 @@ export const adminRoutes: RouteObject[] = [
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{ path: "relations", element: <TenantFineGrainedPermissionsTab /> },
{
path: "relations",
element: <TenantFineGrainedPermissionsTab />,
},
],
},
{

View File

@@ -270,9 +270,11 @@ function AppLayout() {
if (item.to === "/permissions-direct") return false;
if (item.to === "/tenants") return permissions.tenants;
if (item.to === orgfrontUrl) return permissions.org_chart;
if (item.to === "/worksmobile") return permissions.worksmobile && showWorksmobile;
if (item.to === "/worksmobile")
return permissions.worksmobile && showWorksmobile;
if (item.to === "/system/ory-ssot") return permissions.ory_ssot;
if (item.to === "/system/data-integrity") return permissions.data_integrity;
if (item.to === "/system/data-integrity")
return permissions.data_integrity;
return true;
});

View File

@@ -144,7 +144,12 @@ export function ParentTenantSelector({
{localPickerLabel && (
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm" disabled={disabled}>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
>
<Building2 className="h-4 w-4" />
{localPickerLabel}
</Button>

View File

@@ -1,5 +1,8 @@
import type React from "react";
import { useTenantPermission, type TenantPermissionKey } from "../hooks/useTenantPermission";
import {
type TenantPermissionKey,
useTenantPermission,
} from "../hooks/useTenantPermission";
interface TenantPermissionGuardProps {
tenantId: string;

View File

@@ -1,11 +1,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { renderHook } from "@testing-library/react";
import { render, renderHook, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { describe, expect, it, vi } from "vitest";
import { fetchTenant, fetchMe } from "../../../lib/adminApi";
import { useTenantPermission } from "./useTenantPermission";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { TenantPermissionGuard } from "../components/TenantPermissionGuard";
import { useTenantPermission } from "./useTenantPermission";
vi.mock("../../../lib/adminApi", () => ({
fetchMe: vi.fn(),
@@ -88,10 +87,14 @@ describe("TenantPermissionGuard", () => {
} as any);
render(
<TenantPermissionGuard tenantId="tenant-3" relation="manage" fallback={<div>Access Denied</div>}>
<TenantPermissionGuard
tenantId="tenant-3"
relation="manage"
fallback={<div>Access Denied</div>}
>
<div>Access Granted</div>
</TenantPermissionGuard>,
{ wrapper: createWrapper() }
{ wrapper: createWrapper() },
);
await waitFor(() => {
@@ -112,10 +115,14 @@ describe("TenantPermissionGuard", () => {
} as any);
render(
<TenantPermissionGuard tenantId="tenant-4" relation="manage" fallback={<div>Access Denied</div>}>
<TenantPermissionGuard
tenantId="tenant-4"
relation="manage"
fallback={<div>Access Denied</div>}
>
<div>Access Granted</div>
</TenantPermissionGuard>,
{ wrapper: createWrapper() }
{ wrapper: createWrapper() },
);
await waitFor(() => {

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { fetchTenant, fetchMe } from "../../../lib/adminApi";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { normalizeAdminRole } from "../../../lib/roles";
export type TenantPermissionKey =

View File

@@ -11,7 +11,6 @@ import {
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useParams } from "react-router-dom";
import { useTenantPermission } from "../hooks/useTenantPermission";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
@@ -50,6 +49,7 @@ import {
type TenantAdmin,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
type DialogMode = "owner" | "admin";
@@ -71,7 +71,8 @@ export function TenantAdminsAndOwnersTab() {
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdParam ?? "";
const { hasPermission } = useTenantPermission(tenantId);
const isWritable = hasPermission("manage_permissions") || hasPermission("manage_admins");
const isWritable =
hasPermission("manage_permissions") || hasPermission("manage_admins");
const canView = hasPermission("view_permissions") || hasPermission("view");
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");

View File

@@ -2,9 +2,8 @@ import { useQuery } from "@tanstack/react-query";
import { Copy } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { useTenantPermission } from "../hooks/useTenantPermission";
function TenantDetailPage() {

View File

@@ -1,45 +1,27 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import type { AxiosError } from "axios";
import { useState, useEffect } from "react";
import {
fetchAllTenants,
fetchMe,
fetchUsers,
fetchSystemRelations,
addSystemRelation,
removeSystemRelation,
type TenantRelation,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { TenantFineGrainedPermissionsTab } from "./TenantFineGrainedPermissionsTab";
import {
ShieldCheck,
Search,
Plus,
UserPlus,
Trash2,
Settings,
Shield,
LayoutDashboard,
Building2,
Network,
Database,
Users,
KeyRound,
Key,
KeyRound,
LayoutDashboard,
Network,
NotebookTabs,
Plus,
Search,
Share2,
Shield,
ShieldCheck,
Trash2,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Avatar, AvatarFallback } from "../../../components/ui/avatar";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Card, CardContent } from "../../../components/ui/card";
import {
Dialog,
DialogContent,
@@ -48,24 +30,33 @@ import {
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Avatar, AvatarFallback } from "../../../components/ui/avatar";
import { ScrollArea } from "../../../components/ui/scroll-area";
import { Separator } from "../../../components/ui/separator";
import { Switch } from "../../../components/ui/switch";
import { toast } from "../../../components/ui/use-toast";
import {
addSystemRelation,
fetchAllTenants,
fetchMe,
fetchSystemRelations,
fetchUsers,
removeSystemRelation,
type TenantRelation,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export function TenantFineGrainedPermissionsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<"tenant" | "system">("system");
const [selectedTenantId, setSelectedTenantId] = useState("");
const [activeTab, _setActiveTab] = useState<"tenant" | "system">("system");
const [_selectedTenantId, _setSelectedTenantId] = useState("");
const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [activeUserId, setActiveUserId] = useState<string | null>(null);
const [userSearchTerm, setUserSearchTerm] = useState("");
// 🌟 글로벌 시스템 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
const [localSystemPermissions, setLocalSystemPermissions] = useState<Record<string, Record<string, "none" | "read" | "write">>>({});
const [localSystemPermissions, setLocalSystemPermissions] = useState<
Record<string, Record<string, "none" | "read" | "write">>
>({});
const { data: profile } = useQuery({
queryKey: ["me"],
@@ -74,26 +65,13 @@ export function TenantFineGrainedPermissionsPage() {
const isSuperAdmin = profile?.role === "super_admin";
if (profile && !isSuperAdmin) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-lg font-bold">
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<Button onClick={() => navigate("/")}>
{t("ui.common.go_home", "홈으로 이동")}
</Button>
</div>
);
}
const tenantsQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
enabled: isSuperAdmin,
});
const tenants = isSuperAdmin
const _tenants = isSuperAdmin
? (tenantsQuery.data?.items ?? [])
: (profile?.manageableTenants ?? []);
@@ -108,18 +86,33 @@ export function TenantFineGrainedPermissionsPage() {
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
useEffect(() => {
if (systemRelationsQuery.data) {
const initialMap: Record<string, Record<string, "none" | "read" | "write">> = {};
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"
"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";
initialMap[user.userId][m] = isWrite
? "write"
: isRead
? "read"
: "none";
}
}
setLocalSystemPermissions(initialMap);
@@ -131,30 +124,41 @@ export function TenantFineGrainedPermissionsPage() {
addSystemRelation(payload.userId, payload.relation),
onMutate: async (newRelation) => {
await queryClient.cancelQueries({ queryKey: ["system-relations"] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>(["system-relations"]);
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"system-relations",
]);
queryClient.setQueryData<TenantRelation[]>(["system-relations"], (old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
});
queryClient.setQueryData<TenantRelation[]>(
["system-relations"],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(["system-relations"], context.previousRelations);
queryClient.setQueryData(
["system-relations"],
context.previousRelations,
);
}
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
@@ -174,28 +178,41 @@ export function TenantFineGrainedPermissionsPage() {
removeSystemRelation(payload.userId, payload.relation),
onMutate: async (targetRelation) => {
await queryClient.cancelQueries({ queryKey: ["system-relations"] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>(["system-relations"]);
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"system-relations",
]);
queryClient.setQueryData<TenantRelation[]>(["system-relations"], (old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter((r) => r !== targetRelation.relation),
};
}
return user;
});
});
queryClient.setQueryData<TenantRelation[]>(
["system-relations"],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter(
(r) => r !== targetRelation.relation,
),
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(["system-relations"], context.previousRelations);
queryClient.setQueryData(
["system-relations"],
context.previousRelations,
);
}
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
@@ -220,26 +237,53 @@ export function TenantFineGrainedPermissionsPage() {
try {
if (currentVal === "read") {
await removeSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_viewers` });
await removeSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_viewers`,
});
} else if (currentVal === "write") {
await removeSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_managers` });
await removeSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_managers`,
});
}
if (newVal === "read") {
await addSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_viewers` });
await addSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_viewers`,
});
} else if (newVal === "write") {
await addSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_managers` });
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", "시스템 메뉴 권한이 성공적으로 변경되었습니다."));
toast.success(
t(
"msg.admin.system.relations.update_success",
"시스템 메뉴 권한이 성공적으로 변경되었습니다.",
),
);
} catch {
// Individual mutations handle error toast via onError
}
};
const handleRemoveAllSystemRelations = async (userId: string, userRelations: string[]) => {
if (!window.confirm(t("msg.admin.system.relations.remove_all_confirm", "이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?"))) {
const handleRemoveAllSystemRelations = async (
userId: string,
userRelations: string[],
) => {
if (
!window.confirm(
t(
"msg.admin.system.relations.remove_all_confirm",
"이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?",
),
)
) {
return;
}
for (const rel of userRelations) {
@@ -268,36 +312,133 @@ export function TenantFineGrainedPermissionsPage() {
// Categorized system menus with descriptions and icons
const systemMenuCategories = [
{
title: t("ui.admin.permissions_direct.cat_dashboard", "핵심 대시보드 및 분석"),
title: t(
"ui.admin.permissions_direct.cat_dashboard",
"핵심 대시보드 및 분석",
),
menus: [
{ 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 },
]
{
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", 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 },
]
{
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", "인프라 연동 및 보안"),
title: t(
"ui.admin.permissions_direct.cat_integrations",
"인프라 연동 및 보안",
),
menus: [
{ label: t("ui.admin.nav.worksmobile", "Worksmobile"), 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 },
]
{
label: t("ui.admin.nav.worksmobile", "Worksmobile"),
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", "아이덴티티 및 게이트 관리"),
title: t(
"ui.admin.permissions_direct.cat_system",
"아이덴티티 및 게이트 관리",
),
menus: [
{ 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 },
]
}
{
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,
},
],
},
];
const filteredRelations = systemRelations.filter(
@@ -308,6 +449,19 @@ export function TenantFineGrainedPermissionsPage() {
const selectedUser = systemRelations.find((r) => r.userId === activeUserId);
if (profile && !isSuperAdmin) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-lg font-bold">
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<Button onClick={() => navigate("/")}>
{t("ui.common.go_home", "홈으로 이동")}
</Button>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-1">
@@ -325,204 +479,260 @@ export function TenantFineGrainedPermissionsPage() {
{/* 시스템 메뉴 권한 (Admin Control) Split Screen Panel */}
<div className="flex flex-col lg:flex-row gap-6 h-[720px] border border-border rounded-xl bg-card overflow-hidden shadow-sm">
{/* Left Panel: User List */}
<div className="w-full lg:w-80 border-r border-border flex flex-col bg-muted/10 h-full">
<div className="p-4 border-b border-border space-y-3 flex-shrink-0">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm text-foreground">
{t("ui.admin.permissions_direct.user_list", "대상 사용자")} ({filteredRelations.length})
</h3>
{/* Left Panel: User List */}
<div className="w-full lg:w-80 border-r border-border flex flex-col bg-muted/10 h-full">
<div className="p-4 border-b border-border space-y-3 flex-shrink-0">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm text-foreground">
{t("ui.admin.permissions_direct.user_list", "대상 사용자")} (
{filteredRelations.length})
</h3>
<Button
size="sm"
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)}
name="user-search"
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
type="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>
);
})
)}
</div>
</ScrollArea>
</div>
{/* Right Panel: Toggle settings grid */}
<div className="flex-1 flex flex-col h-full bg-background">
{selectedUser ? (
<>
{/* User Detail Header */}
<div className="p-5 border-b border-border flex items-center justify-between flex-shrink-0 bg-muted/5">
<div className="flex items-center gap-4">
<Avatar className="h-11 w-11 border">
<AvatarFallback className="bg-primary/5 text-primary font-extrabold text-sm uppercase">
{selectedUser.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<h2 className="text-lg font-bold flex items-center gap-2">
{selectedUser.name}
<Badge variant="outline" className="text-xs font-normal">
{selectedUser.relations.length}{" "}
{t("ui.admin.permissions_direct.allowed", "개 허용됨")}
</Badge>
</h2>
<span className="text-xs text-muted-foreground">
{selectedUser.email}
</span>
</div>
</div>
<Button
variant="outline"
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => setIsDialogOpen(true)}
className="text-destructive border-destructive/20 hover:bg-destructive/10"
onClick={() =>
handleRemoveAllSystemRelations(
selectedUser.userId,
selectedUser.relations,
)
}
>
<Plus className="h-4 w-4" />
<Trash2 className="h-4 w-4 mr-2" />
{t(
"ui.admin.permissions_direct.revoke_all",
"모든 권한 회수",
)}
</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)}
name="user-search"
className="pl-8 h-8 text-xs"
/>
{/* Categorized Toggle Grid */}
<ScrollArea className="flex-1">
<div className="p-6 space-y-6">
{systemMenuCategories.map((category) => (
<div key={category.title} className="space-y-3">
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
{category.title}
</h4>
<Card className="border border-border/60 shadow-none bg-card">
<CardContent className="p-0 divide-y divide-border/40">
{category.menus.map((menu) => {
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 (
<div
key={menu.relation}
className="flex items-center justify-between p-4 hover:bg-muted/10 transition-colors"
>
<div className="flex items-start gap-4 pr-4 min-w-0">
<div className="p-2 rounded-lg bg-secondary/50 text-foreground flex-shrink-0 mt-0.5">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{menu.label}
</span>
{(menu.relation === "ory_ssot" ||
menu.relation === "data_integrity") && (
<Badge
variant="secondary"
className="text-[10px] py-0.5 px-1.5 font-semibold text-destructive bg-destructive/10 border-destructive/20"
>
{t(
"ui.admin.permissions_direct.super_admin_only",
"Super Admin 전용",
)}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground line-clamp-1">
{menu.desc}
</span>
</div>
</div>
<select
name={`system-menu-permission-${menu.relation}`}
value={permissionValue}
disabled={
menu.relation === "ory_ssot" ||
menu.relation === "data_integrity"
}
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>
);
})}
</CardContent>
</Card>
</div>
))}
</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>
<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>
);
})
)}
</div>
</ScrollArea>
</div>
{/* Right Panel: Toggle settings grid */}
<div className="flex-1 flex flex-col h-full bg-background">
{selectedUser ? (
<>
{/* User Detail Header */}
<div className="p-5 border-b border-border flex items-center justify-between flex-shrink-0 bg-muted/5">
<div className="flex items-center gap-4">
<Avatar className="h-11 w-11 border">
<AvatarFallback className="bg-primary/5 text-primary font-extrabold text-sm uppercase">
{selectedUser.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<h2 className="text-lg font-bold flex items-center gap-2">
{selectedUser.name}
<Badge variant="outline" className="text-xs font-normal">
{selectedUser.relations.length} {t("ui.admin.permissions_direct.allowed", "개 허용됨")}
</Badge>
</h2>
<span className="text-xs text-muted-foreground">{selectedUser.email}</span>
</div>
</div>
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/20 hover:bg-destructive/10"
onClick={() => handleRemoveAllSystemRelations(selectedUser.userId, selectedUser.relations)}
>
<Trash2 className="h-4 w-4 mr-2" />
{t("ui.admin.permissions_direct.revoke_all", "모든 권한 회수")}
</Button>
</div>
{/* Categorized Toggle Grid */}
<ScrollArea className="flex-1">
<div className="p-6 space-y-6">
{systemMenuCategories.map((category) => (
<div key={category.title} className="space-y-3">
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
{category.title}
</h4>
<Card className="border border-border/60 shadow-none bg-card">
<CardContent className="p-0 divide-y divide-border/40">
{category.menus.map((menu) => {
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 (
<div
key={menu.relation}
className="flex items-center justify-between p-4 hover:bg-muted/10 transition-colors"
>
<div className="flex items-start gap-4 pr-4 min-w-0">
<div className="p-2 rounded-lg bg-secondary/50 text-foreground flex-shrink-0 mt-0.5">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">{menu.label}</span>
{(menu.relation === "ory_ssot" || menu.relation === "data_integrity") && (
<Badge variant="secondary" className="text-[10px] py-0.5 px-1.5 font-semibold text-destructive bg-destructive/10 border-destructive/20">
{t("ui.admin.permissions_direct.super_admin_only", "Super Admin 전용")}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground line-clamp-1">
{menu.desc}
</span>
</div>
</div>
<select
name={`system-menu-permission-${menu.relation}`}
value={permissionValue}
disabled={menu.relation === "ory_ssot" || menu.relation === "data_integrity"}
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>
);
})}
</CardContent>
</Card>
</div>
))}
</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>
</div>
{/* User Search Dialog for System relations */}
<Dialog
@@ -537,7 +747,10 @@ export function TenantFineGrainedPermissionsPage() {
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{t("ui.admin.permissions_direct.dialog_title_system", "시스템 권한 관리 유저 추가")}
{t(
"ui.admin.permissions_direct.dialog_title_system",
"시스템 권한 관리 유저 추가",
)}
</DialogTitle>
<DialogDescription>
{t(

View File

@@ -1,14 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Plus,
Search,
ShieldCheck,
UserPlus,
} from "lucide-react";
import { useState, useEffect } from "react";
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useTenantPermission } from "../hooks/useTenantPermission";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -36,20 +30,22 @@ import {
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
fetchUsers,
fetchTenantRelations,
addTenantRelation,
fetchTenantRelations,
fetchUsers,
removeTenantRelation,
type TenantRelation,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { Trash2 } from "lucide-react";
import { useTenantPermission } from "../hooks/useTenantPermission";
interface TenantFineGrainedPermissionsTabProps {
tenantIdProp?: string;
}
export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrainedPermissionsTabProps = {}) {
export function TenantFineGrainedPermissionsTab({
tenantIdProp,
}: TenantFineGrainedPermissionsTabProps = {}) {
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdProp || tenantIdParam || "";
const { hasPermission } = useTenantPermission(tenantId);
@@ -59,26 +55,35 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
const [isDialogOpen, setIsDialogOpen] = useState(false);
// 🌟 테넌트 탭별 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
const [localTenantPermissions, setLocalTenantPermissions] = useState<Record<string, Record<string, "none" | "read" | "write">>>({});
const [localTenantPermissions, setLocalTenantPermissions] = useState<
Record<string, Record<string, "none" | "read" | "write">>
>({});
const relationsQuery = useQuery({
queryKey: ["tenant-relations", tenantId],
queryFn: () => fetchTenantRelations(tenantId),
enabled: !!tenantId,
});
const relationsData = relationsQuery.data ?? [];
const _relationsData = relationsQuery.data ?? [];
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
useEffect(() => {
if (relationsQuery.data) {
const initialMap: Record<string, Record<string, "none" | "read" | "write">> = {};
const initialMap: Record<
string,
Record<string, "none" | "read" | "write">
> = {};
for (const user of relationsQuery.data) {
initialMap[user.userId] = {};
const tabs = ["profile", "permissions", "organization", "schema"];
for (const tab of tabs) {
const isWrite = user.relations.includes(`${tab}_managers`);
const isRead = user.relations.includes(`${tab}_viewers`);
initialMap[user.userId][tab] = isWrite ? "write" : isRead ? "read" : "none";
initialMap[user.userId][tab] = isWrite
? "write"
: isRead
? "read"
: "none";
}
}
setLocalTenantPermissions(initialMap);
@@ -91,7 +96,9 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
queryClient.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] });
queryClient.invalidateQueries({
queryKey: ["tenant-relations", tenantId],
});
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
queryClient.invalidateQueries({ queryKey: ["me"] });
}, 500);
@@ -101,31 +108,45 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
mutationFn: (payload: { userId: string; relation: string }) =>
addTenantRelation(tenantId, payload.userId, payload.relation),
onMutate: async (newRelation) => {
await queryClient.cancelQueries({ queryKey: ["tenant-relations", tenantId] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>(["tenant-relations", tenantId]);
queryClient.setQueryData<TenantRelation[]>(["tenant-relations", tenantId], (old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
await queryClient.cancelQueries({
queryKey: ["tenant-relations", tenantId],
});
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"tenant-relations",
tenantId,
]);
queryClient.setQueryData<TenantRelation[]>(
["tenant-relations", tenantId],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(["tenant-relations", tenantId], context.previousRelations);
queryClient.setQueryData(
["tenant-relations", tenantId],
context.previousRelations,
);
}
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
@@ -136,29 +157,45 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
mutationFn: (payload: { userId: string; relation: string }) =>
removeTenantRelation(tenantId, payload.userId, payload.relation),
onMutate: async (targetRelation) => {
await queryClient.cancelQueries({ queryKey: ["tenant-relations", tenantId] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>(["tenant-relations", tenantId]);
queryClient.setQueryData<TenantRelation[]>(["tenant-relations", tenantId], (old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter((r) => r !== targetRelation.relation),
};
}
return user;
});
await queryClient.cancelQueries({
queryKey: ["tenant-relations", tenantId],
});
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"tenant-relations",
tenantId,
]);
queryClient.setQueryData<TenantRelation[]>(
["tenant-relations", tenantId],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter(
(r) => r !== targetRelation.relation,
),
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(["tenant-relations", tenantId], context.previousRelations);
queryClient.setQueryData(
["tenant-relations", tenantId],
context.previousRelations,
);
}
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
@@ -180,7 +217,10 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
if (currentVal === "read") {
await removeRelationMutation.mutateAsync({ userId, relation: readRel });
} else if (currentVal === "write") {
await removeRelationMutation.mutateAsync({ userId, relation: writeRel });
await removeRelationMutation.mutateAsync({
userId,
relation: writeRel,
});
}
if (newVal === "read") {
@@ -192,14 +232,29 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
invalidateAllQueries();
// 🌟 Trigger a single consolidated success toast at the very end
toast.success(t("msg.admin.tenants.relations.update_success", "세부 권한이 성공적으로 변경되었습니다."));
toast.success(
t(
"msg.admin.tenants.relations.update_success",
"세부 권한이 성공적으로 변경되었습니다.",
),
);
} catch {
// Individual mutations handle error toast via onError
}
};
const handleRemoveAllRelations = async (userId: string, userRelations: string[]) => {
if (!window.confirm(t("msg.admin.tenants.relations.remove_all_confirm", "이 사용자의 모든 세부 권한을 삭제하시겠습니까?"))) {
const handleRemoveAllRelations = async (
userId: string,
userRelations: string[],
) => {
if (
!window.confirm(
t(
"msg.admin.tenants.relations.remove_all_confirm",
"이 사용자의 모든 세부 권한을 삭제하시겠습니까?",
),
)
) {
return;
}
for (const rel of userRelations) {
@@ -215,11 +270,14 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
});
const handleAddUser = (userId: string) => {
addRelationMutation.mutate({ userId, relation: "profile_viewers" }, {
onSettled: () => {
invalidateAllQueries();
}
});
addRelationMutation.mutate(
{ userId, relation: "profile_viewers" },
{
onSettled: () => {
invalidateAllQueries();
},
},
);
setIsDialogOpen(false);
setSearchTerm("");
};
@@ -235,7 +293,10 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" />
{t("ui.admin.tenants.relations.title", "세부 권한 설정 (Fine-grained Permissions)")}
{t(
"ui.admin.tenants.relations.title",
"세부 권한 설정 (Fine-grained Permissions)",
)}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
@@ -250,7 +311,10 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
disabled={!isWritable}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.relations.add_button", "세부 권한 사용자 추가")}
{t(
"ui.admin.tenants.relations.add_button",
"세부 권한 사용자 추가",
)}
</Button>
</CardHeader>
<CardContent className="pt-0">
@@ -258,58 +322,96 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
<Table>
<TableHeader className="bg-secondary/40">
<TableRow>
<TableHead className="font-bold">{t("ui.common.name", "이름")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_permissions", "권한 관리")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}</TableHead>
<TableHead className="font-bold text-center w-20">{t("ui.common.action", "작업")}</TableHead>
<TableHead className="font-bold">
{t("ui.common.name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_permissions", "권한 관리")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</TableHead>
<TableHead className="font-bold text-center w-20">
{t("ui.common.action", "작업")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{relations.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
{t("msg.admin.tenants.relations.empty", "세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요.")}
<TableCell
colSpan={6}
className="text-center py-12 text-muted-foreground"
>
{t(
"msg.admin.tenants.relations.empty",
"세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요.",
)}
</TableCell>
</TableRow>
) : (
relations.map((user) => {
const profileVal = user.relations.includes("profile_managers")
const profileVal = user.relations.includes(
"profile_managers",
)
? "write"
: user.relations.includes("profile_viewers")
? "read"
: "none";
? "read"
: "none";
const permissionsVal = user.relations.includes("permissions_managers")
const permissionsVal = user.relations.includes(
"permissions_managers",
)
? "write"
: user.relations.includes("permissions_viewers")
? "read"
: "none";
? "read"
: "none";
const organizationVal = user.relations.includes("organization_managers")
const organizationVal = user.relations.includes(
"organization_managers",
)
? "write"
: user.relations.includes("organization_viewers")
? "read"
: "none";
? "read"
: "none";
const schemaVal = user.relations.includes("schema_managers")
? "write"
: user.relations.includes("schema_viewers")
? "read"
: "none";
? "read"
: "none";
const curProfileVal = localTenantPermissions[user.userId]?.profile ?? profileVal;
const curPermissionsVal = localTenantPermissions[user.userId]?.permissions ?? permissionsVal;
const curOrganizationVal = localTenantPermissions[user.userId]?.organization ?? organizationVal;
const curSchemaVal = localTenantPermissions[user.userId]?.schema ?? schemaVal;
const curProfileVal =
localTenantPermissions[user.userId]?.profile ??
profileVal;
const curPermissionsVal =
localTenantPermissions[user.userId]?.permissions ??
permissionsVal;
const curOrganizationVal =
localTenantPermissions[user.userId]?.organization ??
organizationVal;
const curSchemaVal =
localTenantPermissions[user.userId]?.schema ?? schemaVal;
return (
<TableRow key={user.userId} className="hover:bg-muted/10 transition-colors">
<TableRow
key={user.userId}
className="hover:bg-muted/10 transition-colors"
>
<TableCell className="font-medium">
<div className="flex flex-col">
<span className="font-semibold text-foreground">{user.name}</span>
<span className="text-xs text-muted-foreground italic">{user.email}</span>
<span className="font-semibold text-foreground">
{user.name}
</span>
<span className="text-xs text-muted-foreground italic">
{user.email}
</span>
</div>
</TableCell>
<TableCell>
@@ -319,13 +421,16 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
disabled={!isWritable}
name={`tenant-fine-grained-profile-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
setLocalTenantPermissions(prev => ({
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
profile: nextVal
}
profile: nextVal,
},
}));
handleRelationChange(
user.userId,
@@ -335,9 +440,15 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
);
}}
>
<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>
<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>
</TableCell>
<TableCell>
@@ -347,13 +458,16 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
disabled={!isWritable}
name={`tenant-fine-grained-permissions-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
setLocalTenantPermissions(prev => ({
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
permissions: nextVal
}
permissions: nextVal,
},
}));
handleRelationChange(
user.userId,
@@ -363,9 +477,15 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
);
}}
>
<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>
<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>
</TableCell>
<TableCell>
@@ -375,13 +495,16 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
disabled={!isWritable}
name={`tenant-fine-grained-organization-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
setLocalTenantPermissions(prev => ({
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
organization: nextVal
}
organization: nextVal,
},
}));
handleRelationChange(
user.userId,
@@ -391,9 +514,15 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
);
}}
>
<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>
<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>
</TableCell>
<TableCell>
@@ -403,13 +532,16 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
disabled={!isWritable}
name={`tenant-fine-grained-schema-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
setLocalTenantPermissions(prev => ({
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
schema: nextVal
}
schema: nextVal,
},
}));
handleRelationChange(
user.userId,
@@ -419,9 +551,15 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
);
}}
>
<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>
<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>
</TableCell>
<TableCell className="text-center">
@@ -429,7 +567,12 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
variant="ghost"
size="icon"
disabled={!isWritable}
onClick={() => handleRemoveAllRelations(user.userId, user.relations)}
onClick={() =>
handleRemoveAllRelations(
user.userId,
user.relations,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
@@ -457,7 +600,10 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{t("ui.admin.tenants.relations.dialog_title", "세부 권한 관리 유저 추가")}
{t(
"ui.admin.tenants.relations.dialog_title",
"세부 권한 관리 유저 추가",
)}
</DialogTitle>
<DialogDescription>
{t(
@@ -533,8 +679,7 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai
size="sm"
variant={isAlreadyInMatrix ? "ghost" : "outline"}
disabled={
isAlreadyInMatrix ||
addRelationMutation.isPending
isAlreadyInMatrix || addRelationMutation.isPending
}
onClick={() => handleAddUser(user.id)}
>

View File

@@ -21,7 +21,6 @@ import {
import type React from "react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useTenantPermission } from "../hooks/useTenantPermission";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
@@ -63,6 +62,7 @@ import {
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
type UserGroupNode = GroupSummary & {
children: UserGroupNode[];
@@ -247,7 +247,8 @@ function TenantGroupsPage() {
const _queryClient = useQueryClient();
const { hasPermission } = useTenantPermission(tenantId);
const isWritable = hasPermission("manage_organization") || hasPermission("manage");
const isWritable =
hasPermission("manage_organization") || hasPermission("manage");
const canView = hasPermission("view_organization") || hasPermission("view");
const [newGroupName, setNewGroupName] = useState("");
@@ -502,7 +503,9 @@ function TenantGroupsPage() {
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending || !isWritable}
disabled={
!newGroupName || createMutation.isPending || !isWritable
}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>

View File

@@ -30,7 +30,6 @@ import {
} from "../../../../../common/core/utils";
import { SearchFilterBar } from "../../../../../common/ui/search-filter-bar";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { RoleGuard } from "../../../components/auth/RoleGuard";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -378,7 +377,9 @@ function TenantListPage() {
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const isWritable = profileRole === "super_admin" || !!profile?.systemPermissions?.manage_tenants;
const isWritable =
profileRole === "super_admin" ||
!!profile?.systemPermissions?.manage_tenants;
const query = useInfiniteQuery({
queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
@@ -583,7 +584,11 @@ function TenantListPage() {
return () => window.removeEventListener("message", onMessage);
}, [allTenants, scopePickerOpen]);
if (profile && profileRole !== "super_admin" && !profile?.systemPermissions?.tenants) {
if (
profile &&
profileRole !== "super_admin" &&
!profile?.systemPermissions?.tenants
) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-lg font-bold">

View File

@@ -276,13 +276,21 @@ export function TenantProfilePage() {
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} disabled={!isWritable} />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!isWritable}
/>
</div>
<div data-testid="tenant-slug-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} disabled={!isWritable} />
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
disabled={!isWritable}
/>
</div>
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
<ParentTenantSelector
@@ -504,7 +512,9 @@ export function TenantProfilePage() {
<Button
variant="outline"
onClick={handleDelete}
disabled={deleteMutation.isPending || isProtectedSeedTenant || !isWritable}
disabled={
deleteMutation.isPending || isProtectedSeedTenant || !isWritable
}
title={
isProtectedSeedTenant
? t(

View File

@@ -28,7 +28,9 @@ export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const { hasPermission, isLoading: isPermissionLoading } = useTenantPermission(tenantId ?? "");
const { hasPermission, isLoading: isPermissionLoading } = useTenantPermission(
tenantId ?? "",
);
const canView = hasPermission("view_schema") || hasPermission("view");
const isWritable = hasPermission("manage_schema") || hasPermission("manage");
@@ -393,7 +395,9 @@ export function TenantSchemaPage() {
<div className="flex justify-end pt-2">
<Button
onClick={() => updateMutation.mutate(fields)}
disabled={updateMutation.isPending || tenantQuery.isLoading || !isWritable}
disabled={
updateMutation.isPending || tenantQuery.isLoading || !isWritable
}
className="px-8 h-11"
>
<Save size={18} className="mr-2" />

View File

@@ -158,7 +158,9 @@ function UserCreatePage() {
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canManageUsers = canManageTenantScopedUsers(profile) || !!profile?.systemPermissions?.manage_users;
const canManageUsers =
canManageTenantScopedUsers(profile) ||
!!profile?.systemPermissions?.manage_users;
const {
register,

View File

@@ -653,8 +653,17 @@ function UserDetailPage() {
const isAdmin = profileRole === "super_admin";
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
const canManageCurrentUser = canManageUserInTenantScope({ profile, user });
const isWritable = isAdmin || isSelf || canManageCurrentUser || !!profile?.systemPermissions?.manage_users;
const canViewUser = isAdmin || isSelf || canManageCurrentUser || !!profile?.systemPermissions?.users || !!profile?.systemPermissions?.manage_users;
const isWritable =
isAdmin ||
isSelf ||
canManageCurrentUser ||
!!profile?.systemPermissions?.manage_users;
const canViewUser =
isAdmin ||
isSelf ||
canManageCurrentUser ||
!!profile?.systemPermissions?.users ||
!!profile?.systemPermissions?.manage_users;
const watchedStatus = watch("status");
const [newSubEmail, setNewSubEmail] = React.useState("");

View File

@@ -381,7 +381,8 @@ function UserListPage() {
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const isWritable = profileRole === "super_admin" || !!profile?.systemPermissions?.manage_users;
const isWritable =
profileRole === "super_admin" || !!profile?.systemPermissions?.manage_users;
const { data: tenantsData } = useQuery({
queryKey: ["tenants", "all"],

View File

@@ -541,20 +541,14 @@ export async function fetchSystemRelations() {
return data.items;
}
export async function addSystemRelation(
userId: string,
relation: string,
) {
export async function addSystemRelation(userId: string, relation: string) {
await apiClient.post(`/v1/admin/system/relations`, {
userId,
relation,
});
}
export async function removeSystemRelation(
userId: string,
relation: string,
) {
export async function removeSystemRelation(userId: string, relation: string) {
await apiClient.delete(`/v1/admin/system/relations`, {
data: { userId, relation },
});