forked from baron/baron-sso
사용자 테넌트 소속 데이터 정리
This commit is contained in:
@@ -28,6 +28,10 @@ type createSuperAdminConfig struct {
|
||||
UpdatePassword bool
|
||||
}
|
||||
|
||||
type clearOrphanUserTenantMembershipsConfig struct {
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
loadEnv()
|
||||
logger.Init(logger.Config{
|
||||
@@ -47,6 +51,11 @@ func main() {
|
||||
slog.Error("create-super-admin failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "clear-orphan-user-tenant-memberships":
|
||||
if err := runClearOrphanUserTenantMemberships(os.Args[2:]); err != nil {
|
||||
slog.Error("clear-orphan-user-tenant-memberships failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
default:
|
||||
printUsage()
|
||||
os.Exit(2)
|
||||
@@ -107,6 +116,37 @@ func runCreateSuperAdmin(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runClearOrphanUserTenantMemberships(args []string) error {
|
||||
config, err := resolveClearOrphanUserTenantMembershipsConfig(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if config.DryRun {
|
||||
count, err := repository.CountOrphanUserTenantMemberships(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("orphan user tenant memberships dry-run: count=%d\n", count)
|
||||
return nil
|
||||
}
|
||||
|
||||
affected, err := repository.ClearOrphanUserTenantMemberships(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("orphan user tenant memberships cleared: count=%d\n", affected)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error) {
|
||||
fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
@@ -135,6 +175,19 @@ func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func resolveClearOrphanUserTenantMembershipsConfig(args []string) (clearOrphanUserTenantMembershipsConfig, error) {
|
||||
fs := flag.NewFlagSet("clear-orphan-user-tenant-memberships", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
config := clearOrphanUserTenantMembershipsConfig{}
|
||||
fs.BoolVar(&config.DryRun, "dry-run", false, "count orphan memberships without updating users")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return config, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func openDB() (*gorm.DB, error) {
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
|
||||
@@ -173,4 +226,5 @@ func getenv(key string, fallback string) string {
|
||||
func printUsage() {
|
||||
fmt.Fprintln(os.Stderr, "usage:")
|
||||
fmt.Fprintln(os.Stderr, " adminctl create-super-admin [--email EMAIL] [--password PASSWORD] [--name NAME] [--update-password]")
|
||||
fmt.Fprintln(os.Stderr, " adminctl clear-orphan-user-tenant-memberships [--dry-run]")
|
||||
}
|
||||
|
||||
@@ -60,3 +60,14 @@ func TestResolveCreateSuperAdminConfigRequiresEmailAndPassword(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClearOrphanUserTenantMembershipsConfig(t *testing.T) {
|
||||
config, err := resolveClearOrphanUserTenantMembershipsConfig([]string{"--dry-run"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveClearOrphanUserTenantMembershipsConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
if !config.DryRun {
|
||||
t.Fatal("dry-run flag was not set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,6 @@ func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser {
|
||||
Attributes: map[string]interface{}{
|
||||
"department": "Admin",
|
||||
"affiliationType": "internal",
|
||||
"companyCode": "",
|
||||
"grade": "",
|
||||
"role": domain.RoleSuperAdmin,
|
||||
},
|
||||
|
||||
@@ -37,6 +37,9 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
if err := dropLegacyTenantDomainUniqueIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dropLegacyUserCompanyColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add all domain models here
|
||||
return db.AutoMigrate(
|
||||
@@ -61,6 +64,21 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
)
|
||||
}
|
||||
|
||||
func dropLegacyUserCompanyColumns(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&domain.User{}) {
|
||||
return nil
|
||||
}
|
||||
for _, column := range []string{"company_code", "company_codes"} {
|
||||
if !db.Migrator().HasColumn(&domain.User{}, column) {
|
||||
continue
|
||||
}
|
||||
if err := db.Migrator().DropColumn(&domain.User{}, column); err != nil {
|
||||
return fmt.Errorf("failed to drop legacy users.%s column: %w", column, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropLegacyTenantDomainUniqueIndex(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&domain.TenantDomain{}) {
|
||||
return nil
|
||||
|
||||
@@ -34,7 +34,6 @@ func SeedAdminIdentity(idp domain.IdentityProvider) (string, error) {
|
||||
Attributes: map[string]interface{}{
|
||||
"department": "Admin",
|
||||
"affiliationType": "internal",
|
||||
"companyCode": "",
|
||||
"grade": "",
|
||||
"role": "super_admin", // Explicitly set role for Kratos traits
|
||||
},
|
||||
|
||||
@@ -60,6 +60,7 @@ type SignupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL"
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
Department string `json:"department"`
|
||||
Metadata JSONMap `json:"metadata,omitempty"`
|
||||
@@ -117,5 +118,6 @@ type PasswordChangeRequest struct {
|
||||
|
||||
type CheckLoginIDRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ type User struct {
|
||||
Phone string `gorm:"column:phone" json:"phone"`
|
||||
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"`
|
||||
CompanyCode string `gorm:"-" json:"companyCode,omitempty"`
|
||||
CompanyCodes pq.StringArray `gorm:"-" json:"companyCodes,omitempty"`
|
||||
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용
|
||||
|
||||
@@ -704,8 +704,12 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Identity provider unavailable")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.CompanyCode) != "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "companyCode is deprecated; use tenantSlug")
|
||||
}
|
||||
|
||||
// 소속이 비어 있는 일반 가입자는 PERSONAL tenant를 자동 생성해 대표소속을 보장합니다.
|
||||
companyCode := ""
|
||||
tenantSlug := strings.TrimSpace(req.TenantSlug)
|
||||
var tenantID *string
|
||||
|
||||
parts := strings.Split(req.Email, "@")
|
||||
@@ -726,33 +730,28 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email)
|
||||
}
|
||||
|
||||
// If user provided a CompanyCode, verify it exists and is a family affiliate
|
||||
if req.CompanyCode != "" {
|
||||
// [Security] Cross-check: If domain is NOT internal, they cannot provide a CompanyCode
|
||||
if tenantSlug != "" {
|
||||
// [Security] Cross-check: If domain is NOT internal, they cannot provide a tenantSlug
|
||||
if !isInternal {
|
||||
slog.Warn("[Signup] Security violation: non-internal email providing CompanyCode", "email", req.Email)
|
||||
slog.Warn("[Signup] Security violation: non-internal email providing tenantSlug", "email", req.Email)
|
||||
return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.")
|
||||
}
|
||||
|
||||
// Verify the selected company code exists and is indeed a family company
|
||||
if !affiliateSlugs[strings.ToLower(req.CompanyCode)] {
|
||||
if !affiliateSlugs[strings.ToLower(tenantSlug)] {
|
||||
return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.")
|
||||
}
|
||||
|
||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode)
|
||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
|
||||
if err == nil && tenant != nil {
|
||||
if tenant.Status == domain.TenantStatusActive {
|
||||
// We no longer strictly cross-check if the chosen tenant owns the email domain.
|
||||
// Being an 'isInternal' (family) email is enough to join ANY family affiliate.
|
||||
|
||||
slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug)
|
||||
companyCode = tenant.Slug
|
||||
tenantSlug = tenant.Slug
|
||||
tenantID = &tenant.ID
|
||||
} else {
|
||||
return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.")
|
||||
}
|
||||
} else {
|
||||
slog.Warn("[Signup] Attempted to join non-existent organization", "slug", req.CompanyCode, "email", req.Email)
|
||||
slog.Warn("[Signup] Attempted to join non-existent organization", "slug", tenantSlug, "email", req.Email)
|
||||
return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.")
|
||||
}
|
||||
} else {
|
||||
@@ -770,7 +769,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant")
|
||||
}
|
||||
companyCode = tenant.Slug
|
||||
tenantSlug = tenant.Slug
|
||||
tenantID = &tenant.ID
|
||||
}
|
||||
|
||||
@@ -789,7 +788,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
attributes := map[string]interface{}{
|
||||
"department": req.Department,
|
||||
"affiliationType": req.AffiliationType,
|
||||
"companyCode": companyCode,
|
||||
"grade": "",
|
||||
"role": domain.RoleUser,
|
||||
}
|
||||
@@ -844,7 +842,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
Phone: normalizedPhone,
|
||||
Role: "user",
|
||||
AffiliationType: req.AffiliationType,
|
||||
CompanyCode: companyCode,
|
||||
Department: req.Department,
|
||||
Status: "active",
|
||||
CreatedAt: time.Now(),
|
||||
@@ -7508,7 +7505,6 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
|
||||
phone, _ := traits["phone_number"].(string)
|
||||
dept, _ := traits["department"].(string)
|
||||
affType, _ := traits["affiliationType"].(string)
|
||||
compCode, _ := traits["companyCode"].(string)
|
||||
role, _ := traits["role"].(string)
|
||||
tenantID, _ := traits["tenant_id"].(string)
|
||||
relyingPartyID, _ := traits["relying_party_id"].(string)
|
||||
@@ -7520,7 +7516,6 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
|
||||
Phone: h.formatPhoneForDisplay(phone),
|
||||
Department: dept,
|
||||
AffiliationType: affType,
|
||||
CompanyCode: compCode,
|
||||
Role: domain.NormalizeRole(role),
|
||||
Metadata: make(map[string]any),
|
||||
}
|
||||
@@ -7591,16 +7586,6 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
|
||||
localUser.AffiliationType = affType
|
||||
}
|
||||
|
||||
companyCode := extractTraitString(traits, "companyCode")
|
||||
if companyCode == "" {
|
||||
companyCode = extractTraitString(traits, "company_code")
|
||||
}
|
||||
if companyCode != "" {
|
||||
localUser.CompanyCode = companyCode
|
||||
}
|
||||
if companyCodes := extractTraitStringArray(traits, "companyCodes"); len(companyCodes) > 0 {
|
||||
localUser.CompanyCodes = pq.StringArray(companyCodes)
|
||||
}
|
||||
if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
|
||||
localUser.TenantID = &tenantID
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
|
||||
require.Equal(t, "New Name", userRepo.updated.Name)
|
||||
require.Equal(t, "+821087654321", userRepo.updated.Phone)
|
||||
require.Equal(t, "New Dept", userRepo.updated.Department)
|
||||
require.Equal(t, "saman", userRepo.updated.CompanyCode)
|
||||
require.Empty(t, userRepo.updated.CompanyCode)
|
||||
require.NotNil(t, userRepo.updated.TenantID)
|
||||
require.Equal(t, "11111111-1111-1111-1111-111111111111", *userRepo.updated.TenantID)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -78,7 +77,7 @@ func (m *MockIdpForSignup) UpdateUserPassword(loginID, newPassword string, r *ht
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSignup_CompanyCodeValidation(t *testing.T) {
|
||||
func TestSignup_TenantSlugValidation(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockTenantSvc := new(MockTenantService)
|
||||
mockRedis := new(MockRedisForSignup)
|
||||
@@ -99,7 +98,7 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
|
||||
})
|
||||
mockRedis.On("Get", mock.Anything).Return(string(verifiedState), nil)
|
||||
|
||||
t.Run("Fail - Tenant not found for CompanyCode", func(t *testing.T) {
|
||||
t.Run("Rejects legacy CompanyCode", func(t *testing.T) {
|
||||
reqBody := domain.SignupRequest{
|
||||
Email: "user@gmail.com",
|
||||
Password: "StrongPass123!",
|
||||
@@ -110,25 +109,21 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil).Once()
|
||||
mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "gmail.com").Return(nil, errors.New("not found")).Maybe()
|
||||
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "new-slug").Return(nil, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Active Company Code", func(t *testing.T) {
|
||||
t.Run("Active Tenant Slug", func(t *testing.T) {
|
||||
reqBody := domain.SignupRequest{
|
||||
Email: "user@hanmaceng.co.kr",
|
||||
Password: "StrongPass123!",
|
||||
Name: "Test User",
|
||||
Phone: "010-1234-5678",
|
||||
TermsAccepted: true,
|
||||
CompanyCode: "hanmac",
|
||||
TenantSlug: "hanmac",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
|
||||
@@ -2386,14 +2386,6 @@ func mapOrgContextMemberAssignments(user domain.User, tenantByID, tenantBySlug m
|
||||
tenant := tenantBySlug[strings.ToLower(user.Tenant.Slug)]
|
||||
addTenant(tenant, tenant.ID != "", nil)
|
||||
}
|
||||
if user.CompanyCode != "" {
|
||||
tenant := tenantBySlug[strings.ToLower(strings.TrimSpace(user.CompanyCode))]
|
||||
addTenant(tenant, tenant.ID != "", nil)
|
||||
}
|
||||
for _, companyCode := range user.CompanyCodes {
|
||||
tenant := tenantBySlug[strings.ToLower(strings.TrimSpace(companyCode))]
|
||||
addTenant(tenant, tenant.ID != "", nil)
|
||||
}
|
||||
return assignments
|
||||
}
|
||||
|
||||
@@ -2596,7 +2588,6 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
sharedRootID := findRoot(link.TenantID)
|
||||
var filteredTenants []domain.Tenant
|
||||
var tenantIDs []string
|
||||
var slugs []string
|
||||
|
||||
for _, t := range allTenants {
|
||||
if findRoot(t.ID) == sharedRootID {
|
||||
@@ -2606,7 +2597,6 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
filteredTenants = filterPublicTenants(filteredTenants)
|
||||
for _, t := range filteredTenants {
|
||||
tenantIDs = append(tenantIDs, t.ID)
|
||||
slugs = append(slugs, t.Slug)
|
||||
}
|
||||
|
||||
type publicUserSummary struct {
|
||||
@@ -2614,7 +2604,7 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
Name string `json:"name"`
|
||||
Position string `json:"position"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
CompanyCode string `json:"companyCode"`
|
||||
TenantSlug string `json:"tenantSlug"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@@ -2629,29 +2619,12 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
continue
|
||||
}
|
||||
seen[u.ID] = true
|
||||
cc := u.CompanyCode
|
||||
if cc == "" && u.Tenant != nil {
|
||||
cc = u.Tenant.Slug
|
||||
tenantSlug := ""
|
||||
if u.Tenant != nil {
|
||||
tenantSlug = 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,
|
||||
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, TenantSlug: tenantSlug, Status: u.Status,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -690,8 +690,8 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
|
||||
require.Equal(t, "기술기획", firstUser["jobTitle"])
|
||||
teamSSO := tenantsPayload[3].(map[string]any)
|
||||
ssoMembers := teamSSO["members"].([]any)
|
||||
require.Len(t, ssoMembers, 2)
|
||||
appointmentOnly := ssoMembers[1].(map[string]any)
|
||||
require.Len(t, ssoMembers, 1)
|
||||
appointmentOnly := ssoMembers[0].(map[string]any)
|
||||
require.Equal(t, "appointment@example.com", appointmentOnly["email"])
|
||||
require.Equal(t, false, appointmentOnly["isOwner"])
|
||||
require.Equal(t, true, appointmentOnly["isLeader"])
|
||||
|
||||
@@ -256,6 +256,26 @@ func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func identityTenantAccessKeys(traits map[string]interface{}) []string {
|
||||
keys := make([]string, 0, 2)
|
||||
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
|
||||
}
|
||||
|
||||
func anyTenantKeyAllowed(keys []string, allowed map[string]bool) bool {
|
||||
for _, key := range keys {
|
||||
if allowed[key] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type userSummary struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
@@ -590,7 +610,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
req.CompanyCode = tenantSlugFromRequest(req.TenantSlug, req.CompanyCode)
|
||||
tenantSlug, err := tenantSlugFromRequest(req.TenantSlug, req.CompanyCode)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
req.CompanyCode = tenantSlug
|
||||
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
|
||||
|
||||
email := strings.TrimSpace(req.Email)
|
||||
@@ -643,7 +667,6 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
"position": req.Position,
|
||||
"jobTitle": req.JobTitle,
|
||||
"affiliationType": "internal",
|
||||
"companyCode": req.CompanyCode,
|
||||
}
|
||||
|
||||
// [Override with explicit LoginID if provided]
|
||||
@@ -687,7 +710,6 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
|
||||
|
||||
attributes["role"] = role
|
||||
attributes["companyCode"] = req.CompanyCode
|
||||
if tenantID != "" {
|
||||
attributes["tenant_id"] = tenantID
|
||||
}
|
||||
@@ -969,13 +991,17 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
email := strings.TrimSpace(item.Email)
|
||||
name := strings.TrimSpace(item.Name)
|
||||
tenantID := strings.TrimSpace(item.TenantID)
|
||||
tenantSlug := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode)
|
||||
tenantSlug, tenantSlugErr := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode)
|
||||
dept := strings.TrimSpace(item.Department)
|
||||
|
||||
if email == "" || name == "" {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "email and name are required"})
|
||||
continue
|
||||
}
|
||||
if tenantSlugErr != nil {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
var tItem tenantCacheItem
|
||||
var err error
|
||||
@@ -1156,7 +1182,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
"position": strings.TrimSpace(item.Position),
|
||||
"jobTitle": strings.TrimSpace(item.JobTitle),
|
||||
"affiliationType": "internal",
|
||||
"companyCode": tenantSlug,
|
||||
"tenant_id": tItem.ID,
|
||||
"role": role,
|
||||
}
|
||||
@@ -1307,7 +1332,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
|
||||
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
tenantSlug := tenantSlugFromRequest(c.Query("tenantSlug"), c.Query("companyCode"))
|
||||
tenantSlug, err := tenantSlugFromRequest(c.Query("tenantSlug"), c.Query("companyCode"))
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
var requesterRole string
|
||||
var manageableSlugs []string
|
||||
@@ -1481,7 +1509,11 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
req.CompanyCode = tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
|
||||
tenantSlug, err := tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
req.CompanyCode = tenantSlug
|
||||
|
||||
if len(req.UserIDs) == 0 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided")
|
||||
@@ -1539,9 +1571,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Authorization check
|
||||
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
||||
if requester.Role == domain.RoleTenantAdmin {
|
||||
if !manageableSlugs[userComp] {
|
||||
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"})
|
||||
continue
|
||||
}
|
||||
@@ -1560,7 +1591,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
traits["role"] = *req.Role
|
||||
}
|
||||
if req.CompanyCode != nil {
|
||||
traits["companyCode"] = *req.CompanyCode
|
||||
delete(traits, "companyCode")
|
||||
delete(traits, "companyCodes")
|
||||
|
||||
// Resolve and update tenant_id in traits if changed
|
||||
if tItem, exists := tenantCache[*req.CompanyCode]; exists {
|
||||
@@ -1702,8 +1734,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
|
||||
|
||||
// Authorization check
|
||||
if requester.Role == domain.RoleTenantAdmin {
|
||||
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
||||
if !manageableSlugs[userComp] {
|
||||
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"})
|
||||
continue
|
||||
}
|
||||
@@ -1767,8 +1798,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
// [New] Check access scope
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
||||
allowed := map[string]bool{}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1797,7 +1838,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
req.CompanyCode = tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
|
||||
tenantSlug, err := tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
req.CompanyCode = tenantSlug
|
||||
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
|
||||
if req.Role != nil {
|
||||
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
@@ -1866,24 +1911,6 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
delete(traits, "hanmacFamily")
|
||||
delete(traits, "userType")
|
||||
|
||||
// [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)
|
||||
}
|
||||
@@ -1898,29 +1925,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if req.CompanyCode != nil {
|
||||
code := strings.TrimSpace(*req.CompanyCode)
|
||||
|
||||
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
|
||||
|
||||
// [Keto Sync] Remove membership for the target tenant
|
||||
if req.IsRemoveTenant {
|
||||
if h.TenantService != nil && h.KetoOutboxRepo != nil && code != "" {
|
||||
go func(removedSlug string) {
|
||||
bgCtx := context.Background()
|
||||
@@ -1935,87 +1940,26 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}(code)
|
||||
}
|
||||
|
||||
// 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"] = ""
|
||||
if h.TenantService != nil && code != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
|
||||
currentTenantID := extractTraitString(traits, "tenant_id")
|
||||
if currentTenantID == tenant.ID {
|
||||
traits["tenant_id"] = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal update (Move): replace primary company code and remove the old one from existingCodes
|
||||
currentPrimary := extractTraitString(traits, "companyCode")
|
||||
if currentPrimary != "" && currentPrimary != code {
|
||||
// Remove old primary from existingCodes
|
||||
var newCodes []string
|
||||
for _, existing := range existingCodes {
|
||||
if existing != currentPrimary {
|
||||
newCodes = append(newCodes, existing)
|
||||
}
|
||||
}
|
||||
existingCodes = newCodes
|
||||
|
||||
// [Keto Sync] Remove membership for the old tenant
|
||||
if h.TenantService != nil && h.KetoOutboxRepo != nil {
|
||||
go func(removedSlug string) {
|
||||
bgCtx := context.Background()
|
||||
if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil {
|
||||
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: t.ID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
}(currentPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
traits["companyCode"] = code
|
||||
// Resolve TenantID for Kratos Trait
|
||||
} else if !req.IsAddTenant {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate and save back companyCodes
|
||||
var codesToSave []string
|
||||
seenCodes := map[string]bool{}
|
||||
for _, c := range existingCodes {
|
||||
if !seenCodes[c] && c != "" {
|
||||
seenCodes[c] = true
|
||||
codesToSave = append(codesToSave, c)
|
||||
}
|
||||
}
|
||||
if len(codesToSave) > 0 {
|
||||
traits["companyCodes"] = codesToSave
|
||||
} else {
|
||||
delete(traits, "companyCodes")
|
||||
traits["tenant_id"] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(traits, "companyCode")
|
||||
delete(traits, "companyCodes")
|
||||
|
||||
if req.Department != nil {
|
||||
traits["department"] = strings.TrimSpace(*req.Department)
|
||||
@@ -2233,8 +2177,18 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
}
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||
if identity != nil {
|
||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
||||
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
||||
allowed := map[string]bool{}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -2277,8 +2231,16 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
traits := identity.Traits
|
||||
role := roleFromTraits(traits)
|
||||
|
||||
compCode := extractTraitString(traits, "companyCode")
|
||||
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode)
|
||||
tenantID := extractTraitString(traits, "tenant_id")
|
||||
tenantSlug := ""
|
||||
var tenantSummary *domain.Tenant
|
||||
if tenantID != "" && h.TenantService != nil {
|
||||
if tenant, err := h.TenantService.GetTenant(ctx, tenantID); err == nil && tenant != nil {
|
||||
tenantSlug = tenant.Slug
|
||||
tenantSummary = tenant
|
||||
}
|
||||
}
|
||||
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "tenantID", tenantID, "tenantSlug", tenantSlug)
|
||||
|
||||
var customLoginIDs []string
|
||||
if raw, ok := traits["custom_login_ids"]; ok {
|
||||
@@ -2302,8 +2264,8 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
Phone: extractTraitString(traits, "phone_number"),
|
||||
Role: role,
|
||||
Status: normalizeStatus(identity.State),
|
||||
TenantSlug: compCode,
|
||||
CompanyCode: compCode,
|
||||
TenantSlug: tenantSlug,
|
||||
CompanyCode: tenantSlug,
|
||||
Department: extractTraitString(traits, "department"),
|
||||
Grade: gradeFromTraits(traits),
|
||||
Position: extractTraitString(traits, "position"),
|
||||
@@ -2326,7 +2288,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
// Otherwise, we put them in a "legacy" or "flat" bucket if needed, but for now let's keep them in summary.Metadata
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "companyCode": true, "department": true,
|
||||
"grade": true, "companyCode": true, "company_code": true, "companyCodes": true, "department": true,
|
||||
"position": true, "jobTitle": true,
|
||||
"affiliationType": true, "role": true, "tenant_id": true,
|
||||
"custom_login_ids": true, "id": true,
|
||||
@@ -2341,11 +2303,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
summary.Metadata[k] = v
|
||||
}
|
||||
|
||||
if compCode != "" && h.TenantService != nil {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
|
||||
summary.Tenant = tenant
|
||||
}
|
||||
}
|
||||
summary.Tenant = tenantSummary
|
||||
|
||||
return summary
|
||||
}
|
||||
@@ -2357,10 +2315,6 @@ func (h *UserHandler) normalizePhoneNumber(phone string) string {
|
||||
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
|
||||
traits := identity.Traits
|
||||
role := roleFromTraits(traits)
|
||||
compCode := extractTraitString(traits, "companyCode")
|
||||
if compCode == "" {
|
||||
compCode = extractTraitString(traits, "company_code")
|
||||
}
|
||||
|
||||
user := &domain.User{
|
||||
ID: identity.ID,
|
||||
@@ -2369,7 +2323,6 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
||||
Phone: extractTraitString(traits, "phone_number"),
|
||||
Role: role,
|
||||
Status: normalizeStatus(identity.State),
|
||||
CompanyCode: compCode,
|
||||
Department: extractTraitString(traits, "department"),
|
||||
Grade: gradeFromTraits(traits),
|
||||
Position: extractTraitString(traits, "position"),
|
||||
@@ -2379,37 +2332,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 != "" {
|
||||
user.TenantID = &tID
|
||||
}
|
||||
|
||||
// 2. Fallback to slug lookup only if tenant_id trait is missing
|
||||
if (user.TenantID == nil || *user.TenantID == "") && compCode != "" && h.TenantService != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
|
||||
user.TenantID = &tenant.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata handling (exclude core fields)
|
||||
user.Metadata = make(domain.JSONMap)
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "companyCode": true, "department": true,
|
||||
"grade": true, "companyCode": true, "companyCodes": true, "department": true,
|
||||
"position": true, "jobTitle": true,
|
||||
"affiliationType": true, "role": true, "tenant_id": true, "company_code": true,
|
||||
"custom_login_ids": true, "id": true,
|
||||
|
||||
@@ -583,7 +583,10 @@ func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t
|
||||
}, nil).Once()
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||
if user.Attributes["tenant_id"] != "t-gpdtdc" || user.Attributes["companyCode"] != "gpdtdc" {
|
||||
if user.Attributes["tenant_id"] != "t-gpdtdc" {
|
||||
return false
|
||||
}
|
||||
if _, hasCompanyCode := user.Attributes["companyCode"]; hasCompanyCode {
|
||||
return false
|
||||
}
|
||||
appointments, ok := user.Attributes["additionalAppointments"].([]any)
|
||||
@@ -653,8 +656,9 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
|
||||
}, nil)
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||
_, hasCompanyCode := user.Attributes["companyCode"]
|
||||
return user.Attributes["tenant_id"] == "t-saman" &&
|
||||
user.Attributes["companyCode"] == "saman" &&
|
||||
!hasCompanyCode &&
|
||||
user.Attributes["additionalAppointments"] == nil
|
||||
}), mock.Anything).Return("u-domain-primary", nil).Once()
|
||||
|
||||
@@ -893,7 +897,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
|
||||
payload := map[string]interface{}{
|
||||
"email": "han@samaneng.com",
|
||||
"name": "한치영",
|
||||
"companyCode": "hanmac",
|
||||
"tenantSlug": "hanmac",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
|
||||
@@ -1406,7 +1410,7 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"email": "new@test.com",
|
||||
"name": "New User",
|
||||
"companyCode": "test-tenant",
|
||||
"tenantSlug": "test-tenant",
|
||||
"metadata": map[string]interface{}{
|
||||
tenantID: map[string]interface{}{
|
||||
"emp_no": "E1001",
|
||||
@@ -1451,8 +1455,9 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
|
||||
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil)
|
||||
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
|
||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||
_, hasCompanyCode := user.Attributes["companyCode"]
|
||||
return user.Attributes["tenant_id"] == tenantID &&
|
||||
user.Attributes["companyCode"] == "saman" &&
|
||||
!hasCompanyCode &&
|
||||
user.Attributes["additionalAppointments"] != nil &&
|
||||
user.Attributes["userType"] == nil
|
||||
}), mock.Anything).Return("u-appointment", nil).Once()
|
||||
@@ -1531,7 +1536,7 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
|
||||
Type: domain.TenantTypePersonal,
|
||||
Status: domain.TenantStatusActive,
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Once()
|
||||
}, nil).Twice()
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-01970f0d96667548963d2890351f03dd").Return(&domain.Tenant{
|
||||
ID: personalTenantID,
|
||||
Slug: "personal-01970f0d96667548963d2890351f03dd",
|
||||
@@ -1539,11 +1544,12 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
|
||||
Type: domain.TenantTypePersonal,
|
||||
Status: domain.TenantStatusActive,
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Once()
|
||||
}, nil).Maybe()
|
||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||
_, hasCompanyCode := user.Attributes["companyCode"]
|
||||
return user.Email == "personal-user@example.com" &&
|
||||
user.Attributes["tenant_id"] == personalTenantID &&
|
||||
user.Attributes["companyCode"] == "personal-01970f0d96667548963d2890351f03dd"
|
||||
!hasCompanyCode
|
||||
}), mock.Anything).Return("u-personal", nil).Once()
|
||||
mockKratos.On("GetIdentity", mock.Anything, "u-personal").Return(&service.KratosIdentity{
|
||||
ID: "u-personal",
|
||||
@@ -1587,12 +1593,12 @@ func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: "tenant-id",
|
||||
Slug: "test-tenant",
|
||||
}, nil).Twice()
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenant", mock.Anything, "tenant-id").Return(&domain.Tenant{
|
||||
ID: "tenant-id",
|
||||
Slug: "test-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Once()
|
||||
}, nil).Twice()
|
||||
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||
_, hasCompanyCode := user.Attributes["companyCode"]
|
||||
return !hasCompanyCode && user.Attributes["tenant_id"] == "tenant-id"
|
||||
@@ -1619,12 +1625,9 @@ func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
||||
mockOry.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(`{"email":"legacy@test.com","password":"Password1!","name":"Legacy User","companyCode":"test-tenant"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err = app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
_, legacyErr := tenantSlugFromRequest("", "test-tenant")
|
||||
require.Error(t, legacyErr)
|
||||
require.Contains(t, legacyErr.Error(), "companyCode is deprecated")
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
|
||||
@@ -1651,12 +1654,12 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
|
||||
ID: "new-tenant-id",
|
||||
Slug: "new-tenant",
|
||||
}, nil).Twice()
|
||||
}, nil).Once()
|
||||
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
|
||||
ID: "new-tenant-id",
|
||||
Slug: "new-tenant",
|
||||
Config: domain.JSONMap{},
|
||||
}, nil).Once()
|
||||
}, nil).Twice()
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
_, hasCompanyCode := traits["companyCode"]
|
||||
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
|
||||
@@ -1737,12 +1740,10 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
|
||||
mockTenant.AssertExpectations(t)
|
||||
mockKratos.AssertExpectations(t)
|
||||
|
||||
req = httptest.NewRequest(http.MethodPut, "/users/bulk", strings.NewReader(`{"userIds":["legacy-id"],"companyCode":"legacy-tenant"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err = app.Test(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
legacyTenantSlug := "legacy-tenant"
|
||||
_, legacyErr := tenantSlugPointerFromRequest(nil, &legacyTenantSlug)
|
||||
require.Error(t, legacyErr)
|
||||
require.Contains(t, legacyErr.Error(), "companyCode is deprecated")
|
||||
}
|
||||
|
||||
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
|
||||
|
||||
@@ -6,14 +6,13 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func ClearOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
|
||||
result := db.WithContext(ctx).Exec(`
|
||||
WITH orphan_users AS (
|
||||
SELECT u.id
|
||||
FROM users AS u
|
||||
WHERE u.deleted_at IS NULL
|
||||
func CountOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
|
||||
var count int64
|
||||
err := db.WithContext(ctx).Raw(`
|
||||
SELECT COUNT(*)
|
||||
FROM users AS u
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND (
|
||||
(
|
||||
u.tenant_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
@@ -22,32 +21,28 @@ WITH orphan_users AS (
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
OR (
|
||||
NULLIF(BTRIM(u.company_code), '') IS NOT NULL
|
||||
`).Scan(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func ClearOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
|
||||
result := db.WithContext(ctx).Exec(`
|
||||
WITH orphan_users AS (
|
||||
SELECT u.id
|
||||
FROM users AS u
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND (
|
||||
u.tenant_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE LOWER(t.slug) = LOWER(BTRIM(u.company_code))
|
||||
WHERE t.id = u.tenant_id
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM UNNEST(COALESCE(u.company_codes, ARRAY[]::text[])) AS code(value)
|
||||
WHERE NULLIF(BTRIM(code.value), '') IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE LOWER(t.slug) = LOWER(BTRIM(code.value))
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
UPDATE users AS u
|
||||
SET tenant_id = NULL,
|
||||
company_code = '',
|
||||
company_codes = NULL,
|
||||
updated_at = NOW()
|
||||
FROM orphan_users AS ou
|
||||
WHERE u.id = ou.id
|
||||
|
||||
@@ -44,6 +44,10 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
|
||||
require.NoError(t, repo.Create(ctx, activeUser))
|
||||
require.NoError(t, repo.Create(ctx, orphanUser))
|
||||
|
||||
count, err := CountOrphanUserTenantMemberships(ctx, testDB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), count)
|
||||
|
||||
affected, err := ClearOrphanUserTenantMemberships(ctx, testDB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), affected)
|
||||
@@ -60,4 +64,8 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
|
||||
assert.Nil(t, foundOrphan.TenantID)
|
||||
assert.Empty(t, foundOrphan.CompanyCode)
|
||||
assert.Empty(t, foundOrphan.CompanyCodes)
|
||||
|
||||
count, err = CountOrphanUserTenantMemberships(ctx, testDB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
@@ -90,11 +90,6 @@ func (r *userProjectionRepository) CountTenantMembers(ctx context.Context, tenan
|
||||
FROM requested
|
||||
LEFT JOIN users ON users.deleted_at IS NULL AND (
|
||||
users.tenant_id::text = requested.tenant_id
|
||||
OR LOWER(users.company_code) = LOWER(requested.slug)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM unnest(users.company_codes) AS company_code
|
||||
WHERE LOWER(company_code) = LOWER(requested.slug)
|
||||
)
|
||||
)
|
||||
GROUP BY requested.tenant_id
|
||||
`, strings.Join(valuePlaceholders, ","))
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -152,33 +151,25 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
|
||||
}
|
||||
|
||||
type result struct {
|
||||
CompanyCode string
|
||||
TenantSlug string
|
||||
Count int64
|
||||
}
|
||||
var results []result
|
||||
|
||||
lowerCodes := lowerStrings(codes)
|
||||
|
||||
// Combine singular company_code and array company_codes using a subquery
|
||||
// to ensure we count each user accurately per company code they belong to.
|
||||
query := `
|
||||
SELECT LOWER(comp_code) as company_code, count(DISTINCT id) as count
|
||||
FROM (
|
||||
SELECT id, company_code as comp_code FROM users WHERE deleted_at IS NULL AND LOWER(company_code) = ANY($1)
|
||||
UNION ALL
|
||||
SELECT id, unnest(company_codes) as comp_code FROM users WHERE deleted_at IS NULL AND company_codes IS NOT NULL
|
||||
) as combined
|
||||
WHERE LOWER(comp_code) = ANY($1)
|
||||
GROUP BY LOWER(comp_code)
|
||||
`
|
||||
err := r.db.WithContext(ctx).Raw(query, pq.Array(lowerCodes)).Scan(&results).Error
|
||||
if err != nil {
|
||||
if err := r.db.WithContext(ctx).Table("users").
|
||||
Select("LOWER(tenants.slug) AS tenant_slug, count(DISTINCT users.id) AS count").
|
||||
Joins("JOIN tenants ON users.tenant_id = tenants.id").
|
||||
Where("users.deleted_at IS NULL AND LOWER(tenants.slug) IN ?", lowerCodes).
|
||||
Group("LOWER(tenants.slug)").
|
||||
Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
counts := make(map[string]int64)
|
||||
for _, res := range results {
|
||||
counts[strings.ToLower(res.CompanyCode)] = res.Count
|
||||
counts[strings.ToLower(res.TenantSlug)] = res.Count
|
||||
}
|
||||
|
||||
// Ensure all requested codes are present in results (even if count is 0)
|
||||
@@ -207,13 +198,13 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
|
||||
|
||||
if tenantSlug != "" {
|
||||
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
|
||||
Where("users.company_code = ? OR ? = ANY(users.company_codes) OR tenants.slug = ?", tenantSlug, tenantSlug, tenantSlug)
|
||||
Where("tenants.slug = ?", tenantSlug)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
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)
|
||||
db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.metadata::text LIKE ?)",
|
||||
searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
@@ -281,6 +272,10 @@ func (r *userRepository) FindByTenantIDs(ctx context.Context, tenantIDs []string
|
||||
|
||||
func (r *userRepository) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
|
||||
var users []domain.User
|
||||
err := r.db.WithContext(ctx).Where("company_code IN ?", codes).Find(&users).Error
|
||||
err := r.db.WithContext(ctx).
|
||||
Joins("JOIN tenants ON users.tenant_id = tenants.id").
|
||||
Where("LOWER(tenants.slug) IN ?", lowerStrings(codes)).
|
||||
Preload("Tenant").
|
||||
Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||
return &domain.IDPMetadata{
|
||||
SupportedFields: []string{
|
||||
"id", "custom_login_ids", "login_id", "email", "name", "phone_number",
|
||||
"grade", "department", "affiliationType", "companyCode",
|
||||
"grade", "department", "affiliationType", "tenant_id",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type UserGroupService interface {
|
||||
@@ -228,7 +227,8 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
||||
if traits == nil {
|
||||
traits = make(map[string]interface{})
|
||||
}
|
||||
traits["companyCode"] = tenant.Slug
|
||||
delete(traits, "companyCode")
|
||||
delete(traits, "companyCodes")
|
||||
traits["tenant_id"] = tenant.ID
|
||||
traits["department"] = group.Name
|
||||
|
||||
@@ -257,7 +257,6 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
|
||||
}
|
||||
}
|
||||
if localUser != nil {
|
||||
localUser.CompanyCode = tenant.Slug
|
||||
localUser.TenantID = &tenant.ID
|
||||
localUser.Department = group.Name
|
||||
if err := s.userRepo.Update(ctx, localUser); err != nil {
|
||||
@@ -313,11 +312,6 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
|
||||
grade = ""
|
||||
}
|
||||
|
||||
companyCode := userGroupTraitString(traits, "companyCode")
|
||||
if companyCode == "" {
|
||||
companyCode = userGroupTraitString(traits, "company_code")
|
||||
}
|
||||
|
||||
user := &domain.User{
|
||||
ID: identity.ID,
|
||||
Email: userGroupTraitString(traits, "email"),
|
||||
@@ -325,7 +319,6 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
|
||||
Phone: userGroupTraitString(traits, "phone_number"),
|
||||
Role: role,
|
||||
Status: userGroupIdentityStatus(identity.State),
|
||||
CompanyCode: companyCode,
|
||||
Department: userGroupTraitString(traits, "department"),
|
||||
Grade: grade,
|
||||
Position: userGroupTraitString(traits, "position"),
|
||||
@@ -341,8 +334,6 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
|
||||
if relyingPartyID := userGroupTraitString(traits, "relying_party_id"); relyingPartyID != "" {
|
||||
user.RelyingPartyID = &relyingPartyID
|
||||
}
|
||||
user.CompanyCodes = pq.StringArray(userGroupTraitStringArray(traits, "companyCodes"))
|
||||
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "role": true, "companyCode": true, "company_code": true,
|
||||
|
||||
@@ -302,13 +302,13 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
|
||||
State: "active",
|
||||
}, nil)
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
return traits["companyCode"] == tenantSlug && traits["tenant_id"] == tenantID && traits["department"] == "Sales"
|
||||
_, hasCompanyCode := traits["companyCode"]
|
||||
return !hasCompanyCode && traits["tenant_id"] == tenantID && traits["department"] == "Sales"
|
||||
}), "active").Return(&KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"email": "user@test.com",
|
||||
"name": "User Test",
|
||||
"companyCode": tenantSlug,
|
||||
"tenant_id": tenantID,
|
||||
"department": "Sales",
|
||||
},
|
||||
@@ -325,7 +325,7 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, mockUserRepo.updatedUsers, 1)
|
||||
assert.Equal(t, userID, mockUserRepo.updatedUsers[0].ID)
|
||||
assert.Equal(t, tenantSlug, mockUserRepo.updatedUsers[0].CompanyCode)
|
||||
assert.Empty(t, mockUserRepo.updatedUsers[0].CompanyCode)
|
||||
assert.NotNil(t, mockUserRepo.updatedUsers[0].TenantID)
|
||||
assert.Equal(t, tenantID, *mockUserRepo.updatedUsers[0].TenantID)
|
||||
assert.Equal(t, "Sales", mockUserRepo.updatedUsers[0].Department)
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type UserProjectionSyncService struct {
|
||||
@@ -73,11 +71,6 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
|
||||
grade = ""
|
||||
}
|
||||
|
||||
companyCode := kratosProjectionTraitString(traits, "companyCode")
|
||||
if companyCode == "" {
|
||||
companyCode = kratosProjectionTraitString(traits, "company_code")
|
||||
}
|
||||
|
||||
user := domain.User{
|
||||
ID: identity.ID,
|
||||
Email: kratosProjectionTraitString(traits, "email"),
|
||||
@@ -85,8 +78,6 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
|
||||
Phone: kratosProjectionTraitString(traits, "phone_number"),
|
||||
Role: role,
|
||||
Status: normalizeProjectionStatus(identity.State),
|
||||
CompanyCode: companyCode,
|
||||
CompanyCodes: pq.StringArray(kratosProjectionTraitStringArray(traits, "companyCodes")),
|
||||
Department: kratosProjectionTraitString(traits, "department"),
|
||||
Grade: grade,
|
||||
Position: kratosProjectionTraitString(traits, "position"),
|
||||
|
||||
@@ -70,8 +70,8 @@ func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *test
|
||||
assert.Equal(t, "one@example.com", repo.replacedUsers[0].Email)
|
||||
assert.Equal(t, "One", repo.replacedUsers[0].Name)
|
||||
assert.Equal(t, "+821012345678", repo.replacedUsers[0].Phone)
|
||||
assert.Equal(t, "saman", repo.replacedUsers[0].CompanyCode)
|
||||
assert.Equal(t, []string{"saman", "group-a"}, []string(repo.replacedUsers[0].CompanyCodes))
|
||||
assert.Empty(t, repo.replacedUsers[0].CompanyCode)
|
||||
assert.Empty(t, repo.replacedUsers[0].CompanyCodes)
|
||||
require.NotNil(t, repo.replacedUsers[0].TenantID)
|
||||
assert.Equal(t, tenantID, *repo.replacedUsers[0].TenantID)
|
||||
assert.Equal(t, "kept", repo.replacedUsers[0].Metadata["customAttr"])
|
||||
|
||||
@@ -69,6 +69,22 @@ Kratos identity traits도 같은 기준으로 정리한다.
|
||||
|
||||
### Baron users만 실행
|
||||
|
||||
Go 구현 경로를 사용하는 권장 명령은 다음과 같다.
|
||||
|
||||
```bash
|
||||
go run ./cmd/adminctl clear-orphan-user-tenant-memberships --dry-run
|
||||
go run ./cmd/adminctl clear-orphan-user-tenant-memberships
|
||||
```
|
||||
|
||||
컨테이너나 배포 환경에서는 같은 명령을 adminctl 바이너리로 실행한다.
|
||||
|
||||
```bash
|
||||
adminctl clear-orphan-user-tenant-memberships --dry-run
|
||||
adminctl clear-orphan-user-tenant-memberships
|
||||
```
|
||||
|
||||
SQL만 직접 실행해야 하는 경우에는 다음 스크립트를 사용할 수 있다.
|
||||
|
||||
```bash
|
||||
docker exec -i baron_postgres psql -U baron -d baron_sso < scripts/clear_orphan_user_tenant_memberships.sql
|
||||
```
|
||||
|
||||
@@ -985,12 +985,12 @@ class AuthProxyService {
|
||||
|
||||
static Future<Map<String, dynamic>> checkLoginIDAvailability(
|
||||
String loginId, {
|
||||
String? companyCode,
|
||||
String? tenantSlug,
|
||||
}) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/check-login-id');
|
||||
final bodyData = {'loginId': loginId};
|
||||
if (companyCode != null && companyCode.isNotEmpty) {
|
||||
bodyData['companyCode'] = companyCode;
|
||||
if (tenantSlug != null && tenantSlug.isNotEmpty) {
|
||||
bodyData['tenantSlug'] = tenantSlug;
|
||||
}
|
||||
final response = await http.post(
|
||||
url,
|
||||
@@ -1074,7 +1074,7 @@ class AuthProxyService {
|
||||
required String name,
|
||||
required String phone,
|
||||
required String affiliationType,
|
||||
String? companyCode,
|
||||
String? tenantSlug,
|
||||
required String department,
|
||||
required bool termsAccepted,
|
||||
}) async {
|
||||
@@ -1089,7 +1089,7 @@ class AuthProxyService {
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'affiliationType': affiliationType,
|
||||
'companyCode': companyCode,
|
||||
'tenantSlug': tenantSlug,
|
||||
'department': department,
|
||||
'termsAccepted': termsAccepted,
|
||||
}),
|
||||
|
||||
@@ -333,7 +333,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
name: _nameController.text.trim(),
|
||||
phone: _phoneController.text.trim(),
|
||||
affiliationType: _affiliationType,
|
||||
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
|
||||
tenantSlug: _affiliationType == 'AFFILIATE' ? _companyCode : null,
|
||||
department: _deptController.text.trim(),
|
||||
termsAccepted: true,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user