diff --git a/backend/src/database/sql.go b/backend/src/database/sql.go index da5e59db..f041a605 100644 --- a/backend/src/database/sql.go +++ b/backend/src/database/sql.go @@ -39,7 +39,8 @@ var ( INNER JOIN public."membership" m ON o.org_id = m.org_id WHERE user_id = $1 - AND o.deleted = false` + AND o.deleted = false + AND m.deleted = false` SelectOrganizationByOrgIdAndOwnerStatement = `SELECT * FROM public."organization" WHERE org_id = $1 diff --git a/backend/src/server/handlers.go b/backend/src/server/handlers.go index ba694a3b..a3b09501 100644 --- a/backend/src/server/handlers.go +++ b/backend/src/server/handlers.go @@ -328,7 +328,7 @@ func handleCancelServiceRequest(logger logger.ServerLogger, client *mongo.Client // Log step cancelled event serviceRequestEvent := database.NewServiceRequestEvent(psqlClient) err = serviceRequestEvent.Create(&models.ServiceRequestEventModel{ - EventType: models.STEP_COMPLETED, + EventType: models.STEP_CANCELLED, ServiceRequestId: sr.Id.Hex(), StepName: sre.StepName, CreatedBy: userId, diff --git a/backend/src/server/server.go b/backend/src/server/server.go index e6aef4e9..9dd04186 100644 --- a/backend/src/server/server.go +++ b/backend/src/server/server.go @@ -24,7 +24,7 @@ func New(c *ServerConfig) http.Server { return http.Server{ Addr: c.Address, Handler: handlers.CORS( - handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE"}), + handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE"}), handlers.AllowedOrigins([]string{"http://localhost:3000"}), handlers.AllowedHeaders([]string{ "Content-Type", diff --git a/flowforge_api_bruno/membership/update membership.bru b/flowforge_api_bruno/membership/update membership.bru index cdd31a07..4a26c238 100644 --- a/flowforge_api_bruno/membership/update membership.bru +++ b/flowforge_api_bruno/membership/update membership.bru @@ -1,19 +1,19 @@ -meta { - name: update membership - type: http - seq: 3 -} - -patch { - url: {{HOST}}/membership - body: json - auth: inherit -} - -body:json { - { - "user_id": "auth0|345678", - "org_id": 4, - "role": "Owner" - } -} +meta { + name: update membership + type: http + seq: 3 +} + +patch { + url: {{HOST}}/membership + body: json + auth: inherit +} + +body:json { + { + "user_id": "auth0|345678", + "org_id": 4, + "role": "Owner" + } +} diff --git a/flowforge_api_bruno/organization/leave organization.bru b/flowforge_api_bruno/organization/leave organization.bru new file mode 100644 index 00000000..73cabb15 --- /dev/null +++ b/flowforge_api_bruno/organization/leave organization.bru @@ -0,0 +1,15 @@ +meta { + name: leave organization + type: http + seq: 6 +} + +delete { + url: {{HOST}}/organization/{{org_id}}/membership + body: none + auth: none +} + +vars:pre-request { + org_id: +} diff --git a/frontend/next.config.js b/frontend/next.config.js index 23be421f..69b7c602 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -8,6 +8,11 @@ const nextConfig = { destination: "/service-catalog", permanent: true, }, + { + source: "/settings", + destination: "/settings/organization", + permanent: true, + }, ] }, } diff --git a/frontend/src/app/(authenticated)/(main)/(admin)/admin-service-requests-dashboard/columns.tsx b/frontend/src/app/(authenticated)/(main)/(admin)/admin-service-requests-dashboard/columns.tsx index 12c012a8..bf20cd75 100644 --- a/frontend/src/app/(authenticated)/(main)/(admin)/admin-service-requests-dashboard/columns.tsx +++ b/frontend/src/app/(authenticated)/(main)/(admin)/admin-service-requests-dashboard/columns.tsx @@ -91,6 +91,7 @@ export const orgServiceRequestColumns: ColumnDef[] = [ title: "Approve Service Request Successful", description: "Please check the dashboard for the updated status of the Service Request.", + variant: "success", }) }) .catch((error) => { @@ -110,6 +111,7 @@ export const orgServiceRequestColumns: ColumnDef[] = [ title: "Reject Service Request Successful", description: "Please check the dashboard for the updated status of the Service Request.", + variant: "success", }) }) .catch((error) => { diff --git a/frontend/src/app/(authenticated)/(main)/(non-admin)/service-catalog/[pipelineId]/_hooks/use-service-request-form.tsx b/frontend/src/app/(authenticated)/(main)/(non-admin)/service-catalog/[pipelineId]/_hooks/use-service-request-form.tsx index 72568bbe..14e64dd6 100644 --- a/frontend/src/app/(authenticated)/(main)/(non-admin)/service-catalog/[pipelineId]/_hooks/use-service-request-form.tsx +++ b/frontend/src/app/(authenticated)/(main)/(non-admin)/service-catalog/[pipelineId]/_hooks/use-service-request-form.tsx @@ -53,7 +53,7 @@ const useServiceRequestForm = ({ router.push("/your-service-request-dashboard") }) .catch((err) => { - console.log(err) + console.error(err) toast({ title: "Request Submission Error", description: "Failed to submit the service request.", diff --git a/frontend/src/app/(authenticated)/(main)/(non-admin)/service-catalog/[pipelineId]/_hooks/use-service-request.ts b/frontend/src/app/(authenticated)/(main)/(non-admin)/service-catalog/[pipelineId]/_hooks/use-service-request.ts index 3a7b624b..5e718523 100644 --- a/frontend/src/app/(authenticated)/(main)/(non-admin)/service-catalog/[pipelineId]/_hooks/use-service-request.ts +++ b/frontend/src/app/(authenticated)/(main)/(non-admin)/service-catalog/[pipelineId]/_hooks/use-service-request.ts @@ -4,7 +4,6 @@ import usePipeline from "@/hooks/use-pipeline" import { createServiceRequest } from "@/lib/service" import { generateUiSchema } from "@/lib/rjsf-utils" import { convertServiceRequestFormToRJSFSchema } from "@/lib/rjsf-utils" -import { FormFieldType, JsonFormComponents } from "@/types/json-form-components" import { IChangeEvent } from "@rjsf/core" import { RJSFSchema } from "@rjsf/utils" import { useMemo, useState } from "react" @@ -35,17 +34,16 @@ const useServiceRequestForm = ({ return } createServiceRequest(organizationId, pipelineId, formData, service?.version) - .then((data) => { + .then(() => { toast({ title: "Request Submission Successful", description: "Please check the dashboard for the status of the request.", variant: "success", }) - console.log("Response: ", data) }) .catch((err) => { - console.log(err) + console.error(err) toast({ title: "Request Submission Error", description: "Failed to submit the service request.", diff --git a/frontend/src/app/(authenticated)/(main)/(non-admin)/service-catalog/_hooks/use-services.ts b/frontend/src/app/(authenticated)/(main)/(non-admin)/service-catalog/_hooks/use-services.ts index 1de10e65..8f824661 100644 --- a/frontend/src/app/(authenticated)/(main)/(non-admin)/service-catalog/_hooks/use-services.ts +++ b/frontend/src/app/(authenticated)/(main)/(non-admin)/service-catalog/_hooks/use-services.ts @@ -10,7 +10,7 @@ const useServices = () => { queryKey: ["pipelines"], queryFn: () => getAllPipeline(organizationId).catch((err) => { - console.log(err) + console.error(err) toast({ title: "Fetching Services Error", description: "Failed to fetch the services. Please try again later.", diff --git a/frontend/src/app/(authenticated)/(main)/(non-admin)/your-service-request-dashboard/columns.tsx b/frontend/src/app/(authenticated)/(main)/(non-admin)/your-service-request-dashboard/columns.tsx index 13f8e17e..cfc8747a 100644 --- a/frontend/src/app/(authenticated)/(main)/(non-admin)/your-service-request-dashboard/columns.tsx +++ b/frontend/src/app/(authenticated)/(main)/(non-admin)/your-service-request-dashboard/columns.tsx @@ -77,9 +77,10 @@ export const columns: ColumnDef[] = [ cancelServiceRequest(serviceRequestId) .then(() => { toast({ - title: "Service Request Cancel Successful", + title: "Cancel Service Request Successful", description: "Please check the dashboard for the updated status of the Service Request.", + variant: "success", }) }) .catch((error) => { @@ -96,9 +97,10 @@ export const columns: ColumnDef[] = [ startServiceRequest(serviceRequestId) .then(() => { toast({ - title: "Service Request Start Successful", + title: "Start Service Request Successful", description: "Please check the dashboard for the updated status of the Service Request.", + variant: "success", }) }) .catch((error) => { diff --git a/frontend/src/app/(authenticated)/(main)/settings/_components/settings-sidebar.tsx b/frontend/src/app/(authenticated)/(main)/settings/_components/settings-sidebar.tsx new file mode 100644 index 00000000..6637fefe --- /dev/null +++ b/frontend/src/app/(authenticated)/(main)/settings/_components/settings-sidebar.tsx @@ -0,0 +1,38 @@ +"use client" + +import { cn } from "@/lib/utils" +import Link from "next/link" +import { usePathname } from "next/navigation" + +interface SettingsSidebarProps { + className?: string +} + +const linkBaseStyle = "transition-colors w-full flex pl-2 py-1" +const linkInactiveStyle = `${linkBaseStyle} hover:rounded-md hover:bg-muted hover:text-muted-foreground` +const linkActiveStyle = `${linkInactiveStyle} rounded-md bg-gray-50` + +const links = [{ name: "Organization", href: "/settings/organization" }] + +export default function SettingsSidebar({ className }: SettingsSidebarProps) { + const pathname = usePathname() + + return ( +
+

Settings

+ +
+ ) +} diff --git a/frontend/src/app/(authenticated)/(main)/settings/layout.tsx b/frontend/src/app/(authenticated)/(main)/settings/layout.tsx new file mode 100644 index 00000000..fe8f0f3f --- /dev/null +++ b/frontend/src/app/(authenticated)/(main)/settings/layout.tsx @@ -0,0 +1,21 @@ +"use client" + +import MainNavigationLayout from "@/components/layouts/main-navigation-layout" +import SettingsSidebar from "./_components/settings-sidebar" + +export default function SettingsLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + +
+
+ +
{children}
+
+
+
+ ) +} diff --git a/frontend/src/app/(authenticated)/(main)/settings/organization/_components/add-member-dialog.tsx b/frontend/src/app/(authenticated)/(main)/settings/organization/_components/add-member-dialog.tsx new file mode 100644 index 00000000..6e445762 --- /dev/null +++ b/frontend/src/app/(authenticated)/(main)/settings/organization/_components/add-member-dialog.tsx @@ -0,0 +1,142 @@ +import { Button, ButtonWithSpinner } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Search, X } from "lucide-react" +import useAddMembers from "../_hooks/use-add-members" +import { UserInfo } from "@/types/user-profile" +import { useState } from "react" +import useDebounce from "@/hooks/use-debounce" +import { Role } from "@/types/membership" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" + +interface AddMemberDialogProps { + children: React.ReactNode + existingMembers: UserInfo[] + organizationId: number + refetchMembers: () => void +} + +export default function AddMemberDialog({ + children, + existingMembers, + organizationId, + refetchMembers, +}: AddMemberDialogProps) { + const [searchFilter, setSearchFilter] = useState("") + // Delay filter execution by 0.5s at each filter change + const { debouncedValue: debouncedFilter } = useDebounce(searchFilter, 500) + const { + allUsers, + selectedMember, + setSelectedMember, + handleAddMember, + isAddingMember, + } = useAddMembers({ + existingMembers, + filter: debouncedFilter, + organizationId, + refetchMembers, + }) + + return ( + + {children} + + + Add member to the organization + + + {selectedMember ? ( + <> +
+

{selectedMember.name}

+ setSelectedMember(undefined)} + /> +
+

+ Select a role for {selectedMember.name} +

+ + + + ) : ( + <> +
+ + setSearchFilter(e.target.value)} + /> +
+
+
+
    + {allUsers?.map((user) => ( +
  • { + // Set default role as Member + setSelectedMember({ ...user, role: Role.Member }) + setSearchFilter("") + }} + > +

    {user.name}

    +
  • + ))} +
+
+
+ + )} + + handleAddMember()} + isLoading={isAddingMember} + > + {!selectedMember + ? "Select a user" + : `Add ${selectedMember.name} to organization`} + + +
+
+ ) +} diff --git a/frontend/src/app/(authenticated)/(main)/settings/organization/_components/change-org-name-form.tsx b/frontend/src/app/(authenticated)/(main)/settings/organization/_components/change-org-name-form.tsx new file mode 100644 index 00000000..ab22fd21 --- /dev/null +++ b/frontend/src/app/(authenticated)/(main)/settings/organization/_components/change-org-name-form.tsx @@ -0,0 +1,60 @@ +"use client" + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import useUpdateOrgNameForm from "../_hooks/use-update-org-name-form" +import { Input } from "@/components/ui/input" +import { useUserMemberships } from "@/contexts/user-memberships-context" +import { ButtonWithSpinner } from "@/components/ui/button" + +interface ChangeOrgNameFormProps { + organizationId: number + updateOrgNameInCookie: (name: string) => void +} + +export default function ChangeOrgNameForm({ + organizationId, + updateOrgNameInCookie, +}: ChangeOrgNameFormProps) { + const { updateOrgNameLoading, form, onFormSubmit } = useUpdateOrgNameForm({ + organizationId, + updateOrgNameInCookie, + }) + + const { isOwner } = useUserMemberships() + return ( +
+ +

Change Organization Name

+ ( + + + Only the owner of the organization can change the name. + + + + + + + )} + /> + + Change + + + + ) +} diff --git a/frontend/src/app/(authenticated)/(main)/settings/organization/_components/leave-organization-dialog.tsx b/frontend/src/app/(authenticated)/(main)/settings/organization/_components/leave-organization-dialog.tsx new file mode 100644 index 00000000..66b9799d --- /dev/null +++ b/frontend/src/app/(authenticated)/(main)/settings/organization/_components/leave-organization-dialog.tsx @@ -0,0 +1,60 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { buttonVariants } from "@/components/ui/button" +import { Loader2 } from "lucide-react" +import { useState } from "react" + +interface LeaveOrganizationDialogProps { + children: React.ReactNode + onConfirm: () => Promise + isOwner?: boolean +} + +export default function LeaveOrganizationDialog({ + children, + onConfirm, + isOwner, +}: LeaveOrganizationDialogProps) { + const [isLoading, setIsLoading] = useState(false) + return ( + + {children} + + + + Are you sure you want to leave the organization? + + + Once you leave, you will lose access to all features available + within the organization. If you are the Owner, + ensure that you have transferred your ownership to another user + before you can leave the organization. + + + + Cancel + { + setIsLoading(true) + onConfirm().finally(() => setIsLoading(false)) + }} + className={buttonVariants({ variant: "destructive" })} + disabled={isOwner || isLoading} + > + {isLoading && } + Leave + + + + + ) +} diff --git a/frontend/src/app/(authenticated)/(main)/settings/organization/_components/member-action-alert-dialog.tsx b/frontend/src/app/(authenticated)/(main)/settings/organization/_components/member-action-alert-dialog.tsx new file mode 100644 index 00000000..277ce0f6 --- /dev/null +++ b/frontend/src/app/(authenticated)/(main)/settings/organization/_components/member-action-alert-dialog.tsx @@ -0,0 +1,57 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { Loader2 } from "lucide-react" +import { useState } from "react" + +interface MemberActionAlertDialogProps { + open: boolean + setOpen: React.Dispatch> + onConfirm: () => Promise + title: string +} + +export default function MemberActionAlertDialog({ + open, + setOpen, + onConfirm, + title, +}: MemberActionAlertDialogProps) { + const [isLoading, setIsLoading] = useState(false) + + return ( + + + + {title} + + This is a permanent action and cannot be undone. + + + + Cancel + { + setIsLoading(true) + onConfirm().finally(() => setIsLoading(false)) + }} + disabled={isLoading} + > + {" "} + {isLoading && } + Confirm + + + + + ) +} diff --git a/frontend/src/app/(authenticated)/(main)/settings/organization/_components/member-actions.tsx b/frontend/src/app/(authenticated)/(main)/settings/organization/_components/member-actions.tsx new file mode 100644 index 00000000..932ee26c --- /dev/null +++ b/frontend/src/app/(authenticated)/(main)/settings/organization/_components/member-actions.tsx @@ -0,0 +1,198 @@ +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import MemberActionAlertDialog from "./member-action-alert-dialog" +import { UserInfo } from "@/types/user-profile" +import { useState } from "react" +import { Role } from "@/types/membership" +import { + demoteToMember, + promoteToAdmin, + removeMember, + transferOwnership, +} from "@/lib/service" +import { toast } from "@/components/ui/use-toast" + +interface MemberActionsProps { + children: React.ReactNode + member: UserInfo + isOwner?: boolean + organizationId: number + refetchMembers: () => void + refetchMemberships: () => void +} + +export default function MemberActions({ + children, + member, + isOwner = false, + organizationId, + refetchMembers, + refetchMemberships, +}: MemberActionsProps) { + const [openPromoteToAdminDialog, setOpenPromoteToAdminDialog] = + useState(false) + + const [openDemoteToMemberDialog, setOpenDemoteToMemberDialog] = + useState(false) + + const [openRemoveFromOrgDialog, setOpenRemoveFromOrgDialog] = useState(false) + const [openTransferOwnershipDialog, setOpenTransferOwnershipDialog] = + useState(false) + + const isTargetMember = member.role === Role.Member + const isTargetAdmin = member.role === Role.Admin + + return ( + + {children} + + {isTargetMember && ( + + + + )} + {isTargetAdmin && ( + + + + )} + {(isTargetMember || isTargetAdmin) && ( + + + + )} + {isOwner && isTargetAdmin && ( + + + + )} + + { + await promoteToAdmin(member.user_id, organizationId) + .then(() => { + toast({ + title: "Promote Successful", + description: `${member.name} has been promoted to Admin.`, + variant: "success", + }) + refetchMembers() + }) + .catch((err) => { + toast({ + title: "Promote Failure", + description: `Error promoting ${member.name} to Admin. Please try again later.`, + variant: "destructive", + }) + console.error(err) + }) + return + }} + title={`Promote ${member.name} to Admin?`} + /> + { + await demoteToMember(member.user_id, organizationId) + .then(() => { + toast({ + title: "Demote Successful", + description: `${member.name} has been demoted to Member.`, + variant: "success", + }) + refetchMembers() + }) + .catch((err) => { + toast({ + title: "Demote Failure", + description: `Error demoting ${member.name} to Member. Please try again later.`, + variant: "destructive", + }) + console.error(err) + }) + return + }} + title={`Demote ${member.name} to Member?`} + /> + { + await removeMember(member.user_id, organizationId, member.role!) + .then(() => { + toast({ + title: "Remove Member Successful", + description: `${member.name} has been removed from the organization.`, + variant: "success", + }) + refetchMembers() + }) + .catch((err) => { + toast({ + title: "Remove Member Failure", + description: `Error removing ${member.name} from the organization. Please try again later.`, + variant: "destructive", + }) + console.error(err) + }) + return + }} + title={`Remove ${member.name} from organization?`} + /> + { + await transferOwnership(member.user_id, organizationId) + .then(() => { + toast({ + title: "Transfer Ownership Successful", + description: `${member.name} has been promoted to Owner and you have been demoted to Admin.`, + variant: "success", + }) + refetchMembers() + refetchMemberships() + }) + .catch((err) => { + toast({ + title: "Transfer Ownership Failure", + description: `Error transferring ownership. Please try again later.`, + variant: "destructive", + }) + console.error(err) + }) + return + }} + title={`Transfer ownership of organization to ${member.name}?`} + /> + + ) +} diff --git a/frontend/src/app/(authenticated)/(main)/settings/organization/_components/membership-section.tsx b/frontend/src/app/(authenticated)/(main)/settings/organization/_components/membership-section.tsx new file mode 100644 index 00000000..bfc968ed --- /dev/null +++ b/frontend/src/app/(authenticated)/(main)/settings/organization/_components/membership-section.tsx @@ -0,0 +1,122 @@ +import useMemberships from "../_hooks/use-memberships" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { useState } from "react" +import useDebounce from "@/hooks/use-debounce" +import AddMemberDialog from "./add-member-dialog" +import { useCurrentUserInfo } from "@/contexts/current-user-info-context" +import { MoreHorizontal, User } from "lucide-react" +import MemberActions from "./member-actions" +import { useUserMemberships } from "@/contexts/user-memberships-context" +import { Role } from "@/types/membership" +import { cn } from "@/lib/utils" +import LeaveOrganizationDialog from "./leave-organization-dialog" +import { leaveOrganization } from "@/lib/service" +import { toast } from "@/components/ui/use-toast" +import { useRouter } from "next/navigation" + +interface MembershipSectionProps { + organizationId: number +} + +export default function MembershipSection({ + organizationId, +}: MembershipSectionProps) { + const [searchFilter, setSearchFilter] = useState("") + + // Delay filter execution by 0.5s at each filter change + const { debouncedValue: debouncedFilter } = useDebounce(searchFilter, 500) + const { members, refetchMembers } = useMemberships({ + orgId: organizationId, + filter: debouncedFilter, + }) + + const userInfo = useCurrentUserInfo() + const { isOwner, isAdmin, refetchMemberships } = useUserMemberships() + + const router = useRouter() + + return ( +
+
+

Members

+
+
+ setSearchFilter(e.target.value)} + /> +
+ + + + { + await leaveOrganization(organizationId) + .then(() => { + router.push("/organization") + }) + .catch((e) => { + toast({ + title: "Leave Organization Error", + description: + "Unable to leave the organization. Please try again later.", + variant: "destructive", + }) + console.error(e) + }) + return + }} + isOwner={isOwner} + > + + +
+
+
+
    + {members.map((member) => ( +
  • +
    + +

    {member.name}

    + {member.user_id === userInfo?.user_id && ( + + )} +
    + +

    {member.role}

    +
    + + + +
  • + ))} +
+
+
+ ) +} diff --git a/frontend/src/app/(authenticated)/(main)/settings/organization/_hooks/use-add-members.tsx b/frontend/src/app/(authenticated)/(main)/settings/organization/_hooks/use-add-members.tsx new file mode 100644 index 00000000..ef3ef53a --- /dev/null +++ b/frontend/src/app/(authenticated)/(main)/settings/organization/_hooks/use-add-members.tsx @@ -0,0 +1,78 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/use-toast" +import { createMembershipForOrg, getAllUsers } from "@/lib/service" +import { UserInfo } from "@/types/user-profile" +import { useEffect, useMemo, useState } from "react" + +interface UseAddMembersOptions { + existingMembers: UserInfo[] + filter: string + organizationId: number + refetchMembers: () => void +} + +export default function useAddMembers({ + existingMembers, + filter, + organizationId, + refetchMembers, +}: UseAddMembersOptions) { + const [allUsers, setAllUsers] = useState() + + const [selectedMember, setSelectedMember] = useState() + + const [isAddingMember, setIsAddingMember] = useState(false) + + useEffect(() => { + getAllUsers() + .then((users) => setAllUsers(users)) + .catch((err) => console.error(err)) + }, []) + + const allUsersOutsideOrg = useMemo(() => { + const existingMemberIdSet = new Set(existingMembers.map((u) => u.user_id)) + return allUsers?.filter((user) => !existingMemberIdSet.has(user.user_id)) + }, [allUsers, existingMembers]) + + const filteredMembers = useMemo(() => { + return allUsersOutsideOrg?.filter((member) => member.name.includes(filter)) + }, [allUsersOutsideOrg, filter]) + + const handleAddMember = () => { + if (!selectedMember || !selectedMember.role) { + return + } + setIsAddingMember(true) + createMembershipForOrg( + selectedMember.user_id, + organizationId, + selectedMember.role + ) + .then(() => { + toast({ + title: "Add Member Successful", + description: `${selectedMember.name} has been added to the organization.`, + variant: "success", + }) + refetchMembers() + setSelectedMember(undefined) + }) + .catch((err) => { + toast({ + title: "Add Member Failure", + description: `Error adding ${selectedMember.name} to the organization.`, + variant: "destructive", + }) + console.error(err) + }) + .finally(() => setIsAddingMember(false)) + } + + return { + allUsers: filteredMembers, + selectedMember, + setSelectedMember, + handleAddMember, + isAddingMember, + } +} diff --git a/frontend/src/app/(authenticated)/(main)/settings/organization/_hooks/use-memberships.tsx b/frontend/src/app/(authenticated)/(main)/settings/organization/_hooks/use-memberships.tsx new file mode 100644 index 00000000..317bb75a --- /dev/null +++ b/frontend/src/app/(authenticated)/(main)/settings/organization/_hooks/use-memberships.tsx @@ -0,0 +1,36 @@ +import { getMembersForOrg } from "@/lib/service" +import { UserInfo } from "@/types/user-profile" +import { useEffect, useMemo, useState } from "react" + +interface UseMembershipOptions { + orgId: number + filter: string +} + +export default function useMemberships({ + orgId, + filter, +}: UseMembershipOptions) { + const [members, setMembers] = useState([]) + const [isLoadingMembers, setIsLoadingMembers] = useState(false) + + const filteredMembers = useMemo(() => { + return members.filter((member) => member.name.includes(filter)) + }, [members, filter]) + + useEffect(() => { + setIsLoadingMembers(true) + getMembersForOrg(orgId) + .then((res) => setMembers(res.members)) + .catch((err) => console.error(err)) + .finally(() => setIsLoadingMembers(false)) + }, [orgId]) + + const refetchMembers = () => { + getMembersForOrg(orgId) + .then((res) => setMembers(res.members)) + .catch((err) => console.error(err)) + } + + return { members: filteredMembers, isLoadingMembers, refetchMembers } +} diff --git a/frontend/src/app/(authenticated)/(main)/settings/organization/_hooks/use-update-org-name-form.tsx b/frontend/src/app/(authenticated)/(main)/settings/organization/_hooks/use-update-org-name-form.tsx new file mode 100644 index 00000000..484d60bc --- /dev/null +++ b/frontend/src/app/(authenticated)/(main)/settings/organization/_hooks/use-update-org-name-form.tsx @@ -0,0 +1,73 @@ +import { toast } from "@/components/ui/use-toast" +import useOrganization from "@/hooks/use-organization" +import { updateOrgName } from "@/lib/service" +import { zodResolver } from "@hookform/resolvers/zod" +import { setCookie } from "cookies-next" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +export const changeOrgNameFormSchema = z.object({ + orgName: z + .string() + .min(1, "Organization name is required") + .max(39, "Organization name can only have a max of 39 characters."), +}) + +interface UseUpdateOrgNameFormOptions { + organizationId: number + updateOrgNameInCookie: (name: string) => void +} + +export default function useUpdateOrgNameForm({ + organizationId, + updateOrgNameInCookie, +}: UseUpdateOrgNameFormOptions) { + const [updateOrgNameLoading, setUpdateOrgNameLoading] = useState(false) + const form = useForm>({ + resolver: zodResolver(changeOrgNameFormSchema), + defaultValues: { + orgName: "", + }, + }) + + const handleUpdateOrgName = (name: string) => { + setUpdateOrgNameLoading(true) + updateOrgName(organizationId, name) + .then(() => { + toast({ + variant: "success", + title: "Organization Name Update Successful", + description: ( +

+ Your organization's name has been updated to{" "} + {name}. +

+ ), + }) + form.reset({ + orgName: "", + }) + updateOrgNameInCookie(name) + }) + .catch((err) => { + toast({ + variant: "destructive", + title: "Update Organization Name Error", + description: "Could not update organization. Please try again later.", + }) + console.error(err) + }) + .finally(() => { + setUpdateOrgNameLoading(false) + }) + } + + const onFormSubmit = ({ + orgName, + }: z.infer) => { + handleUpdateOrgName(orgName) + } + + return { updateOrgNameLoading, form, onFormSubmit } +} diff --git a/frontend/src/app/(authenticated)/(main)/settings/organization/page.tsx b/frontend/src/app/(authenticated)/(main)/settings/organization/page.tsx new file mode 100644 index 00000000..49d2181d --- /dev/null +++ b/frontend/src/app/(authenticated)/(main)/settings/organization/page.tsx @@ -0,0 +1,38 @@ +"use client" + +import { Separator } from "@/components/ui/separator" +import ChangeOrgNameForm from "./_components/change-org-name-form" +import useOrganization from "@/hooks/use-organization" +import MembershipSection from "./_components/membership-section" + +export default function OrganizationSettingsPage() { + const { organizationId, organizationName, updateOrgNameInCookie } = + useOrganization() + return ( +
+ {organizationId ? ( + <> +

+ Organization Settings for {organizationName} +

+ +
+ +
+ + + + ) : ( +
+

+ Please select an organization to continue with organization + settings. +

+
+ )} +
+ ) +} diff --git a/frontend/src/app/(authenticated)/organization/_hooks/use-create-organization-form.tsx b/frontend/src/app/(authenticated)/organization/_hooks/use-create-organization-form.tsx index a804625b..1118f611 100644 --- a/frontend/src/app/(authenticated)/organization/_hooks/use-create-organization-form.tsx +++ b/frontend/src/app/(authenticated)/organization/_hooks/use-create-organization-form.tsx @@ -42,7 +42,7 @@ export default function useCreateOrganizationForm({ title: "Organization Creation Successful", description: (

- Check the new organization {name} under{" "} + Check the new organization {name} under{" "} Your Organizations.

), diff --git a/frontend/src/app/(authenticated)/organization/page.tsx b/frontend/src/app/(authenticated)/organization/page.tsx index dbdffe1d..ab56d217 100644 --- a/frontend/src/app/(authenticated)/organization/page.tsx +++ b/frontend/src/app/(authenticated)/organization/page.tsx @@ -21,7 +21,10 @@ export default function OrganizationsPage() { const router = useRouter() return (
-

Your Organizations

+

Your Organizations

+

+ Please select an organization to access Flowforge features. +

{orgsLoading ? (
diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 0fe49fe4..0bcb6f7c 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -8,7 +8,6 @@ export default function LoginPage() { - {/* TODO: add sign up functionality */}
) } diff --git a/frontend/src/components/layouts/main-navigation-layout.tsx b/frontend/src/components/layouts/main-navigation-layout.tsx index 91e83472..295ef889 100644 --- a/frontend/src/components/layouts/main-navigation-layout.tsx +++ b/frontend/src/components/layouts/main-navigation-layout.tsx @@ -2,33 +2,25 @@ import Navbar from "@/components/layouts/navbar" import Sidebar from "@/components/layouts/sidebar" -import { getUserProfile } from "@/lib/auth0" -import { Auth0UserProfile } from "@/types/user-profile" -import { getCookie } from "cookies-next" -import { ReactNode, useEffect, useState } from "react" +import { useCurrentUserInfo } from "@/contexts/current-user-info-context" +import { ReactNode, useState } from "react" interface MainNavigationLayoutProps { children: ReactNode - name?: string + enableOrgName?: boolean } export default function MainNavigationLayout({ children, - name, + enableOrgName = true, }: MainNavigationLayoutProps) { const [isSidebarOpen, setIsSidebarOpen] = useState(true) const toggleSidebar = () => { setIsSidebarOpen((isSidebarOpen) => !isSidebarOpen) } - const [userProfile, setUserProfile] = useState() - useEffect(() => { - getUserProfile(getCookie("access_token") as string) - .then((userProfile) => setUserProfile(userProfile)) - .catch((error) => { - console.log(error) - }) - }, []) + const userInfo = useCurrentUserInfo() + return (
-
+
-
+
{children}
diff --git a/frontend/src/components/layouts/navbar.tsx b/frontend/src/components/layouts/navbar.tsx index 17aac3b2..2d988bcf 100644 --- a/frontend/src/components/layouts/navbar.tsx +++ b/frontend/src/components/layouts/navbar.tsx @@ -44,6 +44,19 @@ const UserActionsDropdown = ({ username }: UserActionsDropdownProps) => { + + + + + + + + diff --git a/frontend/src/components/layouts/sidebar.tsx b/frontend/src/components/layouts/sidebar.tsx index 36b918f8..a3f14615 100644 --- a/frontend/src/components/layouts/sidebar.tsx +++ b/frontend/src/components/layouts/sidebar.tsx @@ -20,23 +20,27 @@ type LinkType = { | null | undefined isAdminFeature?: boolean + key: string } const links: LinkType[] = [ { title: "Service Catalog", + key: "service-catalog", icon: LibraryBig, href: "/service-catalog", variant: "ghost", }, { title: "Your Service Requests Dashboard", + key: "your-service-request-dashboard", icon: Workflow, href: "/your-service-request-dashboard", variant: "ghost", }, { title: "Admin Service Request Dashboard", + key: "admin-service-request-dashboard", icon: LockKeyhole, href: "/admin-service-requests-dashboard", variant: "ghost", @@ -67,12 +71,12 @@ export default function Sidebar({ className }: SidebarProps) {
- {links.map((link, index) => + {links.map((link) => link.isAdminFeature && !isAdmin ? ( <> ) : ( () const [openAlertDialog, setOpenAlertDialog] = useState(false) const [loadingCreateUser, setLoadingCreateUser] = useState(false) - console.log(userInfo) + useEffect(() => { login() .then((res) => setUserInfo(res)) @@ -73,7 +73,7 @@ export function CurrentUserInfoContextProvider({ description: (

You are able to access Flowforge features now. Welcome{" "} - name! + {username}!

), }) diff --git a/frontend/src/contexts/user-memberships-context.tsx b/frontend/src/contexts/user-memberships-context.tsx index 272fa4c3..22bf15b9 100644 --- a/frontend/src/contexts/user-memberships-context.tsx +++ b/frontend/src/contexts/user-memberships-context.tsx @@ -5,6 +5,8 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react" interface MembershipContextValue { isAdmin?: boolean + isOwner?: boolean + refetchMemberships: () => void } const MembershipContext = createContext(null) @@ -16,13 +18,17 @@ export function UserMembershipsProvider({ }) { const [userMemberships, setUserMemberships] = useState() - useEffect(() => { + const fetchMemberships = () => { getUserMemberships() .then(setUserMemberships) .catch((err) => { console.error(err) }) + } + useEffect(() => { + fetchMemberships() }, []) + const { organizationId } = useOrganization() const isAdminOfCurrentOrg = useMemo(() => { @@ -33,10 +39,19 @@ export function UserMembershipsProvider({ ) }, [userMemberships, organizationId]) + const isOwnerOfCurrentOrg = useMemo(() => { + return userMemberships?.memberships.some( + (membership) => + membership.org_id === organizationId && membership.role === Role.Owner + ) + }, [userMemberships, organizationId]) + return ( {children} diff --git a/frontend/src/hooks/use-debounce.ts b/frontend/src/hooks/use-debounce.ts new file mode 100644 index 00000000..ad534b30 --- /dev/null +++ b/frontend/src/hooks/use-debounce.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react" + +export default function useDebounce(value: string, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + }, [value, delay]) + + return { + debouncedValue, + } +} diff --git a/frontend/src/hooks/use-organization.ts b/frontend/src/hooks/use-organization.ts index 0186378a..c77b088e 100644 --- a/frontend/src/hooks/use-organization.ts +++ b/frontend/src/hooks/use-organization.ts @@ -1,20 +1,27 @@ -import { getCookie } from "cookies-next" +import { getCookie, setCookie } from "cookies-next" import { useRouter } from "next/navigation" -import { useMemo } from "react" +import { useMemo, useState } from "react" const useOrganization = () => { + const [organizationName, setOrganizationName] = useState( + getCookie("org_name") + ) const organizationId = useMemo( () => parseInt(getCookie("org_id") as string, 10), [] ) - const organizationName = useMemo(() => getCookie("org_name"), []) + const updateOrgNameInCookie = (name: string) => { + setCookie("org_name", name) + setOrganizationName(name) + } + const router = useRouter() if (!organizationId) { router.push("/organization") } - return { organizationId, organizationName } + return { organizationId, organizationName, updateOrgNameInCookie } } export default useOrganization diff --git a/frontend/src/hooks/use-service-request-dto.ts b/frontend/src/hooks/use-service-request-dto.ts index 92e2dc26..f9dd5038 100644 --- a/frontend/src/hooks/use-service-request-dto.ts +++ b/frontend/src/hooks/use-service-request-dto.ts @@ -18,7 +18,7 @@ const useServiceRequestDTO = ({ getServiceRequestDTO(serviceRequestId) .then(setServiceRequest) .catch((err) => { - console.log(err) + console.error(err) toast({ title: "Fetching Service Request Error", description: `Failed to fetch Service Request Info. Please try again later.`, diff --git a/frontend/src/hooks/use-service-request-steps.ts b/frontend/src/hooks/use-service-request-steps.ts index c092d5f1..39f643d1 100644 --- a/frontend/src/hooks/use-service-request-steps.ts +++ b/frontend/src/hooks/use-service-request-steps.ts @@ -14,7 +14,7 @@ const useServiceRequestSteps = ({ queryKey: ["pipelines"], queryFn: () => getServiceRequestSteps(serviceRequestId).catch((err) => { - console.log(err) + console.error(err) toast({ title: "Fetching Services Error", description: "Failed to fetch the services. Please try again later.", diff --git a/frontend/src/lib/service.ts b/frontend/src/lib/service.ts index 3316d683..39e584ae 100644 --- a/frontend/src/lib/service.ts +++ b/frontend/src/lib/service.ts @@ -7,7 +7,7 @@ import { } from "@/types/service-request" import { UserInfo } from "@/types/user-profile" import apiClient from "./apiClient" -import { UserMemberships } from "@/types/membership" +import { Role, UserMemberships } from "@/types/membership" /* Pipeline */ @@ -174,14 +174,76 @@ export async function createOrg(orgName: string) { .then((res) => res.data) } -export async function getUserById(userId: string): Promise { - return apiClient.get(`/user/${userId}`).then((res) => res.data) +export async function updateOrgName(orgId: number, orgName: string) { + return apiClient + .patch("/organization", { org_id: orgId, name: orgName }) + .then((res) => res.data) +} + +export async function getMembersForOrg(orgId: number) { + return apiClient.get(`/organization/${orgId}/members`).then((res) => res.data) +} + +export async function leaveOrganization(orgId: number) { + return apiClient + .delete(`/organization/${orgId}/membership`) + .then((res) => res.data) } +/* Membership */ + export async function getUserMemberships(): Promise { return apiClient.get(`/membership`).then((res) => res.data) } +export async function createMembershipForOrg( + userId: string, + orgId: number, + role: Role +) { + return apiClient + .post(`/membership`, { user_id: userId, org_id: orgId, role }) + .then((res) => res.data) +} + +export async function promoteToAdmin(userId: string, orgId: number) { + return apiClient.patch(`/membership`, { + user_id: userId, + org_id: orgId, + role: Role.Admin, + }) +} + +export async function demoteToMember(userId: string, orgId: number) { + return apiClient.patch(`/membership`, { + user_id: userId, + org_id: orgId, + role: Role.Member, + }) +} + +export async function removeMember(userId: string, orgId: number, role: Role) { + return apiClient.delete(`/membership`, { + data: { + user_id: userId, + org_id: orgId, + role: role, + }, + }) +} + +export async function transferOwnership(userId: string, orgId: number) { + return apiClient + .post(`/membership/ownership_transfer`, { user_id: userId, org_id: orgId }) + .then((res) => res.data) +} + +/* User */ + +export async function getUserById(userId: string): Promise { + return apiClient.get(`/user/${userId}`).then((res) => res.data) +} + export async function login(): Promise { return apiClient.get("login").then((res) => res.data) } @@ -189,3 +251,7 @@ export async function login(): Promise { export async function createUser(name: string): Promise { return apiClient.post("/user", { name }).then((res) => res.data) } + +export async function getAllUsers() { + return apiClient.get("/user").then((res) => res.data) +} diff --git a/frontend/src/types/pipeline.ts b/frontend/src/types/pipeline.ts index d04388e4..dab3d499 100644 --- a/frontend/src/types/pipeline.ts +++ b/frontend/src/types/pipeline.ts @@ -31,7 +31,7 @@ enum StepStatus { STEP_NOT_STARTED = "Not Started", STEP_RUNNING = "Running", STEP_FAILED = "Failed", - STEP_CANCELLED = "Canceled", + STEP_CANCELLED = "Cancelled", STEP_COMPLETED = "Completed", } diff --git a/frontend/src/types/user-profile.ts b/frontend/src/types/user-profile.ts index 2a76a197..19b1933f 100644 --- a/frontend/src/types/user-profile.ts +++ b/frontend/src/types/user-profile.ts @@ -1,3 +1,5 @@ +import { Role } from "./membership" + type Auth0UserProfile = { email?: string name?: string @@ -12,6 +14,7 @@ type UserInfo = { created_on: Date deleted: boolean email: string + role?: Role } export type { Auth0UserProfile, UserInfo }