From db3871e6b1e7c2e5c7694f13816dada1861bb1f4 Mon Sep 17 00:00:00 2001 From: alphaeusmote <41258468-alphaeusmote@users.noreply.replit.com> Date: Thu, 10 Apr 2025 12:24:20 +0000 Subject: [PATCH] Add group management feature Replit-Commit-Author: Agent Replit-Commit-Session-Id: 9111ef36-26c8-4085-84ca-a35dc1fec1b5 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7083d608-d6d3-4a6a-9a27-6286c5109627/6cd84756-6960-4edd-8c02-c70b65ec2a57.jpg --- client/src/App.tsx | 2 + client/src/pages/groups.tsx | 983 ++++++++++++++++++++++++++++++++++++ 2 files changed, 985 insertions(+) create mode 100644 client/src/pages/groups.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 89fbe2e..1323f55 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -12,6 +12,7 @@ import HistoryPage from "@/pages/history"; import ApiTokensPage from "@/pages/api-tokens"; import UsersRolesPage from "@/pages/users-roles"; import RolesPage from "@/pages/roles"; +import GroupsPage from "@/pages/groups"; import ProvidersPage from "@/pages/providers"; import SettingsPage from "@/pages/settings"; import OrganizationsPage from "@/pages/organizations"; @@ -35,6 +36,7 @@ function Router() { + diff --git a/client/src/pages/groups.tsx b/client/src/pages/groups.tsx new file mode 100644 index 0000000..dbc27b0 --- /dev/null +++ b/client/src/pages/groups.tsx @@ -0,0 +1,983 @@ +import { useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/hooks/use-auth"; +import { MainLayout } from "@/components/layouts/main-layout"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Group, InsertGroup, User, Organization, MemberType, memberTypes } from "@shared/schema"; +import { format, formatDistanceToNow } from "date-fns"; + +// UI Components +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Pagination } from "@/components/shared/pagination"; + +// Icons +import { + Loader2, + Plus, + Search, + MoreHorizontal, + Users, + Building, + Folders, + UserPlus, + UserCheck, + Trash2, + Lock, + Edit, + Save, + PlusCircle, + UsersRound, + Building2, + FolderTree +} from "lucide-react"; + +// Group form schema +const groupFormSchema = z.object({ + name: z.string().min(3, "Name must be at least 3 characters"), + description: z.string().optional(), + parentGroupId: z.string().optional(), +}); + +// Group member form schema +const groupMemberFormSchema = z.object({ + groupId: z.string(), + memberType: z.enum(["user", "organization", "group"] as [string, ...string[]]), + memberId: z.string(), +}); + +type GroupFormValues = z.infer; +type GroupMemberFormValues = z.infer; + +export default function GroupsPage() { + const { toast } = useToast(); + const { user } = useAuth(); + const [isAddGroupOpen, setIsAddGroupOpen] = useState(false); + const [isEditGroupOpen, setIsEditGroupOpen] = useState(false); + const [isDeleteGroupOpen, setIsDeleteGroupOpen] = useState(false); + const [isAddMemberOpen, setIsAddMemberOpen] = useState(false); + const [selectedGroup, setSelectedGroup] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [selectedMemberId, setSelectedMemberId] = useState(null); + const [selectedMemberType, setSelectedMemberType] = useState(null); + const pageSize = 10; + + const isAdmin = user?.role === "admin" || user?.role === "manager"; + + // Fetch groups + const { data: groups = [], isLoading: isLoadingGroups } = useQuery({ + queryKey: ["/api/groups"], + enabled: isAdmin, + }); + + // Fetch users for member selection + const { data: users = [] } = useQuery({ + queryKey: ["/api/users"], + enabled: isAdmin && isAddMemberOpen, + }); + + // Fetch organizations for member selection + const { data: organizations = [] } = useQuery({ + queryKey: ["/api/organizations"], + enabled: isAdmin && isAddMemberOpen, + }); + + // Fetch group members when a group is selected + const { data: groupMembers = [], isLoading: isLoadingMembers } = useQuery({ + queryKey: ["/api/groups", selectedGroup?.id, "members"], + enabled: !!selectedGroup, + }); + + // Fetch parent groups (all groups except the selected one) + const availableParentGroups = selectedGroup + ? groups.filter(g => g.id !== selectedGroup.id) + : groups; + + // Setup forms + const addGroupForm = useForm({ + resolver: zodResolver(groupFormSchema), + defaultValues: { + name: "", + description: "", + parentGroupId: undefined, + }, + }); + + const editGroupForm = useForm({ + resolver: zodResolver(groupFormSchema), + defaultValues: { + name: "", + description: "", + parentGroupId: undefined, + }, + }); + + const addMemberForm = useForm({ + resolver: zodResolver(groupMemberFormSchema), + defaultValues: { + groupId: "", + memberType: "user", + memberId: "", + }, + }); + + // Mutations + const createGroupMutation = useMutation({ + mutationFn: async (data: GroupFormValues) => { + const res = await apiRequest("POST", "/api/groups", { + ...data, + isActive: true, + createdBy: user?.id, + }); + return await res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/groups"] }); + setIsAddGroupOpen(false); + addGroupForm.reset(); + toast({ + title: "Group created", + description: "The group has been created successfully.", + }); + }, + onError: (error: Error) => { + toast({ + title: "Error creating group", + description: error.message, + variant: "destructive", + }); + }, + }); + + const updateGroupMutation = useMutation({ + mutationFn: async ({ id, data }: { id: string, data: GroupFormValues }) => { + const res = await apiRequest("PUT", `/api/groups/${id}`, data); + return await res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/groups"] }); + setIsEditGroupOpen(false); + setSelectedGroup(null); + toast({ + title: "Group updated", + description: "The group has been updated successfully.", + }); + }, + onError: (error: Error) => { + toast({ + title: "Error updating group", + description: error.message, + variant: "destructive", + }); + }, + }); + + const deleteGroupMutation = useMutation({ + mutationFn: async (id: string) => { + await apiRequest("DELETE", `/api/groups/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/groups"] }); + setIsDeleteGroupOpen(false); + setSelectedGroup(null); + toast({ + title: "Group deleted", + description: "The group has been deleted successfully.", + }); + }, + onError: (error: Error) => { + toast({ + title: "Error deleting group", + description: error.message, + variant: "destructive", + }); + }, + }); + + const addGroupMemberMutation = useMutation({ + mutationFn: async (data: GroupMemberFormValues) => { + const res = await apiRequest("POST", "/api/group-members", { + ...data, + addedBy: user?.id, + }); + return await res.json(); + }, + onSuccess: () => { + if (selectedGroup) { + queryClient.invalidateQueries({ queryKey: ["/api/groups", selectedGroup.id, "members"] }); + } + setIsAddMemberOpen(false); + addMemberForm.reset(); + toast({ + title: "Member added", + description: "The member has been added to the group successfully.", + }); + }, + onError: (error: Error) => { + toast({ + title: "Error adding member", + description: error.message, + variant: "destructive", + }); + }, + }); + + const removeGroupMemberMutation = useMutation({ + mutationFn: async ({ groupId, memberId }: { groupId: string, memberId: string }) => { + await apiRequest("DELETE", `/api/group-members/${memberId}`); + }, + onSuccess: () => { + if (selectedGroup) { + queryClient.invalidateQueries({ queryKey: ["/api/groups", selectedGroup.id, "members"] }); + } + setSelectedMemberId(null); + toast({ + title: "Member removed", + description: "The member has been removed from the group successfully.", + }); + }, + onError: (error: Error) => { + toast({ + title: "Error removing member", + description: error.message, + variant: "destructive", + }); + }, + }); + + // Form handlers + const onCreateGroupSubmit = (data: GroupFormValues) => { + createGroupMutation.mutate(data); + }; + + const onEditGroupSubmit = (data: GroupFormValues) => { + if (selectedGroup) { + updateGroupMutation.mutate({ id: selectedGroup.id, data }); + } + }; + + const onAddMemberSubmit = (data: GroupMemberFormValues) => { + addGroupMemberMutation.mutate(data); + }; + + // Action handlers + const handleEditGroup = (group: Group) => { + setSelectedGroup(group); + editGroupForm.reset({ + name: group.name, + description: group.description || "", + parentGroupId: group.parentGroupId || undefined, + }); + setIsEditGroupOpen(true); + }; + + const handleDeleteGroup = (group: Group) => { + setSelectedGroup(group); + setIsDeleteGroupOpen(true); + }; + + const handleAddMember = (group: Group) => { + setSelectedGroup(group); + addMemberForm.reset({ + groupId: group.id, + memberType: "user", + memberId: "", + }); + setIsAddMemberOpen(true); + }; + + const handleRemoveMember = (memberId: string, memberType: MemberType) => { + if (selectedGroup) { + setSelectedMemberId(memberId); + setSelectedMemberType(memberType); + // Confirm before removing + if (confirm("Are you sure you want to remove this member from the group?")) { + removeGroupMemberMutation.mutate({ groupId: selectedGroup.id, memberId }); + } + } + }; + + const handleViewGroupDetails = (group: Group) => { + setSelectedGroup(group); + }; + + // Display member details + const getMemberDetails = (memberId: string, memberType: MemberType) => { + switch (memberType) { + case "user": + const userMember = users.find(u => u.id === memberId); + return userMember ? { name: userMember.username, email: userMember.email } : { name: "Unknown User", email: "" }; + case "organization": + const orgMember = organizations.find(o => o.id === memberId); + return orgMember ? { name: orgMember.name, email: "" } : { name: "Unknown Organization", email: "" }; + case "group": + const groupMember = groups.find(g => g.id === memberId); + return groupMember ? { name: groupMember.name, email: "" } : { name: "Unknown Group", email: "" }; + default: + return { name: "Unknown Member", email: "" }; + } + }; + + // Get icon for member type + const getMemberTypeIcon = (memberType: MemberType) => { + switch (memberType) { + case "user": + return ; + case "organization": + return ; + case "group": + return ; + default: + return null; + } + }; + + // Pagination + const paginatedGroups = groups.slice((currentPage - 1) * pageSize, currentPage * pageSize); + const totalPages = Math.ceil(groups.length / pageSize); + + // Access control check + if (!isAdmin) { + return ( + + + + +

Access Restricted

+

+ You don't have permission to access this page. Only administrators and managers can manage groups. +

+
+
+
+ ); + } + + return ( + +
+
+

Groups Management

+

+ Create and manage groups with hierarchical structure and add members from users, organizations, and other groups. +

+
+ +
+ +
+ + + Groups + Manage your groups and their hierarchical structure + + + {isLoadingGroups ? ( +
+ {Array(3).fill(0).map((_, i) => ( + + ))} +
+ ) : groups.length === 0 ? ( +
+ +

No Groups

+

+ Get started by creating your first group. +

+ +
+ ) : ( + <> +
+ + + + Name + Parent Group + Members + Created + Actions + + + + {paginatedGroups.map((group) => ( + + {group.name} + + {group.parentGroupId ? + groups.find(g => g.id === group.parentGroupId)?.name || "Unknown" : + "None"} + + + + + + {formatDistanceToNow(new Date(group.createdAt), { addSuffix: true })} + + + + + + + + handleEditGroup(group)}> + + Edit + + handleAddMember(group)}> + + Add Member + + handleDeleteGroup(group)} + className="text-destructive focus:text-destructive" + > + + Delete + + + + + + ))} + +
+
+ + {groups.length > pageSize && ( +
+ +
+ )} + + )} +
+
+ + + + + {selectedGroup ? `Group Details: ${selectedGroup.name}` : "Group Details"} + + + {selectedGroup + ? `View members and details for ${selectedGroup.name}` + : "Select a group to view its details and members"} + + + + {!selectedGroup ? ( +
+ +

No Group Selected

+

+ Select a group to view its details and members. +

+
+ ) : ( + <> +
+
+

Group Information

+
+
Name:
+
{selectedGroup.name}
+ + {selectedGroup.description && ( + <> +
Description:
+
{selectedGroup.description}
+ + )} + +
Parent Group:
+
+ {selectedGroup.parentGroupId ? + groups.find(g => g.id === selectedGroup.parentGroupId)?.name || "Unknown" : + "None"} +
+ +
Created:
+
+ {format(new Date(selectedGroup.createdAt), "PPP")} +
+
+ + + +
+

Members

+ +
+ + {isLoadingMembers ? ( +
+ {Array(3).fill(0).map((_, i) => ( + + ))} +
+ ) : !groupMembers || groupMembers.length === 0 ? ( +
+

+ This group has no members yet. +

+
+ ) : ( +
+ {groupMembers.map((member) => { + const memberDetails = getMemberDetails(member.memberId, member.memberType as MemberType); + return ( +
+
+ {getMemberTypeIcon(member.memberType as MemberType)} +
+
{memberDetails.name}
+ {memberDetails.email && ( +
{memberDetails.email}
+ )} + + {member.memberType.charAt(0).toUpperCase() + member.memberType.slice(1)} + +
+
+ +
+ ); + })} +
+ )} +
+
+ + )} +
+
+
+ + {/* Create Group Dialog */} + + + + Create Group + + Create a new group to organize users, organizations, or other groups. + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Description + +