mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: updated ui for request access feature
This commit is contained in:
@@ -9,12 +9,12 @@ type 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="w-full">
|
||||
<h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
<div className="flex items-center">{children}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-gray-400">{description}</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
</DropdownMenuItem>
|
||||
</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>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -541,18 +555,6 @@ export const MinimizedOrgSidebar = () => {
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</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" />
|
||||
<DropdownMenuItem onClick={logOutUser} icon={<FontAwesomeIcon icon={faSignOut} />}>
|
||||
Log Out
|
||||
|
||||
@@ -91,7 +91,8 @@ export const AccessManagementPage = () => {
|
||||
<p className="mb-2 mt-1 text-sm text-bunker-300">
|
||||
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.
|
||||
</p>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
|
||||
@@ -1,62 +1,22 @@
|
||||
// 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 { useTranslation } from "react-i18next";
|
||||
import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faArrowDownAZ,
|
||||
faArrowRight,
|
||||
faArrowUpZA,
|
||||
faBorderAll,
|
||||
faExclamationCircle,
|
||||
faList,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSearch,
|
||||
faStar as faSolidStar
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faExclamationCircle, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { NewProjectModal } from "@app/components/projects";
|
||||
import {
|
||||
Button,
|
||||
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 { Button, PageHeader } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||
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 { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
enum ProjectsViewMode {
|
||||
GRID = "grid",
|
||||
LIST = "list"
|
||||
}
|
||||
|
||||
enum ProjectOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
import { AllProjectView } from "./components/AllProjectView";
|
||||
import { MyProjectView } from "./components/MyProjectView";
|
||||
import { ProjectListView } from "./components/ProjectListToggle";
|
||||
|
||||
const formatDescription = (type: ProjectType) => {
|
||||
if (type === ProjectType.SecretManager)
|
||||
@@ -76,295 +36,20 @@ type Props = {
|
||||
export const ProductOverviewPage = ({ type }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
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 [projectListView, setProjectListView] = useState(ProjectListView.MyProjects);
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"addNewWs",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const { data: serverDetails } = useFetchServerStatus();
|
||||
const [projectsViewMode, setProjectsViewMode] = useState<ProjectsViewMode>(
|
||||
(localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID
|
||||
);
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
||||
? subscription.workspacesUsed < subscription.workspaceLimit
|
||||
: 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 (
|
||||
<div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800">
|
||||
<Helmet>
|
||||
@@ -395,63 +80,7 @@ export const ProductOverviewPage = ({ type }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 flex flex-col items-start justify-start">
|
||||
<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>
|
||||
<PageHeader title="Projects" description={formatDescription(type)}>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
@@ -471,40 +100,14 @@ export const ProductOverviewPage = ({ type }: Props) => {
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</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>
|
||||
)}
|
||||
</PageHeader>
|
||||
</div>
|
||||
{projectListView === ProjectListView.MyProjects && (
|
||||
<MyProjectView type={type} onListViewToggle={setProjectListView} />
|
||||
)}
|
||||
{projectListView === ProjectListView.AllProjects && (
|
||||
<AllProjectView type={type} onListViewToggle={setProjectListView} />
|
||||
)}
|
||||
<NewProjectModal
|
||||
isOpen={popUp.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 { 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<TAddMemberForm>({
|
||||
@@ -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<TAddMemberForm>({ 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<TAddMemberForm>({ control, name: "orgMemberships" });
|
||||
|
||||
const canInviteNewMembers = permission.can(
|
||||
OrgPermissionActions.Create,
|
||||
OrgPermissionSubjects.Member
|
||||
@@ -155,7 +195,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.addMember?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addMember", isOpen)}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen)
|
||||
navigate({
|
||||
search: (prev) => ({ ...prev, requesterEmail: "" })
|
||||
});
|
||||
handlePopUpToggle("addMember", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
@@ -185,7 +231,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
noOptionsMessage={() => (
|
||||
<>
|
||||
<p>
|
||||
{!filteredOrgUsers.length && (
|
||||
{!projectInviteList.list.length && (
|
||||
<p>All organization members are already assigned to this project.</p>
|
||||
)}
|
||||
</p>
|
||||
@@ -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) => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectRoleSlugs"
|
||||
@@ -263,6 +308,19 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
</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 className="mt-8 flex items-center">
|
||||
<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 { z } from "zod";
|
||||
|
||||
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
|
||||
import { AccessControlPage } from "./AccessControlPage";
|
||||
|
||||
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(
|
||||
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
|
||||
)({
|
||||
component: AccessControlPage,
|
||||
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ requesterEmail: "" })]
|
||||
},
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
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 { z } from "zod";
|
||||
|
||||
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
|
||||
import { AccessControlPage } from "./AccessControlPage";
|
||||
|
||||
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(
|
||||
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
|
||||
)({
|
||||
component: AccessControlPage,
|
||||
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ requesterEmail: "" })]
|
||||
},
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
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 { z } from "zod";
|
||||
|
||||
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
|
||||
import { AccessControlPage } from "./AccessControlPage";
|
||||
|
||||
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(
|
||||
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
|
||||
)({
|
||||
component: AccessControlPage,
|
||||
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ requesterEmail: "" })]
|
||||
},
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
|
||||
Reference in New Issue
Block a user