From d83646a7eff2cc702863bce65f20a57117adefea Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 25 Mar 2026 16:05:39 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=86=8C=EC=9C=A0=EC=9E=90=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=B6=94=EA=B0=80=20=EC=8B=9C?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=EB=93=B1=EB=A1=9D=20=EB=B0=A9=EC=A7=80?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20(#440)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/TenantAdminsAndOwnersTab.tsx | 82 +++++++++++++++++-- backend/internal/handler/tenant_handler.go | 14 ++++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index 4031f590..da160f0f 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -82,14 +82,35 @@ export function TenantAdminsAndOwnersTab() { const addOwnerMutation = useMutation({ mutationFn: (userId: string) => addTenantOwner(tenantId, userId), + onMutate: async (userId) => { + await queryClient.cancelQueries({ queryKey: ["tenant-owners", tenantId] }); + const previousOwners = queryClient.getQueryData(["tenant-owners", tenantId]); + + // Optimistically add to the list to prevent immediate double clicks + const addedUser = searchResults.find(u => u.id === userId); + if (addedUser) { + queryClient.setQueryData(["tenant-owners", tenantId], old => { + if (!old) return [{ id: userId, name: addedUser.name, email: addedUser.email }]; + if (old.some(o => o.id === userId)) return old; + return [...old, { id: userId, name: addedUser.name, email: addedUser.email }]; + }); + } + return { previousOwners }; + }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] }); + // Delay invalidation slightly to give the backend outbox time to process + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] }); + }, 1000); toast.success( t("msg.admin.tenants.owners.add_success", "소유자가 추가되었습니다."), ); setSearchTerm(""); }, - onError: (err: AxiosError<{ error?: string }>) => { + onError: (err: AxiosError<{ error?: string }>, userId, context) => { + if (context?.previousOwners) { + queryClient.setQueryData(["tenant-owners", tenantId], context.previousOwners); + } toast.error( err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."), @@ -99,8 +120,18 @@ export function TenantAdminsAndOwnersTab() { const removeOwnerMutation = useMutation({ mutationFn: (userId: string) => removeTenantOwner(tenantId, userId), + onMutate: async (userId) => { + await queryClient.cancelQueries({ queryKey: ["tenant-owners", tenantId] }); + const previousOwners = queryClient.getQueryData(["tenant-owners", tenantId]); + queryClient.setQueryData(["tenant-owners", tenantId], old => + old ? old.filter(o => o.id !== userId) : [] + ); + return { previousOwners }; + }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] }); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] }); + }, 1000); toast.success( t( "msg.admin.tenants.owners.remove_success", @@ -108,7 +139,10 @@ export function TenantAdminsAndOwnersTab() { ), ); }, - onError: (err: AxiosError<{ error?: string }>) => { + onError: (err: AxiosError<{ error?: string }>, userId, context) => { + if (context?.previousOwners) { + queryClient.setQueryData(["tenant-owners", tenantId], context.previousOwners); + } toast.error( err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."), @@ -118,14 +152,33 @@ export function TenantAdminsAndOwnersTab() { const addAdminMutation = useMutation({ mutationFn: (userId: string) => addTenantAdmin(tenantId, userId), + onMutate: async (userId) => { + await queryClient.cancelQueries({ queryKey: ["tenant-admins", tenantId] }); + const previousAdmins = queryClient.getQueryData(["tenant-admins", tenantId]); + + const addedUser = searchResults.find(u => u.id === userId); + if (addedUser) { + queryClient.setQueryData(["tenant-admins", tenantId], old => { + if (!old) return [{ id: userId, name: addedUser.name, email: addedUser.email }]; + if (old.some(a => a.id === userId)) return old; + return [...old, { id: userId, name: addedUser.name, email: addedUser.email }]; + }); + } + return { previousAdmins }; + }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); + }, 1000); toast.success( t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."), ); setSearchTerm(""); }, - onError: (err: AxiosError<{ error?: string }>) => { + onError: (err: AxiosError<{ error?: string }>, userId, context) => { + if (context?.previousAdmins) { + queryClient.setQueryData(["tenant-admins", tenantId], context.previousAdmins); + } toast.error( err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."), @@ -135,13 +188,26 @@ export function TenantAdminsAndOwnersTab() { const removeAdminMutation = useMutation({ mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId), + onMutate: async (userId) => { + await queryClient.cancelQueries({ queryKey: ["tenant-admins", tenantId] }); + const previousAdmins = queryClient.getQueryData(["tenant-admins", tenantId]); + queryClient.setQueryData(["tenant-admins", tenantId], old => + old ? old.filter(a => a.id !== userId) : [] + ); + return { previousAdmins }; + }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); + }, 1000); toast.success( t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."), ); }, - onError: (err: AxiosError<{ error?: string }>) => { + onError: (err: AxiosError<{ error?: string }>, userId, context) => { + if (context?.previousAdmins) { + queryClient.setQueryData(["tenant-admins", tenantId], context.previousAdmins); + } toast.error( err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."), diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index a9b9c28d..a6fa095e 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -529,6 +529,13 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } + if h.Keto != nil { + relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", "User:"+userID) + if err == nil && len(relations) > 0 { + return errorJSON(c, fiber.StatusConflict, "이미 관리자로 등록된 사용자입니다.") + } + } + if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", @@ -660,6 +667,13 @@ func (h *TenantHandler) AddOwner(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } + if h.Keto != nil { + relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "User:"+userID) + if err == nil && len(relations) > 0 { + return errorJSON(c, fiber.StatusConflict, "이미 소유자로 등록된 사용자입니다.") + } + } + if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant",