1
0
forked from baron/baron-sso

테넌트 목록 및 조직 계층 구조 개선

This commit is contained in:
2026-02-27 10:29:15 +09:00
parent 600961f33d
commit ca45a14bae
27 changed files with 1906 additions and 806 deletions

View File

@@ -19,7 +19,7 @@ describe("Label Component", () => {
<>
<Label htmlFor="test-input">Label Text</Label>
<input id="test-input" />
</>
</>,
);
const label = screen.getByText("Label Text");
expect(label).toHaveAttribute("for", "test-input");

View File

@@ -0,0 +1,87 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const TabsContext = React.createContext<{
value?: string;
onValueChange?: (value: string) => void;
}>({});
const Tabs = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
value?: string;
onValueChange?: (value: string) => void;
}
>(({ className, value, onValueChange, ...props }, ref) => {
return (
<TabsContext.Provider value={{ value, onValueChange }}>
<div ref={ref} className={cn("w-full", className)} {...props} />
</TabsContext.Provider>
);
});
Tabs.displayName = "Tabs";
const TabsList = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = "TabsList";
const TabsTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & { value: string }
>(({ className, value, ...props }, ref) => {
const { value: activeValue, onValueChange } = React.useContext(TabsContext);
const isSelected = activeValue === value;
return (
<button
ref={ref}
type="button"
role="tab"
aria-selected={isSelected}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
data-state={isSelected ? "active" : "inactive"}
onClick={() => onValueChange?.(value)}
{...props}
/>
);
});
TabsTrigger.displayName = "TabsTrigger";
const TabsContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { value: string }
>(({ className, value, ...props }, ref) => {
const { value: activeValue } = React.useContext(TabsContext);
const isSelected = activeValue === value;
if (!isSelected) return null;
return (
<div
ref={ref}
role="tabpanel"
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
);
});
TabsContent.displayName = "TabsContent";
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -29,8 +29,8 @@ function TenantCreatePage() {
const [domains, setDomains] = useState("");
const parentQuery = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
queryKey: ["tenants", { limit: 1000 }],
queryFn: () => fetchTenants(1000, 0),
});
const mutation = useMutation({

View File

@@ -27,118 +27,13 @@ import {
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type TenantNode = TenantSummary & { children: TenantNode[] };
function buildTenantTree(tenants: TenantSummary[]): TenantNode[] {
const tenantMap = new Map<string, TenantNode>();
const rootTenants: TenantNode[] = [];
for (const tenant of tenants) {
tenantMap.set(tenant.id, { ...tenant, children: [] });
}
for (const tenant of tenants) {
const node = tenantMap.get(tenant.id);
if (!node) continue;
if (tenant.parentId) {
const parent = tenantMap.get(tenant.parentId);
if (parent) {
parent.children.push(node);
} else {
rootTenants.push(node); // Orphaned
}
} else {
rootTenants.push(node);
}
}
return rootTenants;
}
const TenantRow: React.FC<{
tenant: TenantNode;
level: number;
onDelete: (id: string, name: string) => void;
isDeleting: boolean;
}> = ({ tenant, level, onDelete, isDeleting }) => {
const navigate = useNavigate();
return (
<>
<TableRow key={tenant.id}>
<TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}>
<div className="flex items-center gap-2">
{level > 0 && (
<CornerDownRight size={14} className="text-muted-foreground" />
)}
<span className="font-semibold">{tenant.name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px] font-mono">
{tenant.type || "PERSONAL"}
</Badge>
</TableCell>
<TableCell>{tenant.slug}</TableCell>
<TableCell>
<Badge
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell>
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
<Pencil size={14} />
{t("ui.common.edit", "편집")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onDelete(tenant.id, tenant.name)}
disabled={isDeleting}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>
</TableRow>
{tenant.children.map((child) => (
<TenantRow
key={child.id}
tenant={child}
level={level + 1}
onDelete={onDelete}
isDeleting={isDeleting}
/>
))}
</>
);
};
function TenantListPage() {
const query = useQuery({
queryKey: ["tenants", { limit: 1000, offset: 0 }], // Fetch all to build tree
queryKey: ["tenants", { limit: 1000, offset: 0 }],
queryFn: () => fetchTenants(1000, 0),
});
const navigate = useNavigate();
const deleteMutation = useMutation({
mutationFn: (tenantId: string) => deleteTenant(tenantId),
onSuccess: () => {
@@ -153,7 +48,7 @@ function TenantListPage() {
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
: null;
const tenantTree = query.data?.items ? buildTenantTree(query.data.items) : [];
const tenants = query.data?.items ?? [];
const handleDelete = (tenantId: string, tenantName: string) => {
if (
@@ -182,12 +77,12 @@ function TenantListPage() {
</span>
</div>
<h2 className="text-3xl font-semibold">
{t("ui.admin.tenants.title", "테넌트 목록")}
{t("ui.admin.tenants.title", "테넌트 레지스트리")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
{t(
"msg.admin.tenants.subtitle",
"현재 등록된 테넌트를 확인하고 상태를 관리합니다.",
"시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.",
)}
</p>
</div>
@@ -213,7 +108,7 @@ function TenantListPage() {
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>
{t("ui.admin.tenants.registry.title", "Tenant registry")}
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
</CardTitle>
<CardDescription>
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
@@ -247,6 +142,9 @@ function TenantListPage() {
<TableHead>
{t("ui.admin.tenants.table.status", "STATUS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.members", "MEMBERS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.updated", "UPDATED")}
</TableHead>
@@ -258,15 +156,15 @@ function TenantListPage() {
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={6}>
<TableCell colSpan={7}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && tenantTree.length === 0 && (
{!query.isLoading && tenants.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
colSpan={7}
className="text-center py-8 text-muted-foreground"
>
{t(
@@ -276,14 +174,63 @@ function TenantListPage() {
</TableCell>
</TableRow>
)}
{tenantTree.map((tenant) => (
<TenantRow
key={tenant.id}
tenant={tenant}
level={0}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending}
/>
{tenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="font-semibold">{tenant.name}</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px] font-mono">
{t(
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
tenant.type,
)}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{tenant.slug}
</TableCell>
<TableCell>
<Badge
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell className="font-medium">
{tenant.memberCount}
</TableCell>
<TableCell className="text-xs">
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
<Pencil size={14} />
{t("ui.common.edit", "편집")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(tenant.id, tenant.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>

View File

@@ -29,6 +29,7 @@ export type TenantSummary = {
domains?: string[];
parentId?: string;
config?: Record<string, unknown>;
memberCount: number; // Added member count
createdAt: string;
updatedAt: string;
};
@@ -55,6 +56,7 @@ export type TenantUpdateRequest = {
name?: string;
type?: string;
slug?: string;
parentId?: string;
description?: string;
status?: string;
domains?: string[];
@@ -380,9 +382,14 @@ export type UserUpdateRequest = {
jobTitle?: string;
};
export async function fetchUsers(limit = 50, offset = 0, search?: string) {
export async function fetchUsers(
limit = 50,
offset = 0,
search?: string,
companyCode?: string,
) {
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
params: { limit, offset, search },
params: { limit, offset, search, companyCode },
});
return data;
}

View File

@@ -16,7 +16,9 @@ describe("i18n utility", () => {
});
it("replaces variables in template", () => {
expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe("Hello World");
expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe(
"Hello World",
);
});
it("respects locale in localStorage", () => {
@@ -27,7 +29,7 @@ describe("i18n utility", () => {
});
it("defaults to ko if no locale set and browser language is ko", () => {
vi.spyOn(window.navigator, 'language', 'get').mockReturnValue('ko-KR');
vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR");
expect(t("ui.common.save", "저장")).toBe("저장");
});
});

View File

@@ -126,6 +126,8 @@ description = "Description"
delete_confirm = "Delete Tenant \\\"{{name}}\\\"?"
empty = "Empty"
fetch_error = "Fetch Error"
not_found = "Tenant not found."
remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?'
subtitle = "Subtitle"
[msg.admin.tenants.create]
@@ -760,10 +762,26 @@ type_boolean = "Boolean"
type_number = "Number"
type_text = "Text"
[ui.admin.tenants.detail]
breadcrumb_list = "Tenant List"
header_subtitle = "Update tenant information or manage integration settings."
loading = "Loading tenant information..."
tab_admins = "Admin Settings"
tab_federation = "External Integration"
tab_organization = "Sub-tenant Management"
tab_profile = "Profile"
tab_schema = "User Schema"
title = "Tenant Details"
[ui.admin.tenants.sub]
add = "Add"
add = "Add Sub-tenant"
add_existing = "Add Existing Tenant"
add_dialog_title = "Add Sub-tenant"
add_dialog_desc = "Search existing tenants to add as sub-tenants."
search_placeholder = "Search name or slug..."
no_candidates = "No available tenants found."
manage = "Manage"
title = "Sub-tenants ({{count}})"
title = "Sub-tenant Management ({{count}})"
[ui.admin.tenants.sub.table]
action = "ACTION"

View File

@@ -149,6 +149,8 @@ delete_success = "테넌트가 삭제되었습니다."
empty = "아직 등록된 테넌트가 없습니다."
fetch_error = "테넌트 목록 조회에 실패했습니다."
missing_id = "테넌트 ID가 없습니다."
not_found = "테넌트를 찾을 수 없습니다."
remove_sub_confirm = '테넌트 "{{name}}"을(를) 하위 조직에서 제외할까요?'
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
[msg.admin.tenants.admins]
@@ -792,7 +794,7 @@ header_subtitle = "테넌트 정보를 수정하거나 연동 설정을 관리
loading = "테넌트 정보를 불러오는 중..."
tab_admins = "관리자 설정"
tab_federation = "외부 연동"
tab_organization = "조직 관리"
tab_organization = "하위 테넌트 관리"
tab_profile = "프로필"
tab_schema = "사용자 스키마"
title = "테넌트 상세"
@@ -866,8 +868,13 @@ type_text = "텍스트 (Text)"
[ui.admin.tenants.sub]
add = "하위 테넌트 추가"
add_existing = "기존 테넌트 추가"
add_dialog_title = "하위 테넌트 추가"
add_dialog_desc = "기존에 등록된 테넌트를 검색하여 하위 조직으로 추가합니다."
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
no_candidates = "추가 가능한 테넌트가 없습니다."
manage = "관리"
title = "Sub-tenants ({{count}})"
title = "하위 테넌트 관리 ({{count}})"
[ui.admin.tenants.sub.table]
action = "ACTION"