forked from baron/baron-sso
내정보 멀티 테턴트 표시
This commit is contained in:
@@ -286,6 +286,49 @@ function AppLayout() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
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 * as React from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
@@ -35,6 +35,75 @@ type UserSchemaField = {
|
|||||||
|
|
||||||
type UserFormValues = UserUpdateRequest & { metadata: Record<string, unknown> };
|
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() {
|
function UserDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const userId = params.id ?? "";
|
const userId = params.id ?? "";
|
||||||
@@ -85,41 +154,7 @@ function UserDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedCompanyCode = watch("companyCode");
|
const isAdmin = profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (user) {
|
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 (
|
return (
|
||||||
<div className="max-w-3xl space-y-8">
|
<div className="max-w-3xl space-y-8">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||||
@@ -236,6 +277,70 @@ function UserDetailPage() {
|
|||||||
</div>
|
</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="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
@@ -328,34 +433,6 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="department">
|
<Label htmlFor="department">
|
||||||
{t("ui.admin.users.detail.form.department", "부서")}
|
{t("ui.admin.users.detail.form.department", "부서")}
|
||||||
@@ -372,89 +449,30 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
{/* Tenant-specific Profiles (Namespaced Metadata) */}
|
||||||
<div className="space-y-2">
|
<div className="border-t pt-6 space-y-6">
|
||||||
<Label htmlFor="position">
|
<div className="flex flex-col gap-1">
|
||||||
{t("ui.admin.users.detail.form.position", "직급")}
|
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
</Label>
|
{t("ui.admin.users.detail.custom_fields.multi_title", "테넌트별 프로필 관리")}
|
||||||
|
</h3>
|
||||||
<Input
|
<p className="text-[11px] text-muted-foreground">
|
||||||
id="position"
|
사용자가 소속된 각 테넌트별 맞춤 정보를 관리합니다.
|
||||||
placeholder={t(
|
</p>
|
||||||
"ui.admin.users.detail.form.position_placeholder",
|
|
||||||
"수석/책임/선임",
|
|
||||||
)}
|
|
||||||
{...register("position")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="grid gap-4">
|
||||||
<Label htmlFor="jobTitle">
|
{userAffiliatedTenants.map((tenant) => (
|
||||||
{t("ui.admin.users.detail.form.job_title", "직무")}
|
<TenantProfileCard
|
||||||
</Label>
|
key={tenant.id}
|
||||||
|
tenant={tenant}
|
||||||
<Input
|
register={register}
|
||||||
id="jobTitle"
|
errors={errors}
|
||||||
placeholder={t(
|
isAdmin={isAdmin}
|
||||||
"ui.admin.users.detail.form.job_title_placeholder",
|
/>
|
||||||
"프론트엔드 개발",
|
))}
|
||||||
)}
|
|
||||||
{...register("jobTitle")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="border-t pt-4">
|
||||||
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
|
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
|
||||||
{t("ui.admin.users.detail.security.title", "보안 설정")}
|
{t("ui.admin.users.detail.security.title", "보안 설정")}
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ export type UserSummary = {
|
|||||||
status: string;
|
status: string;
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
tenant?: TenantSummary;
|
tenant?: TenantSummary;
|
||||||
|
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
department?: string;
|
department?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
|
|||||||
@@ -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) {
|
for (const t of allTenants) {
|
||||||
if (t.parentId) {
|
if (t.parentId && t.parentId !== t.id) {
|
||||||
const parent = tenantMap.get(t.parentId);
|
const parent = tenantMap.get(t.parentId);
|
||||||
const child = tenantMap.get(t.id);
|
const child = tenantMap.get(t.id);
|
||||||
if (parent && child) {
|
if (parent && child) {
|
||||||
|
// Simple cycle prevention during build: don't add if it creates an immediate loop
|
||||||
parent.children.push(child);
|
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 => {
|
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;
|
let total = node.memberCount || 0;
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
total += calculateRecursive(child);
|
total += calculateRecursive(child);
|
||||||
}
|
}
|
||||||
node.recursiveMemberCount = total;
|
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;
|
return total;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate for all top-level nodes (those without parent)
|
// Calculate for all top-level nodes (those without parent)
|
||||||
for (const node of tenantMap.values()) {
|
for (const node of tenantMap.values()) {
|
||||||
if (!node.parentId) {
|
if (!node.parentId) {
|
||||||
|
visitedForCalc.clear();
|
||||||
calculateRecursive(node);
|
calculateRecursive(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +72,7 @@ export function buildTenantFullTree(
|
|||||||
const base = tenantMap.get(rootId);
|
const base = tenantMap.get(rootId);
|
||||||
if (base) {
|
if (base) {
|
||||||
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
|
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
|
||||||
|
visitedForCalc.clear();
|
||||||
calculateRecursive(base);
|
calculateRecursive(base);
|
||||||
return { currentBase: base, subTree: base.children };
|
return { currentBase: base, subTree: base.children };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
slugs := make([]string, 0, len(tenants))
|
||||||
for _, t := range tenants {
|
for _, t := range tenants {
|
||||||
|
tenantIDs = append(tenantIDs, t.ID)
|
||||||
slugs = append(slugs, t.Slug)
|
slugs = append(slugs, t.Slug)
|
||||||
}
|
}
|
||||||
memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), slugs)
|
|
||||||
if err != nil {
|
idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), tenantIDs)
|
||||||
slog.Warn("failed to count members for tenants", "error", err)
|
slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), slugs)
|
||||||
memberCounts = make(map[string]int64)
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]tenantSummary, 0, len(tenants))
|
items := make([]tenantSummary, 0, len(tenants))
|
||||||
for _, t := range tenants {
|
for _, t := range tenants {
|
||||||
summary := mapTenantSummary(t)
|
summary := mapTenantSummary(t)
|
||||||
// Ensure robust matching by trimming and lowercasing the slug key
|
|
||||||
key := strings.ToLower(strings.TrimSpace(t.Slug))
|
// Combine counts from both ID and Slug (Max to avoid double counting if some have one or the other)
|
||||||
summary.MemberCount = memberCounts[key]
|
idCount := idCounts[t.ID]
|
||||||
|
slugCount := slugCounts[strings.ToLower(t.Slug)]
|
||||||
|
|
||||||
|
if idCount > slugCount {
|
||||||
|
summary.MemberCount = idCount
|
||||||
|
} else {
|
||||||
|
summary.MemberCount = slugCount
|
||||||
|
}
|
||||||
|
|
||||||
items = append(items, summary)
|
items = append(items, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,11 +190,17 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug})
|
idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), []string{tenant.ID})
|
||||||
count := int64(0)
|
slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug})
|
||||||
if err == nil {
|
|
||||||
count = memberCounts[strings.ToLower(tenant.Slug)]
|
idCount := idCounts[tenant.ID]
|
||||||
|
slugCount := slugCounts[strings.ToLower(tenant.Slug)]
|
||||||
|
|
||||||
|
count := idCount
|
||||||
|
if slugCount > idCount {
|
||||||
|
count = slugCount
|
||||||
}
|
}
|
||||||
|
|
||||||
summary := mapTenantSummary(tenant)
|
summary := mapTenantSummary(tenant)
|
||||||
summary.MemberCount = count
|
summary.MemberCount = count
|
||||||
|
|
||||||
|
|||||||
@@ -47,19 +47,22 @@ func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProvi
|
|||||||
}
|
}
|
||||||
|
|
||||||
type userSummary struct {
|
type userSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CompanyCode string `json:"companyCode"`
|
CompanyCode string `json:"companyCode"`
|
||||||
Metadata domain.JSONMap `json:"metadata,omitempty"`
|
Metadata domain.JSONMap `json:"metadata,omitempty"`
|
||||||
Tenant *domain.Tenant `json:"tenant,omitempty"`
|
Tenant *domain.Tenant `json:"tenant,omitempty"`
|
||||||
Department string `json:"department"`
|
JoinedTenants []domain.Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
||||||
CreatedAt string `json:"createdAt"`
|
Department string `json:"department"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
Position string `json:"position"`
|
||||||
InitialPassword string `json:"initialPassword,omitempty"`
|
JobTitle string `json:"jobTitle"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
InitialPassword string `json:"initialPassword,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type userListResponse struct {
|
type userListResponse struct {
|
||||||
@@ -222,8 +225,22 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
|||||||
// [New] Check access scope
|
// [New] Check access scope
|
||||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||||
compCode := extractTraitString(identity.Traits, "companyCode")
|
compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
||||||
if requester.CompanyCode == "" || compCode != requester.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")
|
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 {
|
if h.UserRepo != nil {
|
||||||
localUser := h.mapToLocalUser(*identity)
|
localUser := h.mapToLocalUser(*identity)
|
||||||
|
|
||||||
// Sync to local DB
|
// Sync to local DB (Synchronous for immediate consistency)
|
||||||
go func(u *domain.User, role string, tID *string) {
|
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err)
|
||||||
defer cancel()
|
}
|
||||||
|
|
||||||
// Use Update (upsert) instead of Create for robustness
|
// [Keto] Sync relations via Outbox (Synchronous for accurate counting)
|
||||||
if err := h.UserRepo.Update(ctx, u); err != nil {
|
if h.KetoOutboxRepo != nil {
|
||||||
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", u.Email, "error", err)
|
// 1. Role based relations
|
||||||
return
|
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)
|
response := h.mapIdentitySummary(c.Context(), *identity)
|
||||||
@@ -535,34 +545,50 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
continue
|
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 {
|
if h.UserRepo != nil {
|
||||||
identity, _ := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
localUser := &domain.User{
|
||||||
if identity != nil {
|
ID: identityID,
|
||||||
localUser := h.mapToLocalUser(*identity)
|
Email: email,
|
||||||
|
Name: name,
|
||||||
|
Phone: normalizePhoneNumber(item.Phone),
|
||||||
|
Role: role,
|
||||||
|
Status: "active",
|
||||||
|
CompanyCode: compCode,
|
||||||
|
Department: dept,
|
||||||
|
AffiliationType: "internal",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
// [Fix] Override with current loop data to ensure accuracy
|
if tItem.ID != "" {
|
||||||
localUser.CompanyCode = compCode
|
localUser.TenantID = &tItem.ID
|
||||||
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 h.KetoOutboxRepo != nil {
|
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
|
||||||
// 1. Sync Role based relationship
|
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
|
||||||
h.syncKetoRole(context.Background(), localUser.ID, role, "", "", localUser.TenantID)
|
}
|
||||||
|
|
||||||
// 2. Sync direct membership to the Tenant (for count)
|
if h.KetoOutboxRepo != nil {
|
||||||
if localUser.TenantID != nil && *localUser.TenantID != "" {
|
// 1. Sync Role based relationship
|
||||||
_ = h.KetoOutboxRepo.Create(context.Background(), &domain.KetoOutbox{
|
h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID)
|
||||||
Namespace: "Tenant",
|
|
||||||
Object: *localUser.TenantID,
|
// 2. Sync direct membership to the Tenant (for count)
|
||||||
Relation: "members",
|
if localUser.TenantID != nil && *localUser.TenantID != "" {
|
||||||
Subject: "User:" + localUser.ID,
|
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
||||||
Action: domain.KetoOutboxActionCreate,
|
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
|
// [Validation] Based on Tenant Schema (Multi-tenant aware)
|
||||||
schemaCompCode := extractTraitString(identity.Traits, "companyCode")
|
isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin)
|
||||||
if req.CompanyCode != nil {
|
|
||||||
schemaCompCode = *req.CompanyCode
|
|
||||||
}
|
|
||||||
|
|
||||||
if schemaCompCode != "" && h.TenantService != nil {
|
// If metadata is namespaced (key is tenant ID), validate each namespace
|
||||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), schemaCompCode)
|
// If it's flat, validate using schemaCompCode
|
||||||
if err == nil && tenant != nil {
|
for key, val := range req.Metadata {
|
||||||
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
// Basic check if key looks like a UUID (tenant ID)
|
||||||
isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin)
|
if len(key) >= 32 {
|
||||||
if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil {
|
// Namespaced metadata
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
|
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
|
traits["role"] = role
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Refined] Metadata synchronization: replace non-core traits with new Metadata
|
// [Namespaced Metadata Sync]
|
||||||
coreTraits := map[string]bool{
|
coreTraits := map[string]bool{
|
||||||
"email": true, "name": true, "phone_number": true,
|
"email": true, "name": true, "phone_number": true,
|
||||||
"grade": true, "companyCode": true, "department": true,
|
"grade": true, "companyCode": true, "department": true,
|
||||||
"affiliationType": true, "role": true, "tenant_id": true,
|
"affiliationType": true, "role": true, "tenant_id": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Remove existing non-core traits to handle deletions
|
// For namespaced metadata, we don't delete everything, we merge.
|
||||||
for k := range traits {
|
// But we should remove legacy flat traits that are not in the new req.Metadata if we want strict sync.
|
||||||
if !coreTraits[k] {
|
// For now, let's just merge.
|
||||||
delete(traits, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Add new metadata fields
|
|
||||||
for k, v := range req.Metadata {
|
for k, v := range req.Metadata {
|
||||||
if !coreTraits[k] {
|
if !coreTraits[k] {
|
||||||
traits[k] = v
|
traits[k] = v
|
||||||
@@ -1134,16 +1179,30 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
|||||||
UpdatedAt: formatTime(identity.UpdatedAt),
|
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{
|
coreTraits := map[string]bool{
|
||||||
"email": true, "name": true, "phone_number": true,
|
"email": true, "name": true, "phone_number": true,
|
||||||
"grade": true, "companyCode": true, "department": true,
|
"grade": true, "companyCode": true, "department": true,
|
||||||
"affiliationType": true,
|
"affiliationType": true, "role": true, "tenant_id": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range traits {
|
for k, v := range traits {
|
||||||
if !coreTraits[k] {
|
if coreTraits[k] {
|
||||||
summary.Metadata[k] = v
|
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 {
|
if compCode != "" && h.TenantService != nil {
|
||||||
@@ -1165,7 +1224,11 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
|||||||
if role == "" {
|
if role == "" {
|
||||||
role = "user"
|
role = "user"
|
||||||
}
|
}
|
||||||
|
// Try "companyCode" first, then fallback to "company_code"
|
||||||
compCode := extractTraitString(traits, "companyCode")
|
compCode := extractTraitString(traits, "companyCode")
|
||||||
|
if compCode == "" {
|
||||||
|
compCode = extractTraitString(traits, "company_code")
|
||||||
|
}
|
||||||
|
|
||||||
user := &domain.User{
|
user := &domain.User{
|
||||||
ID: identity.ID,
|
ID: identity.ID,
|
||||||
@@ -1181,8 +1244,14 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
|||||||
UpdatedAt: identity.UpdatedAt,
|
UpdatedAt: identity.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if compCode != "" && h.TenantService != nil {
|
// 1. Try to get tenant_id directly from Kratos traits first (Fastest & most reliable)
|
||||||
// Use a background context or a timeout-limited context for tenant lookup
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
|
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)
|
user.Metadata = make(domain.JSONMap)
|
||||||
coreTraits := map[string]bool{
|
coreTraits := map[string]bool{
|
||||||
"email": true, "name": true, "phone_number": true,
|
"email": true, "name": true, "phone_number": true,
|
||||||
"grade": true, "companyCode": true, "department": 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 {
|
for k, v := range traits {
|
||||||
if !coreTraits[k] {
|
if !coreTraits[k] {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserRepository interface {
|
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 {
|
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) {
|
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) {
|
func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||||
type result struct {
|
type result struct {
|
||||||
TenantID string
|
TenantID *string
|
||||||
Count int64
|
Count int64
|
||||||
}
|
}
|
||||||
var results []result
|
var results []result
|
||||||
|
counts := make(map[string]int64)
|
||||||
|
|
||||||
if len(tenantIDs) == 0 {
|
if len(tenantIDs) == 0 {
|
||||||
return make(map[string]int64), nil
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.db.WithContext(ctx).Model(&domain.User{}).
|
if err := r.db.WithContext(ctx).Model(&domain.User{}).
|
||||||
Select("tenant_id, count(*) as count").
|
Select("tenant_id, count(*) as count").
|
||||||
Where("tenant_id IN ?", tenantIDs).
|
Where("tenant_id IN ?", tenantIDs).
|
||||||
@@ -96,10 +104,9 @@ func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []strin
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
counts := make(map[string]int64)
|
|
||||||
for _, res := range results {
|
for _, res := range results {
|
||||||
if res.TenantID != "" {
|
if res.TenantID != nil && *res.TenantID != "" {
|
||||||
counts[res.TenantID] = res.Count
|
counts[*res.TenantID] = res.Count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ensure all requested tenant IDs are in the map, even if count is 0
|
// 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
|
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{}).
|
err := r.db.WithContext(ctx).Model(&domain.User{}).
|
||||||
Select("LOWER(company_code) as company_code, count(*) as count").
|
Select("LOWER(company_code) as company_code, count(*) as count").
|
||||||
Where("LOWER(company_code) IN ?", lowerStrings(codes)).
|
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
|
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 {
|
for _, code := range codes {
|
||||||
lower := strings.ToLower(strings.TrimSpace(code))
|
lower := strings.ToLower(strings.TrimSpace(code))
|
||||||
if _, ok := counts[lower]; !ok {
|
if _, ok := counts[lower]; !ok {
|
||||||
|
|||||||
@@ -814,6 +814,9 @@ tenant_dashboard = "Tenant Dashboard"
|
|||||||
title = "Title"
|
title = "Title"
|
||||||
view_audit_logs = "View Audit Logs"
|
view_audit_logs = "View Audit Logs"
|
||||||
|
|
||||||
|
[ui.admin.profile]
|
||||||
|
manageable_tenants = "Manageable Tenants"
|
||||||
|
|
||||||
[ui.admin.role]
|
[ui.admin.role]
|
||||||
rp_admin = "RP ADMIN"
|
rp_admin = "RP ADMIN"
|
||||||
super_admin = "SUPER ADMIN"
|
super_admin = "SUPER ADMIN"
|
||||||
@@ -1027,6 +1030,11 @@ password = "Password"
|
|||||||
password_placeholder = "Password Placeholder"
|
password_placeholder = "Password Placeholder"
|
||||||
title = "Security Settings"
|
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]
|
[ui.admin.users.list]
|
||||||
add = "User Add"
|
add = "User Add"
|
||||||
delete_aria = "User Delete: {{name}}"
|
delete_aria = "User Delete: {{name}}"
|
||||||
|
|||||||
@@ -814,6 +814,9 @@ tenant_dashboard = "테넌트 대시보드"
|
|||||||
title = "빠른 이동"
|
title = "빠른 이동"
|
||||||
view_audit_logs = "감사 로그 보기"
|
view_audit_logs = "감사 로그 보기"
|
||||||
|
|
||||||
|
[ui.admin.profile]
|
||||||
|
manageable_tenants = "관리 가능한 테넌트"
|
||||||
|
|
||||||
[ui.admin.role]
|
[ui.admin.role]
|
||||||
rp_admin = "RP ADMIN"
|
rp_admin = "RP ADMIN"
|
||||||
super_admin = "SUPER ADMIN"
|
super_admin = "SUPER ADMIN"
|
||||||
@@ -1027,6 +1030,11 @@ password = "비밀번호 변경"
|
|||||||
password_placeholder = "변경할 경우에만 입력"
|
password_placeholder = "변경할 경우에만 입력"
|
||||||
title = "보안 설정"
|
title = "보안 설정"
|
||||||
|
|
||||||
|
[ui.admin.users.detail.tenants_section]
|
||||||
|
additional = "추가 소속/관리 테넌트"
|
||||||
|
primary = "대표 소속 테넌트"
|
||||||
|
title = "소속 및 조직 정보"
|
||||||
|
|
||||||
[ui.admin.users.list]
|
[ui.admin.users.list]
|
||||||
add = "사용자 추가"
|
add = "사용자 추가"
|
||||||
delete_aria = "사용자 삭제: {{name}}"
|
delete_aria = "사용자 삭제: {{name}}"
|
||||||
|
|||||||
Reference in New Issue
Block a user