From a5fdeabd09b1178d6ff06dd7a64ee53192de611f Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 1 Apr 2026 11:19:09 +0900 Subject: [PATCH] fix: resolve tenant user assignment bug (#490) - Fix frontend payload mapping (tenantSlug -> companyCode) in adminApi.ts. - Fix backend group member fetching to avoid dummy members in UserGroupService.List. - Fix backend foreign key violation on group creation by distinguishing between tenant parent and group parent in UserGroupService.Create. --- adminfront/src/lib/adminApi.ts | 30 ++++++++++-- .../internal/service/user_group_service.go | 46 ++++++++----------- 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index aa8d52ab..e8d9ef4e 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -445,9 +445,15 @@ export async function fetchUser(userId: string) { } export async function createUser(payload: UserCreateRequest) { + // Map tenantSlug to companyCode for backend compatibility + const requestPayload: any = { ...payload }; + if (payload.tenantSlug !== undefined) { + requestPayload.companyCode = payload.tenantSlug; + } + const { data } = await apiClient.post( "/v1/admin/users", - payload, + requestPayload, ); return data; } @@ -466,9 +472,16 @@ export function exportUsersCSVUrl(search?: string, tenantSlug?: string) { } export async function bulkCreateUsers(users: BulkUserItem[]) { + const mappedUsers = users.map((u) => { + const mapped: any = { ...u }; + if (u.tenantSlug !== undefined) { + mapped.companyCode = u.tenantSlug; + } + return mapped; + }); const { data } = await apiClient.post( "/v1/admin/users/bulk", - { users }, + { users: mappedUsers }, ); return data; } @@ -480,7 +493,11 @@ export async function bulkUpdateUsers(payload: { tenantSlug?: string; department?: string; }) { - const { data } = await apiClient.put("/v1/admin/users/bulk", payload); + const requestPayload: any = { ...payload }; + if (payload.tenantSlug !== undefined) { + requestPayload.companyCode = payload.tenantSlug; + } + const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload); return data; } @@ -492,9 +509,14 @@ export async function bulkDeleteUsers(userIds: string[]) { } export async function updateUser(userId: string, payload: UserUpdateRequest) { + const requestPayload: any = { ...payload }; + if (payload.tenantSlug !== undefined) { + requestPayload.companyCode = payload.tenantSlug; + } + const { data } = await apiClient.put( `/v1/admin/users/${userId}`, - payload, + requestPayload, ); return data; } diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index 4634c776..52a684ed 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -55,13 +55,14 @@ func NewUserGroupService( } func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) { - // If no parent user group, the parent is the company tenant - if parentID == nil || *parentID == "" { - parentID = &tenantID + // For Keto and Tenant hierarchy, if no parent group, the company tenant is the parent. + actualParentID := parentID + if actualParentID == nil || *actualParentID == "" { + actualParentID = &tenantID } // Validate parent tenant exists - if _, err := s.tenantRepo.FindByID(ctx, *parentID); err != nil { + if _, err := s.tenantRepo.FindByID(ctx, *actualParentID); err != nil { return nil, fmt.Errorf("parent tenant not found or invalid: %w", err) } @@ -71,7 +72,7 @@ func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID groupTenant := &domain.Tenant{ ID: unitID, Type: domain.TenantTypeUserGroup, - ParentID: parentID, + ParentID: actualParentID, Name: name, Slug: fmt.Sprintf("ug-%s", unitID[:8]), Description: description, @@ -84,6 +85,7 @@ func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID } // 2. Create UserGroup metadata + // parent_id in user_groups refers to other groups, so use original parentID (which might be nil) group := &domain.UserGroup{ ID: unitID, TenantID: tenantID, @@ -105,7 +107,7 @@ func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID Namespace: "Tenant", Object: unitID, Relation: "parents", - Subject: "Tenant:" + *parentID, + Subject: "Tenant:" + *actualParentID, Action: domain.KetoOutboxActionCreate, }) } @@ -123,17 +125,12 @@ func (s *userGroupService) Delete(ctx context.Context, tenantID, groupID string) return nil // Placeholder } -func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGroup, error) { - group, err := s.repo.FindByID(ctx, id) - if err != nil { - return nil, err - } - - // Fetch members from Keto (Tenant namespace) +func (s *userGroupService) populateMembers(ctx context.Context, group *domain.UserGroup) { tuples, err := s.ketoService.ListRelations(ctx, "Tenant", group.ID, "members", "") if err != nil { slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID) - return nil, err + group.Members = []domain.User{} + return } var userIDs []string @@ -147,25 +144,21 @@ func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGrou } if len(userIDs) > 0 { - // 1. Try to find in local DB members, err := s.userRepo.FindByIDs(ctx, userIDs) if err != nil { slog.Error("Failed to fetch member details from db", "error", err) } - // 2. Map existing DB members memberMap := make(map[string]domain.User) for _, m := range members { memberMap[m.ID] = m } - // 3. For IDs not in DB, fetch from Kratos var finalMembers []domain.User for _, uid := range userIDs { if m, ok := memberMap[uid]; ok { finalMembers = append(finalMembers, m) } else if s.kratos != nil { - // Fallback to Kratos identity, err := s.kratos.GetIdentity(ctx, uid) if err == nil && identity != nil { name, _ := identity.Traits["name"].(string) @@ -182,7 +175,14 @@ func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGrou } else { group.Members = []domain.User{} } +} +func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGroup, error) { + group, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + s.populateMembers(ctx, group) return group, nil } @@ -196,16 +196,8 @@ func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain. return groups, nil } - // For each group, fetch member count from Keto for i := range groups { - tuples, err := s.ketoService.ListRelations(ctx, "Tenant", groups[i].ID, "members", "") - if err == nil { - // Create dummy members just to carry the count for the JSON response - groups[i].Members = make([]domain.User, len(tuples)) - } else { - slog.Warn("Failed to fetch member count from Keto", "groupID", groups[i].ID, "error", err) - groups[i].Members = []domain.User{} - } + s.populateMembers(ctx, &groups[i]) } return groups, nil