From a5102d9b2570af5f5dbcc1d49678eaeb29c48490 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 4 Mar 2026 15:43:00 +0900 Subject: [PATCH] feat: implement bulk user actions and organization tree search with auto-expansion --- .../routes/TenantUserGroupsTab.tsx | 54 ++++++- .../src/features/users/UserListPage.tsx | 113 ++++++++++++- adminfront/src/lib/adminApi.ts | 16 ++ backend/cmd/server/main.go | 2 + backend/internal/handler/user_handler.go | 149 ++++++++++++++++++ 5 files changed, 331 insertions(+), 3 deletions(-) diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index 6ce2eafa..31fc4a1a 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -581,19 +581,42 @@ const TenantTreeRow: React.FC<{ isRoot: boolean; onRemove: (id: string, name: string) => void; isUpdating: boolean; -}> = ({ node, level, isRoot, onRemove, isUpdating }) => { + searchTerm?: string; +}> = ({ node, level, isRoot, onRemove, isUpdating, searchTerm }) => { const navigate = useNavigate(); const [isExpanded, setIsExpanded] = useState(true); const [isUserAddOpen, setIsUserAddOpen] = useState(false); const [isMemberListOpen, setIsMemberListOpen] = useState(false); const hasChildren = node.children && node.children.length > 0; + // Auto expand if search matches children + React.useEffect(() => { + if (searchTerm) { + const hasMatchingChild = (n: TenantNode): boolean => { + return n.children.some( + (c) => + c.name.toLowerCase().includes(searchTerm.toLowerCase()) || + c.slug.toLowerCase().includes(searchTerm.toLowerCase()) || + hasMatchingChild(c), + ); + }; + if (hasMatchingChild(node)) { + setIsExpanded(true); + } + } + }, [searchTerm, node]); + + const isMatching = + searchTerm && + (node.name.toLowerCase().includes(searchTerm.toLowerCase()) || + node.slug.toLowerCase().includes(searchTerm.toLowerCase())); + const TypeIcon = getTenantIcon(node.type); return ( <>
@@ -750,6 +773,7 @@ const TenantTreeRow: React.FC<{ isRoot={false} onRemove={onRemove} isUpdating={isUpdating} + searchTerm={searchTerm} /> ))} @@ -762,6 +786,7 @@ function TenantUserGroupsTab() { const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isHeaderUserAddOpen, setIsHeaderUserAddOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(""); + const [treeSearchTerm, setTreeSearchTerm] = useState(""); if (!tenantId) return null; @@ -1008,6 +1033,30 @@ function TenantUserGroupsTab() {
+
+
+ + setTreeSearchTerm(e.target.value)} + /> +
+ {treeSearchTerm && ( + + )} +
@@ -1036,6 +1085,7 @@ function TenantUserGroupsTab() { isRoot={true} onRemove={handleRemove} isUpdating={updateParentMutation.isPending} + searchTerm={treeSearchTerm} />
diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 737fd19d..17473ff7 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -41,6 +41,8 @@ import { TableRow, } from "../../components/ui/table"; import { + bulkDeleteUsers, + bulkUpdateUsers, deleteUser, fetchMe, fetchTenant, @@ -63,6 +65,7 @@ function UserListPage() { const [searchDraft, setSearchDraft] = React.useState(""); const [selectedCompany, setSelectedCompany] = React.useState(""); const [visibleColumns, setVisibleColumns] = React.useState>({}); + const [selectedUserIds, setSelectedUserIds] = React.useState([]); const limit = 50; const offset = (page - 1) * limit; @@ -160,6 +163,50 @@ function UserListPage() { const total = query.data?.total ?? 0; const totalPages = Math.ceil(total / limit); + const toggleSelectAll = () => { + if (selectedUserIds.length === items.length) { + setSelectedUserIds([]); + } else { + setSelectedUserIds(items.map((u) => u.id)); + } + }; + + const toggleSelectUser = (id: string) => { + setSelectedUserIds((prev) => + prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id], + ); + }; + + const bulkDeleteMutation = useMutation({ + mutationFn: bulkDeleteUsers, + onSuccess: () => { + query.refetch(); + setSelectedUserIds([]); + toast.success(t("msg.admin.users.bulk.delete_success", "선택한 사용자들이 삭제되었습니다.")); + }, + }); + + const bulkUpdateMutation = useMutation({ + mutationFn: bulkUpdateUsers, + onSuccess: () => { + query.refetch(); + setSelectedUserIds([]); + toast.success(t("msg.admin.users.bulk.update_success", "선택한 사용자들의 정보가 수정되었습니다.")); + }, + }); + + const handleBulkStatusChange = (status: string) => { + if (selectedUserIds.length === 0) return; + bulkUpdateMutation.mutate({ userIds: selectedUserIds, status }); + }; + + const handleBulkDelete = () => { + if (selectedUserIds.length === 0) return; + if (window.confirm(t("msg.admin.users.bulk.delete_confirm", "{{count}}명의 사용자를 정말 삭제하시겠습니까?", { count: selectedUserIds.length }))) { + bulkDeleteMutation.mutate(selectedUserIds); + } + }; + const handleDelete = (userId: string, userName: string) => { if ( !window.confirm( @@ -324,6 +371,14 @@ function UserListPage() { + + 0 && selectedUserIds.length === items.length} + onChange={toggleSelectAll} + /> + {t("ui.admin.users.list.table.name_email", "NAME / EMAIL")} @@ -377,7 +432,18 @@ function UserListPage() { )} {items.map((user) => ( - + + + toggleSelectUser(user.id)} + /> +
@@ -452,6 +518,51 @@ function UserListPage() {
+ {/* Bulk Action Bar */} + {selectedUserIds.length > 0 && ( +
+ + {t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", { count: selectedUserIds.length })} + +
+ + +
+ +
+ +
+ )} + {/* Pagination */} {totalPages > 1 && (
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 8f03cf03..376a005c 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -453,6 +453,22 @@ export async function bulkCreateUsers(users: BulkUserItem[]) { return data; } +export async function bulkUpdateUsers(payload: { + userIds: string[]; + status?: string; + role?: string; +}) { + const { data } = await apiClient.put("/v1/admin/users/bulk", payload); + return data; +} + +export async function bulkDeleteUsers(userIds: string[]) { + const { data } = await apiClient.delete("/v1/admin/users/bulk", { + data: { userIds }, + }); + return data; +} + export async function updateUser(userId: string, payload: UserUpdateRequest) { const { data } = await apiClient.put( `/v1/admin/users/${userId}`, diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 1ea41f4b..82fb685d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -645,6 +645,8 @@ func main() { admin.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin인 경우 해당 테넌트 사용자만 보이도록 Handler 수정 필요 admin.Post("/users", requireAdmin, userHandler.CreateUser) admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers) + admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers) + admin.Delete("/users/bulk", requireAdmin, userHandler.BulkDeleteUsers) admin.Get("/users/:id", requireAdmin, userHandler.GetUser) admin.Put("/users/:id", requireAdmin, userHandler.UpdateUser) admin.Delete("/users/:id", requireAdmin, userHandler.DeleteUser) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 98fac67f..3f84a18c 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -541,6 +541,155 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { }) } +func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { + var req struct { + UserIDs []string `json:"userIds"` + Status *string `json:"status"` + Role *string `json:"role"` + } + if err := c.BodyParser(&req); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + if len(req.UserIDs) == 0 { + return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided") + } + + requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) + if requester == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") + } + + // Build manageable slugs map if tenant_admin + manageableSlugs := make(map[string]bool) + if requester.Role == domain.RoleTenantAdmin { + for _, t := range requester.ManageableTenants { + manageableSlugs[strings.ToLower(t.Slug)] = true + } + if requester.CompanyCode != "" { + manageableSlugs[strings.ToLower(requester.CompanyCode)] = true + } + } + + results := make([]map[string]any, 0, len(req.UserIDs)) + + for _, id := range req.UserIDs { + identity, err := h.KratosAdmin.GetIdentity(c.Context(), id) + if err != nil { + results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"}) + continue + } + + // Authorization check + if requester.Role == domain.RoleTenantAdmin { + userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode")) + if !manageableSlugs[userComp] { + results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"}) + continue + } + } + + // Prepare updates + traits := identity.Traits + if req.Role != nil { + traits["role"] = *req.Role + } + + state := identity.State + if req.Status != nil { + if *req.Status == "active" { + state = "active" + } else { + state = "inactive" + } + } + + _, err = h.KratosAdmin.UpdateIdentity(c.Context(), id, traits, state) + if err != nil { + results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()}) + continue + } + + // Sync to local DB + if h.UserRepo != nil { + localUser := h.mapToLocalUser(*identity) + if req.Role != nil { + localUser.Role = *req.Role + } + if req.Status != nil { + localUser.Status = *req.Status + } + _ = h.UserRepo.Update(c.Context(), localUser) + } + + results = append(results, map[string]any{"id": id, "success": true}) + } + + return c.JSON(fiber.Map{"results": results}) +} + +func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error { + var req struct { + UserIDs []string `json:"userIds"` + } + if err := c.BodyParser(&req); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + if len(req.UserIDs) == 0 { + return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided") + } + + requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) + if requester == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") + } + + manageableSlugs := make(map[string]bool) + if requester.Role == domain.RoleTenantAdmin { + for _, t := range requester.ManageableTenants { + manageableSlugs[strings.ToLower(t.Slug)] = true + } + if requester.CompanyCode != "" { + manageableSlugs[strings.ToLower(requester.CompanyCode)] = true + } + } + + results := make([]map[string]any, 0, len(req.UserIDs)) + + for _, id := range req.UserIDs { + identity, err := h.KratosAdmin.GetIdentity(c.Context(), id) + if err != nil { + results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"}) + continue + } + + // Authorization check + if requester.Role == domain.RoleTenantAdmin { + userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode")) + if !manageableSlugs[userComp] { + results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"}) + continue + } + } + + err = h.KratosAdmin.DeleteIdentity(c.Context(), id) + if err != nil { + results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()}) + continue + } + + // Local DB Sync + if h.UserRepo != nil { + _ = h.UserRepo.Delete(c.Context(), id) + } + + results = append(results, map[string]any{"id": id, "success": true}) + } + + return c.JSON(fiber.Map{"results": results}) +} + func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { if h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")