forked from baron/baron-sso
feat: implement multi-tenant member management and UI improvements
- Add multi-tenant support (isAddTenant, isRemoveTenant) to backend UpdateUser API. - Update UserRepository to support searching in company_codes array. - Implement table sorting and align search bar layout in adminfront. - Add 'Assign Existing Member' and 'Exclude from Organization' features to TenantUsersPage. - Auto-populate tenantSlug in UserCreatePage via query parameters. - Add necessary localization keys for new UI elements. Resolves #644, #639, #642, #641
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -54,6 +55,7 @@ type User struct {
|
||||
Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user
|
||||
AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
|
||||
CompanyCode string `gorm:"column:company_code;index" json:"companyCode"`
|
||||
CompanyCodes pq.StringArray `gorm:"column:company_codes;type:text[]" json:"companyCodes"`
|
||||
TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"`
|
||||
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
||||
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||
|
||||
@@ -1432,6 +1432,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
Role *string `json:"role"`
|
||||
Status *string `json:"status"`
|
||||
CompanyCode *string `json:"companyCode"`
|
||||
IsAddTenant bool `json:"isAddTenant"`
|
||||
IsRemoveTenant bool `json:"isRemoveTenant"`
|
||||
Department *string `json:"department"`
|
||||
Position *string `json:"position"`
|
||||
JobTitle *string `json:"jobTitle"`
|
||||
@@ -1446,9 +1448,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner)
|
||||
|
||||
// [New] Tenant Admin restriction: Cannot change companyCode
|
||||
// [New] Tenant Admin restriction: Cannot change companyCode (except when adding/removing secondary membership)
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||
if req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1531,38 +1533,80 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
if req.CompanyCode != nil {
|
||||
code := strings.TrimSpace(*req.CompanyCode)
|
||||
traits["companyCode"] = code
|
||||
// Resolve TenantID for Kratos Trait
|
||||
if h.TenantService != nil && code != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
||||
traits["tenant_id"] = tenant.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Add to existingCodes if not present
|
||||
found := false
|
||||
for _, existing := range existingCodes {
|
||||
if existing == code {
|
||||
found = true
|
||||
break
|
||||
if req.IsAddTenant {
|
||||
// Add to existingCodes if not present
|
||||
found := false
|
||||
for _, existing := range existingCodes {
|
||||
if existing == code {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && code != "" {
|
||||
existingCodes = append(existingCodes, code)
|
||||
}
|
||||
} else if req.IsRemoveTenant {
|
||||
// Remove from existingCodes
|
||||
var newCodes []string
|
||||
for _, existing := range existingCodes {
|
||||
if existing != code {
|
||||
newCodes = append(newCodes, existing)
|
||||
}
|
||||
}
|
||||
existingCodes = newCodes
|
||||
|
||||
// If removing the primary company code, pick another one as primary if available
|
||||
currentPrimary := extractTraitString(traits, "companyCode")
|
||||
if currentPrimary == code {
|
||||
if len(existingCodes) > 0 {
|
||||
traits["companyCode"] = existingCodes[0]
|
||||
if h.TenantService != nil {
|
||||
if t, err := h.TenantService.GetTenantBySlug(c.Context(), existingCodes[0]); err == nil && t != nil {
|
||||
traits["tenant_id"] = t.ID
|
||||
}
|
||||
}
|
||||
} else {
|
||||
traits["companyCode"] = ""
|
||||
traits["tenant_id"] = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal update: replace primary company code and ensure it's in existingCodes
|
||||
traits["companyCode"] = code
|
||||
// Resolve TenantID for Kratos Trait
|
||||
if h.TenantService != nil && code != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
||||
traits["tenant_id"] = tenant.ID
|
||||
}
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, existing := range existingCodes {
|
||||
if existing == code {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && code != "" {
|
||||
existingCodes = append(existingCodes, code)
|
||||
}
|
||||
}
|
||||
if !found && code != "" {
|
||||
existingCodes = append(existingCodes, code)
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate and save back companyCodes
|
||||
var uniqueCodes []string
|
||||
var codesToSave []string
|
||||
seenCodes := map[string]bool{}
|
||||
for _, c := range existingCodes {
|
||||
if !seenCodes[c] && c != "" {
|
||||
seenCodes[c] = true
|
||||
uniqueCodes = append(uniqueCodes, c)
|
||||
codesToSave = append(codesToSave, c)
|
||||
}
|
||||
}
|
||||
if len(uniqueCodes) > 0 {
|
||||
traits["companyCodes"] = uniqueCodes
|
||||
if len(codesToSave) > 0 {
|
||||
traits["companyCodes"] = codesToSave
|
||||
} else {
|
||||
delete(traits, "companyCodes")
|
||||
}
|
||||
|
||||
if req.Department != nil {
|
||||
@@ -1927,6 +1971,17 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
}
|
||||
|
||||
// [New] Sync multi-tenant codes
|
||||
if codes, ok := traits["companyCodes"].([]interface{}); ok {
|
||||
for _, v := range codes {
|
||||
if str, ok := v.(string); ok && str != "" {
|
||||
user.CompanyCodes = append(user.CompanyCodes, str)
|
||||
}
|
||||
}
|
||||
} else if codes, ok := traits["companyCodes"].([]string); ok {
|
||||
user.CompanyCodes = codes
|
||||
}
|
||||
|
||||
// 1. Try to get tenant_id directly from Kratos traits first (Fastest & most reliable)
|
||||
tID := extractTraitString(traits, "tenant_id")
|
||||
if tID != "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -156,11 +157,12 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
|
||||
}
|
||||
var results []result
|
||||
|
||||
// Search by company_code directly. Normalize inputs using LOWER for robust matching.
|
||||
err := r.db.WithContext(ctx).Model(&domain.User{}).
|
||||
Select("LOWER(company_code) as company_code, count(*) as count").
|
||||
Where("LOWER(company_code) IN ?", lowerStrings(codes)).
|
||||
Group("LOWER(company_code)").
|
||||
// Search by company_codes array using unnest and overlap.
|
||||
// This ensures users with multiple memberships are counted in each tenant they belong to.
|
||||
err := r.db.WithContext(ctx).Table("users").
|
||||
Select("unnest(company_codes) as company_code, count(*) as count").
|
||||
Where("company_codes && ?", pq.Array(lowerStrings(codes))).
|
||||
Group("company_code").
|
||||
Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -168,7 +170,7 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
|
||||
|
||||
counts := make(map[string]int64)
|
||||
for _, res := range results {
|
||||
counts[res.CompanyCode] = res.Count
|
||||
counts[strings.ToLower(res.CompanyCode)] = res.Count
|
||||
}
|
||||
|
||||
// Ensure all requested codes are present in results (even if count is 0)
|
||||
@@ -196,15 +198,15 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
|
||||
db := r.db.WithContext(ctx).Model(&domain.User{})
|
||||
|
||||
if companyCode != "" {
|
||||
// [Matrix Fix] Match users either by their primary company code OR by the slug of the department they are attached to
|
||||
// [Matrix Fix] Match users either by their primary company code OR by being in the company_codes array OR by tenant slug
|
||||
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
|
||||
Where("users.company_code = ? OR tenants.slug = ?", companyCode, companyCode)
|
||||
Where("users.company_code = ? OR ? = ANY(users.company_codes) OR tenants.slug = ?", companyCode, companyCode, companyCode)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.company_code LIKE ? OR users.metadata::text LIKE ?)",
|
||||
searchTerm, searchTerm, searchTerm, searchTerm)
|
||||
db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.company_code LIKE ? OR ? = ANY(users.company_codes) OR users.metadata::text LIKE ?)",
|
||||
searchTerm, searchTerm, searchTerm, search, searchTerm)
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
|
||||
Reference in New Issue
Block a user