From 85707500ef1d80d5456a644e7cc1a20b160f86e9 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 10 Jun 2026 10:01:30 +0900 Subject: [PATCH 01/35] =?UTF-8?q?adminfront=20=EB=B0=8F=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C:=20ReBAC=20=EA=B8=B0=EB=B0=98=20=EA=B0=81=20?= =?UTF-8?q?=ED=83=AD=EB=B3=84=20=EC=9D=BD=EA=B8=B0/=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=A0=9C=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coverage/adminTenantTabs.test.tsx | 7 + .../tenants/components/DomainTagInput.tsx | 21 ++- .../components/ParentTenantSelector.tsx | 6 +- .../components/TenantPermissionGuard.tsx | 26 +++ .../hooks/useTenantPermission.test.tsx | 126 ++++++++++++++ .../tenants/hooks/useTenantPermission.ts | 26 +++ .../routes/TenantAdminsAndOwnersTab.tsx | 5 + .../tenants/routes/TenantDetailPage.tsx | 11 +- .../tenants/routes/TenantGroupsPage.tsx | 18 +- .../tenants/routes/TenantProfilePage.tsx | 30 +++- adminfront/src/lib/adminApi.ts | 5 + backend/internal/handler/tenant_handler.go | 80 +++++++-- .../handler/tenant_handler_get_test.go | 164 ++++++++++++++++++ 13 files changed, 485 insertions(+), 40 deletions(-) create mode 100644 adminfront/src/features/tenants/components/TenantPermissionGuard.tsx create mode 100644 adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx create mode 100644 adminfront/src/features/tenants/hooks/useTenantPermission.ts create mode 100644 backend/internal/handler/tenant_handler_get_test.go diff --git a/adminfront/src/features/coverage/adminTenantTabs.test.tsx b/adminfront/src/features/coverage/adminTenantTabs.test.tsx index 722494d9..eeee7afd 100644 --- a/adminfront/src/features/coverage/adminTenantTabs.test.tsx +++ b/adminfront/src/features/coverage/adminTenantTabs.test.tsx @@ -93,6 +93,13 @@ vi.mock("react-oidc-context", () => ({ })); vi.mock("../../lib/adminApi", () => ({ + fetchMe: vi.fn(async () => users[0]), + fetchTenant: vi.fn(async (tenantId) => ({ + id: tenantId, + name: "Test Tenant", + slug: "test-tenant", + userPermissions: { view: true, manage: true, manage_admins: true }, + })), fetchTenantOwners: vi.fn(async () => [users[0]]), fetchTenantAdmins: vi.fn(async () => [users[1]]), addTenantOwner: vi.fn(async () => undefined), diff --git a/adminfront/src/features/tenants/components/DomainTagInput.tsx b/adminfront/src/features/tenants/components/DomainTagInput.tsx index ecfc5513..f2dc39eb 100644 --- a/adminfront/src/features/tenants/components/DomainTagInput.tsx +++ b/adminfront/src/features/tenants/components/DomainTagInput.tsx @@ -29,6 +29,7 @@ type DomainTagInputProps = { confirmedConflicts?: string[]; onConfirmedConflictsChange?: (domains: string[]) => void; placeholder?: string; + disabled?: boolean; }; export function DomainTagInput({ @@ -40,6 +41,7 @@ export function DomainTagInput({ confirmedConflicts = [], onConfirmedConflictsChange, placeholder, + disabled = false, }: DomainTagInputProps) { const [input, setInput] = useState(""); const [pendingConflict, setPendingConflict] = useState( @@ -107,14 +109,16 @@ export function DomainTagInput({ className="gap-1 rounded-md" > {domain} - + {!disabled && ( + + )} ))} diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx index 3b8830b7..17fbcd33 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx @@ -35,6 +35,7 @@ type ParentTenantSelectorProps = { localTenantFilter?: (tenant: TenantSummary) => boolean; compact?: boolean; controlTestId?: string; + disabled?: boolean; }; export function ParentTenantSelector({ @@ -53,6 +54,7 @@ export function ParentTenantSelector({ localTenantFilter, compact = false, controlTestId, + disabled = false, }: ParentTenantSelectorProps) { const [pickerOpen, setPickerOpen] = useState(false); const [localPickerOpen, setLocalPickerOpen] = useState(false); @@ -112,6 +114,7 @@ export function ParentTenantSelector({ variant="outline" size="sm" className={compact ? "h-8 shrink-0 px-2" : undefined} + disabled={disabled} > {orgChartPickerLabel ?? @@ -141,7 +144,7 @@ export function ParentTenantSelector({ {localPickerLabel && ( - @@ -228,6 +231,7 @@ export function ParentTenantSelector({ className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"} onClick={() => onChange("")} aria-label={noneLabel} + disabled={disabled} > diff --git a/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx b/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx new file mode 100644 index 00000000..7ddee376 --- /dev/null +++ b/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx @@ -0,0 +1,26 @@ +import type React from "react"; +import { useTenantPermission } from "../hooks/useTenantPermission"; + +interface TenantPermissionGuardProps { + tenantId: string; + relation: "view" | "manage" | "manage_admins"; + fallback?: React.ReactNode; + children: React.ReactNode; +} + +export function TenantPermissionGuard({ + tenantId, + relation, + fallback = null, + children, +}: TenantPermissionGuardProps) { + const { hasPermission, isLoading } = useTenantPermission(tenantId); + + if (isLoading) return null; + + if (!hasPermission(relation)) { + return <>{fallback}; + } + + return <>{children}; +} diff --git a/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx b/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx new file mode 100644 index 00000000..d5233b6a --- /dev/null +++ b/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx @@ -0,0 +1,126 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { renderHook } 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 { TenantPermissionGuard } from "../components/TenantPermissionGuard"; + +vi.mock("../../../lib/adminApi", () => ({ + fetchMe: vi.fn(), + fetchTenant: vi.fn(), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe("useTenantPermission", () => { + it("returns true for all permissions if user is super_admin", async () => { + vi.mocked(fetchMe).mockResolvedValue({ + id: "user-super", + role: "super_admin", + } as any); + + vi.mocked(fetchTenant).mockResolvedValue({ + id: "tenant-1", + name: "Super Tenant", + userPermissions: { view: false, manage: false, manage_admins: false }, + } as any); + + const { result } = renderHook(() => useTenantPermission("tenant-1"), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.hasPermission("view")).toBe(true); + expect(result.current.hasPermission("manage")).toBe(true); + expect(result.current.hasPermission("manage_admins")).toBe(true); + }); + + it("returns permissions mapped from userPermissions for normal admins/users", async () => { + vi.mocked(fetchMe).mockResolvedValue({ + id: "user-admin", + role: "tenant_admin", + } as any); + + vi.mocked(fetchTenant).mockResolvedValue({ + id: "tenant-2", + name: "Tenant Admin Corp", + userPermissions: { view: true, manage: true, manage_admins: false }, + } as any); + + const { result } = renderHook(() => useTenantPermission("tenant-2"), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.hasPermission("view")).toBe(true); + expect(result.current.hasPermission("manage")).toBe(true); + expect(result.current.hasPermission("manage_admins")).toBe(false); + }); +}); + +describe("TenantPermissionGuard", () => { + it("renders children when user has permission", async () => { + vi.mocked(fetchMe).mockResolvedValue({ + id: "user-admin", + role: "tenant_admin", + } as any); + + vi.mocked(fetchTenant).mockResolvedValue({ + id: "tenant-3", + userPermissions: { view: true, manage: true, manage_admins: false }, + } as any); + + render( + Access Denied}> +
Access Granted
+
, + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(screen.getByText("Access Granted")).toBeInTheDocument(); + }); + expect(screen.queryByText("Access Denied")).not.toBeInTheDocument(); + }); + + it("renders fallback when user lacks permission", async () => { + vi.mocked(fetchMe).mockResolvedValue({ + id: "user-admin", + role: "tenant_admin", + } as any); + + vi.mocked(fetchTenant).mockResolvedValue({ + id: "tenant-4", + userPermissions: { view: true, manage: false, manage_admins: false }, + } as any); + + render( + Access Denied}> +
Access Granted
+
, + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(screen.getByText("Access Denied")).toBeInTheDocument(); + }); + expect(screen.queryByText("Access Granted")).not.toBeInTheDocument(); + }); +}); diff --git a/adminfront/src/features/tenants/hooks/useTenantPermission.ts b/adminfront/src/features/tenants/hooks/useTenantPermission.ts new file mode 100644 index 00000000..532627fc --- /dev/null +++ b/adminfront/src/features/tenants/hooks/useTenantPermission.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchTenant, fetchMe } from "../../../lib/adminApi"; +import { normalizeAdminRole } from "../../../lib/roles"; + +export function useTenantPermission(tenantId: string) { + const { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + }); + + const { data: tenant } = useQuery({ + queryKey: ["tenant", tenantId], + queryFn: () => fetchTenant(tenantId), + enabled: !!tenantId, + }); + + const hasPermission = (requiredRelation: "view" | "manage" | "manage_admins"): boolean => { + // Super Admin always has full bypass access + if (normalizeAdminRole(profile?.role) === "super_admin") { + return true; + } + return !!tenant?.userPermissions?.[requiredRelation]; + }; + + return { hasPermission, isLoading: !tenant }; +} diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index fc78435d..67d858b6 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -11,6 +11,7 @@ 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"; @@ -69,6 +70,8 @@ export function TenantAdminsAndOwnersTab() { const _currentUserId = auth.user?.profile.sub; const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>(); const tenantId = tenantIdParam ?? ""; + const { hasPermission } = useTenantPermission(tenantId); + const isWritable = hasPermission("manage_admins"); const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); const [dialogMode, setDialogMode] = useState(null); @@ -382,6 +385,7 @@ export function TenantAdminsAndOwnersTab() { @@ -210,6 +214,7 @@ const UserGroupTreeNode: React.FC = ({ e.stopPropagation(); onDelete(node.id); }} + disabled={!isWritable} > @@ -229,6 +234,7 @@ const UserGroupTreeNode: React.FC = ({ onAddSubGroup={onAddSubGroup} addMemberMutation={addMemberMutation} removeMemberMutation={removeMemberMutation} + isWritable={isWritable} /> ))} @@ -240,6 +246,9 @@ function TenantGroupsPage() { const tenantId = params.tenantId ?? ""; const _queryClient = useQueryClient(); + const { hasPermission } = useTenantPermission(tenantId); + const isWritable = hasPermission("manage"); + const [newGroupName, setNewGroupName] = useState(""); const [newGroupDesc, setNewGroupNameDesc] = useState(""); const [newGroupUnitType, setNewGroupUnitType] = useState("Team"); @@ -423,6 +432,7 @@ function TenantGroupsPage() { id="name" value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)} + disabled={!isWritable} placeholder={t( "ui.admin.groups.form.name_placeholder", "예: 개발팀, 인사팀", @@ -437,6 +447,7 @@ function TenantGroupsPage() { id="unitType" value={newGroupUnitType} onChange={(e) => setNewGroupUnitType(e.target.value)} + disabled={!isWritable} placeholder={t( "ui.admin.groups.form.unit_level_placeholder", "예: 본부, 팀, 셀", @@ -449,9 +460,10 @@ function TenantGroupsPage() { setName(e.target.value)} /> + setName(e.target.value)} disabled={!isWritable} />
- setSlug(e.target.value)} /> + setSlug(e.target.value)} disabled={!isWritable} />
@@ -300,6 +305,7 @@ export function TenantProfilePage() { className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" value={type} onChange={(e) => setType(e.target.value)} + disabled={!isWritable} > {orgUnitTypeOptions.map((option) => ( @@ -365,13 +372,14 @@ export function TenantProfilePage() { setWorksmobileExcluded(event.target.value === "excluded") } + disabled={!isWritable} >
- + ) : ( + + + )} } /> @@ -1095,7 +1104,8 @@ function UserListPage() { } disabled={ statusMutation.isPending || - user.id === profile?.id + user.id === profile?.id || + !isWritable } > @@ -1291,6 +1302,7 @@ function UserListPage() { size="sm" className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5" onClick={handleBulkDelete} + disabled={!isWritable} data-testid="bulk-delete-btn" > diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 5360ea97..56b05a7b 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -37,6 +37,14 @@ export type TenantSummary = { view: boolean; manage: boolean; manage_admins: boolean; + view_profile?: boolean; + manage_profile?: boolean; + view_permissions?: boolean; + manage_permissions?: boolean; + view_organization?: boolean; + manage_organization?: boolean; + view_schema?: boolean; + manage_schema?: boolean; }; createdAt: string; updatedAt: string; @@ -1275,6 +1283,18 @@ export type SystemPermissions = { auth_guard: boolean; api_keys: boolean; audit_logs: boolean; + + manage_overview?: boolean; + manage_tenants?: boolean; + manage_org_chart?: boolean; + manage_worksmobile?: boolean; + manage_ory_ssot?: boolean; + manage_data_integrity?: boolean; + manage_users?: boolean; + manage_permissions_direct?: boolean; + manage_auth_guard?: boolean; + manage_api_keys?: boolean; + manage_audit_logs?: boolean; }; export type UserProfileResponse = { diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index 21577181..015d8028 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -81,6 +81,18 @@ type SystemPermissions struct { AuthGuard bool `json:"auth_guard"` ApiKeys bool `json:"api_keys"` AuditLogs bool `json:"audit_logs"` + + ManageOverview bool `json:"manage_overview"` + ManageTenants bool `json:"manage_tenants"` + ManageOrgChart bool `json:"manage_org_chart"` + ManageWorksmobile bool `json:"manage_worksmobile"` + ManageOrySSOT bool `json:"manage_ory_ssot"` + ManageDataIntegrity bool `json:"manage_data_integrity"` + ManageUsers bool `json:"manage_users"` + ManagePermissionsDirect bool `json:"manage_permissions_direct"` + ManageAuthGuard bool `json:"manage_auth_guard"` + ManageApiKeys bool `json:"manage_api_keys"` + ManageAuditLogs bool `json:"manage_audit_logs"` } type UserProfileResponse struct { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 7a87d643..e2ef4a48 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4776,17 +4776,28 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai if profile.Role == "super_admin" { sp = domain.SystemPermissions{ - Overview: true, - Tenants: true, - OrgChart: true, - Worksmobile: true, - OrySSOT: true, - DataIntegrity: true, - Users: true, - PermissionsDirect: true, - AuthGuard: true, - ApiKeys: true, - AuditLogs: true, + Overview: true, + Tenants: true, + OrgChart: true, + Worksmobile: true, + OrySSOT: true, + DataIntegrity: true, + Users: true, + PermissionsDirect: true, + AuthGuard: true, + ApiKeys: true, + AuditLogs: true, + ManageOverview: true, + ManageTenants: true, + ManageOrgChart: true, + ManageWorksmobile: true, + ManageOrySSOT: true, + ManageDataIntegrity: true, + ManageUsers: true, + ManagePermissionsDirect: true, + ManageAuthGuard: true, + ManageApiKeys: true, + ManageAuditLogs: true, } } else { // Query Keto in parallel for maximum performance @@ -4795,17 +4806,28 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai allowed bool } menus := map[string]string{ - "overview": "access_overview", - "tenants": "access_tenants", - "org_chart": "access_org_chart", - "worksmobile": "access_worksmobile", - "ory_ssot": "access_ory_ssot", - "data_integrity": "access_data_integrity", - "users": "access_users", - "permissions_direct": "access_permissions_direct", - "auth_guard": "access_auth_guard", - "api_keys": "access_api_keys", - "audit_logs": "access_audit_logs", + "overview": "access_overview", + "manage_overview": "manage_overview", + "tenants": "access_tenants", + "manage_tenants": "manage_tenants", + "org_chart": "access_org_chart", + "manage_org_chart": "manage_org_chart", + "worksmobile": "access_worksmobile", + "manage_worksmobile": "manage_worksmobile", + "ory_ssot": "access_ory_ssot", + "manage_ory_ssot": "manage_ory_ssot", + "data_integrity": "access_data_integrity", + "manage_data_integrity": "manage_data_integrity", + "users": "access_users", + "manage_users": "manage_users", + "permissions_direct": "access_permissions_direct", + "manage_permissions_direct": "manage_permissions_direct", + "auth_guard": "access_auth_guard", + "manage_auth_guard": "manage_auth_guard", + "api_keys": "access_api_keys", + "manage_api_keys": "manage_api_keys", + "audit_logs": "access_audit_logs", + "manage_audit_logs": "manage_audit_logs", } ch := make(chan checkResult, len(menus)) for m, rel := range menus { @@ -4819,26 +4841,48 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai switch res.menu { case "overview": sp.Overview = res.allowed + case "manage_overview": + sp.ManageOverview = res.allowed case "tenants": sp.Tenants = res.allowed + case "manage_tenants": + sp.ManageTenants = res.allowed case "org_chart": sp.OrgChart = res.allowed + case "manage_org_chart": + sp.ManageOrgChart = res.allowed case "worksmobile": sp.Worksmobile = res.allowed + case "manage_worksmobile": + sp.ManageWorksmobile = res.allowed case "ory_ssot": sp.OrySSOT = res.allowed + case "manage_ory_ssot": + sp.ManageOrySSOT = res.allowed case "data_integrity": sp.DataIntegrity = res.allowed + case "manage_data_integrity": + sp.ManageDataIntegrity = res.allowed case "users": sp.Users = res.allowed + case "manage_users": + sp.ManageUsers = res.allowed case "permissions_direct": sp.PermissionsDirect = res.allowed + case "manage_permissions_direct": + sp.ManagePermissionsDirect = res.allowed case "auth_guard": sp.AuthGuard = res.allowed + case "manage_auth_guard": + sp.ManageAuthGuard = res.allowed case "api_keys": sp.ApiKeys = res.allowed + case "manage_api_keys": + sp.ManageApiKeys = res.allowed case "audit_logs": sp.AuditLogs = res.allowed + case "manage_audit_logs": + sp.ManageAuditLogs = res.allowed } } } diff --git a/backend/internal/handler/tenant_handler_get_test.go b/backend/internal/handler/tenant_handler_get_test.go index 3e059729..7df1ed37 100644 --- a/backend/internal/handler/tenant_handler_get_test.go +++ b/backend/internal/handler/tenant_handler_get_test.go @@ -128,6 +128,7 @@ func TestTenantHandler_GetTenant_NormalUser(t *testing.T) { mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "view").Return(true, nil) mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage").Return(true, nil) mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage_admins").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", mock.Anything).Return(false, nil).Maybe() // We'll simulate middleware setting "user_profile" for a regular admin/user app.Get("/tenants/:id", func(c *fiber.Ctx) error { diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts index a608c1a0..b39b25fb 100644 --- a/docker/ory/keto/namespaces.ts +++ b/docker/ory/keto/namespaces.ts @@ -7,7 +7,7 @@ class System implements Namespace { super_admins: User[] authenticated_users: User[] - // 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 + // 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 - 조회(Read) overview_viewers: User[] tenants_viewers: User[] org_chart_viewers: User[] @@ -19,55 +19,112 @@ class System implements Namespace { auth_guard_viewers: User[] api_keys_viewers: User[] audit_logs_viewers: User[] + + // 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 - 수정(Write) + overview_managers: User[] + tenants_managers: User[] + org_chart_managers: User[] + worksmobile_managers: User[] + ory_ssot_managers: User[] + data_integrity_managers: User[] + users_managers: User[] + permissions_direct_managers: User[] + auth_guard_managers: User[] + api_keys_managers: User[] + audit_logs_managers: User[] } permits = { manage_all: (ctx: Context): boolean => this.related.super_admins.includes(ctx.subject), - // 🌟 글로벌 메뉴 허가 규칙 (Permit Rules) - Super Admin은 언제나 무조건 패스 + // 🌟 글로벌 메뉴 허가 규칙 (Permit Rules) - 조회(access_)와 수정(manage_) 완전 분리 이원화 access_overview: (ctx: Context): boolean => this.related.overview_viewers.includes(ctx.subject) || + this.permits.manage_overview(ctx), + + manage_overview: (ctx: Context): boolean => + this.related.overview_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_tenants: (ctx: Context): boolean => this.related.tenants_viewers.includes(ctx.subject) || + this.permits.manage_tenants(ctx), + + manage_tenants: (ctx: Context): boolean => + this.related.tenants_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_org_chart: (ctx: Context): boolean => this.related.org_chart_viewers.includes(ctx.subject) || + this.permits.manage_org_chart(ctx), + + manage_org_chart: (ctx: Context): boolean => + this.related.org_chart_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_worksmobile: (ctx: Context): boolean => this.related.worksmobile_viewers.includes(ctx.subject) || + this.permits.manage_worksmobile(ctx), + + manage_worksmobile: (ctx: Context): boolean => + this.related.worksmobile_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_ory_ssot: (ctx: Context): boolean => this.related.ory_ssot_viewers.includes(ctx.subject) || + this.permits.manage_ory_ssot(ctx), + + manage_ory_ssot: (ctx: Context): boolean => + this.related.ory_ssot_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_data_integrity: (ctx: Context): boolean => this.related.data_integrity_viewers.includes(ctx.subject) || + this.permits.manage_data_integrity(ctx), + + manage_data_integrity: (ctx: Context): boolean => + this.related.data_integrity_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_users: (ctx: Context): boolean => this.related.users_viewers.includes(ctx.subject) || + this.permits.manage_users(ctx), + + manage_users: (ctx: Context): boolean => + this.related.users_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_permissions_direct: (ctx: Context): boolean => this.related.permissions_direct_viewers.includes(ctx.subject) || + this.permits.manage_permissions_direct(ctx), + + manage_permissions_direct: (ctx: Context): boolean => + this.related.permissions_direct_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_auth_guard: (ctx: Context): boolean => this.related.auth_guard_viewers.includes(ctx.subject) || + this.permits.manage_auth_guard(ctx), + + manage_auth_guard: (ctx: Context): boolean => + this.related.auth_guard_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_api_keys: (ctx: Context): boolean => this.related.api_keys_viewers.includes(ctx.subject) || + this.permits.manage_api_keys(ctx), + + manage_api_keys: (ctx: Context): boolean => + this.related.api_keys_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_audit_logs: (ctx: Context): boolean => this.related.audit_logs_viewers.includes(ctx.subject) || + this.permits.manage_audit_logs(ctx), + + manage_audit_logs: (ctx: Context): boolean => + this.related.audit_logs_managers.includes(ctx.subject) || this.permits.manage_all(ctx) } } From d39838a1c935fe503a78f2097d2dd8e1fd9d72bc Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 12 Jun 2026 11:43:40 +0900 Subject: [PATCH 11/35] =?UTF-8?q?adminfront:=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EB=B6=80=EC=97=AC(Direct=20Permissions)=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=EC=84=9C=20=ED=85=8C=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B6=8C=ED=95=9C=20=ED=83=AD=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EA=B6=8C=ED=95=9C=20=EB=8B=A8=EC=9D=BC=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TenantFineGrainedPermissionsPage.tsx | 75 +------------------ 1 file changed, 3 insertions(+), 72 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx index 80e26a11..5f4e62c3 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx @@ -55,7 +55,7 @@ import { toast } from "../../../components/ui/use-toast"; export function TenantFineGrainedPermissionsPage() { const queryClient = useQueryClient(); - const [activeTab, setActiveTab] = useState<"tenant" | "system">("tenant"); + const [activeTab, setActiveTab] = useState<"tenant" | "system">("system"); const [selectedTenantId, setSelectedTenantId] = useState(""); const [searchTerm, setSearchTerm] = useState(""); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -308,76 +308,8 @@ export function TenantFineGrainedPermissionsPage() {

- {/* Tab Selectors */} - {isSuperAdmin && ( -
- - -
- )} - - {activeTab === "tenant" ? ( - <> - - - - {t("ui.admin.permissions_direct.select_tenant", "대상 테넌트 선택")} - - - {t( - "msg.admin.permissions_direct.select_tenant_desc", - "세부 기능 권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요.", - )} - - - - - - - - {selectedTenantId ? ( - - ) : ( -
- {t("msg.admin.permissions_direct.select_prompt", "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다.")} -
- )} - - ) : ( - /* 시스템 메뉴 권한 (Admin Control) Split Screen Panel */ -
+ {/* 시스템 메뉴 권한 (Admin Control) Split Screen Panel */} +
{/* Left Panel: User List */}
@@ -568,7 +500,6 @@ export function TenantFineGrainedPermissionsPage() { )}
- )} {/* User Search Dialog for System relations */} Date: Fri, 12 Jun 2026 09:01:08 +0900 Subject: [PATCH 12/35] =?UTF-8?q?date/timezone=20=ED=95=9C=20=EC=A4=84=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clients/ClientConsentsPage.test.tsx | 81 +++++++++++++++++++ .../features/clients/ClientConsentsPage.tsx | 12 ++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/devfront/src/features/clients/ClientConsentsPage.test.tsx b/devfront/src/features/clients/ClientConsentsPage.test.tsx index c8d9cede..7a76d587 100644 --- a/devfront/src/features/clients/ClientConsentsPage.test.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.test.tsx @@ -201,4 +201,85 @@ describe("ClientConsentsPage RP custom claims", () => { }), ); }); + + it("keeps date claim inputs and timezone selectors on the same row", async () => { + fetchClientMock.mockResolvedValue({ + ...clientDetail, + client: { + ...clientDetail.client, + metadata: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "contract_date", + value: "", + valueType: "date", + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + }, + }); + fetchConsentsMock.mockResolvedValue({ + items: [ + { + subject: "user-1", + userName: "Consent User", + clientId: "client-a", + clientName: "Claims App", + grantedScopes: ["openid", "profile"], + authenticatedAt: "2026-06-11T09:00:00Z", + createdAt: "2026-06-10T09:00:00Z", + status: "active", + tenantId: "tenant-1", + tenantName: "Hanmac", + rpMetadata: { + contract_date: 1781017200, + contract_date_permissions: { + readPermission: "admin_only", + writePermission: "admin_only", + }, + }, + }, + ], + }); + fetchRPUserMetadataMock.mockResolvedValue({ + clientId: "client-a", + userId: "user-1", + metadata: { + contract_date: 1781017200, + contract_date_permissions: { + readPermission: "admin_only", + writePermission: "admin_only", + }, + }, + }); + + const { container } = await renderPage(); + + const editButton = Array.from(container.querySelectorAll("button")).find( + (button) => + button.textContent?.includes("사용자 Claim 설정") || + button.textContent?.includes("User Claim Settings"), + ); + + await act(async () => { + editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const dateInput = container.querySelector( + 'input[aria-label="contract_date date"]', + ); + const timeZoneSelect = container.querySelector( + 'select[aria-label="contract_date timezone"]', + ); + + expect(dateInput).not.toBeNull(); + expect(timeZoneSelect).not.toBeNull(); + expect(dateInput?.parentElement).toBe(timeZoneSelect?.parentElement); + expect(dateInput?.parentElement?.className).toContain("items-center"); + expect(dateInput?.parentElement?.className).not.toContain("flex-col"); + }); }); diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 1e6f1fa9..b76e0175 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -1060,7 +1060,15 @@ function ClientConsentsPage() { aria-label={`${row.key} ${row.valueType}`} /> ) : ( -
+
{timeZoneOptions.map((timeZone) => ( From ca15e2a35c714e074035a6b83e8611762ce83fcb Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 12 Jun 2026 14:54:49 +0900 Subject: [PATCH 13/35] =?UTF-8?q?offline=5Faccess=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=8A=A4=EC=BD=94=ED=94=84=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?refresh=5Ftoken=20=EB=B0=9C=EA=B8=89=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 2 +- .../internal/handler/client_tenant_access.go | 4 ++-- .../handler/client_tenant_access_test.go | 10 ++++++---- backend/internal/handler/dev_handler.go | 8 ++++---- backend/internal/handler/dev_handler_test.go | 8 ++++---- .../clients/ClientGeneralPage.claims.test.tsx | 4 ++-- .../src/features/clients/ClientGeneralPage.tsx | 18 ++++++++++++++++++ .../tests/devfront-client-claims-cache.spec.ts | 8 ++++---- 8 files changed, 41 insertions(+), 21 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index e92041d3..185a6c68 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -8430,7 +8430,7 @@ func buildHydraAuthorizationURL(clientID string, scopes []string, redirectURIs [ seen := map[string]struct{}{} for _, scope := range append([]string{"openid"}, scopes...) { scope = strings.TrimSpace(scope) - if scope == "" || isRefreshTokenScopeAlias(scope) { + if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { continue } if _, ok := seen[scope]; ok { diff --git a/backend/internal/handler/client_tenant_access.go b/backend/internal/handler/client_tenant_access.go index 670980b4..a5f9bbe3 100644 --- a/backend/internal/handler/client_tenant_access.go +++ b/backend/internal/handler/client_tenant_access.go @@ -464,7 +464,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string { appendIfPresent := func(scope string) { scope = strings.TrimSpace(scope) - if scope == "" || isRefreshTokenScopeAlias(scope) { + if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { return } if _, ok := seen[scope]; ok { @@ -485,7 +485,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string { for _, scope := range combined { scope = strings.TrimSpace(scope) - if scope == "" || isRefreshTokenScopeAlias(scope) { + if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { continue } if _, ok := seen[scope]; ok { diff --git a/backend/internal/handler/client_tenant_access_test.go b/backend/internal/handler/client_tenant_access_test.go index 29caeeb9..661cd631 100644 --- a/backend/internal/handler/client_tenant_access_test.go +++ b/backend/internal/handler/client_tenant_access_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "github.com/gofiber/fiber/v2" @@ -153,7 +154,7 @@ func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAlias []string{"openid", "offline", "profile", "offline_access"}, ) - assert.Equal(t, []string{"openid", "tenant", "profile", "email"}, merged) + assert.Equal(t, []string{"openid", "tenant", "profile", "offline_access", "email"}, merged) } func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) { @@ -166,10 +167,11 @@ func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) parsed, err := url.Parse(urlString) assert.NoError(t, err) scopes := parsed.Query().Get("scope") + scopeItems := strings.Fields(scopes) - assert.Equal(t, "openid profile email", scopes) - assert.NotContains(t, scopes, "offline") - assert.NotContains(t, scopes, "offline_access") + assert.Equal(t, "openid profile offline_access email", scopes) + assert.NotContains(t, scopeItems, "offline") + assert.Contains(t, scopeItems, "offline_access") } func TestGetConsentRequest_DeniesTenantAccess(t *testing.T) { diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 014031f5..eacfdd83 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -3828,7 +3828,7 @@ func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool { } func defaultClientScopes() []string { - return []string{"openid", "profile", "email"} + return []string{"openid", "profile", "email", "offline_access"} } func defaultGrantTypes() []string { @@ -3848,7 +3848,7 @@ func normalizeClientScopes(scopes []string) []string { seen := make(map[string]struct{}, len(scopes)) for _, scope := range scopes { scope = strings.TrimSpace(scope) - if scope == "" || isRefreshTokenScopeAlias(scope) { + if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { continue } if _, ok := seen[scope]; ok { @@ -3860,9 +3860,9 @@ func normalizeClientScopes(scopes []string) []string { return normalized } -func isRefreshTokenScopeAlias(scope string) bool { +func isLegacyRefreshTokenScopeAlias(scope string) bool { switch strings.ToLower(strings.TrimSpace(scope)) { - case "offline", "offline_access": + case "offline": return true default: return false diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index d750e9fd..0ababf7b 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -2229,9 +2229,9 @@ func TestCreateClient_StripsOfflineScopesAndKeepsRefreshTokenGrant(t *testing.T) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) - assert.Equal(t, "openid profile email", captured.Scope) + assert.Equal(t, "openid profile offline_access email", captured.Scope) assert.NotContains(t, strings.Fields(captured.Scope), "offline") - assert.NotContains(t, strings.Fields(captured.Scope), "offline_access") + assert.Contains(t, strings.Fields(captured.Scope), "offline_access") assert.Contains(t, captured.GrantTypes, "refresh_token") } @@ -2296,9 +2296,9 @@ func TestUpdateClient_StripsStoredOfflineScopesAndKeepsRefreshTokenGrant(t *test resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "openid profile email", captured.Scope) + assert.Equal(t, "openid profile offline_access email", captured.Scope) assert.NotContains(t, strings.Fields(captured.Scope), "offline") - assert.NotContains(t, strings.Fields(captured.Scope), "offline_access") + assert.Contains(t, strings.Fields(captured.Scope), "offline_access") assert.Contains(t, captured.GrantTypes, "refresh_token") } diff --git a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx index a244ee31..2aa94e53 100644 --- a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx @@ -409,7 +409,7 @@ describe("ClientGeneralPage RP claims", () => { ); }); - it("shows supported scopes and custom claims without integrated offline_access from the add scope button", async () => { + it("shows supported scopes including offline_access and custom claims from the add scope button", async () => { const { container } = await renderPage(); const addScopeButton = Array.from( @@ -422,7 +422,7 @@ describe("ClientGeneralPage RP claims", () => { }); await flush(); - expect(container.textContent).not.toContain("offline_access"); + expect(container.textContent).toContain("offline_access"); expect(container.textContent).toContain("old_claim"); const customClaimButton = Array.from( diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index af826426..710c7e5b 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -659,6 +659,15 @@ function ClientGeneralPage() { description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"), mandatory: false, }, + { + id: "5", + name: "offline_access", + description: t( + "msg.dev.clients.scopes.offline_access", + "refresh token 발급 요청", + ), + mandatory: false, + }, ]); const [idTokenClaims, setIdTokenClaims] = useState([]); const browserTimeZone = useMemo(() => getBrowserTimeZone(), []); @@ -759,6 +768,15 @@ function ClientGeneralPage() { description: tenantScopeDescription, source: "standard", }, + { + id: "standard-offline-access", + name: "offline_access", + description: t( + "msg.dev.clients.scopes.offline_access", + "refresh token 발급 요청", + ), + source: "standard", + }, ], [tenantScopeDescription], ); diff --git a/devfront/tests/devfront-client-claims-cache.spec.ts b/devfront/tests/devfront-client-claims-cache.spec.ts index 4a21e3c3..cf5161a7 100644 --- a/devfront/tests/devfront-client-claims-cache.spec.ts +++ b/devfront/tests/devfront-client-claims-cache.spec.ts @@ -99,7 +99,7 @@ test.describe("DevFront RP claim cache", () => { await expect(claimKeyInput).toHaveValue("new_claim"); }); - test("adds supported scopes and custom claim keys from the scope picker without offline_access", async ({ + test("adds supported scopes and custom claim keys from the scope picker including offline_access", async ({ page, }) => { const state = { @@ -142,9 +142,9 @@ test.describe("DevFront RP claim cache", () => { .getByRole("button", { name: /스코프 추가|Scope 추가|Add Scope/i }) .click(); - await expect(page.getByText("offline_access", { exact: true })).toHaveCount( - 0, - ); + await expect( + page.getByText("offline_access", { exact: true }), + ).toBeVisible(); await expect( page.getByRole("button", { name: /employee_code/ }), ).toBeVisible(); From c587f370899c928c166f48c003c07646a47ae687 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 12 Jun 2026 15:02:45 +0900 Subject: [PATCH 14/35] =?UTF-8?q?ClientConsentsPage=20Biome=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/clients/ClientConsentsPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index b76e0175..9ccdfcf8 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -1063,8 +1063,7 @@ function ClientConsentsPage() {
Date: Fri, 12 Jun 2026 15:50:46 +0900 Subject: [PATCH 15/35] =?UTF-8?q?adminfront:=20TenantListPage=EC=97=90=20?= =?UTF-8?q?=EC=84=B8=EB=B6=80=20=EA=B8=B0=EB=8A=A5=20=EA=B6=8C=ED=95=9C(te?= =?UTF-8?q?nants=20/=20manage=5Ftenants)=20=EC=9A=B0=ED=9A=8C=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A0=9C=EC=96=B4=20=EC=A0=84=EA=B2=A9=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=A0=91=EA=B7=BC=20=EC=A0=9C=ED=95=9C=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantListPage.tsx | 163 +++++++++--------- 1 file changed, 83 insertions(+), 80 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 40fa5418..dc8cbc2b 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -377,6 +377,7 @@ function TenantListPage() { queryFn: fetchMe, }); const profileRole = normalizeAdminRole(profile?.role); + const isWritable = profileRole === "super_admin" || !!profile?.systemPermissions?.manage_tenants; const query = useInfiniteQuery({ queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId], @@ -581,7 +582,7 @@ function TenantListPage() { return () => window.removeEventListener("message", onMessage); }, [allTenants, scopePickerOpen]); - if (profile && profileRole !== "super_admin") { + if (profile && profileRole !== "super_admin" && !profile?.systemPermissions?.tenants) { return (

@@ -840,81 +841,83 @@ function TenantListPage() { } actions={ <> - - - - - - - - - - {t( - "ui.admin.tenants.csv_template", - "템플릿 다운로드", - )} - - - fileInputRef.current?.click()} - disabled={importMutation.isPending} - data-testid="tenant-import-menu-item" - className="cursor-pointer" - > - - {t("ui.admin.tenants.import", "CSV 가져오기")} - - - exportMutation.mutate(false)} - disabled={exportMutation.isPending} - data-testid="tenant-export-menu-item" - className="cursor-pointer" - > - - {t( - "ui.admin.tenants.export_without_ids", - "UUID 제외 내보내기", - )} - - exportMutation.mutate(true)} - disabled={exportMutation.isPending} - data-testid="tenant-export-with-ids-menu-item" - className="cursor-pointer" - > - - {t( - "ui.admin.tenants.export_with_ids", - "UUID 포함 내보내기", - )} - - - - + {isWritable && ( + <> + + + + + + + + + {t( + "ui.admin.tenants.csv_template", + "템플릿 다운로드", + )} + + + fileInputRef.current?.click()} + disabled={importMutation.isPending} + data-testid="tenant-import-menu-item" + className="cursor-pointer" + > + + {t("ui.admin.tenants.import", "CSV 가져오기")} + + + exportMutation.mutate(false)} + disabled={exportMutation.isPending} + data-testid="tenant-export-menu-item" + className="cursor-pointer" + > + + {t( + "ui.admin.tenants.export_without_ids", + "UUID 제외 내보내기", + )} + + exportMutation.mutate(true)} + disabled={exportMutation.isPending} + data-testid="tenant-export-with-ids-menu-item" + className="cursor-pointer" + > + + {t( + "ui.admin.tenants.export_with_ids", + "UUID 포함 내보내기", + )} + + + + + )} - + {isWritable && ( - + )} } /> @@ -1071,7 +1074,7 @@ function TenantListPage() { {t("ui.common.apply", "적용")}
- + {isWritable && ( - + )}
+

+ ); + } + const tenantsQuery = useQuery({ queryKey: ["tenants", "list-all"], queryFn: () => fetchAllTenants(), From b5ac4e4d3f25e90e875313acf9825c4c6acf41d5 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 12 Jun 2026 18:51:25 +0900 Subject: [PATCH 20/35] =?UTF-8?q?adminfront:=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EB=B6=80=EC=97=AC=20=EB=A7=A4=ED=8A=B8=EB=A6=AD=EC=8A=A4=20?= =?UTF-8?q?=EC=83=81=EC=9D=98=20Ory=20SSOT=20=EB=B0=8F=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EC=98=86=EC=97=90=20'Super=20Admin=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9'=20=EC=8B=9C=EA=B0=81=EC=A0=81=20=EB=B1=83=EC=A7=80?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantFineGrainedPermissionsPage.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx index 8235c7c2..3292336f 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx @@ -457,7 +457,14 @@ export function TenantFineGrainedPermissionsPage() {
- {menu.label} +
+ {menu.label} + {(menu.relation === "ory_ssot" || menu.relation === "data_integrity") && ( + + {t("ui.admin.permissions_direct.super_admin_only", "Super Admin 전용")} + + )} +
{menu.desc} From af48e099048e1f114825d90592a81b25c3075af4 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 12 Jun 2026 18:56:26 +0900 Subject: [PATCH 21/35] =?UTF-8?q?adminfront:=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EB=B6=80=EC=97=AC=20=EB=A7=A4=ED=8A=B8=EB=A6=AD=EC=8A=A4=20?= =?UTF-8?q?=EC=83=81=EC=9D=98=20Ory=20SSOT=20=EB=B0=8F=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=95=ED=95=A9=EC=84=B1=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EB=B0=94(Select)=20=EB=B9=84=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94(Lock)=20=EC=B2=98=EB=A6=AC=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/tenants/routes/TenantFineGrainedPermissionsPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx index 3292336f..1134dbcd 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx @@ -473,6 +473,7 @@ export function TenantFineGrainedPermissionsPage() { 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() {