forked from baron/baron-sso
내정보 멀티 테턴트 표시
This commit is contained in:
@@ -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={() => {
|
||||
|
||||
@@ -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", "보안 설정")}
|
||||
|
||||
@@ -354,6 +354,7 @@ export type UserSummary = {
|
||||
status: string;
|
||||
companyCode?: string;
|
||||
tenant?: TenantSummary;
|
||||
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
||||
metadata?: Record<string, unknown>;
|
||||
department?: 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) {
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user