1
0
forked from baron/baron-sso

내정보 멀티 테턴트 표시

This commit is contained in:
2026-03-05 17:18:49 +09:00
parent c1479a32a7
commit 3113fc09ff
9 changed files with 446 additions and 262 deletions

View File

@@ -286,6 +286,49 @@ function AppLayout() {
</span>
</div>
</div>
{/* Manageable Tenants Section */}
{profile?.manageableTenants &&
profile.manageableTenants.length > 0 && (
<div className="px-2 py-2 border-b border-border/50 mb-1">
<p className="px-1 mb-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{t(
"ui.admin.profile.manageable_tenants",
"Manageable Tenants",
)}
</p>
<div className="max-h-40 overflow-y-auto space-y-1 pr-1 custom-scrollbar">
{profile.manageableTenants.map((tenant) => (
<button
key={tenant.id}
type="button"
onClick={() => {
setIsProfileOpen(false);
navigate(`/tenants/${tenant.id}`);
}}
className="w-full flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-left text-muted-foreground transition hover:bg-muted/50 hover:text-foreground group"
>
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground group-hover:bg-primary/20 group-hover:text-primary transition-colors">
{tenant.type === "USER_GROUP" ? (
<Users size={12} />
) : (
<Building2 size={12} />
)}
</div>
<div className="flex flex-col truncate">
<span className="font-medium truncate">
{tenant.name}
</span>
<span className="text-[9px] opacity-60 font-mono truncate">
{tenant.slug}
</span>
</div>
</button>
))}
</div>
</div>
)}
<button
type="button"
onClick={() => {

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { ArrowLeft, BadgeCheck, Building2, Loader2, Save, Users } from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate, useParams } from "react-router-dom";
@@ -35,6 +35,75 @@ type UserSchemaField = {
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
// [New] Component for per-tenant profile/schema management
function TenantProfileCard({
tenant,
register,
errors,
isAdmin
}: {
tenant: any,
register: any,
errors: any,
isAdmin: boolean
}) {
const { data: detail, isLoading } = useQuery({
queryKey: ["tenant", tenant.id],
queryFn: () => fetchTenant(tenant.id),
});
const schema: UserSchemaField[] = Array.isArray(detail?.config?.userSchema)
? (detail?.config?.userSchema as UserSchemaField[])
: [];
if (isLoading) return <div className="p-4 border rounded-lg animate-pulse bg-muted/20">Loading schema...</div>;
if (schema.length === 0) return null;
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="bg-muted/50 px-4 py-2 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<Building2 size={14} className="text-primary" />
<span className="text-xs font-bold uppercase tracking-tight">{tenant.name}</span>
</div>
<span className="text-[10px] font-mono opacity-50">{tenant.slug}</span>
</div>
<div className="p-4 grid gap-4 md:grid-cols-2">
{schema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${tenant.id}.${field.key}`} className="text-xs">
{field.label}
{field.required && <span className="ml-1 text-destructive">*</span>}
{field.adminOnly && (
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
Admin Only
</span>
)}
</Label>
<Input
id={`metadata.${tenant.id}.${field.key}`}
type={
field.type === "number" ? "number" :
field.type === "date" ? "date" :
field.type === "boolean" ? "checkbox" : "text"
}
className={field.type === "boolean" ? "w-auto h-auto" : "h-8 text-sm"}
{...register(`metadata.${tenant.id}.${field.key}`, {
required: field.required ? t("msg.admin.users.detail.form.field_required", "필수입니다.") : false,
})}
/>
{errors.metadata?.[tenant.id]?.[field.key] && (
<p className="text-[10px] text-destructive">
{errors.metadata[tenant.id][field.key].message}
</p>
)}
</div>
))}
</div>
</div>
);
}
function UserDetailPage() {
const params = useParams<{ id: string }>();
const userId = params.id ?? "";
@@ -85,41 +154,7 @@ function UserDetailPage() {
},
});
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const selectedTenantId = selectedTenant?.id ?? "";
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenantId],
queryFn: () => fetchTenant(selectedTenantId),
enabled: selectedTenantId.length > 0,
});
const userSchema: UserSchemaField[] = Array.isArray(
tenantDetail?.config?.userSchema,
)
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
const registerMetadata = (field: UserSchemaField) =>
register(`metadata.${field.key}` as `metadata.${string}`, {
required: field.required
? t("msg.admin.users.detail.form.field_required", "{{label}}은(는) 필수입니다.", {
label: field.label || field.key,
})
: false,
pattern: field.validation
? {
value: new RegExp(field.validation),
message: t(
"msg.admin.users.detail.form.field_invalid",
"{{label}} 형식이 올바르지 않습니다.",
{ label: field.label || field.key },
),
}
: undefined,
});
const isAdmin = profile?.role === "super_admin" || profile?.role === "tenant_admin";
React.useEffect(() => {
if (user) {
@@ -187,6 +222,12 @@ function UserDetailPage() {
);
}
// Combined affiliated tenants
const userAffiliatedTenants = [...(user.joinedTenants || [])];
if (user.tenant && !userAffiliatedTenants.find(t => t.id === user.tenant?.id)) {
userAffiliatedTenants.push(user.tenant);
}
return (
<div className="max-w-3xl space-y-8">
<header className="flex flex-wrap items-center justify-between gap-4">
@@ -236,6 +277,70 @@ function UserDetailPage() {
</div>
)}
{/* Tenant Affiliation Section (Enhanced) */}
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Building2 size={16} className="text-primary" />
{t("ui.admin.users.detail.tenants_section.title", "소속 및 조직 정보")}
</h3>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
{t("ui.admin.users.detail.tenants_section.primary", "대표 소속 테넌트")}
</Label>
{/* Select box to specify representative tenant from joined ones */}
{userAffiliatedTenants.length > 0 ? (
<div className="relative">
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
{...register("companyCode")}
disabled={profile?.role === "tenant_admin" && userAffiliatedTenants.length <= 1}
>
<option value="">{t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}</option>
{userAffiliatedTenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
<BadgeCheck size={14} className="absolute right-8 top-3 text-primary pointer-events-none" />
</div>
) : (
<div className="flex items-center gap-2 p-2 rounded-md bg-background border border-dashed border-border text-muted-foreground italic text-xs">
{t("ui.admin.users.detail.form.tenant_global", "시스템 전역 (소속 없음)")}
</div>
)}
<p className="text-[10px] text-muted-foreground">
* .
</p>
</div>
{userAffiliatedTenants.length > 1 && (
<div className="space-y-1">
<Label className="text-[10px] uppercase text-muted-foreground tracking-wider font-bold">
{t("ui.admin.users.detail.tenants_section.additional", "전체 소속 목록")}
</Label>
<div className="flex flex-wrap gap-1.5 pt-1">
{userAffiliatedTenants.map(jt => (
<Link
key={jt.id}
to={`/tenants/${jt.id}`}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
jt.id === tenants.find(t => t.slug === watch("companyCode"))?.id ? "bg-primary/10 border-primary/30 text-primary font-bold" : "bg-background border-border text-muted-foreground hover:border-primary/50"
}`}
>
<Users size={10} />
{jt.name}
</Link>
))}
</div>
</div>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">
@@ -328,34 +433,6 @@ function UserDetailPage() {
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode">
{t("ui.admin.users.detail.form.tenant", "테넌트 (Tenant)")}
</Label>
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
disabled={profile?.role === "tenant_admin"}
>
<option value="">
{t(
"ui.admin.users.detail.form.tenant_global",
"시스템 전역 (소속 없음)",
)}
</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department">
{t("ui.admin.users.detail.form.department", "부서")}
@@ -372,89 +449,30 @@ function UserDetailPage() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="position">
{t("ui.admin.users.detail.form.position", "직급")}
</Label>
<Input
id="position"
placeholder={t(
"ui.admin.users.detail.form.position_placeholder",
"수석/책임/선임",
)}
{...register("position")}
/>
{/* Tenant-specific Profiles (Namespaced Metadata) */}
<div className="border-t pt-6 space-y-6">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
{t("ui.admin.users.detail.custom_fields.multi_title", "테넌트별 프로필 관리")}
</h3>
<p className="text-[11px] text-muted-foreground">
.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="jobTitle">
{t("ui.admin.users.detail.form.job_title", "직무")}
</Label>
<Input
id="jobTitle"
placeholder={t(
"ui.admin.users.detail.form.job_title_placeholder",
"프론트엔드 개발",
)}
{...register("jobTitle")}
/>
<div className="grid gap-4">
{userAffiliatedTenants.map((tenant) => (
<TenantProfileCard
key={tenant.id}
tenant={tenant}
register={register}
errors={errors}
isAdmin={isAdmin}
/>
))}
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t(
"ui.admin.users.detail.custom_fields.title",
"테넌트 확장 정보 (Custom Fields)",
)}
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
{field.required && (
<span className="ml-1 text-destructive">*</span>
)}
{field.adminOnly && (
<span className="ml-2 text-[10px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter">
Admin Only
</span>
)}
</Label>
<Input
id={`metadata.${field.key}`}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={
field.type === "boolean" ? "w-auto h-auto" : ""
}
{...registerMetadata(field)}
/>
{errors.metadata?.[field.key] && (
<p className="text-xs text-destructive">
{(errors.metadata[field.key] as any).message}
</p>
)}
</div>
))}
</div>
</div>
)}
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t("ui.admin.users.detail.security.title", "보안 설정")}

View File

@@ -354,6 +354,7 @@ export type UserSummary = {
status: string;
companyCode?: string;
tenant?: TenantSummary;
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record<string, unknown>;
department?: string;
position?: string;

View File

@@ -24,30 +24,45 @@ export function buildTenantFullTree(
});
}
// Build initial children relations
const visitedDuringBuild = new Set<string>();
// 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<string>();
// 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 };
}

View File

@@ -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

View File

@@ -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] {

View File

@@ -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 {

View File

@@ -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}}"

View File

@@ -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}}"