From b714213b78fabd88df74531072955e360d31e8dd Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 15 Jun 2026 09:49:53 +0900 Subject: [PATCH] =?UTF-8?q?i18n,=20adminfront,=20devfront:=20'make=20code-?= =?UTF-8?q?check'=20=ED=86=B5=EA=B3=BC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EC=8B=B1=ED=81=AC=20=EC=97=94=EC=A7=84=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0,=20=EB=A3=B0=20=EC=98=A4=EB=B8=8C=20?= =?UTF-8?q?=ED=9B=85=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EA=B5=90=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=BB=A8=EB=94=94=EC=85=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.tsx | 14 +- .../src/components/layout/AppLayout.tsx | 6 +- .../components/ParentTenantSelector.tsx | 7 +- .../components/TenantPermissionGuard.tsx | 5 +- .../hooks/useTenantPermission.test.tsx | 23 +- .../tenants/hooks/useTenantPermission.ts | 2 +- .../routes/TenantAdminsAndOwnersTab.tsx | 5 +- .../tenants/routes/TenantDetailPage.tsx | 3 +- .../TenantFineGrainedPermissionsPage.tsx | 823 +++++--- .../TenantFineGrainedPermissionsTab.tsx | 385 ++-- .../tenants/routes/TenantGroupsPage.tsx | 9 +- .../tenants/routes/TenantListPage.tsx | 11 +- .../tenants/routes/TenantProfilePage.tsx | 16 +- .../tenants/routes/TenantSchemaPage.tsx | 8 +- .../src/features/users/UserCreatePage.tsx | 4 +- .../src/features/users/UserDetailPage.tsx | 13 +- .../src/features/users/UserListPage.tsx | 3 +- adminfront/src/lib/adminApi.ts | 10 +- adminfront/tests/auth.spec.ts | 22 +- adminfront/tests/security_roles.spec.ts | 20 +- backend/internal/domain/auth_models.go | 22 +- backend/internal/handler/tenant_handler.go | 34 +- common/locales/en.toml | 129 +- common/locales/ko.toml | 129 +- common/locales/template.toml | 129 +- .../tenant-access-allowed-tenant-added.png | Bin 824427 -> 827989 bytes .../tenant-access-allowed-tenant-deleted.png | Bin 834144 -> 840388 bytes .../features/clients/ClientGeneralPage.tsx | 1 + .../devfront-client-claims-cache.spec.ts | 4 + locales/en.toml | 1381 ++++++------ locales/ko.toml | 1866 +++++++---------- locales/template.toml | 1700 ++++++--------- tools/i18n-scanner/index.js | 20 +- tools/i18n-scanner/translate-locales.js | 199 +- userfront/assets/translations/en.toml | 112 +- userfront/assets/translations/ko.toml | 347 +-- userfront/assets/translations/template.toml | 305 +-- 37 files changed, 3597 insertions(+), 4170 deletions(-) diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 5603a013..7b80353d 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -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: }, { path: "tenants/new", element: }, { path: "worksmobile", element: }, - { path: "permissions-direct", element: }, + { + path: "permissions-direct", + element: , + }, { path: "tenants/:tenantId", element: , @@ -62,7 +65,10 @@ export const adminRoutes: RouteObject[] = [ { path: "permissions", element: }, { path: "organization", element: }, { path: "schema", element: }, - { path: "relations", element: }, + { + path: "relations", + element: , + }, ], }, { diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 92c8d00e..77494dc9 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -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; }); diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx index 17fbcd33..d48095e1 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx @@ -144,7 +144,12 @@ export function ParentTenantSelector({ {localPickerLabel && ( - diff --git a/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx b/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx index 33c32697..20cd7132 100644 --- a/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx +++ b/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx @@ -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; diff --git a/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx b/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx index d5233b6a..9f9d20f3 100644 --- a/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx +++ b/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx @@ -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( - Access Denied}> + Access Denied} + >
Access Granted
, - { wrapper: createWrapper() } + { wrapper: createWrapper() }, ); await waitFor(() => { @@ -112,10 +115,14 @@ describe("TenantPermissionGuard", () => { } as any); render( - Access Denied}> + Access Denied} + >
Access Granted
, - { wrapper: createWrapper() } + { wrapper: createWrapper() }, ); await waitFor(() => { diff --git a/adminfront/src/features/tenants/hooks/useTenantPermission.ts b/adminfront/src/features/tenants/hooks/useTenantPermission.ts index 95d2673d..b7c3c5e8 100644 --- a/adminfront/src/features/tenants/hooks/useTenantPermission.ts +++ b/adminfront/src/features/tenants/hooks/useTenantPermission.ts @@ -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 = diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index c7b9817b..c58857a2 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -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(""); diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index c59a2bea..e7f97f5f 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -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() { diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx index 0e5d3747..96a85a5f 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx @@ -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(null); const [userSearchTerm, setUserSearchTerm] = useState(""); // ๐ŸŒŸ ๊ธ€๋กœ๋ฒŒ ์‹œ์Šคํ…œ ๋“œ๋กญ๋‹ค์šด ์ฆ‰๊ฐ ๋ณ€๊ฒฝ์„ ์œ„ํ•œ ์ž„์‹œ ๋กœ์ปฌ ๋งต ์„ ์–ธ - const [localSystemPermissions, setLocalSystemPermissions] = useState>>({}); + const [localSystemPermissions, setLocalSystemPermissions] = useState< + Record> + >({}); const { data: profile } = useQuery({ queryKey: ["me"], @@ -74,26 +65,13 @@ export function TenantFineGrainedPermissionsPage() { const isSuperAdmin = profile?.role === "super_admin"; - if (profile && !isSuperAdmin) { - return ( -
-

- {t("msg.admin.common.forbidden", "์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.")} -

- -
- ); - } - 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> = {}; + const initialMap: Record< + string, + Record + > = {}; 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(["system-relations"]); + const previousRelations = queryClient.getQueryData([ + "system-relations", + ]); - queryClient.setQueryData(["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( + ["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(["system-relations"]); + const previousRelations = queryClient.getQueryData([ + "system-relations", + ]); - queryClient.setQueryData(["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( + ["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 ( +
+

+ {t("msg.admin.common.forbidden", "์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.")} +

+ +
+ ); + } + return (
@@ -325,204 +479,260 @@ export function TenantFineGrainedPermissionsPage() { {/* ์‹œ์Šคํ…œ ๋ฉ”๋‰ด ๊ถŒํ•œ (Admin Control) Split Screen Panel */}
- {/* Left Panel: User List */} -
-
-
-

- {t("ui.admin.permissions_direct.user_list", "๋Œ€์ƒ ์‚ฌ์šฉ์ž")} ({filteredRelations.length}) -

+ {/* Left Panel: User List */} +
+
+
+

+ {t("ui.admin.permissions_direct.user_list", "๋Œ€์ƒ ์‚ฌ์šฉ์ž")} ( + {filteredRelations.length}) +

+ +
+
+ + setUserSearchTerm(e.target.value)} + name="user-search" + className="pl-8 h-8 text-xs" + /> +
+
+ +
+ {filteredRelations.length === 0 ? ( +
+ {t( + "msg.admin.permissions_direct.no_users_found", + "๋“ฑ๋ก๋œ ์‚ฌ์šฉ์ž๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", + )} +
+ ) : ( + filteredRelations.map((user) => { + const isSelected = activeUserId === user.userId; + const activeCount = user.relations.length; + + return ( + + ); + }) + )} +
+
+
+ + {/* Right Panel: Toggle settings grid */} +
+ {selectedUser ? ( + <> + {/* User Detail Header */} +
+
+ + + {selectedUser.name.charAt(0)} + + +
+

+ {selectedUser.name} + + {selectedUser.relations.length}{" "} + {t("ui.admin.permissions_direct.allowed", "๊ฐœ ํ—ˆ์šฉ๋จ")} + +

+ + {selectedUser.email} + +
+
-
- - setUserSearchTerm(e.target.value)} - name="user-search" - className="pl-8 h-8 text-xs" - /> + + {/* Categorized Toggle Grid */} + +
+ {systemMenuCategories.map((category) => ( +
+

+ {category.title} +

+ + + {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 ( +
+
+
+ +
+
+
+ + {menu.label} + + {(menu.relation === "ory_ssot" || + menu.relation === "data_integrity") && ( + + {t( + "ui.admin.permissions_direct.super_admin_only", + "Super Admin ์ „์šฉ", + )} + + )} +
+ + {menu.desc} + +
+
+ +
+ ); + })} +
+
+
+ ))} +
+
+ + ) : ( +
+ +
+

+ {t( + "ui.admin.permissions_direct.no_user_selected", + "์‚ฌ์šฉ์ž๊ฐ€ ์„ ํƒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.", + )} +

+

+ {t( + "msg.admin.permissions_direct.no_user_selected_desc", + "์™ผ์ชฝ์˜ ์‚ฌ์šฉ์ž ๋ฆฌ์ŠคํŠธ์—์„œ ๊ถŒํ•œ์„ ๋ณ€๊ฒฝํ•  ์ธ์›์„ ์„ ํƒํ•ด ์ฃผ์„ธ์š”.", + )} +

- -
- {filteredRelations.length === 0 ? ( -
- {t("msg.admin.permissions_direct.no_users_found", "๋“ฑ๋ก๋œ ์‚ฌ์šฉ์ž๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")} -
- ) : ( - filteredRelations.map((user) => { - const isSelected = activeUserId === user.userId; - const activeCount = user.relations.length; - - return ( - - ); - }) - )} -
-
-
- - {/* Right Panel: Toggle settings grid */} -
- {selectedUser ? ( - <> - {/* User Detail Header */} -
-
- - - {selectedUser.name.charAt(0)} - - -
-

- {selectedUser.name} - - {selectedUser.relations.length} {t("ui.admin.permissions_direct.allowed", "๊ฐœ ํ—ˆ์šฉ๋จ")} - -

- {selectedUser.email} -
-
- -
- - {/* Categorized Toggle Grid */} - -
- {systemMenuCategories.map((category) => ( -
-

- {category.title} -

- - - {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 ( -
-
-
- -
-
-
- {menu.label} - {(menu.relation === "ory_ssot" || menu.relation === "data_integrity") && ( - - {t("ui.admin.permissions_direct.super_admin_only", "Super Admin ์ „์šฉ")} - - )} -
- - {menu.desc} - -
-
- -
- ); - })} -
-
-
- ))} -
-
- - ) : ( -
- -
-

- {t("ui.admin.permissions_direct.no_user_selected", "์‚ฌ์šฉ์ž๊ฐ€ ์„ ํƒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")} -

-

- {t("msg.admin.permissions_direct.no_user_selected_desc", "์™ผ์ชฝ์˜ ์‚ฌ์šฉ์ž ๋ฆฌ์ŠคํŠธ์—์„œ ๊ถŒํ•œ์„ ๋ณ€๊ฒฝํ•  ์ธ์›์„ ์„ ํƒํ•ด ์ฃผ์„ธ์š”.")} -

-
-
- )} -
+ )}
+
{/* User Search Dialog for System relations */} - {t("ui.admin.permissions_direct.dialog_title_system", "์‹œ์Šคํ…œ ๊ถŒํ•œ ๊ด€๋ฆฌ ์œ ์ € ์ถ”๊ฐ€")} + {t( + "ui.admin.permissions_direct.dialog_title_system", + "์‹œ์Šคํ…œ ๊ถŒํ•œ ๊ด€๋ฆฌ ์œ ์ € ์ถ”๊ฐ€", + )} {t( diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx index e119b31a..779237b1 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx @@ -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>>({}); + const [localTenantPermissions, setLocalTenantPermissions] = useState< + Record> + >({}); 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> = {}; + const initialMap: Record< + string, + Record + > = {}; 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(["tenant-relations", tenantId]); - - queryClient.setQueryData(["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([ + "tenant-relations", + tenantId, + ]); + + queryClient.setQueryData( + ["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(["tenant-relations", tenantId]); - - queryClient.setQueryData(["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([ + "tenant-relations", + tenantId, + ]); + + queryClient.setQueryData( + ["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
- {t("ui.admin.tenants.relations.title", "์„ธ๋ถ€ ๊ถŒํ•œ ์„ค์ • (Fine-grained Permissions)")} + {t( + "ui.admin.tenants.relations.title", + "์„ธ๋ถ€ ๊ถŒํ•œ ์„ค์ • (Fine-grained Permissions)", + )} {t( @@ -250,7 +311,10 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai disabled={!isWritable} > - {t("ui.admin.tenants.relations.add_button", "์„ธ๋ถ€ ๊ถŒํ•œ ์‚ฌ์šฉ์ž ์ถ”๊ฐ€")} + {t( + "ui.admin.tenants.relations.add_button", + "์„ธ๋ถ€ ๊ถŒํ•œ ์‚ฌ์šฉ์ž ์ถ”๊ฐ€", + )} @@ -258,58 +322,96 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai - {t("ui.common.name", "์ด๋ฆ„")} - {t("ui.admin.tenants.detail.tab_profile", "ํ…Œ๋„ŒํŠธ ํ”„๋กœํ•„")} - {t("ui.admin.tenants.detail.tab_permissions", "๊ถŒํ•œ ๊ด€๋ฆฌ")} - {t("ui.admin.tenants.detail.tab_organization", "์กฐ์ง ๊ด€๋ฆฌ")} - {t("ui.admin.tenants.detail.tab_schema", "์‚ฌ์šฉ์ž ์Šคํ‚ค๋งˆ")} - {t("ui.common.action", "์ž‘์—…")} + + {t("ui.common.name", "์ด๋ฆ„")} + + + {t("ui.admin.tenants.detail.tab_profile", "ํ…Œ๋„ŒํŠธ ํ”„๋กœํ•„")} + + + {t("ui.admin.tenants.detail.tab_permissions", "๊ถŒํ•œ ๊ด€๋ฆฌ")} + + + {t("ui.admin.tenants.detail.tab_organization", "์กฐ์ง ๊ด€๋ฆฌ")} + + + {t("ui.admin.tenants.detail.tab_schema", "์‚ฌ์šฉ์ž ์Šคํ‚ค๋งˆ")} + + + {t("ui.common.action", "์ž‘์—…")} + {relations.length === 0 ? ( - - {t("msg.admin.tenants.relations.empty", "์„ธ๋ถ€ ๊ถŒํ•œ์ด ์ง€์ •๋œ ์‚ฌ์šฉ์ž๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๋ฅผ ์ถ”๊ฐ€ํ•ด ์„ค์ •ํ•˜์„ธ์š”.")} + + {t( + "msg.admin.tenants.relations.empty", + "์„ธ๋ถ€ ๊ถŒํ•œ์ด ์ง€์ •๋œ ์‚ฌ์šฉ์ž๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๋ฅผ ์ถ”๊ฐ€ํ•ด ์„ค์ •ํ•˜์„ธ์š”.", + )} ) : ( 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 ( - +
- {user.name} - {user.email} + + {user.name} + + + {user.email} +
@@ -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 ); }} > - - - + + + @@ -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 ); }} > - - - + + + @@ -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 ); }} > - - - + + + @@ -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 ); }} > - - - + + + @@ -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, + ) + } > @@ -457,7 +600,10 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai - {t("ui.admin.tenants.relations.dialog_title", "์„ธ๋ถ€ ๊ถŒํ•œ ๊ด€๋ฆฌ ์œ ์ € ์ถ”๊ฐ€")} + {t( + "ui.admin.tenants.relations.dialog_title", + "์„ธ๋ถ€ ๊ถŒํ•œ ๊ด€๋ฆฌ ์œ ์ € ์ถ”๊ฐ€", + )} {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)} > diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index c142577b..9ccf7aab 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -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() { diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index a57baf5c..b85c64f4 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -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 (

diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index ad25ff7d..3bb5c705 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -276,13 +276,21 @@ export function TenantProfilePage() { {t("ui.admin.tenants.profile.name", "ํ…Œ๋„ŒํŠธ ์ด๋ฆ„")}{" "} * - setName(e.target.value)} disabled={!isWritable} /> + setName(e.target.value)} + disabled={!isWritable} + />

- setSlug(e.target.value)} disabled={!isWritable} /> + setSlug(e.target.value)} + disabled={!isWritable} + />
(); 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() {