diff --git a/adminfront/src/components/ui/card.test.tsx b/adminfront/src/components/ui/card.test.tsx new file mode 100644 index 00000000..4bde79e9 --- /dev/null +++ b/adminfront/src/components/ui/card.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "./card"; + +describe("Card Component", () => { + it("renders card structure correctly", () => { + render( + + + Card Title + Card Description + + Card Content + Card Footer + , + ); + + expect(screen.getByText("Card Title")).toBeInTheDocument(); + expect(screen.getByText("Card Description")).toBeInTheDocument(); + expect(screen.getByText("Card Content")).toBeInTheDocument(); + expect(screen.getByText("Card Footer")).toBeInTheDocument(); + }); + + it("applies custom className to Card", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass("custom-card"); + }); +}); diff --git a/adminfront/src/components/ui/input.test.tsx b/adminfront/src/components/ui/input.test.tsx new file mode 100644 index 00000000..011f8404 --- /dev/null +++ b/adminfront/src/components/ui/input.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { Input } from "./input"; + +describe("Input Component", () => { + it("renders correctly", () => { + render(); + expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument(); + }); + + it("handles value changes", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + const input = screen.getByPlaceholderText("Enter text"); + + await user.type(input, "Hello"); + expect(onChange).toHaveBeenCalled(); + expect(input).toHaveValue("Hello"); + }); + + it("is disabled when the disabled prop is passed", () => { + render(); + const input = screen.getByRole("textbox"); + expect(input).toBeDisabled(); + }); +}); diff --git a/adminfront/src/components/ui/label.test.tsx b/adminfront/src/components/ui/label.test.tsx new file mode 100644 index 00000000..25409101 --- /dev/null +++ b/adminfront/src/components/ui/label.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Label } from "./label"; + +describe("Label Component", () => { + it("renders correctly with children", () => { + render(); + expect(screen.getByText("Username")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + render(); + const label = screen.getByText("Password"); + expect(label).toHaveClass("custom-label"); + }); + + it("is associated with an input via htmlFor", () => { + render( + <> + + + , + ); + 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 ( @@ -187,7 +82,7 @@ function TenantListPage() {

{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..03533254 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,963 @@ 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"; +import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; -type UserGroupNode = GroupSummary & { children: UserGroupNode[] }; - -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); - }, - onError: (error: AxiosError<{ error?: string }>) => { - toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), { - description: error.response?.data?.error || error.message, - }); + queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); + toast.success(t("msg.info.saved_success", "저장되었습니다.")); + setIsAddDialogOpen(false); }, }); - 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); - } - }, - onError: (error: AxiosError<{ error?: string }>) => { - toast.error(t("msg.common.error", "그룹 삭제 실패"), { - description: error.response?.data?.error || error.message, - }); - }, - }); + const allTenants = data?.items ?? []; - 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, - }); - }, - }); + const { currentBase, subTree } = useMemo( + () => buildTenantFullTree(allTenants, tenantId), + [allTenants, tenantId], + ); - 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, - }); - }, - }); - - const groupTree = groupsQuery.data ? buildGroupTree(groupsQuery.data) : []; - - const handleAddSubGroup = (parentId: string) => { - setNewGroupParentId(parentId); - }; - - 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 new file mode 100644 index 00000000..cd5b5f0a --- /dev/null +++ b/adminfront/src/lib/i18n.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { t } from "./i18n"; + +describe("i18n utility", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.clearAllMocks(); + }); + + it("returns fallback if key not found", () => { + expect(t("non.existent.key", "Fallback")).toBe("Fallback"); + }); + + it("returns key if fallback not provided and key not found", () => { + expect(t("non.existent.key")).toBe("non.existent.key"); + }); + + it("replaces variables in template", () => { + expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe( + "Hello World", + ); + }); + + it("respects locale in localStorage", () => { + window.localStorage.setItem("locale", "en"); + // We expect some key that exists in en.toml + // Let's use a common one or a fallback if we don't know the content + expect(t("ui.common.save", "Save")).toBe("Save"); + }); + + it("defaults to ko if no locale set and browser language is ko", () => { + vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR"); + expect(t("ui.common.save", "저장")).toBe("저장"); + }); +}); diff --git a/adminfront/src/lib/tenantTree.test.ts b/adminfront/src/lib/tenantTree.test.ts new file mode 100644 index 00000000..7cdf148c --- /dev/null +++ b/adminfront/src/lib/tenantTree.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import type { TenantSummary } from "./adminApi"; +import { buildTenantFullTree } from "./tenantTree"; + +describe("tenantTree utility", () => { + const mockTenants: TenantSummary[] = [ + { + id: "root-1", + name: "Root", + slug: "root", + type: "COMPANY", + memberCount: 10, + parentId: undefined, + description: "", + status: "active", + createdAt: "", + updatedAt: "", + }, + { + id: "child-1", + name: "Child 1", + slug: "child-1", + type: "USER_GROUP", + memberCount: 5, + parentId: "root-1", + description: "", + status: "active", + createdAt: "", + updatedAt: "", + }, + { + id: "grandchild-1", + name: "Grandchild 1", + slug: "grandchild-1", + type: "USER_GROUP", + memberCount: 2, + parentId: "child-1", + description: "", + status: "active", + createdAt: "", + updatedAt: "", + }, + ]; + + it("calculates recursive member counts correctly", () => { + const { currentBase } = buildTenantFullTree(mockTenants, "root-1"); + + expect(currentBase).not.toBeNull(); + if (currentBase) { + // Direct: 10, Child: 5, Grandchild: 2 -> Total: 17 + expect(currentBase.recursiveMemberCount).toBe(17); + expect(currentBase.children).toHaveLength(1); + + const child = currentBase.children[0]; + // Direct: 5, Grandchild: 2 -> Total: 7 + expect(child.recursiveMemberCount).toBe(7); + expect(child.children).toHaveLength(1); + + const grandchild = child.children[0]; + // Direct: 2 -> Total: 2 + expect(grandchild.recursiveMemberCount).toBe(2); + expect(grandchild.children).toHaveLength(0); + } + }); + + it("returns null currentBase if rootId is not found", () => { + const { currentBase } = buildTenantFullTree(mockTenants, "non-existent"); + expect(currentBase).toBeNull(); + }); + + it("builds correct structure with multiple roots", () => { + const multiRootTenants: TenantSummary[] = [ + ...mockTenants, + { + id: "root-2", + name: "Root 2", + slug: "root-2", + type: "COMPANY", + memberCount: 3, + parentId: undefined, + description: "", + status: "active", + createdAt: "", + updatedAt: "", + }, + ]; + + const { subTree } = buildTenantFullTree(multiRootTenants); + expect(subTree).toHaveLength(2); + expect(subTree.map((n) => n.id)).toContain("root-1"); + expect(subTree.map((n) => n.id)).toContain("root-2"); + }); +}); diff --git a/adminfront/src/lib/tenantTree.ts b/adminfront/src/lib/tenantTree.ts new file mode 100644 index 00000000..36b5a4a2 --- /dev/null +++ b/adminfront/src/lib/tenantTree.ts @@ -0,0 +1,69 @@ +import type { TenantSummary } from "./adminApi"; + +export type TenantNode = TenantSummary & { + children: TenantNode[]; + recursiveMemberCount: number; +}; + +/** + * Builds a hierarchical tree from a flat list of tenants and calculates + * direct and recursive member counts for each node. + */ +export function buildTenantFullTree( + allTenants: TenantSummary[], + rootId?: string, +): { currentBase: TenantNode | null; subTree: TenantNode[] } { + 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, + }); + } + + // 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); + } + } + } + + // 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; + }; + + // Calculate for all top-level nodes (those without parent) + for (const node of tenantMap.values()) { + if (!node.parentId) { + calculateRecursive(node); + } + } + + // If a specific rootId is provided, find and return its subtree + if (rootId) { + const base = tenantMap.get(rootId); + if (base) { + // Re-calculate specifically for our current tenant to be sure if it wasn't a global root + calculateRecursive(base); + return { currentBase: base, subTree: base.children }; + } + return { currentBase: null, subTree: [] }; + } + + // If no rootId, return all top-level roots as subTree + const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId); + return { currentBase: null, subTree: roots }; +} diff --git a/adminfront/src/lib/utils.test.ts b/adminfront/src/lib/utils.test.ts new file mode 100644 index 00000000..a5ad7f08 --- /dev/null +++ b/adminfront/src/lib/utils.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { cn } from "./utils"; + +describe("cn utility", () => { + it("merges class names correctly", () => { + expect(cn("a", "b")).toBe("a b"); + expect(cn("a", { b: true, c: false })).toBe("a b"); + }); + + it("handles tailwind class conflicts", () => { + expect(cn("px-2 py-2", "px-4")).toBe("py-2 px-4"); + }); +}); 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 83311d5b..330d9947 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 new file mode 100644 index 00000000..46daaaa8 --- /dev/null +++ b/adminfront/tests/auth.spec.ts @@ -0,0 +1,87 @@ +import { expect, test } from "@playwright/test"; + +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"], + }, + }); + }, + ); + }); + + 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"); + }); + + 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", + }, + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + window.localStorage.setItem(key, JSON.stringify(authData)); + }); + + await page.goto("/"); + + // Wait for the auth loading to finish + 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|운영 도구/); + }); + + 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" }, + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + window.localStorage.setItem(key, JSON.stringify(authData)); + }); + + await page.goto("/"); + await expect(page.locator("aside")).toBeVisible(); + + // Mock window.confirm + page.on("dialog", (dialog) => dialog.accept()); + + // Click logout button (label: ui.admin.nav.logout) + await page.click('button:has-text("Logout"), button:has-text("로그아웃")'); + + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts new file mode 100644 index 00000000..817f285a --- /dev/null +++ b/adminfront/tests/tenants.spec.ts @@ -0,0 +1,225 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Tenants Management", () => { + test.beforeEach(async ({ page }) => { + // Authenticate + 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", + }, + 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" } }); + }, + ); + + // Default mock for tenants to avoid proxy leaks + 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 { + await route.continue(); + } + }); + }); + + 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(), + }, + ], + total: 1, + limit: 1000, + offset: 0, + }, + }); + }); + + 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 }) => { + // 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.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"); + const submitBtn = page.locator('button:has-text("생성")'); + await expect(submitBtn).toBeDisabled(); + + await page.fill("input >> nth=0", "Valid Name"); + await expect(submitBtn).not.toBeDisabled(); + }); + + test("should show organization hierarchy and member list distinction", async ({ + page, + }) => { + // Mock parent tenant and its children + const mockTenants = [ + { + id: "parent-1", + name: "Parent Org", + slug: "parent-slug", + status: "active", + type: "COMPANY", + memberCount: 5, + parentId: null, + }, + { + id: "child-1", + name: "Child Team", + slug: "child-slug", + status: "active", + type: "USER_GROUP", + memberCount: 3, + parentId: "parent-1", + }, + ]; + + await page.route("**/api/v1/admin/tenants**", async (route) => { + await route.fulfill({ + json: { + items: mockTenants, + total: 2, + limit: 1000, + offset: 0, + }, + }); + }); + + // Mock members for parent and child + await page.route( + "**/api/v1/admin/users?*companyCode=parent-slug*", + async (route) => { + await route.fulfill({ + json: { + items: [{ id: "u1", name: "User One", email: "u1@parent.com" }], + total: 1, + }, + }); + }, + ); + + await page.route( + "**/api/v1/admin/users?*companyCode=child-slug*", + async (route) => { + await route.fulfill({ + json: { + items: [{ id: "u2", name: "User Two", email: "u2@child.com" }], + total: 1, + }, + }); + }, + ); + + await page.goto("/tenants/parent-1/organization"); + + // Wait for the table to appear + await expect(page.locator("table")).toBeVisible(); + + // Check if hierarchy shows correctly + await expect(page.locator("table")).toContainText("Parent Org"); + await expect(page.locator("table")).toContainText("Child Team"); + + // Check if member counts (Direct/Total) are displayed + // Parent should have Direct 5, Total 8 + const parentRow = page.locator("tr", { hasText: "Parent Org" }); + await expect(parentRow).toContainText("5"); // Direct + await expect(parentRow).toContainText("8"); // Total (5 + 3) + + // Check for either English or Korean labels + const hasDirectLabel = await parentRow.evaluate( + (el) => + el.textContent?.includes("Direct") || el.textContent?.includes("소속"), + ); + const hasTotalLabel = await parentRow.evaluate( + (el) => + el.textContent?.includes("Total") || el.textContent?.includes("전체"), + ); + expect(hasDirectLabel).toBe(true); + expect(hasTotalLabel).toBe(true); + + // Open Member List Dialog - Click the members count button + const memberButton = parentRow + .getByRole("button") + .filter({ hasText: /Direct|소속/ }); + await memberButton.click(); + + // Check Tabs in Member List Dialog + // Use regex to match either language, ignoring the count suffix + await expect( + page + .locator('button[role="tab"]') + .filter({ hasText: /소속 멤버|Direct Members/ }), + ).toBeVisible(); + await expect( + page + .locator('button[role="tab"]') + .filter({ hasText: /하위 조직 멤버|Descendant Members/ }), + ).toBeVisible(); + + // Direct Members Tab should show parent's user + await expect(page.locator("role=dialog")).toContainText("u1@parent.com"); + + // Switch to Descendant Members Tab + await page.click( + 'button[role="tab"]:has-text("하위 조직 멤버"), button[role="tab"]:has-text("Descendant Members")', + ); + await expect(page.locator("role=dialog")).toContainText("u2@child.com"); + }); +}); diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 806125cf..dd930a94 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..6a27ed2d 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..35c13cf3 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -98,10 +98,22 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) ( return nil, nil } -func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { +func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { 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 +} + +func (m *AsyncMockUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { + return nil, nil +} + type AsyncMockRedisRepo struct { mock.Mock } @@ -128,7 +140,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 } @@ -152,6 +164,10 @@ func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*dom return nil, nil } +func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + return nil, 0, nil +} + func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { return nil, nil } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 8ee3fa64..aa7b7a66 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -4,7 +4,9 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" + "baron-sso-backend/internal/utils" "errors" + "log/slog" "strings" "time" @@ -15,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, @@ -32,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"` } @@ -91,12 +98,10 @@ func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error { } func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { - if h.DB == nil { - return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") - } - limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) + parentId := c.Query("parentId") + if limit <= 0 { limit = 50 } @@ -104,19 +109,29 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { offset = 0 } - var total int64 - if err := h.DB.Model(&domain.Tenant{}).Count(&total).Error; err != nil { - return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + tenants, total, err := h.Service.ListTenants(c.Context(), limit, offset, parentId) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - var tenants []domain.Tenant - if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { - return errorJSON(c, fiber.StatusInternalServerError, 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}) @@ -140,7 +155,15 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, 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 { @@ -151,6 +174,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"` @@ -166,9 +190,20 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusBadRequest, "name is required") } - slug := normalizeTenantSlug(req.Slug) + tenantType := normalizeTenantType(req.Type) + if tenantType == "" { + tenantType = domain.TenantTypeCompany // Default to COMPANY + } + + slug := req.Slug if slug == "" { - slug = normalizeTenantSlug(name) + slug = utils.GenerateUniqueSlug(name, func(s string) bool { + var count int64 + h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", s).Count(&count) + return count > 0 + }) + } else { + slug = utils.GenerateSlug(slug) } if slug == "" { return errorJSON(c, fiber.StatusBadRequest, "slug is required") @@ -186,7 +221,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 errorJSON(c, fiber.StatusConflict, err.Error()) @@ -194,12 +229,16 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, 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 { @@ -222,9 +261,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"` } @@ -239,8 +280,15 @@ 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 := normalizeTenantSlug(*req.Slug) + slug := utils.GenerateSlug(*req.Slug) if slug == "" { return errorJSON(c, fiber.StatusBadRequest, "slug cannot be empty") } @@ -264,6 +312,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 } @@ -425,6 +497,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, @@ -436,18 +510,6 @@ func mapTenantSummary(t domain.Tenant) tenantSummary { } } -func normalizeTenantSlug(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - value = strings.ReplaceAll(value, " ", "-") - var b strings.Builder - for _, r := range value { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { - b.WriteRune(r) - } - } - return strings.Trim(b.String(), "-") -} - func normalizeTenantStatus(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { @@ -458,3 +520,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..80e89d73 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) } @@ -66,10 +66,58 @@ func (m *MockTenantService) GetTenant(ctx context.Context, id string) (*domain.T return args.Get(0).(*domain.Tenant), args.Error(1) } +func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + args := m.Called(ctx, limit, offset, parentID) + return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2) +} + func (m *MockTenantService) SetKetoService(keto service.KetoService) { m.Called(keto) } +type MockUserRepoForHandler struct { + mock.Mock +} + +func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil } +func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil } +func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error { return nil } +func (m *MockUserRepoForHandler) FindByEmail(ctx context.Context, email string) (*domain.User, error) { + return nil, nil +} + +func (m *MockUserRepoForHandler) FindByID(ctx context.Context, id string) (*domain.User, error) { + return nil, nil +} + +func (m *MockUserRepoForHandler) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { + return nil, nil +} + +func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { + return nil, nil +} + +func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { + return nil, 0, nil +} + +func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) { + return 0, nil +} + +func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { + return nil, nil +} + +func (m *MockUserRepoForHandler) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { + args := m.Called(ctx, codes) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]int64), args.Error(1) +} + func TestTenantHandler_CreateTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) @@ -85,7 +133,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)) @@ -98,6 +146,47 @@ func TestTenantHandler_CreateTenant(t *testing.T) { assert.Equal(t, "t1", got["id"]) } +func TestTenantHandler_ListTenants(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + mockUserRepo := new(MockUserRepoForHandler) + + h := &TenantHandler{ + Service: mockSvc, + UserRepo: mockUserRepo, + } + + app.Get("/tenants", h.ListTenants) + + tenants := []domain.Tenant{ + {ID: "t1", Name: "Tenant A", Slug: "slug-a"}, + {ID: "t2", Name: "Tenant B", Slug: "slug-b"}, + } + mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(2), nil) + mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"slug-a", "slug-b"}). + Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil) + + req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res tenantListResponse + json.NewDecoder(resp.Body).Decode(&res) + + assert.Equal(t, int64(2), res.Total) + assert.Len(t, res.Items, 2) + + // Check if counts are mapped correctly + for _, item := range res.Items { + if item.Slug == "slug-a" { + assert.Equal(t, int64(5), item.MemberCount) + } else if item.Slug == "slug-b" { + assert.Equal(t, int64(10), item.MemberCount) + } + } +} + func TestTenantHandler_ApproveTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 16afa296..45d36730 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 errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db") } @@ -289,66 +310,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, 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 errorJSON(c, fiber.StatusInternalServerError, 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 errorJSON(c, fiber.StatusNotFound, "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 errorJSON(c, fiber.StatusInternalServerError, 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 - } + updatedLocalUser := h.mapToLocalUser(*updated) - 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) + 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/tenant_repository.go b/backend/internal/repository/tenant_repository.go index cc20a6b5..9a18c4fe 100644 --- a/backend/internal/repository/tenant_repository.go +++ b/backend/internal/repository/tenant_repository.go @@ -16,6 +16,7 @@ type TenantRepository interface { FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error + List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) } type tenantRepository struct { @@ -90,3 +91,23 @@ func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domai } return r.db.WithContext(ctx).Create(&td).Error } + +func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + var tenants []domain.Tenant + var total int64 + db := r.db.WithContext(ctx).Model(&domain.Tenant{}) + + if parentID != "" { + db = db.Where("parent_id = ?", parentID) + } + + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := db.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { + return nil, 0, err + } + + return tenants, total, nil +} diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 4da5804f..eb17527c 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,110 @@ 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/repository/user_repository_test.go b/backend/internal/repository/user_repository_test.go index 886f297d..7d8d421e 100644 --- a/backend/internal/repository/user_repository_test.go +++ b/backend/internal/repository/user_repository_test.go @@ -56,7 +56,7 @@ func TestUserRepository(t *testing.T) { _ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"}) _ = repo.Create(ctx, &domain.User{Email: "bob@test.com", Name: "Bob", Role: "user"}) - users, total, err := repo.List(ctx, 0, 10, "Alice") + users, total, err := repo.List(ctx, 0, 10, "Alice", "") assert.NoError(t, err) assert.True(t, total >= 1) assert.Equal(t, "Alice", users[0].Name) @@ -73,4 +73,25 @@ func TestUserRepository(t *testing.T) { assert.Error(t, err) // Should not be found assert.Nil(t, found) }) + + t.Run("CountByCompanyCodes", func(t *testing.T) { + // Clean start for this subtest + testDB.Exec("DELETE FROM users") + + users := []domain.User{ + {Email: "u1@a.com", Name: "U1", CompanyCode: "tenant-a"}, + {Email: "u2@a.com", Name: "U2", CompanyCode: "tenant-a"}, + {Email: "u3@b.com", Name: "U3", CompanyCode: "tenant-b"}, + {Email: "u4@none.com", Name: "U4", CompanyCode: ""}, + } + for _, u := range users { + _ = repo.Create(ctx, &u) + } + + counts, err := repo.CountByCompanyCodes(ctx, []string{"tenant-a", "tenant-b", "tenant-c"}) + assert.NoError(t, err) + assert.Equal(t, int64(2), counts["tenant-a"]) + assert.Equal(t, int64(1), counts["tenant-b"]) + assert.Equal(t, int64(0), counts["tenant-c"]) + }) } diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index c52e4287..c1c161a5 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -13,11 +13,12 @@ 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) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) + ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) ApproveTenant(ctx context.Context, id string) error SetKetoService(keto KetoService) // 추가 } @@ -89,7 +90,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 +107,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, @@ -226,3 +227,8 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { return s.repo.FindBySlug(ctx, slug) } + +func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + // Let the repository handle the query and pagination + return s.repo.List(ctx, limit, offset, parentID) +} 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..2952bfe8 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -59,6 +59,11 @@ func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, d return m.Called(ctx, tenantID, domainName, verified).Error(0) } +func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + args := m.Called(ctx, limit, offset, parentID) + return args.Get(0).([]domain.Tenant), int64(args.Int(1)), args.Error(2) +} + type MockKetoSvcForTenant struct { mock.Mock } @@ -116,10 +121,31 @@ func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID strin return nil, nil } -func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { +func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { 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 (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { + args := m.Called(ctx, codes) + 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 +162,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) @@ -199,3 +225,18 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) { mockUserRepo.AssertExpectations(t) mockOutbox.AssertExpectations(t) } + +func TestTenantService_ListTenants(t *testing.T) { + mockRepo := new(MockTenantRepoForSvc) + svc := NewTenantService(mockRepo, nil, nil) + ctx := context.Background() + + tenants := []domain.Tenant{{ID: "t1", Name: "Tenant 1"}} + mockRepo.On("List", ctx, 10, 0, "").Return(tenants, 1, nil) + + result, total, err := svc.ListTenants(ctx, 10, 0, "") + assert.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Equal(t, tenants, result) + mockRepo.AssertExpectations(t) +} diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index 85385ee5..e5772077 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -77,10 +77,31 @@ func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string) return nil, nil } -func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { +func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { 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) +} + +func (m *MockUserRepository) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { + args := m.Called(ctx, codes) + 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 } @@ -121,6 +142,10 @@ func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, d return nil } +func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + return nil, 0, nil +} + func TestUserGroupService_Create(t *testing.T) { mockRepo := new(MockUserGroupRepository) mockTenantRepo := new(MockTenantRepository) diff --git a/backend/internal/utils/slug.go b/backend/internal/utils/slug.go index b736a902..6c9b4ba3 100644 --- a/backend/internal/utils/slug.go +++ b/backend/internal/utils/slug.go @@ -1,6 +1,7 @@ package utils import ( + "fmt" "regexp" "strings" ) @@ -75,3 +76,46 @@ func ValidateSlug(slug string) (bool, string) { return true, "" } + +// GenerateSlug generates a base slug from a given string. +// It removes special characters, replaces spaces with hyphens, and converts to lowercase. +func GenerateSlug(name string) string { + // Convert to lowercase + s := strings.ToLower(strings.TrimSpace(name)) + + // Replace non-alphanumeric characters (including spaces) with a hyphen + re := regexp.MustCompile(`[^a-z0-9]+`) + s = re.ReplaceAllString(s, "-") + + // Remove leading and trailing hyphens + s = strings.Trim(s, "-") + + // Handle empty slug + if s == "" { + s = "tenant" + } + + // Truncate to maximum length of 32 (reserving space for suffixes) + if len(s) > 25 { + s = s[:25] + s = strings.TrimSuffix(s, "-") + } + + return s +} + +// GenerateUniqueSlug generates a unique slug by appending a suffix if the base slug exists. +// It takes the base name and a checker function that returns true if the slug already exists. +func GenerateUniqueSlug(name string, exists func(string) bool) string { + baseSlug := GenerateSlug(name) + + slug := baseSlug + counter := 1 + + for reservedSlugs[slug] || exists(slug) { + slug = fmt.Sprintf("%s-%d", baseSlug, counter) + counter++ + } + + return slug +} diff --git a/backend/internal/utils/slug_test.go b/backend/internal/utils/slug_test.go index 15dad8ef..fd6f400d 100644 --- a/backend/internal/utils/slug_test.go +++ b/backend/internal/utils/slug_test.go @@ -54,3 +54,62 @@ func TestValidateSlug_Format(t *testing.T) { }) } } + +func TestGenerateSlug(t *testing.T) { + tests := []struct { + name string + expected string + }{ + {"Hello World", "hello-world"}, + {"My Company!@#", "my-company"}, + {"---Test---", "test"}, + {" Spaces ", "spaces"}, + {"A VERY LONG NAME THAT EXCEEDS THIRTY TWO CHARACTERS", "a-very-long-name-that-exc"}, + {"한글 테스트", "tenant"}, // Non-ascii characters will be replaced by hyphens and trimmed to empty, then fallback to "tenant" + {"Test 한글 Mix", "test-mix"}, + {"", "tenant"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + slug := GenerateSlug(tt.name) + assert.Equal(t, tt.expected, slug) + // Ensure generated slug is valid (unless it's reserved like "slug" wasn't reserved, but let's check format) + if !reservedSlugs[slug] { + valid, _ := ValidateSlug(slug) + assert.True(t, valid, "Generated slug should be valid format") + } + }) + } +} + +func TestGenerateUniqueSlug(t *testing.T) { + existingSlugs := map[string]bool{ + "my-company": true, + "my-company-1": true, + "test": true, + } + + existsFunc := func(slug string) bool { + return existingSlugs[slug] + } + + tests := []struct { + name string + expected string + }{ + {"My Company", "my-company-2"}, + {"Test", "test-1"}, + {"New Company", "new-company"}, + {"admin", "admin-1"}, // "admin" is reserved, so it should append suffix + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + slug := GenerateUniqueSlug(tt.name, existsFunc) + assert.Equal(t, tt.expected, slug) + valid, _ := ValidateSlug(slug) + assert.True(t, valid, "Generated unique slug should be valid") + }) + } +} 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/image.png b/image.png new file mode 100644 index 00000000..f6fc697b Binary files /dev/null and b/image.png differ diff --git a/locales/en.toml b/locales/en.toml index ec495d5f..ce690bf7 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -196,7 +196,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" @@ -804,8 +806,8 @@ tenant_admin = "TENANT ADMIN" tenant_member = "TENANT MEMBER" [ui.admin.tenants] -add = "Tenant Add" -title = "Tenant List" +add = "Add Tenant" +title = "Tenant Registry" [ui.admin.tenants.admins] add_button = "Add Button" @@ -865,6 +867,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" @@ -1060,6 +1067,8 @@ theme_light = "Light" theme_toggle = "Theme Toggle" unknown = "Unknown" view = "View" +manage = "Manage" +remove = "Remove" [ui.common.badge] admin_only = "Admin only" @@ -1079,6 +1088,12 @@ ok = "Ok" pending = "Pending" success = "Success" +[test] +key = "Test" + +[non.existent] +key = "Non-existent key" + [ui.dev] brand = "Brand" console_title = "Developer Console" @@ -1525,3 +1540,25 @@ verify = "Verify" [ui.userfront.signup.success] action = "Action" + +[msg.admin.tenants] +not_found = "Tenant not found." +remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?' + +[msg.admin.users.create] +success = "User created successfully." + +[ui.admin.tenants.sub] +add_dialog_desc = "Select a tenant to add as a sub-tenant." +add_dialog_title = "Add Sub-tenant" +add_existing = "Add Existing Tenant" +no_candidates = "No available tenants to add." +search_placeholder = "Search by name or slug..." + +[ui.admin.tenants.table] +members = "Members" + +[ui.admin.users.table] +email = "Email" +name = "Name" +role = "Role" diff --git a/locales/ko.toml b/locales/ko.toml index 59767ceb..5bb3325a 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -197,6 +197,8 @@ subtitle = "필수 정보만 입력해도 생성 가능합니다. Slug는 없으 [msg.admin.tenants.members] empty = "소속된 사용자가 없습니다." +desc = "조직에 소속된 사용자 목록을 확인합니다." +limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다." [msg.admin.tenants.registry] count = "총 {{count}}개 테넌트" @@ -865,6 +867,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" @@ -1060,6 +1067,8 @@ theme_light = "Light" theme_toggle = "테마 전환" unknown = "Unknown" view = "보기" +manage = "관리" +remove = "제외" [ui.common.badge] admin_only = "Admin only" @@ -1079,6 +1088,12 @@ ok = "정상" pending = "준비 중" success = "성공" +[test] +key = "테스트" + +[non.existent] +key = "존재하지 않는 키" + [ui.dev] brand = "Baron 로그인" console_title = "Developer Console" @@ -1525,3 +1540,25 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" + +[msg.admin.tenants] +not_found = "테넌트를 찾을 수 없습니다." +remove_sub_confirm = '테넌트 "{{name}}"을(를) 하위 조직에서 제외할까요?' + +[msg.admin.users.create] +success = "사용자가 생성되었습니다." + +[ui.admin.tenants.sub] +add_dialog_desc = "하위 조직으로 추가할 테넌트를 선택하세요." +add_dialog_title = "하위 조직 추가" +add_existing = "기존 테넌트 추가" +no_candidates = "추가 가능한 테넌트가 없습니다." +search_placeholder = "테넌트 이름 또는 슬러그로 검색..." + +[ui.admin.tenants.table] +members = "멤버수" + +[ui.admin.users.table] +email = "이메일" +name = "이름" +role = "역할" diff --git a/locales/template.toml b/locales/template.toml index 9a8fb8ce..65961d15 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -126,6 +126,8 @@ description = "" delete_confirm = "" empty = "" fetch_error = "" +not_found = "" +remove_sub_confirm = "" subtitle = "" [msg.admin.tenants.create] @@ -142,7 +144,9 @@ subtitle = "" subtitle = "" [msg.admin.tenants.members] +desc = "" empty = "" +limit_notice = "" [msg.admin.tenants.registry] count = "" @@ -163,6 +167,7 @@ subtitle = "" [msg.admin.users.create] error = "" password_required = "" +success = "" [msg.admin.users.create.account] subtitle = "" @@ -754,7 +759,12 @@ title = "" title = "" [ui.admin.tenants.members] +descendants = "" +direct = "" +direct_label = "" +list_title = "" title = "" +total_label = "" [ui.admin.tenants.members.table] email = "" @@ -782,7 +792,12 @@ type_text = "" [ui.admin.tenants.sub] add = "" +add_dialog_desc = "" +add_dialog_title = "" +add_existing = "" manage = "" +no_candidates = "" +search_placeholder = "" title = "" [ui.admin.tenants.sub.table] @@ -793,6 +808,7 @@ status = "" [ui.admin.tenants.table] actions = "" +members = "" name = "" slug = "" status = "" @@ -886,9 +902,16 @@ role = "" status = "" tenant_dept = "" +[ui.admin.users.table] +email = "" +name = "" +role = "" + [ui.common] add = "" +admin_only = "" +assign = "" back = "" cancel = "" close = "" @@ -901,9 +924,11 @@ details = "" edit = "" view = "" hyphen = "" +manage = "" na = "" never = "" next = "" +none = "" page_of = "" prev = "" previous = "" @@ -911,10 +936,14 @@ qr = "" reset = "" read_only = "" refresh = "" +remove = "" +requesting = "" resend = "" retry = "" save = "" search = "" +select = "" +select_placeholder = "" show_more = "" language = "" language_ko = "" @@ -942,6 +971,12 @@ ok = "" pending = "" success = "" +[test] +key = "" + +[non.existent] +key = "" + [ui.dev] brand = "" console_title = "" @@ -1508,10 +1543,3 @@ position_placeholder = "" [ui.admin.users.list.table] position_job = "" - -[ui.common] -admin_only = "" -assign = "" -none = "" -select = "" -select_placeholder = "" diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index db6738c6..76911a46 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -336,6 +336,8 @@ theme_light = "Light" theme_toggle = "Theme Toggle" unknown = "Unknown" view = "View" +manage = "Manage" +remove = "Remove" [ui.common.badge] admin_only = "Admin only" @@ -567,3 +569,4 @@ verify = "Verify" [ui.userfront.signup.success] action = "Action" + diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 60cccd4d..7c77d37e 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -336,6 +336,8 @@ theme_light = "Light" theme_toggle = "테마 전환" unknown = "Unknown" view = "보기" +manage = "관리" +remove = "제외" [ui.common.badge] admin_only = "Admin only" @@ -567,3 +569,4 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" + diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 0a335640..6ae3319d 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -305,6 +305,7 @@ details = "" edit = "" view = "" hyphen = "" +manage = "" na = "" never = "" next = "" @@ -315,6 +316,8 @@ qr = "" reset = "" read_only = "" refresh = "" +remove = "" +requesting = "" resend = "" retry = "" save = "" @@ -557,9 +560,3 @@ action = "" # Auto-added missing keys -[ui.common] -admin_only = "" -assign = "" -none = "" -select = "" -select_placeholder = "" diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 7f0da220..dd741004 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -86,8 +86,11 @@ class _ProfilePageState extends ConsumerState { void _onNameFocusChange() { if (!mounted) return; if (!_nameFocus.hasFocus && _nameTouched) { - final profile = ref.read(profileProvider).value ?? _cachedProfile; - if (profile != null) _autoSaveIfEditing(profile, 'name'); + Future.microtask(() { + if (!mounted) return; + final profile = ref.read(profileProvider).value ?? _cachedProfile; + if (profile != null) _autoSaveIfEditing(profile, 'name'); + }); } else if (_nameFocus.hasFocus) { _nameTouched = true; } @@ -101,8 +104,11 @@ class _ProfilePageState extends ConsumerState { hasFocus: _departmentFocus.hasFocus, ); if (!_departmentFocus.hasFocus && _departmentTouched) { - final profile = ref.read(profileProvider).value ?? _cachedProfile; - if (profile != null) _autoSaveIfEditing(profile, 'department'); + Future.microtask(() { + if (!mounted) return; + final profile = ref.read(profileProvider).value ?? _cachedProfile; + if (profile != null) _autoSaveIfEditing(profile, 'department'); + }); } else if (_departmentFocus.hasFocus) { _departmentTouched = true; } @@ -111,8 +117,11 @@ class _ProfilePageState extends ConsumerState { void _onPhoneFocusChange() { if (!mounted) return; if (!_phoneFocus.hasFocus && _phoneTouched) { - final profile = ref.read(profileProvider).value ?? _cachedProfile; - if (profile != null) _handlePhoneFocusChange(profile); + Future.microtask(() { + if (!mounted) return; + final profile = ref.read(profileProvider).value ?? _cachedProfile; + if (profile != null) _handlePhoneFocusChange(profile); + }); } else if (_phoneFocus.hasFocus) { _phoneTouched = true; } @@ -121,8 +130,11 @@ class _ProfilePageState extends ConsumerState { void _onPhoneCodeFocusChange() { if (!mounted) return; if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) { - final profile = ref.read(profileProvider).value ?? _cachedProfile; - if (profile != null) _handlePhoneFocusChange(profile); + Future.microtask(() { + if (!mounted) return; + final profile = ref.read(profileProvider).value ?? _cachedProfile; + if (profile != null) _handlePhoneFocusChange(profile); + }); } else if (_phoneCodeFocus.hasFocus) { _phoneCodeTouched = true; }