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",