feat: updated ui for request access feature

This commit is contained in:
=
2025-04-03 23:28:18 +05:30
parent b18f7b957d
commit 271a8de4c0
16 changed files with 884 additions and 448 deletions
@@ -9,12 +9,12 @@ type Props = {
}; };
export const PageHeader = ({ title, description, children, className }: Props) => ( export const PageHeader = ({ title, description, children, className }: Props) => (
<div className={twMerge("mb-4", className)}> <div className={twMerge("mb-4 w-full", className)}>
<div className="flex w-full justify-between"> <div className="flex w-full justify-between">
<div className="w-full"> <div className="w-full">
<h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1> <h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1>
</div> </div>
<div>{children}</div> <div className="flex items-center">{children}</div>
</div> </div>
<div className="mt-2 text-gray-400">{description}</div> <div className="mt-2 text-gray-400">{description}</div>
</div> </div>
@@ -3,6 +3,7 @@ export {
useDeleteGroupFromWorkspace, useDeleteGroupFromWorkspace,
useLeaveProject, useLeaveProject,
useMigrateProjectToV3, useMigrateProjectToV3,
useRequestProjectAccess,
useUpdateGroupWorkspaceRole useUpdateGroupWorkspaceRole
} from "./mutations"; } from "./mutations";
export { export {
@@ -36,6 +37,7 @@ export {
useListWorkspaceSshCertificates, useListWorkspaceSshCertificates,
useListWorkspaceSshCertificateTemplates, useListWorkspaceSshCertificateTemplates,
useNameWorkspaceSecrets, useNameWorkspaceSecrets,
useSearchProjects,
useToggleAutoCapitalization, useToggleAutoCapitalization,
useUpdateIdentityWorkspaceRole, useUpdateIdentityWorkspaceRole,
useUpdateProject, useUpdateProject,
@@ -107,3 +107,13 @@ export const useMigrateProjectToV3 = () => {
} }
}); });
}; };
export const useRequestProjectAccess = () => {
return useMutation<object, object, { projectId: string; comment: string }>({
mutationFn: ({ projectId, comment }) => {
return apiRequest.post(`/api/v1/workspace/${projectId}/project-access`, {
comment
});
}
});
};
+20 -2
View File
@@ -32,6 +32,7 @@ import {
TGetUpgradeProjectStatusDTO, TGetUpgradeProjectStatusDTO,
TListProjectIdentitiesDTO, TListProjectIdentitiesDTO,
ToggleAutoCapitalizationDTO, ToggleAutoCapitalizationDTO,
TSearchProjectsDTO,
TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceIdentityRoleDTO,
TUpdateWorkspaceUserRoleDTO, TUpdateWorkspaceUserRoleDTO,
UpdateAuditLogsRetentionDTO, UpdateAuditLogsRetentionDTO,
@@ -145,14 +146,31 @@ export const useGetWorkspaceById = (
export const useGetUserWorkspaces = ({ export const useGetUserWorkspaces = ({
includeRoles, includeRoles,
type = "all" type = "all",
options = {}
}: { }: {
includeRoles?: boolean; includeRoles?: boolean;
type?: ProjectType | "all"; type?: ProjectType | "all";
options?: { enabled?: boolean };
} = {}) => } = {}) =>
useQuery({ useQuery({
queryKey: workspaceKeys.getAllUserWorkspace(type), 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) => { const fetchUserWorkspaceMemberships = async (orgId: string) => {
@@ -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"; import type { CaStatus } from "../ca";
@@ -28,6 +28,7 @@ export const workspaceKeys = {
...params ...params
}: TListProjectIdentitiesDTO) => }: TListProjectIdentitiesDTO) =>
[...workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), params] as const, [...workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), params] as const,
searchWorkspace: (dto: TSearchProjectsDTO) => ["search-projects", dto] as const,
getWorkspaceGroupMemberships: (workspaceId: string) => getWorkspaceGroupMemberships: (workspaceId: string) =>
[{ workspaceId }, "workspace-groups"] as const, [{ workspaceId }, "workspace-groups"] as const,
getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) => getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) =>
@@ -173,3 +173,12 @@ export type TListProjectIdentitiesDTO = {
export enum ProjectIdentityOrderBy { export enum ProjectIdentityOrderBy {
Name = "name" Name = "name"
} }
export type TSearchProjectsDTO = {
type?: ProjectType;
name?: string;
limit?: number;
offset?: number;
options?: { enabled?: boolean };
orderBy?: ProjectIdentityOrderBy;
orderDirection?: OrderByDirection;
};
@@ -14,6 +14,7 @@ import {
faPlug, faPlug,
faSignOut, faSignOut,
faUser, faUser,
faUserCog,
faUsers faUsers
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -385,6 +386,19 @@ export const MinimizedOrgSidebar = () => {
Organization Settings Organization Settings
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
<DropdownMenuLabel>Admin Panels</DropdownMenuLabel>
{user?.superAdmin && (
<Link to="/admin">
<DropdownMenuItem icon={<FontAwesomeIcon className="w-3" icon={faUserCog} />}>
Server Admin Console
</DropdownMenuItem>
</Link>
)}
<Link to="/organization/admin">
<DropdownMenuItem icon={<FontAwesomeIcon className="w-3" icon={faCog} />}>
Organization Admin Console
</DropdownMenuItem>
</Link>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -541,18 +555,6 @@ export const MinimizedOrgSidebar = () => {
/> />
</DropdownMenuItem> </DropdownMenuItem>
</a> </a>
{user?.superAdmin && (
<Link to="/admin">
<DropdownMenuItem className="mt-1 border-t border-mineshaft-600">
Server Admin Console
</DropdownMenuItem>
</Link>
)}
<div className="mt-1 border-t border-mineshaft-600 pt-1">
<Link to="/organization/admin">
<DropdownMenuItem>Organization Admin Console</DropdownMenuItem>
</Link>
</div>
<div className="mt-1 h-1 border-t border-mineshaft-600" /> <div className="mt-1 h-1 border-t border-mineshaft-600" />
<DropdownMenuItem onClick={logOutUser} icon={<FontAwesomeIcon icon={faSignOut} />}> <DropdownMenuItem onClick={logOutUser} icon={<FontAwesomeIcon icon={faSignOut} />}>
Log Out Log Out
@@ -91,7 +91,8 @@ export const AccessManagementPage = () => {
<p className="mb-2 mt-1 text-sm text-bunker-300"> <p className="mb-2 mt-1 text-sm text-bunker-300">
We&apos;ve developed an improved privilege management system to better serve your We&apos;ve developed an improved privilege management system to better serve your
security needs. Upgrade to our new permission-based approach that allows you to 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.
</p> </p>
<Button <Button
colorSchema="primary" colorSchema="primary"
@@ -1,62 +1,22 @@
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex // REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
import { ReactNode, useMemo, useState } from "react"; import { useState } from "react";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons"; import { faExclamationCircle, faPlus } from "@fortawesome/free-solid-svg-icons";
import {
faArrowDownAZ,
faArrowRight,
faArrowUpZA,
faBorderAll,
faExclamationCircle,
faList,
faMagnifyingGlass,
faPlus,
faSearch,
faStar as faSolidStar
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { NewProjectModal } from "@app/components/projects"; import { NewProjectModal } from "@app/components/projects";
import { import { Button, PageHeader } from "@app/components/v2";
Button, import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
IconButton,
Input,
PageHeader,
Pagination,
Skeleton,
Tooltip
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} 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 { fetchUserWsKey } from "@app/hooks/api/keys/queries";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { Workspace } from "@app/hooks/api/types";
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
import { ProjectType } from "@app/hooks/api/workspace/types"; import { ProjectType } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
enum ProjectsViewMode { import { AllProjectView } from "./components/AllProjectView";
GRID = "grid", import { MyProjectView } from "./components/MyProjectView";
LIST = "list" import { ProjectListView } from "./components/ProjectListToggle";
}
enum ProjectOrderBy {
Name = "name"
}
const formatDescription = (type: ProjectType) => { const formatDescription = (type: ProjectType) => {
if (type === ProjectType.SecretManager) if (type === ProjectType.SecretManager)
@@ -76,295 +36,20 @@ type Props = {
export const ProductOverviewPage = ({ type }: Props) => { export const ProductOverviewPage = ({ type }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const [projectListView, setProjectListView] = useState(ProjectListView.MyProjects);
const { data: workspaces, isPending: isWorkspaceLoading } = useGetUserWorkspaces({ type });
const { currentOrg } = useOrganization();
const orgWorkspaces = workspaces || [];
const { data: projectFavorites, isPending: isProjectFavoritesLoading } =
useGetUserProjectFavorites(currentOrg?.id);
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading;
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"addNewWs", "addNewWs",
"upgradePlan" "upgradePlan"
] as const); ] as const);
const [searchFilter, setSearchFilter] = useState("");
const { data: serverDetails } = useFetchServerStatus(); const { data: serverDetails } = useFetchServerStatus();
const [projectsViewMode, setProjectsViewMode] = useState<ProjectsViewMode>(
(localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID
);
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const isAddingProjectsAllowed = subscription?.workspaceLimit const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit ? subscription.workspacesUsed < subscription.workspaceLimit
: true; : true;
const isWorkspaceEmpty = !isProjectViewLoading && orgWorkspaces?.length === 0;
const {
setPage,
perPage,
setPerPage,
page,
offset,
limit,
toggleOrderDirection,
orderDirection
} = usePagination(ProjectOrderBy.Name, { initPerPage: 24 });
const filteredWorkspaces = useMemo(
() =>
orgWorkspaces
.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, orgWorkspaces]
);
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
<div
onClick={() => {
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"
>
<div className="flex flex-row justify-between">
<div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(workspace.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={(e) => {
e.stopPropagation();
addProjectToFavorites(workspace.id);
}}
/>
)}
</div>
<div
className="mb-2.5 mt-1 grow text-sm text-mineshaft-300"
style={{
overflow: "hidden",
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2
}}
>
{workspace.description}
</div>
<div className="flex w-full flex-row items-end justify-between place-self-end">
{type === ProjectType.SecretManager && (
<div className="mt-0 text-xs text-mineshaft-400">
{workspace.environments?.length || 0} environments
</div>
)}
<button type="button">
<div className="group ml-auto w-max cursor-pointer rounded-full border border-mineshaft-600 bg-mineshaft-900 px-4 py-2 text-sm text-mineshaft-300 transition-all hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200">
Explore{" "}
<FontAwesomeIcon
icon={faArrowRight}
className="pl-1.5 pr-0.5 duration-200 hover:pl-2 hover:pr-0"
/>
</div>
</button>
</div>
</div>
);
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
<div
onClick={() => {
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"
}`}
>
<div className="flex items-center sm:col-span-3 lg:col-span-4">
<div className="truncate text-sm text-mineshaft-100">{workspace.name}</div>
</div>
<div className="flex items-center justify-end sm:col-span-3 lg:col-span-2">
<div className="text-center text-sm text-mineshaft-300">
{workspace.environments?.length || 0} environments
</div>
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="ml-6 text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(workspace.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="ml-6 text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={(e) => {
e.stopPropagation();
addProjectToFavorites(workspace.id);
}}
/>
)}
</div>
</div>
);
let projectsComponents: ReactNode;
if (filteredWorkspaces.length || isProjectViewLoading) {
switch (projectsViewMode) {
case ProjectsViewMode.GRID:
projectsComponents = (
<div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className="flex h-40 min-w-72 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="mt-0 text-lg text-mineshaft-100">
<Skeleton className="w-3/4 bg-mineshaft-600" />
</div>
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
<div className="flex justify-end">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
</div>
))}
{!isProjectViewLoading && (
<>
{workspacesWithFaveProp.map((workspace) =>
renderProjectGridItem(workspace, workspace.isFavorite)
)}
</>
)}
</div>
);
break;
case ProjectsViewMode.LIST:
default:
projectsComponents = (
<div className="mt-4 w-full rounded-md">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className={`group flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
i === 0 && "rounded-t-md"
} ${i === 2 && "rounded-b-md border-b"}`}
>
<Skeleton className="w-full bg-mineshaft-600" />
</div>
))}
{!isProjectViewLoading &&
workspacesWithFaveProp.map((workspace, ind) =>
renderProjectListItem(workspace, workspace.isFavorite, ind)
)}
</div>
);
break;
}
} else if (orgWorkspaces.length && searchFilter) {
projectsComponents = (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faSearch}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">No projects match search...</div>
</div>
);
}
return ( return (
<div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800"> <div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800">
<Helmet> <Helmet>
@@ -395,63 +80,7 @@ export const ProductOverviewPage = ({ type }: Props) => {
</div> </div>
)} )}
<div className="mb-4 flex flex-col items-start justify-start"> <div className="mb-4 flex flex-col items-start justify-start">
<PageHeader title="Projects" description={formatDescription(type)} /> <PageHeader title="Projects" description={formatDescription(type)}>
<div className="flex w-full flex-row">
<Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by project name..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip content="Toggle Sort Direction">
<IconButton
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
ariaLabel={`Sort ${
orderDirection === OrderByDirection.ASC ? "descending" : "ascending"
}`}
variant="plain"
size="xs"
colorSchema="secondary"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.ASC ? faArrowDownAZ : faArrowUpZA}
/>
</IconButton>
</Tooltip>
</div>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<IconButton
variant="outline_bg"
onClick={() => {
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`}
>
<FontAwesomeIcon icon={faBorderAll} />
</IconButton>
<IconButton
variant="outline_bg"
onClick={() => {
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`}
>
<FontAwesomeIcon icon={faList} />
</IconButton>
</div>
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}> <OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
@@ -471,40 +100,14 @@ export const ProductOverviewPage = ({ type }: Props) => {
</Button> </Button>
)} )}
</OrgPermissionCan> </OrgPermissionCan>
</div> </PageHeader>
{projectsComponents}
{!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
<Pagination
className={
projectsViewMode === ProjectsViewMode.GRID
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
: "rounded-b-md border border-mineshaft-600"
}
perPage={perPage}
perPageList={[12, 24, 48, 96]}
count={filteredWorkspaces.length}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{isWorkspaceEmpty && (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faFolderOpen}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">
You are not part of any projects in this organization yet. When you are, they will
appear here.
</div>
<div className="mt-0.5 text-center font-light">
Create a new project, or ask other organization members to give you necessary
permissions.
</div>
</div>
)}
</div> </div>
{projectListView === ProjectListView.MyProjects && (
<MyProjectView type={type} onListViewToggle={setProjectListView} />
)}
{projectListView === ProjectListView.AllProjects && (
<AllProjectView type={type} onListViewToggle={setProjectListView} />
)}
<NewProjectModal <NewProjectModal
isOpen={popUp.addNewWs.isOpen} isOpen={popUp.addNewWs.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addNewWs", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("addNewWs", isOpen)}
@@ -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 (
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<FormControl label="Note">
<Input {...form.register("note")} />
</FormControl>
<div className="mt-4 flex items-center">
<Button className="mr-4" size="sm" type="submit" isLoading={form.formState.isSubmitting}>
Submit Request
</Button>
<Button colorSchema="secondary" variant="plain" onClick={() => onPopUpToggle()}>
Cancel
</Button>
</div>
</form>
);
};
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 (
<div>
<div className="flex w-full flex-row">
<ProjectListToggle value={ProjectListView.AllProjects} onChange={onListViewToggle} />
<div className="flex-grow" />
<Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
containerClassName="max-w-md"
placeholder="Search by project name..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip content="Toggle Sort Direction">
<IconButton
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
ariaLabel="Sort asc"
variant="plain"
size="xs"
colorSchema="secondary"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon icon={faArrowDownAZ} />
</IconButton>
</Tooltip>
</div>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip content="Disabled across All Project view.">
<IconButton
variant="outline_bg"
ariaLabel="grid"
size="xs"
className="min-w-[2.4rem] border-none bg-transparent hover:bg-mineshaft-600"
>
<FontAwesomeIcon icon={faBorderAll} />
</IconButton>
</Tooltip>
<IconButton
variant="outline_bg"
ariaLabel="list"
size="xs"
className="min-w-[2.4rem] border-none bg-mineshaft-500 hover:bg-mineshaft-600"
>
<FontAwesomeIcon icon={faList} />
</IconButton>
</div>
</div>
<div className="mt-4 w-full rounded-md">
{isProjectLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className={twMerge(
"flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700",
i === 0 && "rounded-t-md",
i === 2 && "rounded-b-md border-b"
)}
>
<Skeleton className="w-full bg-mineshaft-600" />
</div>
))}
{!isProjectLoading &&
searchedProjects?.projects?.map((workspace) => (
<div
role="button"
tabIndex={0}
onKeyDown={(evt) => {
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"
)}
>
<div className="w-full items-center">
<div className="flex flex-grow items-center">
<div className="flex-grow truncate text-sm text-mineshaft-100">
{workspace.name}
</div>
<div className="flex items-center">
{workspace.isMember ? (
<div className="flex items-center text-center text-sm text-primary">
<FontAwesomeIcon icon={faCheck} className="mr-2" />
Joined
</div>
) : (
<div className="opacity-0 transition-all group-hover:opacity-100">
<Button
size="xs"
variant="outline_bg"
onClick={() => handlePopUpOpen("requestAccessConfirmation", workspace)}
>
Request Access
</Button>
</div>
)}
</div>
</div>
<div className="mt-1 max-w-lg overflow-hidden text-ellipsis whitespace-nowrap text-xs text-mineshaft-300">
{workspace.description}
</div>
</div>
</div>
))}
</div>
{!isProjectLoading && Boolean(searchedProjects?.totalCount) && (
<Pagination
className="rounded-b-md border border-mineshaft-600"
perPage={perPage}
perPageList={[12, 24, 48, 96]}
count={searchedProjects?.totalCount || 0}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isProjectLoading && !searchedProjects?.totalCount && (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faFolderOpen}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">
You are not part of any projects in this organization yet. When you are, they will
appear here.
</div>
<div className="mt-0.5 text-center font-light">
Create a new project, or ask other organization members to give you necessary
permissions.
</div>
</div>
)}
<Modal
isOpen={popUp.requestAccessConfirmation.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("requestAccessConfirmation", isOpen)}
>
<ModalContent
title="Confirm Access Request"
subTitle={`Requesting access to project ${requestedWorkspaceDetails?.name}. You may include a note for the admins.`}
>
<RequestAccessModal
onPopUpToggle={() => handlePopUpToggle("requestAccessConfirmation")}
projectId={requestedWorkspaceDetails?.id}
/>
</ModalContent>
</Modal>
</div>
);
};
@@ -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<ProjectsViewMode>(
(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
<div
onClick={() => {
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"
>
<div className="flex flex-row justify-between">
<div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(workspace.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={(e) => {
e.stopPropagation();
addProjectToFavorites(workspace.id);
}}
/>
)}
</div>
<div
className="mb-2.5 mt-1 grow text-sm text-mineshaft-300"
style={{
overflow: "hidden",
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2
}}
>
{workspace.description}
</div>
<div className="flex w-full flex-row items-end justify-between place-self-end">
{type === ProjectType.SecretManager && (
<div className="mt-0 text-xs text-mineshaft-400">
{workspace.environments?.length || 0} environments
</div>
)}
<button type="button">
<div className="group ml-auto w-max cursor-pointer rounded-full border border-mineshaft-600 bg-mineshaft-900 px-4 py-2 text-sm text-mineshaft-300 transition-all hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200">
Explore{" "}
<FontAwesomeIcon
icon={faArrowRight}
className="pl-1.5 pr-0.5 duration-200 hover:pl-2 hover:pr-0"
/>
</div>
</button>
</div>
</div>
);
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
<div
onClick={() => {
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"
}`}
>
<div className="flex items-center sm:col-span-3 lg:col-span-4">
<div className="truncate text-sm text-mineshaft-100">{workspace.name}</div>
</div>
<div className="flex items-center justify-end sm:col-span-3 lg:col-span-2">
{type === ProjectType.SecretManager && (
<div className="text-center text-sm text-mineshaft-300">
{workspace.environments?.length || 0} environments
</div>
)}
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="ml-6 text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(workspace.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="ml-6 text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={(e) => {
e.stopPropagation();
addProjectToFavorites(workspace.id);
}}
/>
)}
</div>
</div>
);
let projectsComponents: ReactNode;
if (filteredWorkspaces.length || isProjectViewLoading) {
switch (projectsViewMode) {
case ProjectsViewMode.GRID:
projectsComponents = (
<div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className="flex h-40 min-w-72 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="mt-0 text-lg text-mineshaft-100">
<Skeleton className="w-3/4 bg-mineshaft-600" />
</div>
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
<div className="flex justify-end">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
</div>
))}
{!isProjectViewLoading && (
<>
{workspacesWithFaveProp.map((workspace) =>
renderProjectGridItem(workspace, workspace.isFavorite)
)}
</>
)}
</div>
);
break;
case ProjectsViewMode.LIST:
default:
projectsComponents = (
<div className="mt-4 w-full rounded-md">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className={`group flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
i === 0 && "rounded-t-md"
} ${i === 2 && "rounded-b-md border-b"}`}
>
<Skeleton className="w-full bg-mineshaft-600" />
</div>
))}
{!isProjectViewLoading &&
workspacesWithFaveProp.map((workspace, ind) =>
renderProjectListItem(workspace, workspace.isFavorite, ind)
)}
</div>
);
break;
}
} else if (workspaces.length && searchFilter) {
projectsComponents = (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faSearch}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">No projects match search...</div>
</div>
);
}
return (
<div>
<div className="flex w-full flex-row">
<ProjectListToggle value={ProjectListView.MyProjects} onChange={onListViewToggle} />
<div className="flex-grow" />
<Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
containerClassName="max-w-md"
placeholder="Search by project name..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip content="Toggle Sort Direction">
<IconButton
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
ariaLabel={`Sort ${
orderDirection === OrderByDirection.ASC ? "descending" : "ascending"
}`}
variant="plain"
size="xs"
colorSchema="secondary"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.ASC ? faArrowDownAZ : faArrowUpZA}
/>
</IconButton>
</Tooltip>
</div>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<IconButton
variant="outline_bg"
onClick={() => {
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`}
>
<FontAwesomeIcon icon={faBorderAll} />
</IconButton>
<IconButton
variant="outline_bg"
onClick={() => {
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`}
>
<FontAwesomeIcon icon={faList} />
</IconButton>
</div>
</div>
{projectsComponents}
{!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
<Pagination
className={
projectsViewMode === ProjectsViewMode.GRID
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
: "rounded-b-md border border-mineshaft-600"
}
perPage={perPage}
perPageList={[12, 24, 48, 96]}
count={filteredWorkspaces.length}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{isWorkspaceEmpty && (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faFolderOpen}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">
You are not part of any projects in this organization yet. When you are, they will
appear here.
</div>
<div className="mt-0.5 text-center font-light">
Create a new project, or ask other organization members to give you necessary
permissions.
</div>
</div>
)}
</div>
);
};
@@ -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 (
<Select value={value} onValueChange={onChange}>
<SelectItem value={ProjectListView.MyProjects}>My Projects</SelectItem>
<SelectItem value={ProjectListView.AllProjects}>All Projects</SelectItem>
</Select>
);
};
@@ -1,11 +1,20 @@
import { useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form"; import { Controller, useFieldArray, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; 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 { CreatableSelect } from "@app/components/v2/CreatableSelect";
import { import {
OrgPermissionActions, OrgPermissionActions,
@@ -48,7 +57,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const navigate = useNavigate({ from: "" });
const { permission } = useOrgPermission(); const { permission } = useOrgPermission();
const requesterEmail = useSearch({
strict: false,
select: (el) => el?.requesterEmail
});
const orgId = currentOrg?.id || ""; const orgId = currentOrg?.id || "";
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
@@ -62,6 +76,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
control, control,
handleSubmit, handleSubmit,
reset, reset,
setValue,
watch, watch,
formState: { isSubmitting, errors } formState: { isSubmitting, errors }
} = useForm<TAddMemberForm>({ } = useForm<TAddMemberForm>({
@@ -71,6 +86,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
const { mutateAsync: addMembersToProject } = useAddUsersToOrg(); const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
useEffect(() => {
if (requesterEmail) {
handlePopUpToggle("addMember", true);
}
}, [requesterEmail]);
const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => { const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => {
if (!currentWorkspace) return; if (!currentWorkspace) return;
if (!currentOrg?.id) return; if (!currentOrg?.id) return;
@@ -126,12 +147,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
reset(); reset();
}; };
const filteredOrgUsers = useMemo(() => { const { append } = useFieldArray<TAddMemberForm>({ control, name: "orgMemberships" });
const projectInviteList = useMemo(() => {
const wsUserUsernames = new Map(); const wsUserUsernames = new Map();
members?.forEach((member) => { members?.forEach((member) => {
wsUserUsernames.set(member.user.username, true); wsUserUsernames.set(member.user.username, true);
}); });
return (orgUsers || []) const list = (orgUsers || [])
.filter(({ user: u }) => !wsUserUsernames.has(u.username)) .filter(({ user: u }) => !wsUserUsernames.has(u.username))
.map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({ .map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({
value: id, value: id,
@@ -140,13 +162,31 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
? `${firstName} ${lastName}` ? `${firstName} ${lastName}`
: firstName || lastName || email || inviteEmail : 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]); }, [orgUsers, members]);
const selectedOrgMemberships = watch("orgMemberships"); const selectedOrgMemberships = watch("orgMemberships");
const selectedRoleSlugs = watch("projectRoleSlugs"); const selectedRoleSlugs = watch("projectRoleSlugs");
const { append } = useFieldArray<TAddMemberForm>({ control, name: "orgMemberships" });
const canInviteNewMembers = permission.can( const canInviteNewMembers = permission.can(
OrgPermissionActions.Create, OrgPermissionActions.Create,
OrgPermissionSubjects.Member OrgPermissionSubjects.Member
@@ -155,7 +195,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
return ( return (
<Modal <Modal
isOpen={popUp?.addMember?.isOpen} isOpen={popUp?.addMember?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addMember", isOpen)} onOpenChange={(isOpen) => {
if (!isOpen)
navigate({
search: (prev) => ({ ...prev, requesterEmail: "" })
});
handlePopUpToggle("addMember", isOpen);
}}
> >
<ModalContent <ModalContent
bodyClassName="overflow-visible" bodyClassName="overflow-visible"
@@ -185,7 +231,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
noOptionsMessage={() => ( noOptionsMessage={() => (
<> <>
<p> <p>
{!filteredOrgUsers.length && ( {!projectInviteList.list.length && (
<p>All organization members are already assigned to this project.</p> <p>All organization members are already assigned to this project.</p>
)} )}
</p> </p>
@@ -221,7 +267,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
placeholder="Add one or more users..." placeholder="Add one or more users..."
isMulti isMulti
name="members" name="members"
options={filteredOrgUsers} options={projectInviteList.list}
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
/> />
@@ -231,7 +277,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
placeholder="Add one or more users..." placeholder="Add one or more users..."
isMulti isMulti
name="members" name="members"
options={filteredOrgUsers} options={projectInviteList.list}
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
/> />
@@ -239,7 +285,6 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl> </FormControl>
)} )}
/> />
<Controller <Controller
control={control} control={control}
name="projectRoleSlugs" name="projectRoleSlugs"
@@ -263,6 +308,19 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl> </FormControl>
)} )}
/> />
{requesterEmail && projectInviteList.requesterStatus.isProjectUser && (
<Alert hideTitle variant="danger">
<AlertDescription>Requested user is part of the project.</AlertDescription>
</Alert>
)}
{requesterEmail && !projectInviteList.requesterStatus.isProjectUser && (
<Alert hideTitle>
<AlertDescription>
Assign a role to provide access to requesting user{" "}
<b>{projectInviteList.requesterStatus.userLabel}</b>.
</AlertDescription>
</Alert>
)}
</div> </div>
<div className="mt-8 flex items-center"> <div className="mt-8 flex items-center">
<Button <Button
@@ -1,4 +1,4 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router"; import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter"; import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod"; import { z } from "zod";
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
import { AccessControlPage } from "./AccessControlPage"; import { AccessControlPage } from "./AccessControlPage";
const AccessControlPageQuerySchema = z.object({ const AccessControlPageQuerySchema = z.object({
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member) selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
requesterEmail: z.string().catch("")
}); });
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
)({ )({
component: AccessControlPage, component: AccessControlPage,
validateSearch: zodValidator(AccessControlPageQuerySchema), validateSearch: zodValidator(AccessControlPageQuerySchema),
search: {
middlewares: [stripSearchParams({ requesterEmail: "" })]
},
beforeLoad: ({ context, params }) => { beforeLoad: ({ context, params }) => {
return { return {
breadcrumbs: [ breadcrumbs: [
@@ -1,4 +1,4 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router"; import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter"; import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod"; import { z } from "zod";
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
import { AccessControlPage } from "./AccessControlPage"; import { AccessControlPage } from "./AccessControlPage";
const AccessControlPageQuerySchema = z.object({ const AccessControlPageQuerySchema = z.object({
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member) selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
requesterEmail: z.string().catch("")
}); });
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
)({ )({
component: AccessControlPage, component: AccessControlPage,
validateSearch: zodValidator(AccessControlPageQuerySchema), validateSearch: zodValidator(AccessControlPageQuerySchema),
search: {
middlewares: [stripSearchParams({ requesterEmail: "" })]
},
beforeLoad: ({ context, params }) => { beforeLoad: ({ context, params }) => {
return { return {
breadcrumbs: [ breadcrumbs: [
@@ -1,4 +1,4 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router"; import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter"; import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod"; import { z } from "zod";
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
import { AccessControlPage } from "./AccessControlPage"; import { AccessControlPage } from "./AccessControlPage";
const AccessControlPageQuerySchema = z.object({ const AccessControlPageQuerySchema = z.object({
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member) selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
requesterEmail: z.string().catch("")
}); });
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
)({ )({
component: AccessControlPage, component: AccessControlPage,
validateSearch: zodValidator(AccessControlPageQuerySchema), validateSearch: zodValidator(AccessControlPageQuerySchema),
search: {
middlewares: [stripSearchParams({ requesterEmail: "" })]
},
beforeLoad: ({ context, params }) => { beforeLoad: ({ context, params }) => {
return { return {
breadcrumbs: [ breadcrumbs: [