From ca45a14bae598e7a88ebd814c77321183200f7f4 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 27 Feb 2026 10:29:15 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EB=84=8C=ED=8A=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=8F=20=EC=A1=B0=EC=A7=81=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/components/ui/label.test.tsx | 2 +- adminfront/src/components/ui/tabs.tsx | 87 ++ .../tenants/routes/TenantCreatePage.tsx | 4 +- .../tenants/routes/TenantListPage.tsx | 191 +-- .../routes/TenantUserGroupsTab.tsx | 1354 ++++++++++++----- adminfront/src/lib/adminApi.ts | 11 +- adminfront/src/lib/i18n.test.ts | 6 +- adminfront/src/locales/en.toml | 22 +- adminfront/src/locales/ko.toml | 11 +- adminfront/tests/auth.spec.ts | 83 +- adminfront/tests/tenants.spec.ts | 88 +- backend/cmd/server/main.go | 2 +- backend/internal/bootstrap/tenant_seed.go | 2 +- backend/internal/domain/user.go | 32 +- .../handler/auth_handler_async_test.go | 11 +- backend/internal/handler/tenant_handler.go | 111 +- .../internal/handler/tenant_handler_test.go | 6 +- backend/internal/handler/user_handler.go | 318 ++-- .../internal/repository/user_repository.go | 107 +- backend/internal/service/tenant_service.go | 6 +- .../service/tenant_service_edge_test.go | 6 +- .../internal/service/tenant_service_test.go | 17 +- .../service/user_group_service_test.go | 14 + docs/UI_DESIGN_POLICY.md | 118 ++ docs/keto-rebac-namespaces-diagram.md | 87 ++ locales/en.toml | 9 +- locales/ko.toml | 7 + 27 files changed, 1906 insertions(+), 806 deletions(-) create mode 100644 adminfront/src/components/ui/tabs.tsx create mode 100644 docs/UI_DESIGN_POLICY.md create mode 100644 docs/keto-rebac-namespaces-diagram.md diff --git a/adminfront/src/components/ui/label.test.tsx b/adminfront/src/components/ui/label.test.tsx index cfde252a..25409101 100644 --- a/adminfront/src/components/ui/label.test.tsx +++ b/adminfront/src/components/ui/label.test.tsx @@ -19,7 +19,7 @@ describe("Label Component", () => { <> - + , ); const label = screen.getByText("Label Text"); expect(label).toHaveAttribute("for", "test-input"); diff --git a/adminfront/src/components/ui/tabs.tsx b/adminfront/src/components/ui/tabs.tsx new file mode 100644 index 00000000..71364fda --- /dev/null +++ b/adminfront/src/components/ui/tabs.tsx @@ -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 & { + value?: string; + onValueChange?: (value: string) => void; + } +>(({ className, value, onValueChange, ...props }, ref) => { + return ( + +
+ + ); +}); +Tabs.displayName = "Tabs"; + +const TabsList = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TabsList.displayName = "TabsList"; + +const TabsTrigger = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes & { value: string } +>(({ className, value, ...props }, ref) => { + const { value: activeValue, onValueChange } = React.useContext(TabsContext); + const isSelected = activeValue === value; + + return ( + - -
- - - {tenant.children.map((child) => ( - - ))} - - ); -}; - 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() {

- {t("ui.admin.tenants.title", "테넌트 목록")} + {t("ui.admin.tenants.title", "테넌트 레지스트리")}

{t( "msg.admin.tenants.subtitle", - "현재 등록된 테넌트를 확인하고 상태를 관리합니다.", + "시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.", )}

@@ -213,7 +108,7 @@ function TenantListPage() {
- {t("ui.admin.tenants.registry.title", "Tenant registry")} + {t("ui.admin.tenants.registry.title", "Tenant Registry")} {t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", { @@ -247,6 +142,9 @@ function TenantListPage() { {t("ui.admin.tenants.table.status", "STATUS")} + + {t("ui.admin.tenants.table.members", "MEMBERS")} + {t("ui.admin.tenants.table.updated", "UPDATED")} @@ -258,15 +156,15 @@ function TenantListPage() { {query.isLoading && ( - + {t("msg.common.loading", "로딩 중...")} )} - {!query.isLoading && tenantTree.length === 0 && ( + {!query.isLoading && tenants.length === 0 && ( {t( @@ -276,14 +174,63 @@ function TenantListPage() { )} - {tenantTree.map((tenant) => ( - + {tenants.map((tenant) => ( + + {tenant.name} + + + {t( + `domain.tenant_type.${tenant.type?.toLowerCase()}`, + tenant.type, + )} + + + + {tenant.slug} + + + + {t(`ui.common.status.${tenant.status}`, tenant.status)} + + + + {tenant.memberCount} + + + {tenant.updatedAt + ? new Date(tenant.updatedAt).toLocaleString("ko-KR") + : "-"} + + +
+ + +
+
+
))}
diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index 8a053d7c..13bc0537 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -1,19 +1,25 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { + ArrowRight, + Briefcase, + Building2, + Check, ChevronDown, ChevronRight, + CornerDownRight, + Network, Plus, RefreshCw, - Shield, + Search, Trash2, - UserMinus, + UserCircle, UserPlus, Users, } from "lucide-react"; import type React from "react"; -import { useState } from "react"; -import { useParams } from "react-router-dom"; +import { useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; @@ -24,6 +30,15 @@ import { CardHeader, CardTitle, } from "../../../components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../../components/ui/dialog"; import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; import { @@ -35,498 +50,1015 @@ import { TableRow, } from "../../../components/ui/table"; import { - type GroupSummary, - addGroupMember, - createGroup, - deleteGroup, - fetchGroups, - removeGroupMember, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "../../../components/ui/tabs"; +import { + type TenantSummary, + type UserSummary, + createUser, + fetchTenants, + fetchUsers, + updateTenant, + updateUser, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; -type UserGroupNode = GroupSummary & { children: UserGroupNode[] }; +type TenantNode = TenantSummary & { + children: TenantNode[]; + recursiveMemberCount: number; +}; -function buildGroupTree(groups: GroupSummary[]): UserGroupNode[] { - const nodeMap = new Map(); - const rootNodes: UserGroupNode[] = []; - - for (const group of groups) { - nodeMap.set(group.id, { ...group, children: [] }); +const getTenantIcon = (type?: string) => { + switch (type?.toUpperCase()) { + case "COMPANY_GROUP": + return Briefcase; + case "PERSONAL": + return UserCircle; + case "USER_GROUP": + return Network; + default: + return Building2; } +}; - for (const group of groups) { - const node = nodeMap.get(group.id); - if (!node) continue; +const MemberListDialog: React.FC<{ + node: TenantNode; + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}> = ({ node, trigger, open, onOpenChange }) => { + const [activeTab, setActiveTab] = useState("direct"); - if (group.parentId && nodeMap.has(group.parentId)) { - const parent = nodeMap.get(group.parentId); - if (parent) { - parent.children.push(node); + const { + data: directData, + isLoading: isDirectLoading, + refetch: refetchDirect, + } = useQuery({ + queryKey: ["tenant-members", node.slug], + queryFn: () => fetchUsers(100, 0, undefined, node.slug), + enabled: open && activeTab === "direct", + }); + + const descendantSlugs = useMemo(() => { + const slugs: string[] = []; + const collect = (n: TenantNode) => { + for (const child of n.children) { + slugs.push(child.slug); + collect(child); } - } else { - rootNodes.push(node); - } - } + }; + collect(node); + return slugs; + }, [node]); - const sortNodes = (nodes: UserGroupNode[]) => { - nodes.sort((a, b) => a.name.localeCompare(b.name)); - for (const node of nodes) { - sortNodes(node.children); + const { + data: descendantData, + isLoading: isDescendantLoading, + refetch: refetchDescendant, + } = useQuery({ + queryKey: ["tenant-descendant-members", node.id], + queryFn: async () => { + if (descendantSlugs.length === 0) return []; + // Fetch users for all descendant slugs in parallel + const results = await Promise.all( + descendantSlugs + .slice(0, 10) + .map((slug) => fetchUsers(50, 0, undefined, slug)), + ); + return results.flatMap((res) => res.items); + }, + enabled: open && activeTab === "descendants" && descendantSlugs.length > 0, + }); + + const directMembers = directData?.items ?? []; + const descendantMembers = descendantData ?? []; + + return ( + + {trigger && {trigger}} + + + + + {node.name}{" "} + {t("ui.admin.tenants.members.list_title", "구성원 관리")} + + + {t( + "msg.admin.tenants.members.desc", + "조직에 소속된 사용자 목록을 확인합니다.", + )} + + + + +
+ + + {t("ui.admin.tenants.members.direct", "소속 멤버")} ( + {node.memberCount || 0}) + + + {t("ui.admin.tenants.members.descendants", "하위 조직 멤버")} ( + {node.recursiveMemberCount - (node.memberCount || 0)}) + + +
+ + + + + + + + {descendantSlugs.length > 10 && ( +

+ *{" "} + {t( + "msg.admin.tenants.members.limit_notice", + "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다.", + )} +

+ )} +
+
+ + + + +
+
+ ); +}; + +const MemberTable: React.FC<{ + members: UserSummary[]; + isLoading: boolean; + onRefresh: () => void; + showTenant?: boolean; +}> = ({ members, isLoading, onRefresh, showTenant }) => ( +
+ + + + + {t("ui.admin.users.table.name", "NAME")} + + + {t("ui.admin.users.table.email", "EMAIL")} + + {showTenant && ( + + {t("ui.admin.tenants.table.slug", "TENANT")} + + )} + + {t("ui.admin.users.table.role", "ROLE")} + + + + + {isLoading ? ( + + + {t("msg.common.loading", "로딩 중...")} + + + ) : members.length === 0 ? ( + + +
+ +

{t("msg.admin.users.list.empty", "멤버가 없습니다.")}

+ +
+
+
+ ) : ( + members.map((user) => ( + + {user.name} + + {user.email} + + {showTenant && ( + + + {user.companyCode} + + + )} + + + {user.role} + + + + )) + )} +
+
+
+); + +const UserAddDialog: React.FC<{ + tenantSlug: string; + tenantName: string; + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}> = ({ tenantSlug, tenantName, trigger, open, onOpenChange }) => { + const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState("select"); + + // Create state + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + + // Select state + const [userSearch, setUserSearch] = useState(""); + const [isSearching, setIsSearching] = useState(false); + const [searchResults, setSearchResults] = useState([]); + const [selectedUserId, setSelectedUserId] = useState(null); + + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSearch = async () => { + if (!userSearch) return; + setIsSearching(true); + try { + const res = await fetchUsers(20, 0, userSearch); + setSearchResults(res.items); + } catch (err) { + toast.error(t("msg.admin.users.list.fetch_error", "사용자 검색 실패")); + } finally { + setIsSearching(false); } }; - sortNodes(rootNodes); - return rootNodes; -} + const handleCreate = async () => { + if (!email || !name) { + toast.error( + t( + "msg.admin.users.create.form.email_required", + "이메일과 이름은 필수입니다.", + ), + ); + return; + } + setIsSubmitting(true); + try { + const res = await createUser({ + email, + name, + companyCode: tenantSlug, + role: "user", + }); + toast.success( + t("msg.admin.users.create.success", "사용자가 생성되었습니다."), + { + description: res.initialPassword + ? `초기 비밀번호: ${res.initialPassword}` + : undefined, + duration: 10000, + }, + ); -interface UserGroupTreeNodeProps { - node: UserGroupNode; + // Refresh tenant tree to update member counts + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); + }, 1000); // Wait 1s for backend async sync + + onOpenChange?.(false); + resetFields(); + } catch (err: unknown) { + const error = err as { response?: { data?: { error?: string } } }; + toast.error( + error.response?.data?.error || + t("msg.admin.users.create.error", "사용자 생성 실패"), + ); + } finally { + setIsSubmitting(false); + } + }; + + const handleAssign = async () => { + if (!selectedUserId) return; + setIsSubmitting(true); + try { + await updateUser(selectedUserId, { companyCode: tenantSlug }); + toast.success( + t("msg.info.saved_success", "사용자가 테넌트에 배정되었습니다."), + ); + + // Refresh tenant tree to update member counts + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); + }, 1000); // Wait 1s for backend async sync + + onOpenChange?.(false); + resetFields(); + } catch (err: unknown) { + const error = err as { response?: { data?: { error?: string } } }; + toast.error( + error.response?.data?.error || + t("msg.admin.users.detail.update_error", "배정 실패"), + ); + } finally { + setIsSubmitting(false); + } + }; + + const resetFields = () => { + setEmail(""); + setName(""); + setUserSearch(""); + setSearchResults([]); + setSelectedUserId(null); + }; + + return ( + { + onOpenChange?.(v); + if (!v) resetFields(); + }} + > + {trigger && {trigger}} + + + + {t("ui.admin.users.create.title", "사용자 추가")} + + + [{tenantName}] 테넌트에 사용자를 등록하거나 기존 사용자를 + 배정합니다. + + + + + + + {t("ui.common.select", "기존 사용자 선택")} + + + {t("ui.common.create", "신규 생성")} + + + + +
+ setUserSearch(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + /> + +
+ +
+ + + {searchResults.length === 0 ? ( + + + {isSearching + ? t("msg.common.loading", "검색 중...") + : t( + "msg.admin.users.list.empty", + "사용자를 검색해 주세요.", + )} + + + ) : ( + searchResults.map((user) => ( + setSelectedUserId(user.id)} + > + +
+
+ {user.name} +
+
+ {user.email} +
+ {user.companyCode && ( + + {user.companyCode} + + )} +
+ {selectedUserId === user.id && ( + + )} +
+
+ )) + )} +
+
+
+
+ + +
+ + setEmail(e.target.value)} + placeholder="user@example.com" + /> +
+
+ + setName(e.target.value)} + placeholder="홍길동" + /> +
+
+
+ + + + {activeTab === "create" ? ( + + ) : ( + + )} + +
+
+ ); +}; + +const TenantTreeRow: React.FC<{ + node: TenantNode; level: number; - onSelect: (groupId: string) => void; - selectedGroupId: string | null; - onDelete: (groupId: string, groupName: string) => void; - onAddSubGroup: (parentId: string) => void; -} - -const UserGroupTreeNode: React.FC = ({ - node, - level, - onSelect, - selectedGroupId, - onDelete, - onAddSubGroup, -}) => { + isRoot: boolean; + onRemove: (id: string, name: string) => void; + isUpdating: boolean; +}> = ({ node, level, isRoot, onRemove, isUpdating }) => { + const navigate = useNavigate(); const [isExpanded, setIsExpanded] = useState(true); + const [isUserAddOpen, setIsUserAddOpen] = useState(false); + const [isMemberListOpen, setIsMemberListOpen] = useState(false); const hasChildren = node.children && node.children.length > 0; + const TypeIcon = getTenantIcon(node.type); + return ( <> onSelect(node.id)} + className={`group hover:bg-muted/30 transition-colors ${isRoot ? "bg-primary/5 font-bold" : ""}`} >
- {hasChildren && ( + {hasChildren ? ( + ) : ( + level > 0 && ( +
+
+
+ ) )} - {!hasChildren &&
} - - {node.name} - - {node.unitType || "Team"} + {!isRoot && ( + + )} +
+ +
+ {node.name} + {isRoot && ( + + Root + + )} + + {t(`domain.tenant_type.${node.type?.toLowerCase()}`, node.type)}
- - {node.members?.length || 0} + + {node.slug} - - - + + + + - - + {t(`ui.common.status.${node.status}`, node.status)} + + + +
+ + + {!isRoot && ( + + )} +
+
{isExpanded && - hasChildren && node.children.map((child) => ( - ))} ); }; -export function TenantUserGroupsTab() { - const params = useParams<{ tenantId: string }>(); - const tenantId = params.tenantId ?? ""; +function TenantUserGroupsTab() { + const { tenantId } = useParams<{ tenantId: string }>(); + const queryClient = useQueryClient(); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [isHeaderUserAddOpen, setIsHeaderUserAddOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); - const [newGroupName, setNewGroupName] = useState(""); - const [newGroupDesc, setNewGroupNameDesc] = useState(""); - const [newGroupUnitType, setNewGroupUnitType] = useState("Team"); - const [newGroupParentId, setNewGroupParentId] = useState(null); + if (!tenantId) return null; - const [selectedGroupId, setSelectedGroupId] = useState(null); - - const groupsQuery = useQuery({ - queryKey: ["groups", tenantId], - queryFn: () => fetchGroups(tenantId), - enabled: !!tenantId, + const { data, isLoading, refetch } = useQuery({ + queryKey: ["tenants-full-tree-v2"], + queryFn: () => fetchTenants(1000, 0), }); - const createMutation = useMutation({ - mutationFn: () => - createGroup(tenantId, { - name: newGroupName, - description: newGroupDesc, - unitType: newGroupUnitType, - parentId: newGroupParentId || undefined, - }), + const updateParentMutation = useMutation({ + mutationFn: ({ + id, + parentId, + }: { id: string; parentId: string | undefined }) => + updateTenant(id, { parentId: parentId || "" }), onSuccess: () => { - toast.success( - t( - "msg.admin.groups.list.create_success", - "그룹이 성공적으로 생성되었습니다.", - ), - ); - groupsQuery.refetch(); - setNewGroupName(""); - setNewGroupNameDesc(""); - setNewGroupUnitType("Team"); - setNewGroupParentId(null); + queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); + toast.success(t("msg.info.saved_success", "저장되었습니다.")); + setIsAddDialogOpen(false); }, - onError: (error: AxiosError<{ error?: string }>) => { - toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), { - description: error.response?.data?.error || error.message, + }); + + const allTenants = data?.items ?? []; + + const { currentBase, subTree } = useMemo(() => { + if (allTenants.length === 0) return { currentBase: null, subTree: [] }; + + const tenantMap = new Map(); + for (const t of allTenants) { + tenantMap.set(t.id, { + ...t, + children: [], + recursiveMemberCount: t.memberCount || 0, }); - }, - }); + } - const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteGroup(tenantId, id), - onSuccess: () => { - toast.success( - t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."), - ); - groupsQuery.refetch(); - if (selectedGroupId && selectedGroupId === deleteMutation.variables) { - setSelectedGroupId(null); + // Build initial children relations + for (const t of allTenants) { + if (t.parentId) { + const parent = tenantMap.get(t.parentId); + const child = tenantMap.get(t.id); + if (parent && child) { + parent.children.push(child); + } } - }, - onError: (error: AxiosError<{ error?: string }>) => { - toast.error(t("msg.common.error", "그룹 삭제 실패"), { - description: error.response?.data?.error || error.message, - }); - }, - }); + } - const addMemberMutation = useMutation({ - mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => - addGroupMember(tenantId, groupId, userId), - onSuccess: () => { - toast.success( - t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."), - ); - groupsQuery.refetch(); - }, - onError: (error: AxiosError<{ error?: string }>) => { - toast.error(t("msg.common.error", "오류 발생"), { - description: error.response?.data?.error || error.message, - }); - }, - }); + // Function to calculate recursive counts + const calculateRecursive = (node: TenantNode): number => { + let total = node.memberCount || 0; + for (const child of node.children) { + total += calculateRecursive(child); + } + node.recursiveMemberCount = total; + return total; + }; - const removeMemberMutation = useMutation({ - mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => - removeGroupMember(tenantId, groupId, userId), - onSuccess: () => { - toast.success( - t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."), - ); - groupsQuery.refetch(); - }, - onError: (error: AxiosError<{ error?: string }>) => { - toast.error(t("msg.common.error", "오류 발생"), { - description: error.response?.data?.error || error.message, - }); - }, - }); + // Calculate for all root nodes (those without parent or top-level in current context) + for (const node of tenantMap.values()) { + // We only strictly need to calculate from the top-most nodes to cover everything + if (!node.parentId) { + calculateRecursive(node); + } + } - const groupTree = groupsQuery.data ? buildGroupTree(groupsQuery.data) : []; + // Re-calculate specifically for our current tenant to be sure if it wasn't a global root + const base = tenantMap.get(tenantId); + if (base) { + calculateRecursive(base); + } - const handleAddSubGroup = (parentId: string) => { - setNewGroupParentId(parentId); - }; + return { + currentBase: base || null, + subTree: base ? base.children : [], + }; + }, [allTenants, tenantId]); - const handleDeleteGroup = (groupId: string, groupName: string) => { + const handleAdd = (id: string) => + updateParentMutation.mutate({ id, parentId: tenantId }); + const handleRemove = (id: string, name: string) => { if ( window.confirm( t( - "msg.admin.groups.list.delete_confirm", - `그룹 "{{name}}"을(를) 삭제하시겠습니까?`, - { name: groupName }, + "msg.admin.tenants.remove_sub_confirm", + `${name} 테넌트를 하위 조직에서 제외할까요?`, + { name }, ), ) ) { - deleteMutation.mutate(groupId); + updateParentMutation.mutate({ id, parentId: undefined }); } }; - const handleAddMember = (groupId: string) => { - const userId = window.prompt( - t( - "msg.admin.groups.prompt.user_id", - "추가할 사용자의 UUID를 입력하세요:", - ), + if (isLoading) + return ( +
+ {t("msg.common.loading", "로딩 중...")} +
+ ); + if (!currentBase) + return ( +
+ {t("msg.admin.tenants.not_found", "현재 테넌트를 찾을 수 없습니다.")}{" "} + (ID: {tenantId}) +
); - if (userId) { - addMemberMutation.mutate({ groupId, userId }); - } - }; - const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId); + const candidates = allTenants.filter((t) => { + if (t.id === tenantId) return false; + // Check if it's already a child + if (t.parentId === tenantId) return false; + // Basic search + if (searchTerm === "") return true; + return ( + t.name.toLowerCase().includes(searchTerm.toLowerCase()) || + t.slug.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }); + + const BaseIcon = getTenantIcon(currentBase.type); return (
-
- - - - {" "} - {t("ui.admin.groups.create.title", "새 그룹 생성")} - - - -
- - setNewGroupName(e.target.value)} - /> -
-
- - setNewGroupUnitType(e.target.value)} - /> -
-
- - -
-
- - setNewGroupNameDesc(e.target.value)} - /> -
- -
-
- - - -
- - {t("ui.admin.groups.list.title", "User Groups")} - - - {t( - "msg.admin.groups.list.subtitle", - "이 테넌트에 정의된 사용자 그룹 목록입니다.", - )} - -
- -
- - - - - - {t("ui.admin.groups.table.name", "NAME")} - - - {t("ui.admin.groups.table.members", "MEMBERS")} - - - {t("ui.admin.groups.table.actions", "ACTIONS")} - - - - - {groupsQuery.isLoading && ( - - - {t("msg.admin.groups.list.loading", "로딩 중...")} - - - )} - {!groupsQuery.isLoading && groupTree.length === 0 && ( - - - {t( - "msg.admin.groups.list.empty", - "아직 등록된 그룹이 없습니다.", - )} - - - )} - {groupTree.map((node) => ( - - ))} - -
-
-
-
- - {currentGroup && ( - - - - {" "} - {t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", { - name: currentGroup.name, + + +
+ + + {t("ui.admin.tenants.sub.title", "조직 계층 구조", { + count: subTree.length, })} - - -
- -
- - - - - {t("ui.admin.groups.members.table.name", "이름")} - - - {t("ui.admin.groups.members.table.email", "이메일")} - - - {t("ui.admin.groups.members.table.remove", "제거")} - - - - - {currentGroup.members?.length === 0 && ( - - - {t("msg.admin.groups.members.empty", "멤버가 없습니다.")} - - - )} - {currentGroup.members?.map((user) => ( - - {user.name} - - {user.email} - - - + + + + + + + + + + + + + + {t( + "ui.admin.tenants.sub.add_dialog_title", + "하위 테넌트 추가", + )} + + + {t( + "ui.admin.tenants.sub.add_dialog_desc", + "기존에 등록된 테넌트를 검색하여 하위 조직으로 추가합니다.", + )} + + +
+
+ + setSearchTerm(e.target.value)} + /> +
+
+
+ + {candidates.length === 0 ? ( + + + {t( + "ui.admin.tenants.sub.no_candidates", + "검색 결과 없음", + )} + + + ) : ( + candidates.map((tenant) => { + const CandidateIcon = getTenantIcon(tenant.type); + return ( + + +
+
+ +
+
+
+ {tenant.name} +
+
+ {tenant.slug} +
+
+
+
+ + + {t( + `domain.tenant_type.${tenant.type?.toLowerCase()}`, + tenant.type, + )} + + + + + +
+ ); }) - } - disabled={removeMemberMutation.isPending} - > - - - - - ))} -
-
-
- - )} + )} + + +
+
+ + +
+ + + + + + + {t("ui.admin.tenants.table.name", "NAME")} + + + {t("ui.admin.tenants.table.slug", "SLUG")} + + + {t("ui.admin.tenants.table.members", "MEMBERS")} + + + {t("ui.admin.tenants.table.status", "STATUS")} + + + {t("ui.admin.tenants.table.actions", "ACTIONS")} + + + + + + +
+
+
); } diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index c18ff65e..1d68ad73 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -29,6 +29,7 @@ export type TenantSummary = { domains?: string[]; parentId?: string; config?: Record; + 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("/v1/admin/users", { - params: { limit, offset, search }, + params: { limit, offset, search, companyCode }, }); return data; } diff --git a/adminfront/src/lib/i18n.test.ts b/adminfront/src/lib/i18n.test.ts index fbfd4e13..cd5b5f0a 100644 --- a/adminfront/src/lib/i18n.test.ts +++ b/adminfront/src/lib/i18n.test.ts @@ -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("저장"); }); }); diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index a12529d1..65edff3f 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -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" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 7119000d..a6304bc6 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -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" diff --git a/adminfront/tests/auth.spec.ts b/adminfront/tests/auth.spec.ts index 1ce4b243..fff2875e 100644 --- a/adminfront/tests/auth.spec.ts +++ b/adminfront/tests/auth.spec.ts @@ -1,79 +1,84 @@ -import { test, expect } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -test.describe('Authentication', () => { +test.describe("Authentication", () => { test.beforeEach(async ({ page }) => { // Mock OIDC configuration - await page.route('**/oidc/.well-known/openid-configuration', async route => { - await route.fulfill({ - json: { - issuer: "http://localhost:5000/oidc", - authorization_endpoint: "http://localhost:5000/oidc/auth", - token_endpoint: "http://localhost:5000/oidc/token", - jwks_uri: "http://localhost:5000/oidc/jwks", - response_types_supported: ["code"], - subject_types_supported: ["public"], - id_token_signing_alg_values_supported: ["RS256"] - } - }); - }); + await page.route( + "**/oidc/.well-known/openid-configuration", + async (route) => { + await route.fulfill({ + json: { + issuer: "http://localhost:5000/oidc", + authorization_endpoint: "http://localhost:5000/oidc/auth", + token_endpoint: "http://localhost:5000/oidc/token", + jwks_uri: "http://localhost:5000/oidc/jwks", + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + }, + }); + }, + ); }); - test('should redirect unauthorized users to login page', async ({ page }) => { - await page.goto('/'); + test("should redirect unauthorized users to login page", async ({ page }) => { + await page.goto("/"); // Should be redirected to /login await expect(page).toHaveURL(/\/login/); - await expect(page.locator('h1')).toContainText('Baron SSO'); + await expect(page.locator("h1")).toContainText("Baron SSO"); }); - test('should allow access to dashboard when authenticated', async ({ page }) => { + test("should allow access to dashboard when authenticated", async ({ + page, + }) => { await page.addInitScript(() => { const authority = "http://localhost:5000/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { - access_token: 'fake-token', - token_type: 'Bearer', - profile: { - sub: 'admin-user', - name: 'Admin User', - email: 'admin@example.com' + access_token: "fake-token", + token_type: "Bearer", + profile: { + sub: "admin-user", + name: "Admin User", + email: "admin@example.com", }, expires_at: Math.floor(Date.now() / 1000) + 3600, }; window.localStorage.setItem(key, JSON.stringify(authData)); }); - await page.goto('/'); - + await page.goto("/"); + // Wait for the auth loading to finish - await expect(page.locator('.animate-spin')).not.toBeVisible(); - + await expect(page.locator(".animate-spin")).not.toBeVisible(); + // Should be on the dashboard/overview - await expect(page.locator('aside')).toBeVisible(); - await expect(page.locator('h1')).toContainText('Admin Control'); + await expect(page.locator("aside")).toBeVisible(); + await expect(page.locator("h1")).toContainText("Admin Control"); }); - test('should logout and redirect to login page', async ({ page }) => { + test("should logout and redirect to login page", async ({ page }) => { // Start authenticated await page.addInitScript(() => { const authority = "http://localhost:5000/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { - access_token: 'fake-token', - token_type: 'Bearer', - profile: { sub: 'admin-user', name: 'Admin' }, + access_token: "fake-token", + token_type: "Bearer", + profile: { sub: "admin-user", name: "Admin" }, expires_at: Math.floor(Date.now() / 1000) + 3600, }; window.localStorage.setItem(key, JSON.stringify(authData)); }); - await page.goto('/'); - await expect(page.locator('aside')).toBeVisible(); + await page.goto("/"); + await expect(page.locator("aside")).toBeVisible(); // Mock window.confirm - page.on('dialog', dialog => dialog.accept()); - + page.on("dialog", (dialog) => dialog.accept()); + // Click logout button (label: ui.admin.nav.logout) await page.click('button:has-text("Logout"), button:has-text("로그아웃")'); diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 57ce51cb..da5b3862 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -1,6 +1,6 @@ -import { test, expect } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -test.describe('Tenants Management', () => { +test.describe("Tenants Management", () => { test.beforeEach(async ({ page }) => { // Authenticate await page.addInitScript(() => { @@ -8,68 +8,90 @@ test.describe('Tenants Management', () => { const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { - access_token: 'fake-token', - token_type: 'Bearer', - profile: { sub: 'admin-user', name: 'Admin User', email: 'admin@example.com' }, + access_token: "fake-token", + token_type: "Bearer", + profile: { + sub: "admin-user", + name: "Admin User", + email: "admin@example.com", + }, expires_at: Math.floor(Date.now() / 1000) + 3600, }; window.localStorage.setItem(key, JSON.stringify(authData)); }); // Mock OIDC config to avoid redirects - await page.route('**/oidc/.well-known/openid-configuration', async route => { - await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); - }); + await page.route( + "**/oidc/.well-known/openid-configuration", + async (route) => { + await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + }, + ); }); - test('should list tenants', async ({ page }) => { - await page.route('**/api/v1/admin/tenants*', async route => { + test("should list tenants", async ({ page }) => { + await page.route("**/api/v1/admin/tenants*", async (route) => { await route.fulfill({ json: { items: [ - { id: '1', name: 'Tenant A', slug: 'tenant-a', status: 'active', type: 'COMPANY', updatedAt: new Date().toISOString() }, + { + id: "1", + name: "Tenant A", + slug: "tenant-a", + status: "active", + type: "COMPANY", + updatedAt: new Date().toISOString(), + }, ], total: 1, limit: 1000, - offset: 0 - } + offset: 0, + }, }); }); - await page.goto('/tenants'); - await expect(page.locator('h2')).toContainText('테넌트 목록'); - await expect(page.locator('table')).toContainText('Tenant A'); + await page.goto("/tenants"); + await expect(page.locator("h2")).toContainText("테넌트 목록"); + await expect(page.locator("table")).toContainText("Tenant A"); }); - test('should create a new tenant', async ({ page }) => { + test("should create a new tenant", async ({ page }) => { // Mock GET for list (empty) and for parents - await page.route('**/api/v1/admin/tenants*', async route => { - if (route.request().method() === 'GET') { - await route.fulfill({ json: { items: [], total: 0, limit: 100, offset: 0 } }); - } else if (route.request().method() === 'POST') { - await route.fulfill({ - json: { id: '2', name: 'New Tenant', slug: 'new-tenant', status: 'active', type: 'COMPANY' } + await page.route("**/api/v1/admin/tenants*", async (route) => { + if (route.request().method() === "GET") { + await route.fulfill({ + json: { items: [], total: 0, limit: 100, offset: 0 }, + }); + } else if (route.request().method() === "POST") { + await route.fulfill({ + json: { + id: "2", + name: "New Tenant", + slug: "new-tenant", + status: "active", + type: "COMPANY", + }, }); } }); - await page.goto('/tenants/new'); - - await page.fill('input >> nth=0', 'New Tenant'); - await page.fill('input >> nth=1', 'new-tenant'); - await page.fill('textarea', 'Description'); - + await page.goto("/tenants/new"); + + await page.fill("input >> nth=0", "New Tenant"); + await page.fill("input >> nth=1", "new-tenant"); + await page.fill("textarea", "Description"); + await page.click('button:has-text("생성")'); await expect(page).toHaveURL(/\/tenants$/); }); - test('should show validation error on empty name', async ({ page }) => { - await page.goto('/tenants/new'); + test("should show validation error on empty name", async ({ page }) => { + await page.goto("/tenants/new"); const submitBtn = page.locator('button:has-text("생성")'); await expect(submitBtn).toBeDisabled(); - - await page.fill('input >> nth=0', 'Valid Name'); + + await page.fill("input >> nth=0", "Valid Name"); await expect(submitBtn).not.toBeDisabled(); }); }); diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 06c75760..fdf8d032 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -277,7 +277,7 @@ func main() { authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) adminHandler := handler.NewAdminHandler(ketoService) devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler) - tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, ketoOutboxRepo, kratosAdminService) + tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService) userGroupHandler := handler.NewUserGroupHandler(userGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo) diff --git a/backend/internal/bootstrap/tenant_seed.go b/backend/internal/bootstrap/tenant_seed.go index b7abc258..e4bad4e8 100644 --- a/backend/internal/bootstrap/tenant_seed.go +++ b/backend/internal/bootstrap/tenant_seed.go @@ -59,7 +59,7 @@ func SeedTenants(db *gorm.DB) error { } slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug) - tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, config.Description, config.Domains, nil) + tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil) if err != nil { slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err) return err diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index 0e9824d4..a5b9b794 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -19,23 +19,23 @@ const ( type User struct { ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` Email string `gorm:"uniqueIndex;not null" json:"email"` - PasswordHash string `gorm:"not null" json:"-"` - Name string `gorm:"not null" json:"name"` - Phone string `json:"phone"` - Role string `gorm:"default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user - AffiliationType string `json:"affiliationType"` - CompanyCode string `json:"companyCode"` - TenantID *string `gorm:"type:uuid;index" json:"tenantId,omitempty"` + PasswordHash *string `gorm:"column:password_hash" json:"-"` + Name string `gorm:"column:name;not null" json:"name"` + Phone string `gorm:"column:phone" json:"phone"` + Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user + AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"` + CompanyCode string `gorm:"column:company_code;index" json:"companyCode"` + TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"` Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` - RelyingPartyID *string `gorm:"type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 - Department string `json:"department"` - Position string `json:"position"` // 직급 (예: 수석, 책임, 선임) - JobTitle string `json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획) - Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"` - Status string `gorm:"default:'active'" json:"status"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 + Department string `gorm:"column:department" json:"department"` + Position string `gorm:"column:position" json:"position"` // 직급 (예: 수석, 책임, 선임) + JobTitle string `gorm:"column:job_title" json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획) + Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"` + Status string `gorm:"column:status;default:'active'" json:"status"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"` } // BeforeCreate hook to generate UUID if not present diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index 6a5d043e..3a7ed64c 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -102,6 +102,15 @@ func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search return nil, 0, nil } +func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { + return 0, nil +} + +func (m *AsyncMockUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { + return nil, nil +} + + type AsyncMockRedisRepo struct { mock.Mock } @@ -128,7 +137,7 @@ type AsyncMockTenantService struct { mock.Mock } -func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) { +func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { return nil, nil } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 737e0105..c758a021 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -6,6 +6,7 @@ import ( "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "errors" + "log/slog" "strings" "time" @@ -16,15 +17,17 @@ import ( type TenantHandler struct { DB *gorm.DB Service service.TenantService + UserRepo repository.UserRepository Keto service.KetoService KetoOutbox repository.KetoOutboxRepository KratosAdmin service.KratosAdminService } -func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler { +func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler { return &TenantHandler{ DB: db, Service: svc, + UserRepo: userRepo, Keto: keto, KetoOutbox: outbox, KratosAdmin: kratos, @@ -33,12 +36,15 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoS type tenantSummary struct { ID string `json:"id"` + Type string `json:"type"` + ParentID *string `json:"parentId"` Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` Status string `json:"status"` Domains []string `json:"domains,omitempty"` Config domain.JSONMap `json:"config,omitempty"` + MemberCount int64 `json:"memberCount"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } @@ -98,6 +104,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) + parentId := c.Query("parentId") + if limit <= 0 { limit = 50 } @@ -105,19 +113,45 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { offset = 0 } + // Use separate queries for count and find to avoid GORM statement contamination + countQuery := h.DB.Model(&domain.Tenant{}) + if parentId != "" { + countQuery = countQuery.Where("parent_id = ?", parentId) + } + var total int64 - if err := h.DB.Model(&domain.Tenant{}).Count(&total).Error; err != nil { + if err := countQuery.Count(&total).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + findQuery := h.DB.Model(&domain.Tenant{}) + if parentId != "" { + findQuery = findQuery.Where("parent_id = ?", parentId) + } + var tenants []domain.Tenant - if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { + if err := findQuery.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + // Fetch member counts for all tenants in one query using slugs (company codes) + slugs := make([]string, 0, len(tenants)) + for _, t := range tenants { + 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) + } + items := make([]tenantSummary, 0, len(tenants)) for _, t := range tenants { - items = append(items, mapTenantSummary(t)) + summary := mapTenantSummary(t) + // Ensure robust matching by trimming and lowercasing the slug key + key := strings.ToLower(strings.TrimSpace(t.Slug)) + summary.MemberCount = memberCounts[key] + items = append(items, summary) } return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) @@ -141,7 +175,15 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - return c.JSON(mapTenantSummary(tenant)) + memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug}) + count := int64(0) + if err == nil { + count = memberCounts[strings.ToLower(tenant.Slug)] + } + summary := mapTenantSummary(tenant) + summary.MemberCount = count + + return c.JSON(summary) } func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { @@ -152,6 +194,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { var req struct { Name string `json:"name"` Slug string `json:"slug"` + Type string `json:"type"` Description string `json:"description"` Status string `json:"status"` Domains []string `json:"domains"` @@ -167,6 +210,11 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) } + tenantType := normalizeTenantType(req.Type) + if tenantType == "" { + tenantType = domain.TenantTypeCompany // Default to COMPANY + } + slug := req.Slug if slug == "" { slug = utils.GenerateUniqueSlug(name, func(s string) bool { @@ -193,7 +241,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { parentID = &pid } - tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, req.Description, req.Domains, parentID) + tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID) if err != nil { if strings.Contains(err.Error(), "already exists") { return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) @@ -201,12 +249,16 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + summary := mapTenantSummary(*tenant) + summary.MemberCount = 0 + if req.Config != nil { tenant.Config = req.Config h.DB.Save(tenant) + summary.Config = tenant.Config } - return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(*tenant)) + return c.Status(fiber.StatusCreated).JSON(summary) } func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { @@ -229,9 +281,11 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { var req struct { Name *string `json:"name"` + Type *string `json:"type"` Slug *string `json:"slug"` Description *string `json:"description"` Status *string `json:"status"` + ParentID *string `json:"parentId"` Domains []string `json:"domains"` Config map[string]any `json:"config"` } @@ -246,6 +300,13 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { } tenant.Name = name } + if req.Type != nil { + tenantType := normalizeTenantType(*req.Type) + if tenantType == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant type"}) + } + tenant.Type = tenantType + } if req.Slug != nil { slug := utils.GenerateSlug(*req.Slug) if slug == "" { @@ -271,6 +332,30 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { } tenant.Status = status } + if req.ParentID != nil { + pid := strings.TrimSpace(*req.ParentID) + if pid == "" { + tenant.ParentID = nil + } else { + tenant.ParentID = &pid + } + + // [Keto] Sync hierarchy via Outbox + if h.KetoOutbox != nil { + if tenant.ParentID != nil { + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "parents", + Subject: "Tenant:" + *tenant.ParentID, + Action: domain.KetoOutboxActionCreate, + }) + } else { + // We don't have enough info here to delete specific parent if we don't know the old one, + // but for now we focus on adding. + } + } + } if req.Config != nil { tenant.Config = req.Config } @@ -432,6 +517,8 @@ func mapTenantSummary(t domain.Tenant) tenantSummary { return tenantSummary{ ID: t.ID, + Type: t.Type, + ParentID: t.ParentID, Name: t.Name, Slug: t.Slug, Description: t.Description, @@ -453,3 +540,13 @@ func normalizeTenantStatus(value string) string { } return value } + +func normalizeTenantType(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + switch value { + case domain.TenantTypePersonal, domain.TenantTypeCompany, domain.TenantTypeCompanyGroup, domain.TenantTypeUserGroup: + return value + default: + return "" + } +} diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index c9698159..b15b4a65 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -21,8 +21,8 @@ type MockTenantService struct { mock.Mock } -func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) { - args := m.Called(ctx, name, slug, description, domains, parentID) +func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { + args := m.Called(ctx, name, slug, tenantType, description, domains, parentID) if args.Get(0) == nil { return nil, args.Error(1) } @@ -85,7 +85,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) { } body, _ := json.Marshal(input) - mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", "", []string{"test.com"}, (*string)(nil)). + mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil)). Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil) req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body)) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 0602f843..6b2d8be5 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -68,6 +68,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) search := strings.TrimSpace(c.Query("search")) + companyCode := strings.TrimSpace(c.Query("companyCode")) if limit <= 0 { limit = 50 @@ -89,14 +90,21 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { // Tenant Admin filtering if requesterRole == domain.RoleTenantAdmin { - if requesterCompany == "" || compCode != requesterCompany { + if requesterCompany == "" || !strings.EqualFold(compCode, requesterCompany) { continue } } - // Search filtering + // Dedicated companyCode filter + if companyCode != "" && !strings.EqualFold(compCode, companyCode) { + continue + } + + // Search filtering (Keyword search in email, name, or companyCode) if search != "" { - if !strings.Contains(email, searchLower) && !strings.Contains(name, searchLower) { + if !strings.Contains(email, searchLower) && + !strings.Contains(name, searchLower) && + !strings.Contains(strings.ToLower(compCode), searchLower) { continue } } @@ -118,14 +126,27 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { items = append(items, summary) } + // [Lazy Sync] Asynchronously update local DB with fresh data from Kratos + // This ensures that member counts (which use local DB) eventually match reality + if h.UserRepo != nil { + go func(ids []service.KratosIdentity) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for _, identity := range ids { + localUser := h.mapToLocalUser(identity) + _ = h.UserRepo.Update(ctx, localUser) + } + }(filtered) + } + return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) } - // 2. Fallback to Local DB if Kratos is down (Development only recommended) + // 2. Fallback to Local DB if Kratos is down slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err) // Fetch from UserRepo - users, total, err := h.UserRepo.List(c.Context(), offset, limit, search) + users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, companyCode) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch users from both kratos and local db"}) } @@ -289,66 +310,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - // [New] Local DB Sync - localUser := &domain.User{ - ID: identityID, - Email: email, - Name: name, - Phone: normalizePhoneNumber(req.Phone), - AffiliationType: "internal", - CompanyCode: req.CompanyCode, - Department: req.Department, - Role: role, - Status: "active", - Metadata: req.Metadata, - } - if tenantID != "" { - localUser.TenantID = &tenantID - } - - // [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다. - if h.UserRepo != nil { - go func(u *domain.User) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := h.UserRepo.Create(ctx, u); err != nil { - slog.Error("[UserHandler] Failed to sync user to local DB", "email", u.Email, "error", err) - } - }(localUser) - } - - // [Keto] Sync relations via Outbox - if h.KetoOutboxRepo != nil { - // 1. Tenant Membership - if localUser.TenantID != nil { - _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{ - Namespace: "Tenant", - Object: *localUser.TenantID, - Relation: "members", - Subject: "User:" + identityID, - Action: domain.KetoOutboxActionCreate, - }) - } - // 2. Role Specifics - if role == domain.RoleSuperAdmin { - _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{ - Namespace: "System", - Object: "global", - Relation: "super_admins", - Subject: "User:" + identityID, - Action: domain.KetoOutboxActionCreate, - }) - } else if role == domain.RoleTenantAdmin && localUser.TenantID != nil { - _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{ - Namespace: "Tenant", - Object: *localUser.TenantID, - Relation: "admins", - Subject: "User:" + identityID, - Action: domain.KetoOutboxActionCreate, - }) - } - } - + // Fetch the newly created identity to ensure we have all traits identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) @@ -357,6 +319,28 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword}) } + // [New] Local DB Sync - Ensure user exists in read-model + 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() + + // 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 + if h.KetoOutboxRepo != nil { + h.syncKetoRole(ctx, u.ID, role, "", "", tID) + } + }(localUser, role, localUser.TenantID) + } + response := h.mapIdentitySummary(c.Context(), *identity) if generatedPassword != "" { response.InitialPassword = generatedPassword @@ -382,6 +366,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) } + // Capture current local state for transition comparison + var oldRole string + var oldTenantID string + if h.UserRepo != nil { + if local, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && local != nil { + oldRole = local.Role + if local.TenantID != nil { + oldTenantID = *local.TenantID + } + } + } + // [New] Check access scope requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if requester != nil && requester.Role == domain.RoleTenantAdmin { @@ -420,7 +416,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { traits["name"] = strings.TrimSpace(*req.Name) } if req.Phone != nil { - traits["phone_number"] = normalizePhoneNumber(strings.TrimSpace(*req.Phone)) + phone := normalizePhoneNumber(strings.TrimSpace(*req.Phone)) + if phone == "" { + delete(traits, "phone_number") + } else { + traits["phone_number"] = phone + } } if req.CompanyCode != nil { code := strings.TrimSpace(*req.CompanyCode) @@ -471,92 +472,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - // [New] Local DB Sync + // [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller if h.UserRepo != nil { - if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil { - oldRole := localUser.Role - oldTenantID := "" - if localUser.TenantID != nil { - oldTenantID = *localUser.TenantID - } - - if req.Name != nil { - localUser.Name = *req.Name - } - if req.Phone != nil { - localUser.Phone = normalizePhoneNumber(*req.Phone) - } - if req.CompanyCode != nil { - localUser.CompanyCode = *req.CompanyCode - if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil { - localUser.TenantID = &tenant.ID - } - } - if req.Department != nil { - localUser.Department = *req.Department - } - if req.Role != nil { - localUser.Role = *req.Role - } - if req.Status != nil { - localUser.Status = *req.Status - } - if req.Metadata != nil { - localUser.Metadata = req.Metadata - } - - // [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다. - // [ReBAC Policy] 로컬 DB와 Keto 간의 정합성을 위해 Outbox를 함께 기록합니다. - go func(u *domain.User, rRole *string, oRole string, oTenantID string) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if err := h.UserRepo.Update(ctx, u); err == nil { - // [Keto Sync on Role Change] via Outbox - if h.KetoOutboxRepo != nil && rRole != nil && *rRole != oRole { - uID := u.ID - newR := *rRole - if oRole == domain.RoleSuperAdmin { - _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "System", - Object: "global", - Relation: "super_admins", - Subject: "User:" + uID, - Action: domain.KetoOutboxActionDelete, - }) - } else if oRole == domain.RoleTenantAdmin && oTenantID != "" { - _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: oTenantID, - Relation: "admins", - Subject: "User:" + uID, - Action: domain.KetoOutboxActionDelete, - }) - } - - if newR == domain.RoleSuperAdmin { - _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "System", - Object: "global", - Relation: "super_admins", - Subject: "User:" + uID, - Action: domain.KetoOutboxActionCreate, - }) - } else if newR == domain.RoleTenantAdmin && u.TenantID != nil { - _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: *u.TenantID, - Relation: "admins", - Subject: "User:" + uID, - Action: domain.KetoOutboxActionCreate, - }) - } - } - } else { - slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", u.ID, "error", err) - } - }(localUser, req.Role, oldRole, oldTenantID) + updatedLocalUser := h.mapToLocalUser(*updated) + + ctx := context.Background() // Use request context if appropriate, but sync must finish + if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil { + slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err) } + + // [Keto Sync] asynchronously as it's less critical for immediate UI count + go h.syncKetoRole(context.Background(), updatedLocalUser.ID, + extractTraitString(updated.Traits, "grade"), oldRole, oldTenantID, updatedLocalUser.TenantID) } if req.Password != nil && *req.Password != "" { @@ -654,6 +581,97 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K return summary } +func (h *UserHandler) normalizePhoneNumber(phone string) string { + return normalizePhoneNumber(phone) +} + +func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User { + traits := identity.Traits + role := extractTraitString(traits, "grade") + if role == "" { + role = "user" + } + compCode := extractTraitString(traits, "companyCode") + + user := &domain.User{ + ID: identity.ID, + Email: extractTraitString(traits, "email"), + Name: extractTraitString(traits, "name"), + Phone: extractTraitString(traits, "phone_number"), + Role: role, + Status: normalizeStatus(identity.State), + CompanyCode: compCode, + Department: extractTraitString(traits, "department"), + AffiliationType: extractTraitString(traits, "affiliationType"), + CreatedAt: identity.CreatedAt, + UpdatedAt: identity.UpdatedAt, + } + + if compCode != "" && h.TenantService != nil { + // Use a background context or a timeout-limited context for tenant lookup + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil { + user.TenantID = &tenant.ID + } + } + + // Metadata + 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, + } + for k, v := range traits { + if !coreTraits[k] { + user.Metadata[k] = v + } + } + + return user +} + +func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole, oldTenantID string, newTenantID *string) { + // Remove old roles + if oldRole == domain.RoleSuperAdmin { + _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "System", + Object: "global", + Relation: "super_admins", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionDelete, + }) + } else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" { + _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: oldTenantID, + Relation: "admins", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionDelete, + }) + } + + // Add new roles + if newRole == domain.RoleSuperAdmin { + _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "System", + Object: "global", + Relation: "super_admins", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionCreate, + }) + } else if newRole == domain.RoleTenantAdmin && newTenantID != nil { + _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: *newTenantID, + Relation: "admins", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionCreate, + }) + } +} + func extractTraitString(traits map[string]interface{}, key string) string { if traits == nil { return "" diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 4da5804f..543a1697 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -3,6 +3,7 @@ package repository import ( "baron-sso-backend/internal/domain" "context" + "strings" "gorm.io/gorm" ) @@ -14,7 +15,10 @@ type UserRepository interface { FindByID(ctx context.Context, id string) (*domain.User, error) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) - List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) + List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) + CountByTenant(ctx context.Context, tenantID string) (int64, error) + CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) + CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) Delete(ctx context.Context, id string) error } @@ -69,14 +73,111 @@ func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]d return users, nil } -func (r *userRepository) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { +func (r *userRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&domain.User{}).Where("tenant_id = ?", tenantID).Count(&count).Error + return count, err +} + +func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { + type result struct { + TenantID string + Count int64 + } + var results []result + if len(tenantIDs) == 0 { + return make(map[string]int64), nil + } + if err := r.db.WithContext(ctx).Model(&domain.User{}). + Select("tenant_id, count(*) as count"). + Where("tenant_id IN ?", tenantIDs). + Group("tenant_id"). + Find(&results).Error; err != nil { + return nil, err + } + + counts := make(map[string]int64) + for _, res := range results { + if res.TenantID != "" { + counts[res.TenantID] = res.Count + } + } + // Ensure all requested tenant IDs are in the map, even if count is 0 + for _, id := range tenantIDs { + if _, ok := counts[id]; !ok { + counts[id] = 0 + } + } + return counts, nil +} + +func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { + if len(codes) == 0 { + return make(map[string]int64), nil + } + + // 1. Resolve IDs for these codes to support dual counting (slug or ID) + var tenants []domain.Tenant + _ = r.db.WithContext(ctx).Where("slug IN ?", codes).Find(&tenants).Error + + idToSlug := make(map[string]string) + slugToNormalized := make(map[string]string) + + for _, code := range codes { + slugToNormalized[strings.ToLower(strings.TrimSpace(code))] = code + } + for _, t := range tenants { + idToSlug[t.ID] = t.Slug + } + + type result struct { + CompanyCode string + TenantID string + Count int64 + } + var results []result + + // Use a more comprehensive aggregation + err := r.db.WithContext(ctx).Model(&domain.User{}). + Select("company_code, tenant_id, count(*) as count"). + Where("company_code IN ? OR tenant_id IN (SELECT id FROM tenants WHERE slug IN ?)", codes, codes). + Group("company_code, tenant_id"). + Scan(&results).Error + + if err != nil { + return nil, err + } + + counts := make(map[string]int64) + for _, res := range results { + var slug string + if res.CompanyCode != "" { + slug = res.CompanyCode + } else if res.TenantID != "" { + slug = idToSlug[res.TenantID] + } + + if slug != "" { + normalizedSlug := strings.ToLower(strings.TrimSpace(slug)) + counts[normalizedSlug] += res.Count + } + } + + return counts, nil +} + +func (r *userRepository) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { var users []domain.User var total int64 db := r.db.WithContext(ctx).Model(&domain.User{}) + if companyCode != "" { + db = db.Where("company_code = ?", companyCode) + } + if search != "" { searchTerm := "%" + search + "%" - db = db.Where("email LIKE ? OR name LIKE ?", searchTerm, searchTerm) + db = db.Where("(email LIKE ? OR name LIKE ? OR company_code LIKE ?)", searchTerm, searchTerm, searchTerm) } if err := db.Count(&total).Error; err != nil { diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index c52e4287..2f358ec5 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -13,7 +13,7 @@ import ( ) type TenantService interface { - RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) + RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) @@ -89,7 +89,7 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string return s.repo.FindByIDs(ctx, allIDs) } -func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) { +func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { // Validate Slug if ok, msg := utils.ValidateSlug(slug); !ok { return nil, errors.New(msg) @@ -106,7 +106,7 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript // 2. Create Tenant tenant := &domain.Tenant{ - Type: domain.TenantTypeCompany, // Default to COMPANY for manual registration + Type: tenantType, Name: name, Slug: slug, Description: description, diff --git a/backend/internal/service/tenant_service_edge_test.go b/backend/internal/service/tenant_service_edge_test.go index e48980d1..f446b11a 100644 --- a/backend/internal/service/tenant_service_edge_test.go +++ b/backend/internal/service/tenant_service_edge_test.go @@ -21,7 +21,7 @@ func TestTenantService_RegisterTenant_DuplicateSlug(t *testing.T) { // Mock: slug already exists mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "existing-id", Slug: slug}, nil) - tenant, err := svc.RegisterTenant(ctx, "New Name", slug, "", nil, nil) + tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil) assert.Error(t, err) assert.Contains(t, err.Error(), "already exists") assert.Nil(t, tenant) @@ -32,11 +32,11 @@ func TestTenantService_RegisterTenant_InvalidSlug(t *testing.T) { ctx := context.Background() // Case 1: Too short - _, err := svc.RegisterTenant(ctx, "Name", "a", "", nil, nil) + _, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil) assert.Error(t, err) // Case 2: Invalid characters - _, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", "", nil, nil) + _, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil) assert.Error(t, err) } diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 8698cb87..2216ffdb 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -120,6 +120,21 @@ func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, sea return nil, 0, nil } +func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID string) (int64, error) { + args := m.Called(tenantID) + return int64(args.Int(0)), args.Error(1) +} + +func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { + args := m.Called(tenantIDs) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]int64), args.Error(1) +} + + + func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { mockRepo := new(MockTenantRepoForSvc) mockOutbox := new(MockKetoOutboxRepositoryShared) @@ -136,7 +151,7 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil) mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once() - tenant, err := svc.RegisterTenant(ctx, name, slug, "", domains, nil) + tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil) assert.NoError(t, err) assert.NotNil(t, tenant) assert.Equal(t, "t1", tenant.ID) diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index 85385ee5..b740909e 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -81,6 +81,20 @@ func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search return nil, 0, nil } +func (m *MockUserRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) { + args := m.Called(tenantID) + return int64(args.Int(0)), args.Error(1) +} + +func (m *MockUserRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { + args := m.Called(tenantIDs) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]int64), args.Error(1) +} + + type MockTenantRepository struct { mock.Mock } diff --git a/docs/UI_DESIGN_POLICY.md b/docs/UI_DESIGN_POLICY.md new file mode 100644 index 00000000..3b0d3f31 --- /dev/null +++ b/docs/UI_DESIGN_POLICY.md @@ -0,0 +1,118 @@ +# UI 버튼 위치 및 정렬 정책 (UI Button Placement Policy) + +본 문서는 Baron SSO 프로젝트 내 모든 프론트엔드 애플리케이션(`userfront`, `devfront`, `adminfront`)에서 일관된 사용자 경험(UX)을 제공하기 위한 UI 버튼 배치 및 정렬 가이드라인을 정의합니다. (관련 이슈: [#308](https://gitea.hmac.kr/baron/baron-sso/issues/308)) + +## 1. 버튼 종류별 위치 (Button Placement by Type) + +버튼의 성격에 따라 다음과 같이 배치합니다. + +* **Primary Action (주요 동작)** + * **예시**: 저장, 확인, 제출, 생성 등 + * **위치**: 우측 하단 (Bottom Right) 또는 모달/다이얼로그의 우측 끝에 배치합니다. 사용자의 시선 흐름(좌에서 우, 위에서 아래)에 따라 최종 액션을 우측 하단에서 마무리하도록 유도합니다. +* **Secondary Action (보조 동작)** + * **예시**: 취소, 닫기, 이전으로 등 + * **위치**: Primary 버튼의 바로 **좌측**에 배치합니다. +* **Destructive Action (파괴적 동작)** + * **예시**: 삭제, 초기화, 권한 해제 등 + * **위치 및 스타일**: 붉은색(Red/Destructive) 스타일을 적용하여 시각적으로 명확히 구분합니다. Primary/Secondary 그룹과 물리적으로 분리하거나 (예: 좌측 끝 배치), Secondary 액션 위치에 두되 색상으로 강력한 경고를 줍니다. + +## 2. 정렬 기준 (Alignment Rules) + +* **폼(Form) 하단 버튼 그룹** + * **기본 정렬**: 우측 정렬 (Right-aligned). "취소"는 왼쪽, "저장"은 오른쪽에 위치합니다. `[ 취소 ] [ 저장 ]` +* **리스트 아이템 내부 액션 버튼** + * **기본 정렬**: 리스트/테이블의 각 행(Row) 우측 끝에 배치합니다. + * 버튼 개수가 많을 경우 (3개 이상), 툴팁이나 Dropdown 메뉴(예: 햄버거 버튼 또는 "더보기" 아이콘)로 숨겨 UI 복잡도를 낮춥니다. + +## 3. 반응형 고려 (Responsive Design) + +* **모바일 환경 (Mobile / Small Screens)** + * 화면 너비가 좁은 모바일 기기(예: `userfront` 앱 환경, `devfront`/`adminfront`의 모바일 뷰)에서는 버튼 그룹을 **Full Width (화면 가득 채움)**로 변경하여 터치 영역을 확보합니다. + * 여러 개의 버튼이 있는 경우 세로로 스택(Stack)하며, **Primary Action을 맨 위**에, Secondary Action을 그 아래에 배치합니다. + * *데스크탑*: `[ 취소 ] [ 확인 ]` + * *모바일*: + ``` + [ 확인 ] + [ 취소 ] + ``` + +## 4. 로딩 및 피드백 (Loading & Feedback) + +* **중복 제출 방지**: 폼 전송이나 API 호출을 발생시키는 버튼을 클릭하면 즉각적으로 버튼을 비활성화(Disabled) 상태로 변경하여 다중 클릭을 방지합니다. +* **로딩 스피너**: 버튼 내부에 로딩 스피너(Spinner)를 표시하여 사용자에게 진행 상황을 시각적으로 알립니다. +* **스켈레톤 로딩(Skeleton Loading)**: 화면 진입 시 전체 데이터를 로딩해야 하는 경우, 무의미한 빈 화면(빈 공간) 대신 스켈레톤 UI를 사용하여 로딩 중임을 직관적으로 알리고 체감 대기 시간을 줄입니다. +* **작업 결과 안내**: 성공, 실패 등의 결과는 Toast 메시지 (혹은 스낵바)를 통해 화면 하단/상단에 일시적으로 노출하여 사용자가 흐름을 끊지 않고도 인지할 수 있게 돕습니다. + +## 5. 빈 상태 처리 (Empty State) + +* **빈 목록 안내**: 테이블이나 리스트에 표시할 항목이 없는 경우 단순히 빈 화면으로 두지 않고 중앙 정렬된 아이콘이나 일러스트와 함께 "데이터가 없습니다." 등의 명확한 문구를 표시합니다. +* **콜 투 액션(Call to Action)**: 데이터가 비어 있는 경우 생성 버튼(Primary Action)을 빈 상태 안내 영역 아래에 배치하여 사용자가 즉시 데이터를 추가할 수 있도록 유도합니다. + +## 6. 오류 표시 (Error Handling) + +* **인라인(Inline) 오류**: 폼(Form)의 유효성 검사에서 실패한 경우, 각 입력 필드 바로 아래에 붉은색 텍스트로 실패 원인을 명확하게 표시합니다. +* **포커스 이동**: 제출 버튼 클릭 시 오류가 있는 첫 번째 입력 필드로 자동 스크롤 하거나 포커스(Focus)를 이동시켜 수정이 용이하게 합니다. + +## 7. 접근성 (Accessibility - a11y) + +* **포커스 링(Focus Ring)**: 키보드를 통해 탐색(Tab)하는 사용자를 위해 버튼, 텍스트 입력창 등에 포커스가 갈 경우 외곽선을 명확히 렌더링(예: 파란색 테두리 등)해야 합니다. `outline: none`을 무분별하게 사용하지 않습니다. +* **대체 텍스트**: 텍스트 없이 아이콘만 존재하는 버튼(예: X 형태의 닫기 버튼)의 경우 반드시 `aria-label` 속성(또는 Flutter의 `Semantics`)을 사용하여 스크린 리더 사용자가 해당 버튼의 역할을 알 수 있게 해야 합니다. + +## 8. 프론트엔드 환경별 구현 가이드 (Implementation Guide) + +현재 운영 중인 프론트엔드 환경에 맞춘 구현 가이드라인입니다. + +### 8.1. React 환경 (`devfront`, `adminfront`) +Tailwind CSS 기반의 컴포넌트를 사용하여 아래와 같이 구현합니다. + +* **버튼 그룹 우측 정렬 (데스크탑)**: `flex justify-end gap-2` +* **반응형 (모바일 세로 배치, 데스크탑 가로 배치)**: `flex flex-col-reverse sm:flex-row sm:justify-end gap-2` + *(참고: `flex-col-reverse`를 사용하면 코드 상 먼저 작성된 취소 버튼이 모바일에서는 아래로, 나중에 작성된 확인 버튼이 위로 올라가게 배치할 수 있습니다.)* +* **코드 예시**: + ```tsx +
+ + +
+ ``` + +### 8.2. Flutter 환경 (`userfront`) +Flutter 프레임워크를 사용하는 환경에서는 화면 너비에 따라 위젯 구성을 동적으로 처리해야 합니다. + +* **폼 하단 정렬**: `Row` 위젯과 `MainAxisAlignment.end` 사용. +* **반응형 대응**: 화면 너비(MediaQuery)에 따라 `Row`를 전체 너비를 채우는 `Column`으로 스위칭하거나, `OverflowBar` 위젯 등을 활용할 수 있습니다. +* **코드 예시**: + ```dart + // 데스크탑/태블릿용 (우측 정렬) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: isLoading ? null : onCancel, child: const Text('취소')), + const SizedBox(width: 8), + ElevatedButton( + onPressed: isLoading ? null : onSave, + child: isLoading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('확인') + ), + ], + ) + + // 모바일용 (전체 너비 세로 배치) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton( + onPressed: isLoading ? null : onSave, + child: isLoading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('확인') + ), + const SizedBox(height: 8), + TextButton(onPressed: isLoading ? null : onCancel, child: const Text('취소')), + ], + ) + ``` \ No newline at end of file diff --git a/docs/keto-rebac-namespaces-diagram.md b/docs/keto-rebac-namespaces-diagram.md new file mode 100644 index 00000000..1c6247dc --- /dev/null +++ b/docs/keto-rebac-namespaces-diagram.md @@ -0,0 +1,87 @@ +# Ory Keto (ReBAC) 네임스페이스 및 권한 상속 다이어그램 + +이 문서는 `docker/ory/keto/namespaces.ts`에 정의된 Baron SSO 프로젝트의 Ory Keto(ReBAC) 네임스페이스와 각 네임스페이스 간의 권한 상속(Permits) 및 관계(Relations)를 나타내는 Mermaid 다이어그램입니다. + +## 네임스페이스 설계 구조 + +Ory Keto는 다음과 같은 4개의 주요 네임스페이스로 구성되어 있습니다: + +1. **`User`**: 권한의 주체가 되는 기본 사용자. +2. **`System`**: 시스템 전역 권한 (최고 관리자 및 인증된 사용자). +3. **`Tenant`**: 조직/회사/부서 등 모든 형태의 격리 공간. 상위-하위(`parents`) 계층 구조를 가짐. +4. **`RelyingParty`**: OIDC 클라이언트(앱/리소스). 특정 `Tenant`에 종속될 수 있음. + +--- + +## Mermaid 다이어그램 + +```mermaid +classDiagram + class User { + <> + } + + class System { + <> + -- Relations -- + super_admins: User[] + authenticated_users: User[] + -- Permits -- + manage_all: super_admins + } + + class Tenant { + <> + -- Relations -- + owners: User[] + admins: User[] | SubjectSet~Tenant, owners~ + members: User[] + parents: Tenant[] + -- Permits -- + view: members OR admins OR parents.view + manage: admins OR parents.manage + create_subtenant: manage + } + + class RelyingParty { + <> + -- Relations -- + admins: User[] + parents: Tenant[] + access: User[] | SubjectSet~Tenant, members~ | SubjectSet~System, authenticated_users~ + -- Permits -- + view: admins OR parents.view + manage: admins OR parents.manage + access: access OR manage + } + + %% Relationship lines indicating references (SubjectSets or Direct inclusion) + User ..> System : super_admins, authenticated_users + User ..> Tenant : owners, admins, members + User ..> RelyingParty : admins, access + + Tenant "1" --> "*" Tenant : parents (상위 조직 상속) + Tenant ..> RelyingParty : parents (소유권 상속) + Tenant ..> RelyingParty : access (members 접근 권한) + + System ..> RelyingParty : access (authenticated_users) + + %% Styling + style User fill:#e1f5fe,stroke:#333,stroke-width:2px + style System fill:#ffe0b2,stroke:#333,stroke-width:2px + style Tenant fill:#fff9c4,stroke:#333,stroke-width:2px + style RelyingParty fill:#e1bee7,stroke:#333,stroke-width:2px +``` + +### 권한 평가(Permit) 상세 로직 설명 + +- **Tenant (테넌트/조직):** + - `view` (조회): 테넌트의 일반 멤버(`members`), 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다. + - `manage` (관리): 테넌트의 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다. + - _참고:_ 조직장(`owners`)은 자동으로 `admins` 집합(SubjectSet)에 포함됩니다. + +- **RelyingParty (OIDC 앱):** + - `view` (조회): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다. + - `manage` (관리): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다. + - `access` (접근/로그인 가능 여부): 이 앱에 직접 접근 권한을 부여받은 유저/그룹(`access`), 또는 앱을 관리할 수 있는 권한(`manage`)을 가진 사람이 접근할 수 있습니다. + - _접근 대상(access)은 특정 유저, 특정 테넌트의 전 멤버, 또는 전역 인증된 유저(System:authenticated_users)가 될 수 있습니다._ diff --git a/locales/en.toml b/locales/en.toml index 68f376b5..6c980370 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -178,7 +178,9 @@ subtitle = "Subtitle" subtitle = "Subtitle" [msg.admin.tenants.members] -empty = "Empty" +empty = "No members found." +desc = "View the list of users belonging to this organization." +limit_notice = "Showing members from the first 10 descendant organizations due to size limits." [msg.admin.tenants.registry] count = "Count" @@ -836,6 +838,11 @@ select_placeholder = "Select Placeholder" [ui.admin.tenants.members] title = "Tenant Members ({{count}})" +direct_label = "Direct" +total_label = "Total" +list_title = "Member Management" +direct = "Direct Members" +descendants = "Descendant Members" [ui.admin.tenants.members.table] email = "EMAIL" diff --git a/locales/ko.toml b/locales/ko.toml index fb3b4804..e42e83ef 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -179,6 +179,8 @@ subtitle = "필수 정보만 입력해도 생성 가능합니다. Slug는 없으 [msg.admin.tenants.members] empty = "소속된 사용자가 없습니다." +desc = "조직에 소속된 사용자 목록을 확인합니다." +limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다." [msg.admin.tenants.registry] count = "총 {{count}}개 테넌트" @@ -836,6 +838,11 @@ select_placeholder = "테넌트를 선택하세요" [ui.admin.tenants.members] title = "Tenant Members ({{count}})" +direct_label = "소속" +total_label = "전체" +list_title = "구성원 관리" +direct = "소속 멤버" +descendants = "하위 조직 멤버" [ui.admin.tenants.members.table] email = "EMAIL"