diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index 337011b38..49c10805c 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -9,12 +9,12 @@ type Props = { }; export const PageHeader = ({ title, description, children, className }: Props) => ( -
+

{title}

-
{children}
+
{children}
{description}
diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 665462e7d..3f5209aca 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -3,6 +3,7 @@ export { useDeleteGroupFromWorkspace, useLeaveProject, useMigrateProjectToV3, + useRequestProjectAccess, useUpdateGroupWorkspaceRole } from "./mutations"; export { @@ -36,6 +37,7 @@ export { useListWorkspaceSshCertificates, useListWorkspaceSshCertificateTemplates, useNameWorkspaceSecrets, + useSearchProjects, useToggleAutoCapitalization, useUpdateIdentityWorkspaceRole, useUpdateProject, diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx index 1ae0efe08..56f83f601 100644 --- a/frontend/src/hooks/api/workspace/mutations.tsx +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -107,3 +107,13 @@ export const useMigrateProjectToV3 = () => { } }); }; + +export const useRequestProjectAccess = () => { + return useMutation({ + mutationFn: ({ projectId, comment }) => { + return apiRequest.post(`/api/v1/workspace/${projectId}/project-access`, { + comment + }); + } + }); +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 0b154b78b..ae832520e 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -32,6 +32,7 @@ import { TGetUpgradeProjectStatusDTO, TListProjectIdentitiesDTO, ToggleAutoCapitalizationDTO, + TSearchProjectsDTO, TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceUserRoleDTO, UpdateAuditLogsRetentionDTO, @@ -145,14 +146,31 @@ export const useGetWorkspaceById = ( export const useGetUserWorkspaces = ({ includeRoles, - type = "all" + type = "all", + options = {} }: { includeRoles?: boolean; type?: ProjectType | "all"; + options?: { enabled?: boolean }; } = {}) => useQuery({ queryKey: workspaceKeys.getAllUserWorkspace(type), - queryFn: () => fetchUserWorkspaces(includeRoles, type) + queryFn: () => fetchUserWorkspaces(includeRoles, type), + ...options + }); + +export const useSearchProjects = ({ options, ...dto }: TSearchProjectsDTO) => + useQuery({ + queryKey: workspaceKeys.searchWorkspace(dto), + queryFn: async () => { + const { data } = await apiRequest.post<{ + projects: (Workspace & { isMember: boolean })[]; + totalCount: number; + }>("/api/v1/workspace/search", dto); + + return data; + }, + ...options }); const fetchUserWorkspaceMemberships = async (orgId: string) => { diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 44fee901c..7ef482a20 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -1,4 +1,4 @@ -import { TListProjectIdentitiesDTO } from "@app/hooks/api/workspace/types"; +import { TListProjectIdentitiesDTO, TSearchProjectsDTO } from "@app/hooks/api/workspace/types"; import type { CaStatus } from "../ca"; @@ -28,6 +28,7 @@ export const workspaceKeys = { ...params }: TListProjectIdentitiesDTO) => [...workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), params] as const, + searchWorkspace: (dto: TSearchProjectsDTO) => ["search-projects", dto] as const, getWorkspaceGroupMemberships: (workspaceId: string) => [{ workspaceId }, "workspace-groups"] as const, getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) => diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 0980bc715..afeb9a6c4 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -173,3 +173,12 @@ export type TListProjectIdentitiesDTO = { export enum ProjectIdentityOrderBy { Name = "name" } +export type TSearchProjectsDTO = { + type?: ProjectType; + name?: string; + limit?: number; + offset?: number; + options?: { enabled?: boolean }; + orderBy?: ProjectIdentityOrderBy; + orderDirection?: OrderByDirection; +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx index a4ff5fff1..e31018334 100644 --- a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx @@ -14,6 +14,7 @@ import { faPlug, faSignOut, faUser, + faUserCog, faUsers } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -385,6 +386,19 @@ export const MinimizedOrgSidebar = () => { Organization Settings + Admin Panels + {user?.superAdmin && ( + + }> + Server Admin Console + + + )} + + }> + Organization Admin Console + +
@@ -541,18 +555,6 @@ export const MinimizedOrgSidebar = () => { /> - {user?.superAdmin && ( - - - Server Admin Console - - - )} -
- - Organization Admin Console - -
}> Log Out diff --git a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx index b245260c6..61c4f4411 100644 --- a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx @@ -91,7 +91,8 @@ export const AccessManagementPage = () => {

We've developed an improved privilege management system to better serve your security needs. Upgrade to our new permission-based approach that allows you to - explicitly designate who can modify specific access levels, rather than relying on hierarchy comparisons. + explicitly designate who can modify specific access levels, rather than relying on + hierarchy comparisons.

-
- - ); - - const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
{ - navigate({ - to: getProjectHomePage(workspace), - params: { - projectId: workspace.id - } - }); - }} - key={workspace.id} - className={`group grid h-14 min-w-72 cursor-pointer grid-cols-6 border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ - index === 0 && "rounded-t-md" - }`} - > -
-
{workspace.name}
-
-
-
- {workspace.environments?.length || 0} environments -
- {isFavorite ? ( - { - e.stopPropagation(); - removeProjectFromFavorites(workspace.id); - }} - /> - ) : ( - { - e.stopPropagation(); - addProjectToFavorites(workspace.id); - }} - /> - )} -
-
- ); - - let projectsComponents: ReactNode; - - if (filteredWorkspaces.length || isProjectViewLoading) { - switch (projectsViewMode) { - case ProjectsViewMode.GRID: - projectsComponents = ( -
- {isProjectViewLoading && - Array.apply(0, Array(3)).map((_x, i) => ( -
-
- -
-
- -
-
- -
-
- ))} - {!isProjectViewLoading && ( - <> - {workspacesWithFaveProp.map((workspace) => - renderProjectGridItem(workspace, workspace.isFavorite) - )} - - )} -
- ); - - break; - case ProjectsViewMode.LIST: - default: - projectsComponents = ( -
- {isProjectViewLoading && - Array.apply(0, Array(3)).map((_x, i) => ( -
- -
- ))} - {!isProjectViewLoading && - workspacesWithFaveProp.map((workspace, ind) => - renderProjectListItem(workspace, workspace.isFavorite, ind) - )} -
- ); - break; - } - } else if (orgWorkspaces.length && searchFilter) { - projectsComponents = ( -
- -
No projects match search...
-
- ); - } - return (
@@ -395,63 +80,7 @@ export const ProductOverviewPage = ({ type }: Props) => {
)}
- -
- setSearchFilter(e.target.value)} - leftIcon={} - /> -
- - - - - -
-
- { - localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID); - setProjectsViewMode(ProjectsViewMode.GRID); - }} - ariaLabel="grid" - size="xs" - className={`${ - projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent" - } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} - > - - - { - localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST); - setProjectsViewMode(ProjectsViewMode.LIST); - }} - ariaLabel="list" - size="xs" - className={`${ - projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent" - } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} - > - - -
+ {(isAllowed) => ( )} -
- {projectsComponents} - {!isProjectViewLoading && Boolean(filteredWorkspaces.length) && ( - - )} - {isWorkspaceEmpty && ( -
- -
- You are not part of any projects in this organization yet. When you are, they will - appear here. -
-
- Create a new project, or ask other organization members to give you necessary - permissions. -
-
- )} +
+ {projectListView === ProjectListView.MyProjects && ( + + )} + {projectListView === ProjectListView.AllProjects && ( + + )} handlePopUpToggle("addNewWs", isOpen)} diff --git a/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx b/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx new file mode 100644 index 000000000..3cfc5972e --- /dev/null +++ b/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx @@ -0,0 +1,286 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { + faArrowDownAZ, + faBorderAll, + faCheck, + faFolderOpen, + faList, + faMagnifyingGlass +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Pagination, + Skeleton, + Tooltip +} from "@app/components/v2"; +import { getProjectHomePage } from "@app/helpers/project"; +import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; +import { useRequestProjectAccess, useSearchProjects } from "@app/hooks/api"; +import { ProjectType, Workspace } from "@app/hooks/api/workspace/types"; + +import { ProjectListToggle, ProjectListView } from "./ProjectListToggle"; + +type Props = { + type: ProjectType; + onListViewToggle: (value: ProjectListView) => void; +}; + +type RequestAccessModalProps = { + projectId: string; + onPopUpToggle: () => void; +}; + +const RequestAccessModal = ({ projectId, onPopUpToggle }: RequestAccessModalProps) => { + const form = useForm<{ note: string }>(); + + const requestProjectAccess = useRequestProjectAccess(); + + const onFormSubmit = ({ note }: { note: string }) => { + if (requestProjectAccess.isPending) return; + requestProjectAccess.mutate( + { + comment: note, + projectId + }, + { + onSuccess: () => { + createNotification({ + type: "success", + title: "Project Access Request Sent", + text: "Project admins will receive an email and will grant you access." + }); + onPopUpToggle(); + } + } + ); + }; + + return ( +
+ + + +
+ + +
+
+ ); +}; + +export const AllProjectView = ({ type, onListViewToggle }: Props) => { + const navigate = useNavigate(); + const [searchFilter, setSearchFilter] = useState(""); + const [debouncedSearch] = useDebounce(searchFilter); + const { + setPage, + perPage, + setPerPage, + page, + offset, + limit, + toggleOrderDirection, + orderDirection + } = usePagination("name", { initPerPage: 50 }); + const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([ + "requestAccessConfirmation" + ] as const); + + const { data: searchedProjects, isPending: isProjectLoading } = useSearchProjects({ + limit, + offset, + type, + name: debouncedSearch || undefined, + orderDirection + }); + + useResetPageHelper({ + setPage, + offset, + totalCount: searchedProjects?.totalCount || 0 + }); + const requestedWorkspaceDetails = popUp.requestAccessConfirmation.data as Workspace; + + return ( +
+
+ +
+ setSearchFilter(e.target.value)} + leftIcon={} + /> +
+ + + + + +
+
+ + + + + + + + +
+
+
+ {isProjectLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+ +
+ ))} + {!isProjectLoading && + searchedProjects?.projects?.map((workspace) => ( +
{ + if (evt.key === "Enter" && workspace.isMember) { + navigate({ + to: getProjectHomePage(workspace), + params: { + projectId: workspace.id + } + }); + } + }} + onClick={() => { + if (workspace.isMember) { + navigate({ + to: getProjectHomePage(workspace), + params: { + projectId: workspace.id + } + }); + } + }} + key={workspace.id} + className={twMerge( + "group flex min-w-72 grid-cols-6 items-center justify-center border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 py-3 first:rounded-t-md", + workspace.isMember ? "cursor-pointer hover:bg-mineshaft-700" : "cursor-default" + )} + > +
+
+
+ {workspace.name} +
+
+ {workspace.isMember ? ( +
+ + Joined +
+ ) : ( +
+ +
+ )} +
+
+
+ {workspace.description} +
+
+
+ ))} +
+ {!isProjectLoading && Boolean(searchedProjects?.totalCount) && ( + + )} + {!isProjectLoading && !searchedProjects?.totalCount && ( +
+ +
+ You are not part of any projects in this organization yet. When you are, they will + appear here. +
+
+ Create a new project, or ask other organization members to give you necessary + permissions. +
+
+ )} + handlePopUpToggle("requestAccessConfirmation", isOpen)} + > + + handlePopUpToggle("requestAccessConfirmation")} + projectId={requestedWorkspaceDetails?.id} + /> + + +
+ ); +}; diff --git a/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx b/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx new file mode 100644 index 000000000..2eecb540d --- /dev/null +++ b/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx @@ -0,0 +1,414 @@ +import { ReactNode, useMemo, useState } from "react"; +import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons"; +import { + faArrowDownAZ, + faArrowRight, + faArrowUpZA, + faBorderAll, + faList, + faMagnifyingGlass, + faSearch, + faStar as faSolidStar +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +import { createNotification } from "@app/components/notifications"; +import { IconButton, Input, Pagination, Skeleton, Tooltip } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { getProjectHomePage } from "@app/helpers/project"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useGetUserWorkspaces } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation"; +import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; +import { ProjectType, Workspace } from "@app/hooks/api/workspace/types"; + +import { ProjectListToggle, ProjectListView } from "./ProjectListToggle"; + +type Props = { + type: ProjectType; + onListViewToggle: (value: ProjectListView) => void; +}; + +enum ProjectOrderBy { + Name = "name" +} + +enum ProjectsViewMode { + GRID = "grid", + LIST = "list" +} + +export const MyProjectView = ({ type, onListViewToggle }: Props) => { + const navigate = useNavigate(); + const { currentOrg } = useOrganization(); + + const { data: workspaces = [], isPending: isWorkspaceLoading } = useGetUserWorkspaces({ + type + }); + const { + setPage, + perPage, + setPerPage, + page, + offset, + limit, + toggleOrderDirection, + orderDirection + } = usePagination(ProjectOrderBy.Name, { initPerPage: 24 }); + const { data: projectFavorites, isPending: isProjectFavoritesLoading } = + useGetUserProjectFavorites(currentOrg?.id); + + const [projectsViewMode, setProjectsViewMode] = useState( + (localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID + ); + const [searchFilter, setSearchFilter] = useState(""); + + const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading; + const isWorkspaceEmpty = !isProjectViewLoading && workspaces?.length === 0; + const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); + + const filteredWorkspaces = useMemo( + () => + workspaces + .filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())) + .sort((a, b) => + orderDirection === OrderByDirection.ASC + ? a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + : b.name.toLowerCase().localeCompare(a.name.toLowerCase()) + ), + [searchFilter, orderDirection, workspaces] + ); + + useResetPageHelper({ + setPage, + offset, + totalCount: filteredWorkspaces.length + }); + + const { workspacesWithFaveProp } = useMemo(() => { + const workspacesWithFav = filteredWorkspaces + .map((w): Workspace & { isFavorite: boolean } => ({ + ...w, + isFavorite: Boolean(projectFavorites?.includes(w.id)) + })) + .sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite)) + .slice(offset, limit * page); + + return { + workspacesWithFaveProp: workspacesWithFav + }; + }, [filteredWorkspaces, projectFavorites, offset, limit, page]); + + const addProjectToFavorites = async (projectId: string) => { + try { + if (currentOrg?.id) { + await updateUserProjectFavorites({ + orgId: currentOrg?.id, + projectFavorites: [...(projectFavorites || []), projectId] + }); + } + } catch { + createNotification({ + text: "Failed to add project to favorites.", + type: "error" + }); + } + }; + const removeProjectFromFavorites = async (projectId: string) => { + try { + if (currentOrg?.id) { + await updateUserProjectFavorites({ + orgId: currentOrg?.id, + projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)] + }); + } + } catch { + createNotification({ + text: "Failed to remove project from favorites.", + type: "error" + }); + } + }; + + const renderProjectGridItem = (workspace: Workspace, isFavorite: boolean) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + navigate({ + to: getProjectHomePage(workspace), + params: { + projectId: workspace.id + } + }); + }} + key={workspace.id} + className="flex h-40 min-w-72 cursor-pointer flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4" + > +
+
{workspace.name}
+ {isFavorite ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(workspace.id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(workspace.id); + }} + /> + )} +
+ +
+ {workspace.description} +
+ +
+ {type === ProjectType.SecretManager && ( +
+ {workspace.environments?.length || 0} environments +
+ )} + +
+
+ ); + const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + navigate({ + to: getProjectHomePage(workspace), + params: { + projectId: workspace.id + } + }); + }} + key={workspace.id} + className={`group grid h-14 min-w-72 cursor-pointer grid-cols-6 border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ + index === 0 && "rounded-t-md" + }`} + > +
+
{workspace.name}
+
+
+ {type === ProjectType.SecretManager && ( +
+ {workspace.environments?.length || 0} environments +
+ )} + {isFavorite ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(workspace.id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(workspace.id); + }} + /> + )} +
+
+ ); + let projectsComponents: ReactNode; + + if (filteredWorkspaces.length || isProjectViewLoading) { + switch (projectsViewMode) { + case ProjectsViewMode.GRID: + projectsComponents = ( +
+ {isProjectViewLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+
+ +
+
+ +
+
+ +
+
+ ))} + {!isProjectViewLoading && ( + <> + {workspacesWithFaveProp.map((workspace) => + renderProjectGridItem(workspace, workspace.isFavorite) + )} + + )} +
+ ); + + break; + case ProjectsViewMode.LIST: + default: + projectsComponents = ( +
+ {isProjectViewLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+ +
+ ))} + {!isProjectViewLoading && + workspacesWithFaveProp.map((workspace, ind) => + renderProjectListItem(workspace, workspace.isFavorite, ind) + )} +
+ ); + break; + } + } else if (workspaces.length && searchFilter) { + projectsComponents = ( +
+ +
No projects match search...
+
+ ); + } + return ( +
+
+ +
+ setSearchFilter(e.target.value)} + leftIcon={} + /> +
+ + + + + +
+
+ { + localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID); + setProjectsViewMode(ProjectsViewMode.GRID); + }} + ariaLabel="grid" + size="xs" + className={`${ + projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent" + } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} + > + + + { + localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST); + setProjectsViewMode(ProjectsViewMode.LIST); + }} + ariaLabel="list" + size="xs" + className={`${ + projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent" + } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} + > + + +
+
+ {projectsComponents} + {!isProjectViewLoading && Boolean(filteredWorkspaces.length) && ( + + )} + {isWorkspaceEmpty && ( +
+ +
+ You are not part of any projects in this organization yet. When you are, they will + appear here. +
+
+ Create a new project, or ask other organization members to give you necessary + permissions. +
+
+ )} +
+ ); +}; diff --git a/frontend/src/pages/organization/SecretManagerOverviewPage/components/ProjectListToggle.tsx b/frontend/src/pages/organization/SecretManagerOverviewPage/components/ProjectListToggle.tsx new file mode 100644 index 000000000..4c16f8cfb --- /dev/null +++ b/frontend/src/pages/organization/SecretManagerOverviewPage/components/ProjectListToggle.tsx @@ -0,0 +1,20 @@ +import { Select, SelectItem } from "@app/components/v2"; + +export enum ProjectListView { + MyProjects = "my-projects", + AllProjects = "all-projects" +} + +type Props = { + value: ProjectListView; + onChange: (value: ProjectListView) => void; +}; + +export const ProjectListToggle = ({ value, onChange }: Props) => { + return ( + + ); +}; diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx index 9afedcbc5..fa4249fc4 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx @@ -1,11 +1,20 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2"; +import { + Alert, + AlertDescription, + Button, + FilterableSelect, + FormControl, + Modal, + ModalContent +} from "@app/components/v2"; import { CreatableSelect } from "@app/components/v2/CreatableSelect"; import { OrgPermissionActions, @@ -48,7 +57,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { const { t } = useTranslation(); const { currentOrg } = useOrganization(); const { currentWorkspace } = useWorkspace(); + const navigate = useNavigate({ from: "" }); const { permission } = useOrgPermission(); + const requesterEmail = useSearch({ + strict: false, + select: (el) => el?.requesterEmail + }); const orgId = currentOrg?.id || ""; const workspaceId = currentWorkspace?.id || ""; @@ -62,6 +76,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { control, handleSubmit, reset, + setValue, watch, formState: { isSubmitting, errors } } = useForm({ @@ -71,6 +86,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { const { mutateAsync: addMembersToProject } = useAddUsersToOrg(); + useEffect(() => { + if (requesterEmail) { + handlePopUpToggle("addMember", true); + } + }, [requesterEmail]); + const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => { if (!currentWorkspace) return; if (!currentOrg?.id) return; @@ -126,12 +147,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { reset(); }; - const filteredOrgUsers = useMemo(() => { + const { append } = useFieldArray({ control, name: "orgMemberships" }); + const projectInviteList = useMemo(() => { const wsUserUsernames = new Map(); members?.forEach((member) => { wsUserUsernames.set(member.user.username, true); }); - return (orgUsers || []) + const list = (orgUsers || []) .filter(({ user: u }) => !wsUserUsernames.has(u.username)) .map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({ value: id, @@ -140,13 +162,31 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { ? `${firstName} ${lastName}` : firstName || lastName || email || inviteEmail })); + const requesterStatus = { isProjectUser: wsUserUsernames.has(requesterEmail), userLabel: "" }; + if (!requesterStatus.isProjectUser) { + const userDetails = orgUsers?.find((el) => el.user.username === requesterEmail); + if (userDetails) { + const { user } = userDetails; + const label = + user.firstName && user.lastName + ? `${user.firstName} ${user.lastName}` + : user.firstName || user.lastName || (user.email as string); + requesterStatus.userLabel = label; + setValue("orgMemberships", [ + { + value: userDetails.id, + label + } + ]); + } + } + + return { list, requesterStatus }; }, [orgUsers, members]); const selectedOrgMemberships = watch("orgMemberships"); const selectedRoleSlugs = watch("projectRoleSlugs"); - const { append } = useFieldArray({ control, name: "orgMemberships" }); - const canInviteNewMembers = permission.can( OrgPermissionActions.Create, OrgPermissionSubjects.Member @@ -155,7 +195,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { return ( handlePopUpToggle("addMember", isOpen)} + onOpenChange={(isOpen) => { + if (!isOpen) + navigate({ + search: (prev) => ({ ...prev, requesterEmail: "" }) + }); + handlePopUpToggle("addMember", isOpen); + }} > { noOptionsMessage={() => ( <>

- {!filteredOrgUsers.length && ( + {!projectInviteList.list.length && (

All organization members are already assigned to this project.

)}

@@ -221,7 +267,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { placeholder="Add one or more users..." isMulti name="members" - options={filteredOrgUsers} + options={projectInviteList.list} value={field.value} onChange={field.onChange} /> @@ -231,7 +277,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { placeholder="Add one or more users..." isMulti name="members" - options={filteredOrgUsers} + options={projectInviteList.list} value={field.value} onChange={field.onChange} /> @@ -239,7 +285,6 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> - { )} /> + {requesterEmail && projectInviteList.requesterStatus.isProjectUser && ( + + Requested user is part of the project. + + )} + {requesterEmail && !projectInviteList.requesterStatus.isProjectUser && ( + + + Assign a role to provide access to requesting user{" "} + {projectInviteList.requesterStatus.userLabel}. + + + )}