diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 649b2905..502fe16d 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -583,8 +583,8 @@ function UserDetailPage() { name: typeof metadata.primaryTenantName === "string" ? metadata.primaryTenantName - : user.tenant?.name || user.companyCode || "", - slug: user.companyCode, + : user.tenant?.name || user.tenantSlug || "", + slug: user.tenantSlug, } : null; const fallbackAppointment = @@ -603,7 +603,7 @@ function UserDetailPage() { role: user.role, status: user.status, tenantSlug: - user.companyCode || + user.tenantSlug || user.joinedTenants?.find( (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP", )?.slug || @@ -624,7 +624,6 @@ function UserDetailPage() { hanmacFamilyTenantId, ); const isPersonalUser = - user.companyCode === personalTenant.slug || user.tenantSlug === personalTenant.slug || user.tenant?.id === personalTenant.id || user.tenant?.slug === personalTenant.slug || @@ -896,7 +895,7 @@ function UserDetailPage() { > {user.tenant?.name || - user.companyCode || + user.tenantSlug || user.joinedTenants?.find( (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP", )?.name || diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 7e175093..923c05e1 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -778,7 +778,7 @@ function UserListPage() {
{user.tenant?.name || - user.companyCode || + user.tenantSlug || t("ui.common.unassigned", "미배정")} {user.department && ( diff --git a/adminfront/src/features/users/orgChartPicker.ts b/adminfront/src/features/users/orgChartPicker.ts index 3bc61a76..05b7a511 100644 --- a/adminfront/src/features/users/orgChartPicker.ts +++ b/adminfront/src/features/users/orgChartPicker.ts @@ -160,7 +160,6 @@ export function isHanmacFamilyUser( ...metadataAppointments.map((appointment) => tenantById.get(appointment.id ?? ""), ), - tenantBySlug.get(user.companyCode?.toLowerCase() ?? ""), tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""), ]; diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 9c842e03..b3e79bae 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4564,14 +4564,6 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai profile.Tenant = tenant } } - if profile.Tenant == nil && profile.CompanyCode != "" { - if tenant, err := h.TenantService.GetTenantBySlug(ctx, profile.CompanyCode); err == nil && tenant != nil { - profile.Tenant = tenant - if profile.TenantID == nil || *profile.TenantID == "" { - profile.TenantID = &tenant.ID - } - } - } } if h.TenantService != nil { diff --git a/backend/internal/handler/client_tenant_access.go b/backend/internal/handler/client_tenant_access.go index 097be621..8a4c2313 100644 --- a/backend/internal/handler/client_tenant_access.go +++ b/backend/internal/handler/client_tenant_access.go @@ -258,22 +258,11 @@ func resolveCurrentTenantDetails(c *fiber.Ctx, tenantSvc service.TenantService, } } } - if strings.TrimSpace(profile.CompanyCode) != "" { - if tenant, err := tenantSvc.GetTenantBySlug(c.Context(), strings.TrimSpace(profile.CompanyCode)); err == nil && tenant != nil { - return tenantAccessDeniedTenant{ - ID: strings.TrimSpace(tenant.ID), - Slug: strings.TrimSpace(tenant.Slug), - Name: strings.TrimSpace(tenant.Name), - Identifier: firstNonEmptyString(strings.TrimSpace(tenant.Slug), strings.TrimSpace(tenant.ID)), - } - } - } } return tenantAccessDeniedTenant{ ID: strings.TrimSpace(pointerValue(profile.TenantID)), - Slug: strings.TrimSpace(profile.CompanyCode), - Identifier: firstNonEmptyString(strings.TrimSpace(profile.CompanyCode), strings.TrimSpace(pointerValue(profile.TenantID))), + Identifier: strings.TrimSpace(pointerValue(profile.TenantID)), } } diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index fae65ce8..8f3ed43d 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -603,7 +603,6 @@ func manageableTenantKeysFromProfile(profile *domain.UserProfileResponse) map[st } } - addKey(profile.CompanyCode) if profile.TenantID != nil { addKey(*profile.TenantID) } @@ -631,8 +630,6 @@ func canAccessIdentityByTenant(profile *domain.UserProfileResponse, identity ser for _, raw := range []string{ extractTraitString(identity.Traits, "tenant_id"), - extractTraitString(identity.Traits, "companyCode"), - extractTraitString(identity.Traits, "company_code"), } { if _, ok := keys[strings.ToLower(strings.TrimSpace(raw))]; ok { return true diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index cf909220..ba446949 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -266,15 +266,6 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { baseTenantIDs = append(baseTenantIDs, *profile.TenantID) } - // Try to find by companyCode if needed - if profile.CompanyCode != "" { - for _, t := range allTenants { - if strings.EqualFold(t.Slug, profile.CompanyCode) { - baseTenantIDs = append(baseTenantIDs, t.ID) - } - } - } - parentMap := make(map[string]string) for _, t := range allTenants { if t.ParentID != nil { diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 588485b1..4a6abcbf 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -261,9 +261,6 @@ func identityTenantAccessKeys(traits map[string]interface{}) []string { if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" { keys = append(keys, tenantID) } - if legacySlug := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "companyCode"))); legacySlug != "" { - keys = append(keys, legacySlug) - } return keys } @@ -276,6 +273,60 @@ func anyTenantKeyAllowed(keys []string, allowed map[string]bool) bool { return false } +func profileTenantAccessKeys(profile *domain.UserProfileResponse) map[string]bool { + allowed := make(map[string]bool) + if profile == nil { + return allowed + } + if profile.TenantID != nil { + if id := strings.ToLower(strings.TrimSpace(*profile.TenantID)); id != "" { + allowed[id] = true + } + } + for _, tenant := range profile.ManageableTenants { + if id := strings.ToLower(strings.TrimSpace(tenant.ID)); id != "" { + allowed[id] = true + } + if slug := strings.ToLower(strings.TrimSpace(tenant.Slug)); slug != "" { + allowed[slug] = true + } + } + for _, tenant := range profile.JoinedTenants { + if id := strings.ToLower(strings.TrimSpace(tenant.ID)); id != "" { + allowed[id] = true + } + if slug := strings.ToLower(strings.TrimSpace(tenant.Slug)); slug != "" { + allowed[slug] = true + } + } + return allowed +} + +func profileCanAccessTenant(profile *domain.UserProfileResponse, tenantID, tenantSlug string) bool { + allowed := profileTenantAccessKeys(profile) + if id := strings.ToLower(strings.TrimSpace(tenantID)); id != "" && allowed[id] { + return true + } + if slug := strings.ToLower(strings.TrimSpace(tenantSlug)); slug != "" && allowed[slug] { + return true + } + return false +} + +func userTenantID(user domain.User) string { + if user.TenantID == nil { + return "" + } + return strings.TrimSpace(*user.TenantID) +} + +func userTenantSlug(user domain.User) string { + if user.Tenant != nil { + return strings.TrimSpace(user.Tenant.Slug) + } + return "" +} + type userSummary struct { ID string `json:"id"` Email string `json:"email"` @@ -352,10 +403,6 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { manageableSlugs[strings.ToLower(t.ID)] = true baseTenantIDs = append(baseTenantIDs, t.ID) } - // Include primary tenant slug if not already there - if profile.CompanyCode != "" { - manageableSlugs[strings.ToLower(profile.CompanyCode)] = true - } if profile.TenantID != nil { manageableSlugs[strings.ToLower(*profile.TenantID)] = true baseTenantIDs = append(baseTenantIDs, *profile.TenantID) @@ -423,21 +470,11 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { for _, identity := range identities { email := strings.ToLower(extractTraitString(identity.Traits, "email")) name := strings.ToLower(extractTraitString(identity.Traits, "name")) - compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode")) tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id")) - secondaryCodes := extractTraitStringArray(identity.Traits, "companyCodes") // Tenant Admin & Member filtering if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin { - hasAccess := manageableSlugs[compCode] || manageableSlugs[tID] - if !hasAccess && len(secondaryCodes) > 0 { - for _, code := range secondaryCodes { - if manageableSlugs[strings.ToLower(code)] { - hasAccess = true - break - } - } - } + hasAccess := manageableSlugs[tID] if !hasAccess { continue } @@ -445,34 +482,16 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { // Dedicated tenantSlug filter if tenantSlug != "" { - matches := strings.EqualFold(compCode, tenantSlug) || tID == targetTenantID - if !matches && len(secondaryCodes) > 0 { - for _, code := range secondaryCodes { - if strings.EqualFold(code, tenantSlug) { - matches = true - break - } - } - } + matches := tID == targetTenantID if !matches { continue } } - // Search filtering (Keyword search in email, name, or companyCode) + // Search filtering if search != "" { matchesSearch := strings.Contains(email, searchLower) || - strings.Contains(name, searchLower) || - strings.Contains(strings.ToLower(compCode), searchLower) - - if !matchesSearch && len(secondaryCodes) > 0 { - for _, code := range secondaryCodes { - if strings.Contains(strings.ToLower(code), searchLower) { - matchesSearch = true - break - } - } - } + strings.Contains(name, searchLower) if !matchesSearch { continue @@ -560,22 +579,8 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error { // [New] Check access scope requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if requester != nil && requester.Role == domain.RoleTenantAdmin { - compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode")) - - // Check if the target user's companyCode is in requester's manageable tenants - allowed := false - for _, t := range requester.ManageableTenants { - if strings.ToLower(t.Slug) == compCode { - allowed = true - break - } - } - // Also check primary company code - if !allowed && strings.ToLower(requester.CompanyCode) == compCode { - allowed = true - } - - if !allowed { + allowedKeys := profileTenantAccessKeys(requester) + if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowedKeys) { return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied") } } @@ -1043,7 +1048,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { // Role-based access check if requester != nil && requester.Role == domain.RoleTenantAdmin { - if tenantSlug != requester.CompanyCode { + if !profileCanAccessTenant(requester, tItem.ID, tenantSlug) { results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"}) continue } @@ -1057,11 +1062,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { if appointmentTenantSlug == "" { continue } - if requester != nil && requester.Role == domain.RoleTenantAdmin && appointmentTenantSlug != requester.CompanyCode { - results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"}) - appointmentFailed = true - break - } appointmentTenant, exists := tenantCache[strings.ToLower(appointmentTenantSlug)] if !exists { @@ -1072,6 +1072,11 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { break } } + if requester != nil && requester.Role == domain.RoleTenantAdmin && !profileCanAccessTenant(requester, appointmentTenant.ID, appointmentTenant.Slug) { + results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"}) + appointmentFailed = true + break + } appointment := make(map[string]any, len(rawAppointment)+3) for key, value := range rawAppointment { @@ -1244,7 +1249,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { Phone: normalizePhoneNumber(item.Phone), Role: role, Status: "active", - CompanyCode: tenantSlug, Department: dept, Grade: strings.TrimSpace(item.Grade), AffiliationType: "internal", @@ -1376,9 +1380,10 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error { if profile != nil && requesterRole == domain.RoleTenantAdmin { for _, t := range profile.ManageableTenants { manageableSlugs = append(manageableSlugs, strings.ToLower(t.Slug)) + manageableSlugs = append(manageableSlugs, strings.ToLower(t.ID)) } - if profile.CompanyCode != "" { - manageableSlugs = append(manageableSlugs, strings.ToLower(profile.CompanyCode)) + if profile.TenantID != nil { + manageableSlugs = append(manageableSlugs, strings.ToLower(*profile.TenantID)) } } @@ -1396,7 +1401,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error { slugMap[s] = true } for _, u := range users { - if slugMap[strings.ToLower(u.CompanyCode)] { + if slugMap[strings.ToLower(userTenantSlug(u))] || slugMap[strings.ToLower(userTenantID(u))] { filtered = append(filtered, u) } } @@ -1452,7 +1457,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error { u.Name, u.Phone, u.Status, - u.CompanyCode, + userTenantSlug(u), u.Grade, u.Position, u.JobTitle, @@ -1466,7 +1471,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error { u.Phone, u.Status, tenantID, - u.CompanyCode, + userTenantSlug(u), u.Grade, u.Position, u.JobTitle, @@ -1534,7 +1539,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { *req.Role = role } - // [New] Pre-fetch tenant cache if companyCode is being changed + // Pre-fetch tenant cache if tenantSlug is being changed. type tenantCacheItem struct { ID string Schema []interface{} @@ -1543,12 +1548,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { 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 - } + manageableSlugs = profileTenantAccessKeys(requester) } results := make([]map[string]any, 0, len(req.UserIDs)) @@ -1576,9 +1576,14 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"}) continue } - // If changing companyCode, must be to a manageable one if req.CompanyCode != nil { - if !manageableSlugs[strings.ToLower(*req.CompanyCode)] { + targetAllowed := manageableSlugs[strings.ToLower(*req.CompanyCode)] + if !targetAllowed && h.TenantService != nil { + if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil { + targetAllowed = manageableSlugs[strings.ToLower(tenant.ID)] + } + } + if !targetAllowed { results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: target tenant not manageable"}) continue } @@ -1646,9 +1651,6 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { if req.Status != nil { localUser.Status = *req.Status } - if req.CompanyCode != nil { - localUser.CompanyCode = *req.CompanyCode - } if req.Department != nil { localUser.Department = *req.Department } @@ -1659,7 +1661,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { localUser.JobTitle = *req.JobTitle } - // Resolve TenantID if changing companyCode + // Resolve TenantID if changing tenantSlug. if req.CompanyCode != nil && h.TenantService != nil { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil { localUser.TenantID = &tenant.ID @@ -1705,12 +1707,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error { 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 - } + manageableSlugs = profileTenantAccessKeys(requester) } results := make([]map[string]any, 0, len(req.UserIDs)) @@ -1802,12 +1799,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { if requester.TenantID != nil { allowed[strings.ToLower(*requester.TenantID)] = true } - if requester.CompanyCode != "" { - allowed[strings.ToLower(requester.CompanyCode)] = true - } for _, tenant := range requester.ManageableTenants { allowed[strings.ToLower(tenant.ID)] = true - allowed[strings.ToLower(tenant.Slug)] = true } if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowed) { return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot update user in another tenant") @@ -1855,10 +1848,19 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { *req.Role = role } - // [New] Tenant Admin restriction: Cannot change companyCode (except when adding/removing secondary membership) + // Tenant admins can only move users within tenants they can manage. if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin { - if !req.IsAddTenant && !req.IsRemoveTenant && req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode { - return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant") + if !req.IsAddTenant && !req.IsRemoveTenant && req.CompanyCode != nil { + targetSlug := strings.TrimSpace(*req.CompanyCode) + targetAllowed := profileCanAccessTenant(requester, "", targetSlug) + if !targetAllowed && h.TenantService != nil && targetSlug != "" { + if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), targetSlug); err == nil && tenant != nil { + targetAllowed = profileCanAccessTenant(requester, tenant.ID, tenant.Slug) + } + } + if !targetAllowed { + return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant") + } } } @@ -1884,19 +1886,20 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { } } } else { - // Legacy/Flat metadata - validate using primary tenant schema - schemaCompCode := extractTraitString(identity.Traits, "companyCode") + schemaTenantSlug := "" if req.CompanyCode != nil { - schemaCompCode = *req.CompanyCode + schemaTenantSlug = *req.CompanyCode } - if schemaCompCode != "" && h.TenantService != nil { - tenant, err := h.TenantService.GetTenantBySlug(c.Context(), schemaCompCode) - if err == nil && tenant != nil { - if schema, ok := tenant.Config["userSchema"].([]interface{}); ok { - // For flat metadata, we validate the whole req.Metadata against this schema - if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil { - return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error()) - } + var tenant *domain.Tenant + if schemaTenantSlug != "" && h.TenantService != nil { + tenant, _ = h.TenantService.GetTenantBySlug(c.Context(), schemaTenantSlug) + } else if tenantID := extractTraitString(identity.Traits, "tenant_id"); tenantID != "" && h.TenantService != nil { + tenant, _ = h.TenantService.GetTenant(c.Context(), tenantID) + } + if tenant != nil { + if schema, ok := tenant.Config["userSchema"].([]interface{}); ok { + if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error()) } } } @@ -2106,24 +2109,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { } } - // [Self-Healing] Sync all companyCodes to Keto if h.KetoOutboxRepo != nil && h.TenantService != nil { - if codes, ok := updated.Traits["companyCodes"].([]interface{}); ok { - for _, cVal := range codes { - if cStr, ok := cVal.(string); ok && cStr != "" { - if tenant, err := h.TenantService.GetTenantBySlug(bgCtx, cStr); err == nil && tenant != nil { - _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: tenant.ID, - Relation: "members", - Subject: "User:" + updatedLocalUser.ID, - Action: domain.KetoOutboxActionCreate, - }) - } - } - } - } else if updatedLocalUser.TenantID != nil { - // Fallback if companyCodes doesn't exist + if updatedLocalUser.TenantID != nil { _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{ Namespace: "Tenant", Object: *updatedLocalUser.TenantID, @@ -2181,12 +2168,8 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { if requester.TenantID != nil { allowed[strings.ToLower(*requester.TenantID)] = true } - if requester.CompanyCode != "" { - allowed[strings.ToLower(requester.CompanyCode)] = true - } for _, tenant := range requester.ManageableTenants { allowed[strings.ToLower(tenant.ID)] = true - allowed[strings.ToLower(tenant.Slug)] = true } if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowed) { return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot delete user in another tenant") diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index bd7d9f4d..85a61eb5 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -217,21 +217,23 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T app.Get("/users/export", h.ExportUsersCSV) createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) + tenantID := "tenant-uuid" mockRepo.On("List", mock.Anything, 0, 10000, "", "test-tenant"). Return([]domain.User{ { - ID: "u-1", - Email: "user@test.com", - Name: "Test User", - Phone: "010-1111-2222", - Role: domain.RoleSuperAdmin, - Status: "active", - CompanyCode: "test-tenant", - Department: "Legacy Department", - Grade: "책임", - Position: "팀장", - JobTitle: "플랫폼 운영", - CreatedAt: createdAt, + ID: "u-1", + Email: "user@test.com", + Name: "Test User", + Phone: "010-1111-2222", + Role: domain.RoleSuperAdmin, + Status: "active", + TenantID: &tenantID, + Tenant: &domain.Tenant{ID: tenantID, Slug: "test-tenant"}, + Department: "Legacy Department", + Grade: "책임", + Position: "팀장", + JobTitle: "플랫폼 운영", + CreatedAt: createdAt, }, }, int64(1), nil).Once() @@ -243,7 +245,7 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T bodyBytes, _ := io.ReadAll(resp.Body) body := strings.TrimPrefix(string(bodyBytes), "\ufeff") assert.Contains(t, body, "user_id,Email,Name,Phone,Status,tenant_id,tenant_slug,Grade,Position,JobTitle,CreatedAt") - assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,,test-tenant,책임,팀장") + assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,tenant-uuid,test-tenant,책임,팀장") assert.NotContains(t, body, "Role") assert.NotContains(t, body, "Department") mockRepo.AssertExpectations(t) @@ -267,17 +269,17 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) { mockRepo.On("List", mock.Anything, 0, 10000, "", ""). Return([]domain.User{ { - ID: "user-uuid", - Email: "user@test.com", - Name: "Test User", - Phone: "010-1111-2222", - Status: "active", - CompanyCode: "test-tenant", - TenantID: &tenantID, - Grade: "책임", - Position: "팀장", - JobTitle: "플랫폼 운영", - CreatedAt: createdAt, + ID: "user-uuid", + Email: "user@test.com", + Name: "Test User", + Phone: "010-1111-2222", + Status: "active", + TenantID: &tenantID, + Tenant: &domain.Tenant{ID: tenantID, Slug: "test-tenant"}, + Grade: "책임", + Position: "팀장", + JobTitle: "플랫폼 운영", + CreatedAt: createdAt, }, }, int64(1), nil).Once() @@ -296,6 +298,64 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) { mockRepo.AssertExpectations(t) } +func TestUserHandler_ExportUsersCSV_TenantAdminFiltersByTenantIDWithoutCompanyCode(t *testing.T) { + app := fiber.New() + mockRepo := new(MockUserRepoForHandler) + h := &UserHandler{UserRepo: mockRepo} + + tenantID := "tenant-uuid" + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + Role: domain.RoleTenantAdmin, + TenantID: &tenantID, + ManageableTenants: []domain.Tenant{ + {ID: tenantID, Slug: "test-tenant"}, + }, + }) + return c.Next() + }) + app.Get("/users/export", h.ExportUsersCSV) + + createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) + otherTenantID := "other-tenant-uuid" + mockRepo.On("List", mock.Anything, 0, 10000, "", ""). + Return([]domain.User{ + { + ID: "user-uuid", + Email: "user@test.com", + Name: "Test User", + Phone: "010-1111-2222", + Status: "active", + TenantID: &tenantID, + Tenant: &domain.Tenant{ID: tenantID, Slug: "test-tenant"}, + Grade: "책임", + Position: "팀장", + JobTitle: "플랫폼 운영", + CreatedAt: createdAt, + }, + { + ID: "other-user", + Email: "other@test.com", + Name: "Other User", + Status: "active", + TenantID: &otherTenantID, + Tenant: &domain.Tenant{ID: otherTenantID, Slug: "other-tenant"}, + CreatedAt: createdAt, + }, + }, int64(2), nil).Once() + + req := httptest.NewRequest("GET", "/users/export?includeIds=false", nil) + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + bodyBytes, _ := io.ReadAll(resp.Body) + body := strings.TrimPrefix(string(bodyBytes), "\ufeff") + assert.Contains(t, body, "user@test.com,Test User,010-1111-2222,active,test-tenant") + assert.NotContains(t, body, "other@test.com") + mockRepo.AssertExpectations(t) +} + func TestUserHandler_BulkCreateUsers(t *testing.T) { app := fiber.New() mockKratos := new(MockKratosAdmin) @@ -1049,12 +1109,15 @@ func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) { }) t.Run("Fail - Regular user updating admin_only field", func(t *testing.T) { + tenantID := "t-123" mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ ID: "u-1", - Traits: map[string]interface{}{"email": "user@test.com", "companyCode": "test-tenant"}, + Traits: map[string]interface{}{"email": "user@test.com", "tenant_id": tenantID}, }, nil) - mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{ + mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{ + ID: tenantID, + Slug: "test-tenant", Config: domain.JSONMap{ "userSchema": []interface{}{ map[string]interface{}{"key": "salary", "adminOnly": true}, diff --git a/orgfront/src/features/orgchart/pickerTree.ts b/orgfront/src/features/orgchart/pickerTree.ts index c1acbdf9..7aa12922 100644 --- a/orgfront/src/features/orgchart/pickerTree.ts +++ b/orgfront/src/features/orgchart/pickerTree.ts @@ -6,9 +6,7 @@ import { filterTenantsByVisibility } from "./tenantVisibility"; import { getOrgChartUserDisplayName } from "./userDisplay"; function getUserTenantSlug(user: UserSummary) { - return ( - user.companyCode?.toLowerCase() || user.tenantSlug?.toLowerCase() || "" - ); + return user.tenantSlug?.toLowerCase() || ""; } function isOrgFrontTenantType(tenant: TenantSummary) { diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx index 42a7eea3..68d8c1ce 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx @@ -990,8 +990,8 @@ function isSystemGlobalUser(user: UserSummary) { normalizedRole === "system-admin" || isSystemGlobalTenant(user.tenant) || isSystemGlobalTenant({ - id: user.companyCode || user.tenantSlug || "", - slug: user.companyCode || user.tenantSlug || "", + id: user.tenantSlug || "", + slug: user.tenantSlug || "", type: user.role, name: user.role, }) @@ -1145,8 +1145,7 @@ function buildUsersMap( if (!isVisibleOrgChartUser(user)) continue; const slugs = new Set(); - const primarySlug = - user.companyCode?.toLowerCase() || user.tenantSlug?.toLowerCase() || ""; + const primarySlug = user.tenantSlug?.toLowerCase() || ""; if ( primarySlug && !isSystemGlobalTenant({ diff --git a/orgfront/src/features/profile/ProfilePage.tsx b/orgfront/src/features/profile/ProfilePage.tsx index e19164bc..bc9c8f9a 100644 --- a/orgfront/src/features/profile/ProfilePage.tsx +++ b/orgfront/src/features/profile/ProfilePage.tsx @@ -58,8 +58,7 @@ function ProfilePage() { profile.tenantId || auth.user?.profile?.tenant_id?.toString() || "-"; - const displayCompanyCode = - profile.companyCode || auth.user?.profile?.companyCode?.toString() || "-"; + const displayTenantSlug = profile.tenant?.slug || profile.tenantId || "-"; return (
@@ -160,9 +159,9 @@ function ProfilePage() {

- {t("ui.dev.profile.org.company_code", "회사 코드")} + {t("ui.dev.profile.org.tenant_slug", "테넌트 Slug")}

-

{displayCompanyCode}

+

{displayTenantSlug}

diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 4393a094..af3ad5f9 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -599,6 +599,7 @@ department = "Department" email = "Email" name = "Name" tenant = "Tenant" +tenant_slug = "Tenant Slug" [ui.userfront.profile.password] change = "Change" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 8a84d53f..d953140d 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -821,6 +821,7 @@ department = "소속" email = "이메일" name = "이름" tenant = "소속 테넌트" +tenant_slug = "테넌트 Slug" [ui.userfront.profile.password] change = "비밀번호 변경" diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart index f1dc9d81..761456d6 100644 --- a/userfront/lib/features/auth/presentation/error_screen.dart +++ b/userfront/lib/features/auth/presentation/error_screen.dart @@ -125,11 +125,6 @@ class _ErrorScreenState extends State { } } - final companyCode = profile['companyCode']?.toString().trim() ?? ''; - if (companyCode.isNotEmpty) { - return companyCode; - } - return ''; } @@ -259,11 +254,6 @@ class _ErrorScreenState extends State { appendLabel(tenant); } - final companyCode = profile['companyCode']?.toString().trim() ?? ''; - if (companyCode.isNotEmpty) { - appendLabel(companyCode); - } - return labels; } diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index b1b2a04e..4ed3e63b 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -1178,11 +1178,11 @@ class _ProfilePageState extends ConsumerState { profile.tenant!.name, ), ], - if (profile.companyCode.isNotEmpty) ...[ + if (profile.tenant?.slug.isNotEmpty ?? false) ...[ const Divider(height: 24), _buildReadOnlyTile( - tr('ui.userfront.profile.field.company_code'), - profile.companyCode, + tr('ui.userfront.profile.field.tenant_slug'), + profile.tenant!.slug, ), ], ], diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart index 816137ed..cee5b67e 100644 --- a/userfront/lib/i18n_data.dart +++ b/userfront/lib/i18n_data.dart @@ -1930,6 +1930,7 @@ const Map koStrings = { "ui.userfront.profile.field.email": "이메일", "ui.userfront.profile.field.name": "이름", "ui.userfront.profile.field.tenant": "소속 테넌트", + "ui.userfront.profile.field.tenant_slug": "테넌트 Slug", "ui.userfront.profile.manage": "프로필 관리", "ui.userfront.profile.password.change": "비밀번호 변경", "ui.userfront.profile.password.confirm": "새 비밀번호 확인", @@ -4125,6 +4126,7 @@ const Map enStrings = { "ui.userfront.profile.field.email": "Email", "ui.userfront.profile.field.name": "Name", "ui.userfront.profile.field.tenant": "Tenant", + "ui.userfront.profile.field.tenant_slug": "Tenant Slug", "ui.userfront.profile.manage": "Manage profile", "ui.userfront.profile.password.change": "Change", "ui.userfront.profile.password.confirm": "Confirm",