1
0
forked from baron/baron-sso

Merge pull request 'feature/org-chart-tab-separation' (#568) from feature/org-chart-tab-separation into dev

Reviewed-on: baron/baron-sso#568
This commit is contained in:
2026-04-16 09:08:48 +09:00
18 changed files with 513 additions and 528 deletions

View File

@@ -3784,6 +3784,13 @@ func extractFirstString(data map[string]interface{}, keys ...string) string {
if str, ok := val.(string); ok && str != "" {
return str
}
// Handle numeric types by converting to string
if num, ok := val.(float64); ok {
return fmt.Sprint(num)
}
if num, ok := val.(int); ok {
return fmt.Sprint(num)
}
}
}
return ""

View File

@@ -114,10 +114,20 @@ func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string)
return 0, nil
}
func (m *AsyncMockUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
args := m.Called(ctx, tenantIDs)
return args.Get(0).([]domain.User), args.Error(1)
}
func (m *AsyncMockUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
}
func (m *AsyncMockUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
args := m.Called(ctx, codes)
return args.Get(0).([]domain.User), args.Error(1)
}
func (m *AsyncMockUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
return nil, nil
}

View File

@@ -20,9 +20,10 @@ type TenantHandler struct {
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
SharedLink service.SharedLinkService
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler {
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService) *TenantHandler {
return &TenantHandler{
DB: db,
Service: svc,
@@ -30,6 +31,7 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repositor
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
SharedLink: sharedLink,
}
}
@@ -865,3 +867,136 @@ func normalizeTenantType(value string) string {
return ""
}
}
func (h *TenantHandler) CreateShareLink(c *fiber.Ctx) error {
tenantID := c.Params("id")
var req struct {
Name string `json:"name"`
Description string `json:"description"`
ExpiresAt *time.Time `json:"expiresAt"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
link, err := h.SharedLink.CreateLink(c.Context(), tenantID, req.Name, req.Description, req.ExpiresAt)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(link)
}
func (h *TenantHandler) ListShareLinks(c *fiber.Ctx) error {
tenantID := c.Params("id")
links, err := h.SharedLink.GetLinksByTenant(c.Context(), tenantID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(links)
}
func (h *TenantHandler) DeleteShareLink(c *fiber.Ctx) error {
id := c.Params("id")
if err := h.SharedLink.DeactivateLink(c.Context(), id); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(fiber.Map{"message": "Share link deleted successfully"})
}
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
token := c.Query("token")
if token == "" {
return errorJSON(c, fiber.StatusUnauthorized, "share token is required")
}
link, err := h.SharedLink.ValidateToken(c.Context(), token)
if err != nil {
return errorJSON(c, fiber.StatusUnauthorized, err.Error())
}
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
parentMap := make(map[string]string)
for _, t := range allTenants {
if t.ParentID != nil {
parentMap[t.ID] = *t.ParentID
}
}
findRoot := func(id string) string {
curr := id
for {
p, exists := parentMap[curr]
if !exists || p == "" { break }
curr = p
}
return curr
}
sharedRootID := findRoot(link.TenantID)
var filteredTenants []domain.Tenant
var tenantIDs []string
var slugs []string
for _, t := range allTenants {
if findRoot(t.ID) == sharedRootID {
filteredTenants = append(filteredTenants, t)
tenantIDs = append(tenantIDs, t.ID)
slugs = append(slugs, t.Slug)
}
}
type publicUserSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
CompanyCode string `json:"companyCode"`
Status string `json:"status"`
}
var publicUsers []publicUserSummary
seen := make(map[string]bool)
// Fetch users by IDs
var usersByID []domain.User
h.DB.Where("tenant_id IN ?", tenantIDs).Preload("Tenant").Find(&usersByID)
for _, u := range usersByID {
if u.Status != "active" || seen[u.ID] { continue }
seen[u.ID] = true
cc := u.CompanyCode
if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug }
publicUsers = append(publicUsers, publicUserSummary{
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status,
})
}
// Fetch users by Slugs
var usersBySlug []domain.User
h.DB.Where("company_code IN ?", slugs).Preload("Tenant").Find(&usersBySlug)
for _, u := range usersBySlug {
if u.Status != "active" || seen[u.ID] { continue }
seen[u.ID] = true
cc := u.CompanyCode
if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug }
publicUsers = append(publicUsers, publicUserSummary{
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status,
})
}
tenantSummaries := make([]tenantSummary, 0, len(filteredTenants))
for _, t := range filteredTenants {
tenantSummaries = append(tenantSummaries, mapTenantSummary(t))
}
return c.JSON(fiber.Map{
"tenants": tenantSummaries,
"users": publicUsers,
"sharedWith": link.Name,
})
}

View File

@@ -127,10 +127,20 @@ func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID str
return 0, nil
}
func (m *MockUserRepoForHandler) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
args := m.Called(ctx, tenantIDs)
return args.Get(0).([]domain.User), args.Error(1)
}
func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
}
func (m *MockUserRepoForHandler) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
args := m.Called(ctx, codes)
return args.Get(0).([]domain.User), args.Error(1)
}
func (m *MockUserRepoForHandler) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
args := m.Called(ctx, codes)
if args.Get(0) == nil {

View File

@@ -1266,6 +1266,25 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if traits == nil {
traits = map[string]interface{}{}
}
// [Preserve & Merge] Multi-Tenant Info
var existingCodes []string
if codes, ok := traits["companyCodes"].([]interface{}); ok {
for _, v := range codes {
if str, ok := v.(string); ok && str != "" {
existingCodes = append(existingCodes, str)
}
}
}
// Keto에서 "실제" 소속 정보를 먼저 확인 (엑셀 임포트 사용자 대응)
if len(existingCodes) <= 1 && h.TenantService != nil {
if joined, err := h.TenantService.ListJoinedTenants(c.Context(), userID); err == nil {
for _, t := range joined {
existingCodes = append(existingCodes, t.Slug)
}
}
}
if req.Name != nil {
traits["name"] = strings.TrimSpace(*req.Name)
}
@@ -1286,7 +1305,33 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["tenant_id"] = tenant.ID
}
}
// 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)
}
}
// Deduplicate and save back companyCodes
var uniqueCodes []string
seenCodes := map[string]bool{}
for _, c := range existingCodes {
if !seenCodes[c] && c != "" {
seenCodes[c] = true
uniqueCodes = append(uniqueCodes, c)
}
}
if len(uniqueCodes) > 0 {
traits["companyCodes"] = uniqueCodes
}
if req.Department != nil {
traits["department"] = strings.TrimSpace(*req.Department)
}
@@ -1420,16 +1465,32 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
}
// [Self-Healing] If the UI explicitly assigned the tenant, force a Keto relation sync.
// This fixes issues where local DB had the tenant, but Keto failed to create the relation previously.
if req.CompanyCode != nil && h.KetoOutboxRepo != nil && updatedLocalUser.TenantID != nil {
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: *updatedLocalUser.TenantID,
Relation: "members",
Subject: "User:" + updatedLocalUser.ID,
Action: domain.KetoOutboxActionCreate,
})
// [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
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: *updatedLocalUser.TenantID,
Relation: "members",
Subject: "User:" + updatedLocalUser.ID,
Action: domain.KetoOutboxActionCreate,
})
}
}
}()
}