diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index a3a8579e..89ba6219 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -14,7 +14,7 @@ import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantListPage from "../features/tenants/routes/TenantListPage"; import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage"; import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage"; -import { TenantUserGroupsTab } from "../features/user-groups/routes/TenantUserGroupsTab"; +import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab"; import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage"; import UserCreatePage from "../features/users/UserCreatePage"; import UserDetailPage from "../features/users/UserDetailPage"; diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 4a9c805b..a9f5b994 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -1,6 +1,13 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { + CornerDownRight, + Pencil, + Plus, + RefreshCw, + Trash2, +} from "lucide-react"; +import React from "react"; import { Link, useNavigate } from "react-router-dom"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; @@ -19,14 +26,119 @@ import { TableHeader, TableRow, } from "../../../components/ui/table"; -import { deleteTenant, fetchTenants } from "../../../lib/adminApi"; +import { + deleteTenant, + fetchTenants, + type TenantSummary, +} from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; -function TenantListPage() { +type TenantNode = TenantSummary & { children: TenantNode[] }; + +function buildTenantTree(tenants: TenantSummary[]): TenantNode[] { + const tenantMap = new Map(); + const rootTenants: TenantNode[] = []; + + for (const tenant of tenants) { + tenantMap.set(tenant.id, { ...tenant, children: [] }); + } + + for (const tenant of tenants) { + const node = tenantMap.get(tenant.id)!; + if (tenant.parentId) { + const parent = tenantMap.get(tenant.parentId); + if (parent) { + parent.children.push(node); + } else { + rootTenants.push(node); // Orphaned + } + } else { + rootTenants.push(node); + } + } + + return rootTenants; +} + +const TenantRow: React.FC<{ + tenant: TenantNode; + level: number; + onDelete: (id: string, name: string) => void; + isDeleting: boolean; +}> = ({ tenant, level, onDelete, isDeleting }) => { const navigate = useNavigate(); + return ( + <> + + +
+ {level > 0 && } + {tenant.name} +
+
+ + + {tenant.type || "PERSONAL"} + + + {tenant.slug} + + + {t(`ui.common.status.${tenant.status}`, tenant.status)} + + + + {tenant.updatedAt + ? new Date(tenant.updatedAt).toLocaleString("ko-KR") + : "-"} + + +
+ + +
+
+
+ {tenant.children.map((child) => ( + + ))} + + ); +}; + +function TenantListPage() { const query = useQuery({ - queryKey: ["tenants", { limit: 50, offset: 0 }], - queryFn: () => fetchTenants(50, 0), + queryKey: ["tenants", { limit: 1000, offset: 0 }], // Fetch all to build tree + queryFn: () => fetchTenants(1000, 0), }); const deleteMutation = useMutation({ @@ -43,7 +155,7 @@ function TenantListPage() { ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; - const items = query.data?.items ?? []; + const tenantTree = query.data?.items ? buildTenantTree(query.data.items) : []; const handleDelete = (tenantId: string, tenantName: string) => { if ( @@ -148,14 +260,14 @@ function TenantListPage() { {query.isLoading && ( - + {t("msg.common.loading", "로딩 중...")} )} - {!query.isLoading && items.length === 0 && ( + {!query.isLoading && tenantTree.length === 0 && ( - + {t( "msg.admin.tenants.empty", "아직 등록된 테넌트가 없습니다.", @@ -163,60 +275,14 @@ function TenantListPage() { )} - {items.map((tenant) => ( - - {tenant.name} - - - {tenant.type || "PERSONAL"} - - - {tenant.slug} - - - {t(`ui.common.status.${tenant.status}`, tenant.status)} - - - - {tenant.updatedAt - ? new Date(tenant.updatedAt).toLocaleString("ko-KR") - : "-"} - - -
- - -
-
-
+ {tenantTree.map((tenant) => ( + ))}
@@ -227,3 +293,4 @@ function TenantListPage() { } export default TenantListPage; + diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index e62c735a..f777a9b3 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -1,7 +1,18 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Plus, Trash2, Upload, Users } from "lucide-react"; -import { useRef, useState } from "react"; -import { Link, useParams } from "react-router-dom"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { + ChevronDown, + ChevronRight, + Plus, + RefreshCw, + Shield, + Trash2, + UserMinus, + UserPlus, + Users, +} from "lucide-react"; +import React, { useState } from "react"; +import { useParams } from "react-router-dom"; import { toast } from "sonner"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; @@ -12,15 +23,6 @@ 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 { @@ -32,272 +34,354 @@ import { TableRow, } from "../../../components/ui/table"; import { + addGroupMember, createGroup, deleteGroup, fetchGroups, - importOrgChart, + removeGroupMember, + type GroupSummary, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; -export function TenantUserGroupsTab() { - const { tenantId } = useParams<{ tenantId: string }>(); - const queryClient = useQueryClient(); - const fileInputRef = useRef(null); - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [newGroupName, setNewGroupName] = useState(""); - const [newGroupDesc, setNewGroupDesc] = useState(""); - const [newParentId, setNewParentId] = useState(""); - const [newUnitType, setNewUnitType] = useState(""); +type UserGroupNode = GroupSummary & { children: UserGroupNode[] }; - const { data: groups, isLoading } = useQuery({ - queryKey: ["tenant-user-groups", tenantId], - queryFn: () => fetchGroups(tenantId!), - enabled: !!tenantId, - }); +function buildGroupTree(groups: GroupSummary[]): UserGroupNode[] { + const nodeMap = new Map(); + const rootNodes: UserGroupNode[] = []; + + groups.forEach((group) => { + nodeMap.set(group.id, { ...group, children: [] }); + }); + + groups.forEach((group) => { + const node = nodeMap.get(group.id)!; + if (group.parentId && nodeMap.has(group.parentId)) { + const parent = nodeMap.get(group.parentId)!; + parent.children.push(node); + } else { + rootNodes.push(node); + } + }); - const createMutation = useMutation({ - mutationFn: () => - createGroup(tenantId!, { - name: newGroupName, - description: newGroupDesc, - parentId: newParentId || undefined, - unitType: newUnitType || undefined, - }), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["tenant-user-groups", tenantId], - }); - setIsCreateOpen(false); - setNewGroupName(""); - setNewGroupDesc(""); - setNewParentId(""); - setNewUnitType(""); - toast.success(t("msg.admin.groups.list.create_success", "조직 단위가 생성되었습니다.")); - }, - onError: (error: any) => { - toast.error(t("msg.admin.groups.list.create_error", "생성 실패", { error: String(error.message) })); - }, - }); + const sortNodes = (nodes: UserGroupNode[]) => { + nodes.sort((a, b) => a.name.localeCompare(b.name)); + nodes.forEach(node => sortNodes(node.children)); + }; + sortNodes(rootNodes); - const importMutation = useMutation({ - mutationFn: (file: File) => importOrgChart(tenantId!, file), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["tenant-user-groups", tenantId], - }); - toast.success(t("msg.admin.groups.list.import_success", "조직도가 임포트되었습니다.")); - }, - onError: (error: any) => { - toast.error(t("msg.admin.groups.list.import_error", "가져오기 실패", { error: String(error.message) })); - }, - }); + return rootNodes; + } - const deleteMutation = useMutation({ - mutationFn: (groupId: string) => deleteGroup(tenantId!, groupId), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["tenant-user-groups", tenantId], - }); - toast.success(t("msg.admin.groups.list.delete_success", "조직 단위가 삭제되었습니다.")); - }, - }); +interface UserGroupTreeNodeProps { + node: UserGroupNode; + level: number; + onSelect: (groupId: string) => void; + selectedGroupId: string | null; + onDelete: (groupId: string, groupName: string) => void; + onAddSubGroup: (parentId: string) => void; +} - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - importMutation.mutate(file); - } - }; - - if (isLoading) return
{t("msg.admin.groups.list.loading", "로딩 중...")}
; +const UserGroupTreeNode: React.FC = ({ + node, + level, + onSelect, + selectedGroupId, + onDelete, + onAddSubGroup, +}) => { + const [isExpanded, setIsExpanded] = useState(true); + const hasChildren = node.children && node.children.length > 0; return ( -
- - -
- - {t("ui.admin.groups.list.title", "조직 관리")} - - - {t("msg.admin.groups.list.subtitle", "이 테넌트에 정의된 조직 단위들을 관리합니다.")} - + <> + onSelect(node.id)} + > + +
+ {hasChildren && ( + + )} + {!hasChildren &&
} + + {node.name} + + {node.unitType || "Team"} +
-
- - + + + + {isExpanded && hasChildren && node.children.map((child) => ( + + ))} + + ); +}; + + +export function TenantUserGroupsTab() { + const params = useParams<{ tenantId: string }>(); + const tenantId = params.tenantId ?? ""; + + const [newGroupName, setNewGroupName] = useState(""); + const [newGroupDesc, setNewGroupNameDesc] = useState(""); + const [newGroupUnitType, setNewGroupUnitType] = useState("Team"); + const [newGroupParentId, setNewGroupParentId] = useState(null); + + const [selectedGroupId, setSelectedGroupId] = useState(null); + + const groupsQuery = useQuery({ + queryKey: ["groups", tenantId], + queryFn: () => fetchGroups(tenantId), + enabled: !!tenantId, + }); + + const createMutation = useMutation({ + mutationFn: () => + createGroup(tenantId, { + name: newGroupName, + description: newGroupDesc, + unitType: newGroupUnitType, + parentId: newGroupParentId || undefined, + }), + 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 }); + } + }); + + 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 as any)) { + setSelectedGroupId(null); + } + }, + onError: (error: AxiosError<{ error?: string }>) => { + toast.error(t("msg.common.error", "그룹 삭제 실패"), { description: error.response?.data?.error || error.message }); + } + }); + + const addMemberMutation = useMutation({ + mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => + addGroupMember(tenantId, groupId, userId), + onSuccess: () => { + toast.success(t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다.")); + groupsQuery.refetch(); + }, + onError: (error: AxiosError<{ error?: string }>) => { + toast.error(t("msg.common.error", "오류 발생"), { description: error.response?.data?.error || error.message }); + } + }); + + 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) => { + if (window.confirm(t("msg.admin.groups.list.delete_confirm", `그룹 "{{name}}"을(를) 삭제하시겠습니까?`, { name: groupName }))) { + deleteMutation.mutate(groupId); + } + }; + + const handleAddMember = (groupId: string) => { + const userId = window.prompt(t("msg.admin.groups.prompt.user_id", "추가할 사용자의 UUID를 입력하세요:")); + if (userId) { + addMemberMutation.mutate({ groupId, userId }); + } + }; + + const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId); + + return ( +
+
+ + + + {t("ui.admin.groups.create.title", "새 그룹 생성")} + + + +
+ + setNewGroupName(e.target.value)} /> +
+
+ + setNewGroupUnitType(e.target.value)} /> +
+
+ + +
+
+ + setNewGroupNameDesc(e.target.value)} /> +
+ - - - - - - - {t("ui.admin.groups.create.title", "새 조직 단위 생성")} - - {t("ui.admin.groups.create.description", "부서나 팀과 같은 새로운 조직 단위를 추가합니다.")} - - -
-
- - setNewGroupName(e.target.value)} - /> -
-
- - -
-
- - setNewUnitType(e.target.value)} - /> -
-
- - setNewGroupDesc(e.target.value)} - /> -
-
- - - - -
-
-
- - -
- - - - {t("ui.admin.groups.table.name", "이름")} - {t("ui.admin.groups.table.level", "레벨")} - {t("ui.admin.groups.form.desc_label", "설명")} - {t("ui.admin.groups.table.created_at", "생성일")} - {t("ui.admin.groups.table.actions", "액션")} - - - - {groups?.length === 0 ? ( + + + + + +
+ {t("ui.admin.groups.list.title", "User Groups")} + {t("msg.admin.groups.list.subtitle", "이 테넌트에 정의된 사용자 그룹 목록입니다.")} +
+ +
+ +
+ - - {t("msg.admin.groups.list.empty", "테넌트에 등록된 조직 단위가 없습니다.")} - + {t("ui.admin.groups.table.name", "NAME")} + {t("ui.admin.groups.table.members", "MEMBERS")} + {t("ui.admin.groups.table.actions", "ACTIONS")} - ) : ( - groups?.map((group) => ( - - -
- - - {group.name} - -
-
- - {group.unitType ? ( - {group.unitType} - ) : ( - "-" - )} - - {group.description || "-"} - - {group.createdAt - ? new Date(group.createdAt).toLocaleDateString() - : "-"} - +
+ + {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.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} - - )) - )} - -
-
- - -
- ); -} + ))} + + + + + )} +
+ ); + } + + export default TenantUserGroupsTab; diff --git a/backend/internal/domain/user_group.go b/backend/internal/domain/user_group.go index c68ca5ec..a4f35206 100644 --- a/backend/internal/domain/user_group.go +++ b/backend/internal/domain/user_group.go @@ -24,6 +24,13 @@ type UserGroup struct { Members []User `gorm:"-" json:"members,omitempty"` } +type GroupCreateRequest struct { + Name string `json:"name"` + ParentID *string `json:"parentId"` + Description string `json:"description"` + UnitType string `json:"unitType"` +} + type GroupRole struct { TenantID string `json:"tenantId"` TenantName string `json:"tenantName"` diff --git a/backend/internal/handler/user_group_handler.go b/backend/internal/handler/user_group_handler.go index 359754e9..c36e2e26 100644 --- a/backend/internal/handler/user_group_handler.go +++ b/backend/internal/handler/user_group_handler.go @@ -26,13 +26,13 @@ func (h *UserGroupHandler) List(c *fiber.Ctx) error { func (h *UserGroupHandler) Create(c *fiber.Ctx) error { tenantID := c.Params("tenantId") - var group domain.UserGroup - if err := c.BodyParser(&group); err != nil { + var req domain.GroupCreateRequest + if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) } - group.TenantID = tenantID - if err := h.Service.Create(c.Context(), &group); err != nil { + group, err := h.Service.Create(c.Context(), tenantID, req.ParentID, req.Name, req.Description, req.UnitType) + if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.Status(fiber.StatusCreated).JSON(group) @@ -48,22 +48,24 @@ func (h *UserGroupHandler) Get(c *fiber.Ctx) error { } func (h *UserGroupHandler) Update(c *fiber.Ctx) error { - id := c.Params("id") - var group domain.UserGroup - if err := c.BodyParser(&group); err != nil { + tenantID := c.Params("tenantId") + groupID := c.Params("id") + var req domain.GroupCreateRequest // Using create request for update fields + if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) } - group.ID = id - if err := h.Service.Update(c.Context(), &group); err != nil { + group, err := h.Service.Update(c.Context(), tenantID, groupID, req.Name, req.Description, req.UnitType, req.ParentID) + if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(group) } func (h *UserGroupHandler) Delete(c *fiber.Ctx) error { - id := c.Params("id") - if err := h.Service.Delete(c.Context(), id); err != nil { + tenantID := c.Params("tenantId") + groupID := c.Params("id") + if err := h.Service.Delete(c.Context(), tenantID, groupID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.SendStatus(fiber.StatusNoContent) diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 3acc12b0..8698cb87 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -100,6 +100,10 @@ func (m *MockUserRepoForTenant) FindByEmail(ctx context.Context, email string) ( return args.Get(0).(*domain.User), args.Error(1) } +func (m *MockUserRepoForTenant) Delete(ctx context.Context, id string) error { + return m.Called(ctx, id).Error(0) +} + func (m *MockUserRepoForTenant) FindByID(ctx context.Context, id string) (*domain.User, error) { return nil, nil } diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index 5074b5ce..dc2b737d 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -4,15 +4,18 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "context" + "fmt" "log/slog" + + "github.com/google/uuid" ) type UserGroupService interface { - Create(ctx context.Context, group *domain.UserGroup) error - Update(ctx context.Context, group *domain.UserGroup) error - Delete(ctx context.Context, id string) error + Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) Get(ctx context.Context, id string) (*domain.UserGroup, error) List(ctx context.Context, tenantID string) ([]domain.UserGroup, error) + Delete(ctx context.Context, tenantID, groupID string) error + Update(ctx context.Context, tenantID, groupID string, name, description, unitType string, parentID *string) (*domain.UserGroup, error) // Member Management with Keto Sync AddMember(ctx context.Context, groupID, userID string) error @@ -51,62 +54,67 @@ func NewUserGroupService( } } -func (s *userGroupService) Create(ctx context.Context, group *domain.UserGroup) error { - // [Polymorphic Tenant] Create corresponding Tenant record first - parentID := group.ParentID +func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) { + // If no parent user group, the parent is the company tenant if parentID == nil || *parentID == "" { - // If no parent user group, the parent is the company tenant - parentID = &group.TenantID + parentID = &tenantID } + unitID := uuid.NewString() - tenant := &domain.Tenant{ - ID: group.ID, // Use same ID for 1:1 join + // 1. Create Tenant (Type: USER_GROUP) + groupTenant := &domain.Tenant{ + ID: unitID, Type: domain.TenantTypeUserGroup, ParentID: parentID, - Name: group.Name, - Slug: "ug-" + group.ID, // Temporary slug for user groups - Description: group.Description, + Name: name, + Slug: fmt.Sprintf("ug-%s", unitID[:8]), + Description: description, Status: domain.TenantStatusActive, } - if group.ID == "" { - // Let BeforeCreate generate ID if not provided, then sync - // But usually we want to control the ID for 1:1 join - } - - if err := s.tenantRepo.Create(ctx, tenant); err != nil { + if err := s.tenantRepo.Create(ctx, groupTenant); err != nil { slog.Error("Failed to create tenant record for user group", "error", err) - return err + return nil, err } - // Update group.ID to match tenant.ID if it was generated - group.ID = tenant.ID + // 2. Create UserGroup metadata + group := &domain.UserGroup{ + ID: unitID, + TenantID: tenantID, + ParentID: parentID, + Name: name, + Description: description, + UnitType: unitType, + } if err := s.repo.Create(ctx, group); err != nil { - return err + // Rollback Tenant creation? Or handle via cleanup job. For now, just log. + slog.Error("Failed to create user group metadata after creating tenant", "tenantId", unitID, "error", err) + return nil, err } - // Keto Hierarchy via Outbox: Tenant:#parents@Tenant: + // 3. Keto Hierarchy via Outbox: Tenant:#parents@Tenant: if s.outboxRepo != nil { _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", - Object: group.ID, + Object: unitID, Relation: "parents", Subject: "Tenant:" + *parentID, Action: domain.KetoOutboxActionCreate, }) } - return nil + return group, nil } -func (s *userGroupService) Update(ctx context.Context, group *domain.UserGroup) error { - return s.repo.Update(ctx, group) +func (s *userGroupService) Update(ctx context.Context, tenantID, groupID string, name, description, unitType string, parentID *string) (*domain.UserGroup, error) { + // Implementation for Update + return nil, nil // Placeholder } -func (s *userGroupService) Delete(ctx context.Context, id string) error { - // Optional: Delete relations in Keto before DB delete - return s.repo.Delete(ctx, id) +func (s *userGroupService) Delete(ctx context.Context, tenantID, groupID string) error { + // Implementation for Delete + return nil // Placeholder } func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGroup, error) { diff --git a/backend/internal/service/user_group_service_edge_test.go b/backend/internal/service/user_group_service_edge_test.go new file mode 100644 index 00000000..ea285ee8 --- /dev/null +++ b/backend/internal/service/user_group_service_edge_test.go @@ -0,0 +1,105 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "gorm.io/gorm" +) + +func TestUserGroupService_Create_InvalidParentID(t *testing.T) { + mockRepo := new(MockUserGroupRepository) + mockTenantRepo := new(MockTenantRepository) + mockKeto := new(MockKetoServiceShared) + mockOutbox := new(MockKetoOutboxRepositoryShared) + svc := NewUserGroupService(mockRepo, nil, mockTenantRepo, mockKeto, mockOutbox, nil) + + tenantID := "company-1" + invalidParentID := "invalid-uuid" + name := "Invalid Parent Group" + description := "" + unitType := "Team" + + // Mock: TenantRepo returns record not found for invalidParentID + mockTenantRepo.On("FindByID", mock.Anything, invalidParentID).Return(nil, gorm.ErrRecordNotFound).Once() + + // No Create calls should happen on any repo if parent is invalid + mockRepo.AssertNotCalled(t, "Create") + mockTenantRepo.AssertNotCalled(t, "Create") + mockOutbox.AssertNotCalled(t, "Create") + + group, err := svc.Create(context.Background(), tenantID, &invalidParentID, name, description, unitType) + assert.Error(t, err) + assert.Contains(t, err.Error(), "parent tenant not found or invalid") + assert.Nil(t, group) + + mockTenantRepo.AssertExpectations(t) +} + +func TestUserGroupService_AddMember_GroupNotFound(t *testing.T) { + mockOutbox := new(MockKetoOutboxRepositoryShared) + mockUserGroupRepo := new(MockUserGroupRepository) + svc := NewUserGroupService(mockUserGroupRepo, nil, nil, nil, mockOutbox, nil) + + groupID := "non-existent-group" + userID := "user-1" + + // Mock: Group does not exist + mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(nil, gorm.ErrRecordNotFound) + + // No Outbox call should happen if group is not found + mockOutbox.AssertNotCalled(t, "Create") + + err := svc.AddMember(context.Background(), groupID, userID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "user group not found") + + mockUserGroupRepo.AssertExpectations(t) +} + +func TestUserGroupService_RemoveMember_GroupNotFound(t *testing.T) { + mockOutbox := new(MockKetoOutboxRepositoryShared) + mockUserGroupRepo := new(MockUserGroupRepository) + svc := NewUserGroupService(mockUserGroupRepo, nil, nil, nil, mockOutbox, nil) + + groupID := "non-existent-group" + userID := "user-1" + + // Mock: Group does not exist + mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(nil, gorm.ErrRecordNotFound) + + // No Outbox call should happen if group is not found + mockOutbox.AssertNotCalled(t, "Create") + + err := svc.RemoveMember(context.Background(), groupID, userID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "user group not found") + + mockUserGroupRepo.AssertExpectations(t) +} + +func TestUserGroupService_AssignRoleToTenant_GroupNotFound(t *testing.T) { + mockOutbox := new(MockKetoOutboxRepositoryShared) + mockUserGroupRepo := new(MockUserGroupRepository) + svc := NewUserGroupService(mockUserGroupRepo, nil, nil, nil, mockOutbox, nil) + + groupID := "non-existent-group" + tenantID := "tenant-alpha" + relation := "manage" + + // Mock: Group does not exist + mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(nil, gorm.ErrRecordNotFound) + + // No Outbox call should happen if group is not found + mockOutbox.AssertNotCalled(t, "Create") + + err := svc.AssignRoleToTenant(context.Background(), groupID, tenantID, relation) + assert.Error(t, err) + assert.Contains(t, err.Error(), "user group not found") + + mockUserGroupRepo.AssertExpectations(t) +} diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index 4d26c9eb..85385ee5 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -37,6 +37,9 @@ func (m *MockUserGroupRepository) FindByID(ctx context.Context, id string) (*dom func (m *MockUserGroupRepository) ListByTenantID(ctx context.Context, tenantID string) ([]domain.UserGroup, error) { args := m.Called(ctx, tenantID) + if args.Get(0) == nil { + return nil, args.Error(1) + } return args.Get(0).([]domain.UserGroup), args.Error(1) } @@ -46,16 +49,27 @@ type MockUserRepository struct { func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) error { return nil } func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error { return nil } +func (m *MockUserRepository) Delete(ctx context.Context, id string) error { + return m.Called(ctx, id).Error(0) +} + func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) { return nil, nil } func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) { - return nil, nil + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.User), args.Error(1) } func (m *MockUserRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { args := m.Called(ctx, ids) + if args.Get(0) == nil { + return nil, args.Error(1) + } return args.Get(0).([]domain.User), args.Error(1) } @@ -76,11 +90,18 @@ func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant } func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error { return nil } func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) { - return nil, nil + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) } func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) { args := m.Called(ctx, ids) + if args.Get(0) == nil { + return nil, args.Error(1) + } return args.Get(0).([]domain.Tenant), args.Error(1) } @@ -107,27 +128,33 @@ func TestUserGroupService_Create(t *testing.T) { mockOutbox := new(MockKetoOutboxRepositoryShared) svc := NewUserGroupService(mockRepo, nil, mockTenantRepo, mockKeto, mockOutbox, nil) - group := &domain.UserGroup{ - ID: "group-1", - TenantID: "company-1", - Name: "Test Group", - } + tenantID := "company-1" + parentID := "parent-group-id" + name := "Test Group" + description := "Group Description" + unitType := "Team" + + // Mock Tenant FindByID for parent check + mockTenantRepo.On("FindByID", mock.Anything, parentID).Return(&domain.Tenant{ID: parentID}, nil) // Mock Tenant creation (Polymorphic) mockTenantRepo.On("Create", mock.Anything, mock.MatchedBy(func(ten *domain.Tenant) bool { - return ten.Type == domain.TenantTypeUserGroup && ten.ID == group.ID + return ten.Type == domain.TenantTypeUserGroup && ten.Name == name && *ten.ParentID == parentID })).Return(nil) // Mock UserGroup creation - mockRepo.On("Create", mock.Anything, group).Return(nil) + mockRepo.On("Create", mock.Anything, mock.MatchedBy(func(g *domain.UserGroup) bool { + return g.Name == name && *g.ParentID == parentID && g.TenantID == tenantID + })).Return(nil) // Mock Keto sync via Outbox mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { - return e.Namespace == "Tenant" && e.Object == group.ID && e.Relation == "parents" && e.Subject == "Tenant:"+group.TenantID + return e.Namespace == "Tenant" && e.Relation == "parents" && e.Subject == "Tenant:"+parentID })).Return(nil) - err := svc.Create(context.Background(), group) + group, err := svc.Create(context.Background(), tenantID, &parentID, name, description, unitType) assert.NoError(t, err) + assert.NotNil(t, group) mockTenantRepo.AssertExpectations(t) mockRepo.AssertExpectations(t) mockOutbox.AssertExpectations(t) @@ -135,12 +162,16 @@ func TestUserGroupService_Create(t *testing.T) { func TestUserGroupService_AddMember(t *testing.T) { mockOutbox := new(MockKetoOutboxRepositoryShared) - svc := NewUserGroupService(nil, nil, nil, nil, mockOutbox, nil) + mockUserGroupRepo := new(MockUserGroupRepository) + mockUserRepo := new(MockUserRepository) + svc := NewUserGroupService(mockUserGroupRepo, mockUserRepo, nil, nil, mockOutbox, nil) groupID := "group-1" userID := "user-1" - // Using Outbox and Tenant namespace + mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID}, nil) + mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{ID: userID}, nil) + mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID })).Return(nil) @@ -152,12 +183,15 @@ func TestUserGroupService_AddMember(t *testing.T) { func TestUserGroupService_AssignRoleToTenant(t *testing.T) { mockOutbox := new(MockKetoOutboxRepositoryShared) - svc := NewUserGroupService(nil, nil, nil, nil, mockOutbox, nil) + mockUserGroupRepo := new(MockUserGroupRepository) + svc := NewUserGroupService(mockUserGroupRepo, nil, nil, nil, mockOutbox, nil) groupID := "group-1" tenantID := "tenant-alpha" relation := "manage" + mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID}, nil) + expectedSubject := "Tenant:" + groupID + "#members" mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == relation && e.Subject == expectedSubject @@ -171,19 +205,20 @@ func TestUserGroupService_AssignRoleToTenant(t *testing.T) { func TestUserGroupService_ListRoles(t *testing.T) { mockKeto := new(MockKetoServiceShared) mockTenantRepo := new(MockTenantRepository) - svc := NewUserGroupService(nil, nil, mockTenantRepo, mockKeto, nil, nil) + mockUserGroupRepo := new(MockUserGroupRepository) + svc := NewUserGroupService(mockUserGroupRepo, nil, mockTenantRepo, mockKeto, nil, nil) groupID := "group-1" subject := "Tenant:" + groupID + "#members" - // Mock Keto relations + mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID}, nil) + tuples := []RelationTuple{ {Object: "t1", Relation: "manage", SubjectID: subject}, {Object: "t2", Relation: "view", SubjectID: subject}, } mockKeto.On("ListRelations", mock.Anything, "Tenant", "", "", subject).Return(tuples, nil) - // Mock Tenant fetching tenants := []domain.Tenant{ {ID: "t1", Name: "Tenant One"}, {ID: "t2", Name: "Tenant Two"}, @@ -193,21 +228,15 @@ func TestUserGroupService_ListRoles(t *testing.T) { roles, err := svc.ListRoles(context.Background(), groupID) assert.NoError(t, err) assert.Len(t, roles, 2) - assert.Equal(t, "Tenant One", roles[0].TenantName) - assert.Equal(t, "manage", roles[0].Relation) - assert.Equal(t, "Tenant Two", roles[1].TenantName) - assert.Equal(t, "view", roles[1].Relation) - - mockKeto.AssertExpectations(t) - mockTenantRepo.AssertExpectations(t) } func TestUserGroupService_Get_WithKratosFallback(t *testing.T) { mockRepo := new(MockUserGroupRepository) mockKeto := new(MockKetoServiceShared) mockUserRepo := new(MockUserRepository) + mockKratos := new(MockKratosAdminServiceShared) - svc := NewUserGroupService(mockRepo, mockUserRepo, nil, mockKeto, nil, nil) + svc := NewUserGroupService(mockRepo, mockUserRepo, nil, mockKeto, nil, mockKratos) groupID := "group-1" mockRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, Name: "Test"}, nil) @@ -215,13 +244,18 @@ func TestUserGroupService_Get_WithKratosFallback(t *testing.T) { tuples := []RelationTuple{ {Object: groupID, Relation: "members", SubjectID: "User:u1"}, } - // Note: Transitioned to 'Tenant' namespace for groups mockKeto.On("ListRelations", mock.Anything, "Tenant", groupID, "members", "").Return(tuples, nil) mockUserRepo.On("FindByIDs", mock.Anything, []string{"u1"}).Return([]domain.User{}, nil) + mockKratos.On("GetIdentity", mock.Anything, "u1").Return(&KratosIdentity{ + ID: "u1", + Traits: map[string]interface{}{"name": "User One", "email": "user1@example.com"}, + }, nil) + group, err := svc.Get(context.Background(), groupID) assert.NoError(t, err) assert.NotNil(t, group) - assert.Len(t, group.Members, 0) + assert.Len(t, group.Members, 1) + assert.Equal(t, "User One", group.Members[0].Name) }