+ {/* Tenant-specific Profiles (Namespaced Metadata) */}
+
+
+
+ {t("ui.admin.users.detail.custom_fields.multi_title", "테넌트별 프로필 관리")}
+
+
+ 사용자가 소속된 각 테넌트별 맞춤 정보를 관리합니다.
+
-
-
-
-
+
+ {userAffiliatedTenants.map((tenant) => (
+
+ ))}
- {userSchema.length > 0 && (
-
-
- {t(
- "ui.admin.users.detail.custom_fields.title",
- "테넌트 확장 정보 (Custom Fields)",
- )}
-
-
-
- {userSchema.map((field) => (
-
-
-
-
- {errors.metadata?.[field.key] && (
-
- {(errors.metadata[field.key] as any).message}
-
- )}
-
- ))}
-
-
- )}
-
{t("ui.admin.users.detail.security.title", "보안 설정")}
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts
index 74d4c3f4..f63c5c3a 100644
--- a/adminfront/src/lib/adminApi.ts
+++ b/adminfront/src/lib/adminApi.ts
@@ -354,6 +354,7 @@ export type UserSummary = {
status: string;
companyCode?: string;
tenant?: TenantSummary;
+ joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record;
department?: string;
position?: string;
diff --git a/adminfront/src/lib/tenantTree.ts b/adminfront/src/lib/tenantTree.ts
index 36b5a4a2..bafa9568 100644
--- a/adminfront/src/lib/tenantTree.ts
+++ b/adminfront/src/lib/tenantTree.ts
@@ -24,30 +24,45 @@ export function buildTenantFullTree(
});
}
- // Build initial children relations
+ const visitedDuringBuild = new Set();
+ // Build initial children relations and prevent simple cycles
for (const t of allTenants) {
- if (t.parentId) {
+ if (t.parentId && t.parentId !== t.id) {
const parent = tenantMap.get(t.parentId);
const child = tenantMap.get(t.id);
if (parent && child) {
+ // Simple cycle prevention during build: don't add if it creates an immediate loop
parent.children.push(child);
}
}
}
- // Function to calculate recursive counts
+ const visitedForCalc = new Set();
+ // Function to calculate recursive counts with cycle protection
const calculateRecursive = (node: TenantNode): number => {
+ if (visitedForCalc.has(node.id)) {
+ console.warn(`Circular dependency detected in tenant tree for ID: ${node.id}`);
+ return 0; // Prevent infinite loop
+ }
+ visitedForCalc.add(node.id);
+
let total = node.memberCount || 0;
for (const child of node.children) {
total += calculateRecursive(child);
}
node.recursiveMemberCount = total;
+
+ // We don't remove from visitedForCalc here because a tree shouldn't have
+ // multiple paths to the same node anyway (it's a tree, not a graph).
+ // If it were a DAG, we'd need different logic, but for a tree with parentIds,
+ // a node should only be visited once.
return total;
};
// Calculate for all top-level nodes (those without parent)
for (const node of tenantMap.values()) {
if (!node.parentId) {
+ visitedForCalc.clear();
calculateRecursive(node);
}
}
@@ -57,6 +72,7 @@ export function buildTenantFullTree(
const base = tenantMap.get(rootId);
if (base) {
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
+ visitedForCalc.clear();
calculateRecursive(base);
return { currentBase: base, subTree: base.children };
}
diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go
index 56cf1bc4..b12db5c9 100644
--- a/backend/internal/handler/tenant_handler.go
+++ b/backend/internal/handler/tenant_handler.go
@@ -141,23 +141,31 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
}
- // Fetch member counts for all tenants in one query using slugs (company codes)
+ // Fetch member counts for all tenants in one query using IDs
+ tenantIDs := make([]string, 0, len(tenants))
slugs := make([]string, 0, len(tenants))
for _, t := range tenants {
+ tenantIDs = append(tenantIDs, t.ID)
slugs = append(slugs, t.Slug)
}
- memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), slugs)
- if err != nil {
- slog.Warn("failed to count members for tenants", "error", err)
- memberCounts = make(map[string]int64)
- }
+
+ idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), tenantIDs)
+ slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), slugs)
items := make([]tenantSummary, 0, len(tenants))
for _, t := range tenants {
summary := mapTenantSummary(t)
- // Ensure robust matching by trimming and lowercasing the slug key
- key := strings.ToLower(strings.TrimSpace(t.Slug))
- summary.MemberCount = memberCounts[key]
+
+ // Combine counts from both ID and Slug (Max to avoid double counting if some have one or the other)
+ idCount := idCounts[t.ID]
+ slugCount := slugCounts[strings.ToLower(t.Slug)]
+
+ if idCount > slugCount {
+ summary.MemberCount = idCount
+ } else {
+ summary.MemberCount = slugCount
+ }
+
items = append(items, summary)
}
@@ -182,11 +190,17 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
- memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug})
- count := int64(0)
- if err == nil {
- count = memberCounts[strings.ToLower(tenant.Slug)]
+ idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), []string{tenant.ID})
+ slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug})
+
+ idCount := idCounts[tenant.ID]
+ slugCount := slugCounts[strings.ToLower(tenant.Slug)]
+
+ count := idCount
+ if slugCount > idCount {
+ count = slugCount
}
+
summary := mapTenantSummary(tenant)
summary.MemberCount = count
diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go
index 18a6ef62..54b23e72 100644
--- a/backend/internal/handler/user_handler.go
+++ b/backend/internal/handler/user_handler.go
@@ -47,19 +47,22 @@ func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProvi
}
type userSummary struct {
- ID string `json:"id"`
- Email string `json:"email"`
- Name string `json:"name"`
- Phone string `json:"phone"`
- Role string `json:"role"`
- Status string `json:"status"`
- CompanyCode string `json:"companyCode"`
- Metadata domain.JSONMap `json:"metadata,omitempty"`
- Tenant *domain.Tenant `json:"tenant,omitempty"`
- Department string `json:"department"`
- CreatedAt string `json:"createdAt"`
- UpdatedAt string `json:"updatedAt"`
- InitialPassword string `json:"initialPassword,omitempty"`
+ ID string `json:"id"`
+ Email string `json:"email"`
+ Name string `json:"name"`
+ Phone string `json:"phone"`
+ Role string `json:"role"`
+ Status string `json:"status"`
+ CompanyCode string `json:"companyCode"`
+ Metadata domain.JSONMap `json:"metadata,omitempty"`
+ Tenant *domain.Tenant `json:"tenant,omitempty"`
+ JoinedTenants []domain.Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
+ Department string `json:"department"`
+ Position string `json:"position"`
+ JobTitle string `json:"jobTitle"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+ InitialPassword string `json:"initialPassword,omitempty"`
}
type userListResponse struct {
@@ -222,8 +225,22 @@ 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 := extractTraitString(identity.Traits, "companyCode")
- if requester.CompanyCode == "" || compCode != requester.CompanyCode {
+ 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 {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied")
}
}
@@ -361,34 +378,27 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if h.UserRepo != nil {
localUser := h.mapToLocalUser(*identity)
- // Sync to local DB
- go func(u *domain.User, role string, tID *string) {
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- defer cancel()
+ // Sync to local DB (Synchronous for immediate consistency)
+ if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
+ slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err)
+ }
- // Use Update (upsert) instead of Create for robustness
- if err := h.UserRepo.Update(ctx, u); err != nil {
- slog.Error("[UserHandler] Failed to sync new user to local DB", "email", u.Email, "error", err)
- return
+ // [Keto] Sync relations via Outbox (Synchronous for accurate counting)
+ if h.KetoOutboxRepo != nil {
+ // 1. Role based relations
+ h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID)
+
+ // 2. Direct membership to the Tenant (for accurate counting)
+ if localUser.TenantID != nil && *localUser.TenantID != "" {
+ _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
+ Namespace: "Tenant",
+ Object: *localUser.TenantID,
+ Relation: "members",
+ Subject: "User:" + localUser.ID,
+ Action: domain.KetoOutboxActionCreate,
+ })
}
-
- // [Keto] Sync relations via Outbox
- if h.KetoOutboxRepo != nil {
- // 1. Role based relations
- h.syncKetoRole(ctx, u.ID, role, "", "", tID)
-
- // 2. Direct membership to the Tenant (for accurate counting)
- if tID != nil && *tID != "" {
- _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
- Namespace: "Tenant",
- Object: *tID,
- Relation: "members",
- Subject: "User:" + u.ID,
- Action: domain.KetoOutboxActionCreate,
- })
- }
- }
- }(localUser, role, localUser.TenantID)
+ }
}
response := h.mapIdentitySummary(c.Context(), *identity)
@@ -535,34 +545,50 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
continue
}
- // Sync to local DB
+ // [CRITICAL FIX] Sync to local DB directly using current data
+ // Don't fetch from Kratos here as it might have propagation lag
if h.UserRepo != nil {
- identity, _ := h.KratosAdmin.GetIdentity(c.Context(), identityID)
- if identity != nil {
- localUser := h.mapToLocalUser(*identity)
-
- // [Fix] Override with current loop data to ensure accuracy
- localUser.CompanyCode = compCode
- if tItem.ID != "" {
- localUser.TenantID = &tItem.ID
- }
+ localUser := &domain.User{
+ ID: identityID,
+ Email: email,
+ Name: name,
+ Phone: normalizePhoneNumber(item.Phone),
+ Role: role,
+ Status: "active",
+ CompanyCode: compCode,
+ Department: dept,
+ AffiliationType: "internal",
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+
+ if tItem.ID != "" {
+ localUser.TenantID = &tItem.ID
+ }
- _ = h.UserRepo.Update(context.Background(), localUser)
+ // Merge metadata
+ localUser.Metadata = make(domain.JSONMap)
+ for k, v := range item.Metadata {
+ localUser.Metadata[k] = v
+ }
+
+ if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
+ slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
+ }
+
+ if h.KetoOutboxRepo != nil {
+ // 1. Sync Role based relationship
+ h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID)
- if h.KetoOutboxRepo != nil {
- // 1. Sync Role based relationship
- h.syncKetoRole(context.Background(), localUser.ID, role, "", "", localUser.TenantID)
-
- // 2. Sync direct membership to the Tenant (for count)
- if localUser.TenantID != nil && *localUser.TenantID != "" {
- _ = h.KetoOutboxRepo.Create(context.Background(), &domain.KetoOutbox{
- Namespace: "Tenant",
- Object: *localUser.TenantID,
- Relation: "members",
- Subject: "User:" + localUser.ID,
- Action: domain.KetoOutboxActionCreate,
- })
- }
+ // 2. Sync direct membership to the Tenant (for count)
+ if localUser.TenantID != nil && *localUser.TenantID != "" {
+ _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
+ Namespace: "Tenant",
+ Object: *localUser.TenantID,
+ Relation: "members",
+ Subject: "User:" + localUser.ID,
+ Action: domain.KetoOutboxActionCreate,
+ })
}
}
}
@@ -964,21 +990,45 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
}
- // [Validation] Based on Tenant Schema
- schemaCompCode := extractTraitString(identity.Traits, "companyCode")
- if req.CompanyCode != nil {
- schemaCompCode = *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 {
- isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin)
- if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil {
- return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
+ // [Validation] Based on Tenant Schema (Multi-tenant aware)
+ isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin)
+
+ // If metadata is namespaced (key is tenant ID), validate each namespace
+ // If it's flat, validate using schemaCompCode
+ for key, val := range req.Metadata {
+ // Basic check if key looks like a UUID (tenant ID)
+ if len(key) >= 32 {
+ // Namespaced metadata
+ if h.TenantService != nil {
+ tenant, err := h.TenantService.GetTenant(c.Context(), key)
+ if err == nil && tenant != nil {
+ if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
+ if subMeta, ok := val.(map[string]any); ok {
+ if err := h.validateMetadataWithAuth(subMeta, schema, isAdmin, false); err != nil {
+ return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed for tenant "+tenant.Name+": "+err.Error())
+ }
+ }
+ }
}
}
+ } else {
+ // Legacy/Flat metadata - validate using primary tenant schema
+ schemaCompCode := extractTraitString(identity.Traits, "companyCode")
+ if req.CompanyCode != nil {
+ schemaCompCode = *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())
+ }
+ }
+ }
+ }
+ break // Only need to check flat metadata once
}
}
@@ -1019,21 +1069,16 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["role"] = role
}
- // [Refined] Metadata synchronization: replace non-core traits with new Metadata
+ // [Namespaced Metadata Sync]
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"affiliationType": true, "role": true, "tenant_id": true,
}
- // 1. Remove existing non-core traits to handle deletions
- for k := range traits {
- if !coreTraits[k] {
- delete(traits, k)
- }
- }
-
- // 2. Add new metadata fields
+ // For namespaced metadata, we don't delete everything, we merge.
+ // But we should remove legacy flat traits that are not in the new req.Metadata if we want strict sync.
+ // For now, let's just merge.
for k, v := range req.Metadata {
if !coreTraits[k] {
traits[k] = v
@@ -1134,16 +1179,30 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
UpdatedAt: formatTime(identity.UpdatedAt),
}
- // Filter out core traits and put everything else in Metadata
+ // [New] Fetch all manageable tenants (for Multi-tenancy support)
+ if h.TenantService != nil {
+ if joined, err := h.TenantService.ListManageableTenants(ctx, identity.ID); err == nil {
+ summary.JoinedTenants = joined
+ }
+ }
+
+ // [Namespaced Metadata] Handling
+ // We assume core traits are at the top level.
+ // For other keys, if they are UUIDs (tenant IDs), we treat them as namespaced metadata.
+ // 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,
- "affiliationType": true,
+ "affiliationType": true, "role": true, "tenant_id": true,
}
+
for k, v := range traits {
- if !coreTraits[k] {
- summary.Metadata[k] = v
+ if coreTraits[k] {
+ continue
}
+ // If the key is a tenant ID (uuid-like), it's namespaced metadata
+ // If not, it's flat metadata (for backward compatibility)
+ summary.Metadata[k] = v
}
if compCode != "" && h.TenantService != nil {
@@ -1165,7 +1224,11 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
if role == "" {
role = "user"
}
+ // Try "companyCode" first, then fallback to "company_code"
compCode := extractTraitString(traits, "companyCode")
+ if compCode == "" {
+ compCode = extractTraitString(traits, "company_code")
+ }
user := &domain.User{
ID: identity.ID,
@@ -1181,8 +1244,14 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
UpdatedAt: identity.UpdatedAt,
}
- if compCode != "" && h.TenantService != nil {
- // Use a background context or a timeout-limited context for tenant lookup
+ // 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 {
@@ -1190,12 +1259,12 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
}
}
- // Metadata
+ // 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,
- "affiliationType": true, "role": true, "tenant_id": true,
+ "affiliationType": true, "role": true, "tenant_id": true, "company_code": true,
}
for k, v := range traits {
if !coreTraits[k] {
diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go
index 9a8483e5..3cce7a18 100644
--- a/backend/internal/repository/user_repository.go
+++ b/backend/internal/repository/user_repository.go
@@ -6,6 +6,7 @@ import (
"strings"
"gorm.io/gorm"
+ "gorm.io/gorm/clause"
)
type UserRepository interface {
@@ -35,7 +36,11 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
}
func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
- return r.db.WithContext(ctx).Save(user).Error
+ // Use Upsert logic: if email exists, update all fields
+ return r.db.WithContext(ctx).Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "email"}},
+ UpdateAll: true,
+ }).Save(user).Error
}
func (r *userRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
@@ -81,13 +86,16 @@ func (r *userRepository) CountByTenant(ctx context.Context, tenantID string) (in
func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
type result struct {
- TenantID string
+ TenantID *string
Count int64
}
var results []result
+ counts := make(map[string]int64)
+
if len(tenantIDs) == 0 {
- return make(map[string]int64), nil
+ return counts, nil
}
+
if err := r.db.WithContext(ctx).Model(&domain.User{}).
Select("tenant_id, count(*) as count").
Where("tenant_id IN ?", tenantIDs).
@@ -96,10 +104,9 @@ func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []strin
return nil, err
}
- counts := make(map[string]int64)
for _, res := range results {
- if res.TenantID != "" {
- counts[res.TenantID] = res.Count
+ if res.TenantID != nil && *res.TenantID != "" {
+ counts[*res.TenantID] = res.Count
}
}
// Ensure all requested tenant IDs are in the map, even if count is 0
@@ -122,7 +129,7 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
}
var results []result
- // Search by company_code directly. Normalize inputs.
+ // 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)).
@@ -137,7 +144,7 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
counts[res.CompanyCode] = res.Count
}
- // Ensure all requested codes are present in results
+ // Ensure all requested codes are present in results (even if count is 0)
for _, code := range codes {
lower := strings.ToLower(strings.TrimSpace(code))
if _, ok := counts[lower]; !ok {
diff --git a/locales/en.toml b/locales/en.toml
index af2e11bd..10930d16 100644
--- a/locales/en.toml
+++ b/locales/en.toml
@@ -814,6 +814,9 @@ tenant_dashboard = "Tenant Dashboard"
title = "Title"
view_audit_logs = "View Audit Logs"
+[ui.admin.profile]
+manageable_tenants = "Manageable Tenants"
+
[ui.admin.role]
rp_admin = "RP ADMIN"
super_admin = "SUPER ADMIN"
@@ -1027,6 +1030,11 @@ password = "Password"
password_placeholder = "Password Placeholder"
title = "Security Settings"
+[ui.admin.users.detail.tenants_section]
+additional = "Additional Affiliated/Manageable Tenants"
+primary = "Representative Affiliated Tenant"
+title = "Affiliation & Organization Info"
+
[ui.admin.users.list]
add = "User Add"
delete_aria = "User Delete: {{name}}"
diff --git a/locales/ko.toml b/locales/ko.toml
index abc0bfff..d4074331 100644
--- a/locales/ko.toml
+++ b/locales/ko.toml
@@ -814,6 +814,9 @@ tenant_dashboard = "테넌트 대시보드"
title = "빠른 이동"
view_audit_logs = "감사 로그 보기"
+[ui.admin.profile]
+manageable_tenants = "관리 가능한 테넌트"
+
[ui.admin.role]
rp_admin = "RP ADMIN"
super_admin = "SUPER ADMIN"
@@ -1027,6 +1030,11 @@ password = "비밀번호 변경"
password_placeholder = "변경할 경우에만 입력"
title = "보안 설정"
+[ui.admin.users.detail.tenants_section]
+additional = "추가 소속/관리 테넌트"
+primary = "대표 소속 테넌트"
+title = "소속 및 조직 정보"
+
[ui.admin.users.list]
add = "사용자 추가"
delete_aria = "사용자 삭제: {{name}}"