diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx
index 337011b38..49c10805c 100644
--- a/frontend/src/components/v2/PageHeader/PageHeader.tsx
+++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx
@@ -9,12 +9,12 @@ type Props = {
};
export const PageHeader = ({ title, description, children, className }: Props) => (
-
+
{title}
-
{children}
+
{children}
{description}
diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx
index 665462e7d..3f5209aca 100644
--- a/frontend/src/hooks/api/workspace/index.tsx
+++ b/frontend/src/hooks/api/workspace/index.tsx
@@ -3,6 +3,7 @@ export {
useDeleteGroupFromWorkspace,
useLeaveProject,
useMigrateProjectToV3,
+ useRequestProjectAccess,
useUpdateGroupWorkspaceRole
} from "./mutations";
export {
@@ -36,6 +37,7 @@ export {
useListWorkspaceSshCertificates,
useListWorkspaceSshCertificateTemplates,
useNameWorkspaceSecrets,
+ useSearchProjects,
useToggleAutoCapitalization,
useUpdateIdentityWorkspaceRole,
useUpdateProject,
diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx
index 1ae0efe08..56f83f601 100644
--- a/frontend/src/hooks/api/workspace/mutations.tsx
+++ b/frontend/src/hooks/api/workspace/mutations.tsx
@@ -107,3 +107,13 @@ export const useMigrateProjectToV3 = () => {
}
});
};
+
+export const useRequestProjectAccess = () => {
+ return useMutation
({
+ mutationFn: ({ projectId, comment }) => {
+ return apiRequest.post(`/api/v1/workspace/${projectId}/project-access`, {
+ comment
+ });
+ }
+ });
+};
diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx
index 0b154b78b..ae832520e 100644
--- a/frontend/src/hooks/api/workspace/queries.tsx
+++ b/frontend/src/hooks/api/workspace/queries.tsx
@@ -32,6 +32,7 @@ import {
TGetUpgradeProjectStatusDTO,
TListProjectIdentitiesDTO,
ToggleAutoCapitalizationDTO,
+ TSearchProjectsDTO,
TUpdateWorkspaceIdentityRoleDTO,
TUpdateWorkspaceUserRoleDTO,
UpdateAuditLogsRetentionDTO,
@@ -145,14 +146,31 @@ export const useGetWorkspaceById = (
export const useGetUserWorkspaces = ({
includeRoles,
- type = "all"
+ type = "all",
+ options = {}
}: {
includeRoles?: boolean;
type?: ProjectType | "all";
+ options?: { enabled?: boolean };
} = {}) =>
useQuery({
queryKey: workspaceKeys.getAllUserWorkspace(type),
- queryFn: () => fetchUserWorkspaces(includeRoles, type)
+ queryFn: () => fetchUserWorkspaces(includeRoles, type),
+ ...options
+ });
+
+export const useSearchProjects = ({ options, ...dto }: TSearchProjectsDTO) =>
+ useQuery({
+ queryKey: workspaceKeys.searchWorkspace(dto),
+ queryFn: async () => {
+ const { data } = await apiRequest.post<{
+ projects: (Workspace & { isMember: boolean })[];
+ totalCount: number;
+ }>("/api/v1/workspace/search", dto);
+
+ return data;
+ },
+ ...options
});
const fetchUserWorkspaceMemberships = async (orgId: string) => {
diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx
index 44fee901c..7ef482a20 100644
--- a/frontend/src/hooks/api/workspace/query-keys.tsx
+++ b/frontend/src/hooks/api/workspace/query-keys.tsx
@@ -1,4 +1,4 @@
-import { TListProjectIdentitiesDTO } from "@app/hooks/api/workspace/types";
+import { TListProjectIdentitiesDTO, TSearchProjectsDTO } from "@app/hooks/api/workspace/types";
import type { CaStatus } from "../ca";
@@ -28,6 +28,7 @@ export const workspaceKeys = {
...params
}: TListProjectIdentitiesDTO) =>
[...workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), params] as const,
+ searchWorkspace: (dto: TSearchProjectsDTO) => ["search-projects", dto] as const,
getWorkspaceGroupMemberships: (workspaceId: string) =>
[{ workspaceId }, "workspace-groups"] as const,
getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) =>
diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts
index 0980bc715..afeb9a6c4 100644
--- a/frontend/src/hooks/api/workspace/types.ts
+++ b/frontend/src/hooks/api/workspace/types.ts
@@ -173,3 +173,12 @@ export type TListProjectIdentitiesDTO = {
export enum ProjectIdentityOrderBy {
Name = "name"
}
+export type TSearchProjectsDTO = {
+ type?: ProjectType;
+ name?: string;
+ limit?: number;
+ offset?: number;
+ options?: { enabled?: boolean };
+ orderBy?: ProjectIdentityOrderBy;
+ orderDirection?: OrderByDirection;
+};
diff --git a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx
index a4ff5fff1..e31018334 100644
--- a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx
+++ b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx
@@ -14,6 +14,7 @@ import {
faPlug,
faSignOut,
faUser,
+ faUserCog,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -385,6 +386,19 @@ export const MinimizedOrgSidebar = () => {
Organization Settings
+ Admin Panels
+ {user?.superAdmin && (
+
+ }>
+ Server Admin Console
+
+
+ )}
+
+ }>
+ Organization Admin Console
+
+
@@ -541,18 +555,6 @@ export const MinimizedOrgSidebar = () => {
/>
- {user?.superAdmin && (
-
-
- Server Admin Console
-
-
- )}
-
-
- Organization Admin Console
-
-
}>
Log Out
diff --git a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx
index b245260c6..61c4f4411 100644
--- a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx
+++ b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx
@@ -91,7 +91,8 @@ export const AccessManagementPage = () => {
We've developed an improved privilege management system to better serve your
security needs. Upgrade to our new permission-based approach that allows you to
- explicitly designate who can modify specific access levels, rather than relying on hierarchy comparisons.
+ explicitly designate who can modify specific access levels, rather than relying on
+ hierarchy comparisons.
{
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(
- (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
- {
- navigate({
- to: getProjectHomePage(workspace),
- params: {
- projectId: workspace.id
- }
- });
- }}
- key={workspace.id}
- className="flex h-40 min-w-72 cursor-pointer flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
- >
-
-
{workspace.name}
- {isFavorite ? (
-
{
- e.stopPropagation();
- removeProjectFromFavorites(workspace.id);
- }}
- />
- ) : (
- {
- e.stopPropagation();
- addProjectToFavorites(workspace.id);
- }}
- />
- )}
-
-
-
- {workspace.description}
-
-
-
- {type === ProjectType.SecretManager && (
-
- {workspace.environments?.length || 0} environments
-
- )}
-
-
- Explore{" "}
-
-
-
-
-
- );
-
- const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => (
- // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
- {
- navigate({
- to: getProjectHomePage(workspace),
- params: {
- projectId: workspace.id
- }
- });
- }}
- key={workspace.id}
- className={`group grid h-14 min-w-72 cursor-pointer grid-cols-6 border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
- index === 0 && "rounded-t-md"
- }`}
- >
-
-
-
- {workspace.environments?.length || 0} environments
-
- {isFavorite ? (
-
{
- e.stopPropagation();
- removeProjectFromFavorites(workspace.id);
- }}
- />
- ) : (
- {
- e.stopPropagation();
- addProjectToFavorites(workspace.id);
- }}
- />
- )}
-
-
- );
-
- let projectsComponents: ReactNode;
-
- if (filteredWorkspaces.length || isProjectViewLoading) {
- switch (projectsViewMode) {
- case ProjectsViewMode.GRID:
- projectsComponents = (
-
- {isProjectViewLoading &&
- Array.apply(0, Array(3)).map((_x, i) => (
-
- ))}
- {!isProjectViewLoading && (
- <>
- {workspacesWithFaveProp.map((workspace) =>
- renderProjectGridItem(workspace, workspace.isFavorite)
- )}
- >
- )}
-
- );
-
- break;
- case ProjectsViewMode.LIST:
- default:
- projectsComponents = (
-
- {isProjectViewLoading &&
- Array.apply(0, Array(3)).map((_x, i) => (
-
-
-
- ))}
- {!isProjectViewLoading &&
- workspacesWithFaveProp.map((workspace, ind) =>
- renderProjectListItem(workspace, workspace.isFavorite, ind)
- )}
-
- );
- break;
- }
- } else if (orgWorkspaces.length && searchFilter) {
- projectsComponents = (
-
-
-
No projects match search...
-
- );
- }
-
return (
@@ -395,63 +80,7 @@ export const ProductOverviewPage = ({ type }: Props) => {
)}
-
-
-
setSearchFilter(e.target.value)}
- leftIcon={
}
- />
-
-
-
-
-
-
-
-
- {
- localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID);
- setProjectsViewMode(ProjectsViewMode.GRID);
- }}
- ariaLabel="grid"
- size="xs"
- className={`${
- projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent"
- } min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
- >
-
-
- {
- localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST);
- setProjectsViewMode(ProjectsViewMode.LIST);
- }}
- ariaLabel="list"
- size="xs"
- className={`${
- projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent"
- } min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
- >
-
-
-
+
{(isAllowed) => (
{
)}
-
- {projectsComponents}
- {!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
-
- )}
- {isWorkspaceEmpty && (
-
-
-
- You are not part of any projects in this organization yet. When you are, they will
- appear here.
-
-
- Create a new project, or ask other organization members to give you necessary
- permissions.
-
-
- )}
+
+ {projectListView === ProjectListView.MyProjects && (
+
+ )}
+ {projectListView === ProjectListView.AllProjects && (
+
+ )}
handlePopUpToggle("addNewWs", isOpen)}
diff --git a/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx b/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx
new file mode 100644
index 000000000..3cfc5972e
--- /dev/null
+++ b/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx
@@ -0,0 +1,286 @@
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import {
+ faArrowDownAZ,
+ faBorderAll,
+ faCheck,
+ faFolderOpen,
+ faList,
+ faMagnifyingGlass
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useNavigate } from "@tanstack/react-router";
+import { twMerge } from "tailwind-merge";
+
+import { createNotification } from "@app/components/notifications";
+import {
+ Button,
+ FormControl,
+ IconButton,
+ Input,
+ Modal,
+ ModalContent,
+ Pagination,
+ Skeleton,
+ Tooltip
+} from "@app/components/v2";
+import { getProjectHomePage } from "@app/helpers/project";
+import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
+import { useRequestProjectAccess, useSearchProjects } from "@app/hooks/api";
+import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
+
+import { ProjectListToggle, ProjectListView } from "./ProjectListToggle";
+
+type Props = {
+ type: ProjectType;
+ onListViewToggle: (value: ProjectListView) => void;
+};
+
+type RequestAccessModalProps = {
+ projectId: string;
+ onPopUpToggle: () => void;
+};
+
+const RequestAccessModal = ({ projectId, onPopUpToggle }: RequestAccessModalProps) => {
+ const form = useForm<{ note: string }>();
+
+ const requestProjectAccess = useRequestProjectAccess();
+
+ const onFormSubmit = ({ note }: { note: string }) => {
+ if (requestProjectAccess.isPending) return;
+ requestProjectAccess.mutate(
+ {
+ comment: note,
+ projectId
+ },
+ {
+ onSuccess: () => {
+ createNotification({
+ type: "success",
+ title: "Project Access Request Sent",
+ text: "Project admins will receive an email and will grant you access."
+ });
+ onPopUpToggle();
+ }
+ }
+ );
+ };
+
+ return (
+
+ );
+};
+
+export const AllProjectView = ({ type, onListViewToggle }: Props) => {
+ const navigate = useNavigate();
+ const [searchFilter, setSearchFilter] = useState("");
+ const [debouncedSearch] = useDebounce(searchFilter);
+ const {
+ setPage,
+ perPage,
+ setPerPage,
+ page,
+ offset,
+ limit,
+ toggleOrderDirection,
+ orderDirection
+ } = usePagination("name", { initPerPage: 50 });
+ const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([
+ "requestAccessConfirmation"
+ ] as const);
+
+ const { data: searchedProjects, isPending: isProjectLoading } = useSearchProjects({
+ limit,
+ offset,
+ type,
+ name: debouncedSearch || undefined,
+ orderDirection
+ });
+
+ useResetPageHelper({
+ setPage,
+ offset,
+ totalCount: searchedProjects?.totalCount || 0
+ });
+ const requestedWorkspaceDetails = popUp.requestAccessConfirmation.data as Workspace;
+
+ return (
+
+
+
+
+
setSearchFilter(e.target.value)}
+ leftIcon={
}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isProjectLoading &&
+ Array.apply(0, Array(3)).map((_x, i) => (
+
+
+
+ ))}
+ {!isProjectLoading &&
+ searchedProjects?.projects?.map((workspace) => (
+
{
+ if (evt.key === "Enter" && workspace.isMember) {
+ navigate({
+ to: getProjectHomePage(workspace),
+ params: {
+ projectId: workspace.id
+ }
+ });
+ }
+ }}
+ onClick={() => {
+ if (workspace.isMember) {
+ navigate({
+ to: getProjectHomePage(workspace),
+ params: {
+ projectId: workspace.id
+ }
+ });
+ }
+ }}
+ key={workspace.id}
+ className={twMerge(
+ "group flex min-w-72 grid-cols-6 items-center justify-center border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 py-3 first:rounded-t-md",
+ workspace.isMember ? "cursor-pointer hover:bg-mineshaft-700" : "cursor-default"
+ )}
+ >
+
+
+
+ {workspace.name}
+
+
+ {workspace.isMember ? (
+
+
+ Joined
+
+ ) : (
+
+ handlePopUpOpen("requestAccessConfirmation", workspace)}
+ >
+ Request Access
+
+
+ )}
+
+
+
+ {workspace.description}
+
+
+
+ ))}
+
+ {!isProjectLoading && Boolean(searchedProjects?.totalCount) && (
+
+ )}
+ {!isProjectLoading && !searchedProjects?.totalCount && (
+
+
+
+ You are not part of any projects in this organization yet. When you are, they will
+ appear here.
+
+
+ Create a new project, or ask other organization members to give you necessary
+ permissions.
+
+
+ )}
+
handlePopUpToggle("requestAccessConfirmation", isOpen)}
+ >
+
+ handlePopUpToggle("requestAccessConfirmation")}
+ projectId={requestedWorkspaceDetails?.id}
+ />
+
+
+
+ );
+};
diff --git a/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx b/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx
new file mode 100644
index 000000000..2eecb540d
--- /dev/null
+++ b/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx
@@ -0,0 +1,414 @@
+import { ReactNode, useMemo, useState } from "react";
+import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons";
+import {
+ faArrowDownAZ,
+ faArrowRight,
+ faArrowUpZA,
+ faBorderAll,
+ faList,
+ faMagnifyingGlass,
+ faSearch,
+ faStar as faSolidStar
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useNavigate } from "@tanstack/react-router";
+
+import { createNotification } from "@app/components/notifications";
+import { IconButton, Input, Pagination, Skeleton, Tooltip } from "@app/components/v2";
+import { useOrganization } from "@app/context";
+import { getProjectHomePage } from "@app/helpers/project";
+import { usePagination, useResetPageHelper } from "@app/hooks";
+import { useGetUserWorkspaces } from "@app/hooks/api";
+import { OrderByDirection } from "@app/hooks/api/generic/types";
+import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
+import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
+import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
+
+import { ProjectListToggle, ProjectListView } from "./ProjectListToggle";
+
+type Props = {
+ type: ProjectType;
+ onListViewToggle: (value: ProjectListView) => void;
+};
+
+enum ProjectOrderBy {
+ Name = "name"
+}
+
+enum ProjectsViewMode {
+ GRID = "grid",
+ LIST = "list"
+}
+
+export const MyProjectView = ({ type, onListViewToggle }: Props) => {
+ const navigate = useNavigate();
+ const { currentOrg } = useOrganization();
+
+ const { data: workspaces = [], isPending: isWorkspaceLoading } = useGetUserWorkspaces({
+ type
+ });
+ const {
+ setPage,
+ perPage,
+ setPerPage,
+ page,
+ offset,
+ limit,
+ toggleOrderDirection,
+ orderDirection
+ } = usePagination(ProjectOrderBy.Name, { initPerPage: 24 });
+ const { data: projectFavorites, isPending: isProjectFavoritesLoading } =
+ useGetUserProjectFavorites(currentOrg?.id);
+
+ const [projectsViewMode, setProjectsViewMode] = useState(
+ (localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID
+ );
+ const [searchFilter, setSearchFilter] = useState("");
+
+ const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading;
+ const isWorkspaceEmpty = !isProjectViewLoading && workspaces?.length === 0;
+ const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
+
+ const filteredWorkspaces = useMemo(
+ () =>
+ workspaces
+ .filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()))
+ .sort((a, b) =>
+ orderDirection === OrderByDirection.ASC
+ ? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
+ : b.name.toLowerCase().localeCompare(a.name.toLowerCase())
+ ),
+ [searchFilter, orderDirection, workspaces]
+ );
+
+ useResetPageHelper({
+ setPage,
+ offset,
+ totalCount: filteredWorkspaces.length
+ });
+
+ const { workspacesWithFaveProp } = useMemo(() => {
+ const workspacesWithFav = filteredWorkspaces
+ .map((w): Workspace & { isFavorite: boolean } => ({
+ ...w,
+ isFavorite: Boolean(projectFavorites?.includes(w.id))
+ }))
+ .sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite))
+ .slice(offset, limit * page);
+
+ return {
+ workspacesWithFaveProp: workspacesWithFav
+ };
+ }, [filteredWorkspaces, projectFavorites, offset, limit, page]);
+
+ const addProjectToFavorites = async (projectId: string) => {
+ try {
+ if (currentOrg?.id) {
+ await updateUserProjectFavorites({
+ orgId: currentOrg?.id,
+ projectFavorites: [...(projectFavorites || []), projectId]
+ });
+ }
+ } catch {
+ createNotification({
+ text: "Failed to add project to favorites.",
+ type: "error"
+ });
+ }
+ };
+ const removeProjectFromFavorites = async (projectId: string) => {
+ try {
+ if (currentOrg?.id) {
+ await updateUserProjectFavorites({
+ orgId: currentOrg?.id,
+ projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)]
+ });
+ }
+ } catch {
+ createNotification({
+ text: "Failed to remove project from favorites.",
+ type: "error"
+ });
+ }
+ };
+
+ const renderProjectGridItem = (workspace: Workspace, isFavorite: boolean) => (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
+ {
+ navigate({
+ to: getProjectHomePage(workspace),
+ params: {
+ projectId: workspace.id
+ }
+ });
+ }}
+ key={workspace.id}
+ className="flex h-40 min-w-72 cursor-pointer flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
+ >
+
+
{workspace.name}
+ {isFavorite ? (
+
{
+ e.stopPropagation();
+ removeProjectFromFavorites(workspace.id);
+ }}
+ />
+ ) : (
+ {
+ e.stopPropagation();
+ addProjectToFavorites(workspace.id);
+ }}
+ />
+ )}
+
+
+
+ {workspace.description}
+
+
+
+ {type === ProjectType.SecretManager && (
+
+ {workspace.environments?.length || 0} environments
+
+ )}
+
+
+ Explore{" "}
+
+
+
+
+
+ );
+ const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
+ {
+ navigate({
+ to: getProjectHomePage(workspace),
+ params: {
+ projectId: workspace.id
+ }
+ });
+ }}
+ key={workspace.id}
+ className={`group grid h-14 min-w-72 cursor-pointer grid-cols-6 border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
+ index === 0 && "rounded-t-md"
+ }`}
+ >
+
+
+ {type === ProjectType.SecretManager && (
+
+ {workspace.environments?.length || 0} environments
+
+ )}
+ {isFavorite ? (
+
{
+ e.stopPropagation();
+ removeProjectFromFavorites(workspace.id);
+ }}
+ />
+ ) : (
+ {
+ e.stopPropagation();
+ addProjectToFavorites(workspace.id);
+ }}
+ />
+ )}
+
+
+ );
+ let projectsComponents: ReactNode;
+
+ if (filteredWorkspaces.length || isProjectViewLoading) {
+ switch (projectsViewMode) {
+ case ProjectsViewMode.GRID:
+ projectsComponents = (
+
+ {isProjectViewLoading &&
+ Array.apply(0, Array(3)).map((_x, i) => (
+
+ ))}
+ {!isProjectViewLoading && (
+ <>
+ {workspacesWithFaveProp.map((workspace) =>
+ renderProjectGridItem(workspace, workspace.isFavorite)
+ )}
+ >
+ )}
+
+ );
+
+ break;
+ case ProjectsViewMode.LIST:
+ default:
+ projectsComponents = (
+
+ {isProjectViewLoading &&
+ Array.apply(0, Array(3)).map((_x, i) => (
+
+
+
+ ))}
+ {!isProjectViewLoading &&
+ workspacesWithFaveProp.map((workspace, ind) =>
+ renderProjectListItem(workspace, workspace.isFavorite, ind)
+ )}
+
+ );
+ break;
+ }
+ } else if (workspaces.length && searchFilter) {
+ projectsComponents = (
+
+
+
No projects match search...
+
+ );
+ }
+ return (
+
+
+
+
+
setSearchFilter(e.target.value)}
+ leftIcon={
}
+ />
+
+
+
+
+
+
+
+
+ {
+ localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID);
+ setProjectsViewMode(ProjectsViewMode.GRID);
+ }}
+ ariaLabel="grid"
+ size="xs"
+ className={`${
+ projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent"
+ } min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
+ >
+
+
+ {
+ localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST);
+ setProjectsViewMode(ProjectsViewMode.LIST);
+ }}
+ ariaLabel="list"
+ size="xs"
+ className={`${
+ projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent"
+ } min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
+ >
+
+
+
+
+ {projectsComponents}
+ {!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
+
+ )}
+ {isWorkspaceEmpty && (
+
+
+
+ You are not part of any projects in this organization yet. When you are, they will
+ appear here.
+
+
+ Create a new project, or ask other organization members to give you necessary
+ permissions.
+
+
+ )}
+
+ );
+};
diff --git a/frontend/src/pages/organization/SecretManagerOverviewPage/components/ProjectListToggle.tsx b/frontend/src/pages/organization/SecretManagerOverviewPage/components/ProjectListToggle.tsx
new file mode 100644
index 000000000..4c16f8cfb
--- /dev/null
+++ b/frontend/src/pages/organization/SecretManagerOverviewPage/components/ProjectListToggle.tsx
@@ -0,0 +1,20 @@
+import { Select, SelectItem } from "@app/components/v2";
+
+export enum ProjectListView {
+ MyProjects = "my-projects",
+ AllProjects = "all-projects"
+}
+
+type Props = {
+ value: ProjectListView;
+ onChange: (value: ProjectListView) => void;
+};
+
+export const ProjectListToggle = ({ value, onChange }: Props) => {
+ return (
+
+ My Projects
+ All Projects
+
+ );
+};
diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx
index 9afedcbc5..fa4249fc4 100644
--- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx
+++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx
@@ -1,11 +1,20 @@
-import { useMemo } from "react";
+import { useEffect, useMemo } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod";
+import { useNavigate, useSearch } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
-import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
+import {
+ Alert,
+ AlertDescription,
+ Button,
+ FilterableSelect,
+ FormControl,
+ Modal,
+ ModalContent
+} from "@app/components/v2";
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
import {
OrgPermissionActions,
@@ -48,7 +57,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
const { t } = useTranslation();
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
+ const navigate = useNavigate({ from: "" });
const { permission } = useOrgPermission();
+ const requesterEmail = useSearch({
+ strict: false,
+ select: (el) => el?.requesterEmail
+ });
const orgId = currentOrg?.id || "";
const workspaceId = currentWorkspace?.id || "";
@@ -62,6 +76,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
control,
handleSubmit,
reset,
+ setValue,
watch,
formState: { isSubmitting, errors }
} = useForm({
@@ -71,6 +86,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
+ useEffect(() => {
+ if (requesterEmail) {
+ handlePopUpToggle("addMember", true);
+ }
+ }, [requesterEmail]);
+
const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => {
if (!currentWorkspace) return;
if (!currentOrg?.id) return;
@@ -126,12 +147,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
reset();
};
- const filteredOrgUsers = useMemo(() => {
+ const { append } = useFieldArray({ control, name: "orgMemberships" });
+ const projectInviteList = useMemo(() => {
const wsUserUsernames = new Map();
members?.forEach((member) => {
wsUserUsernames.set(member.user.username, true);
});
- return (orgUsers || [])
+ const list = (orgUsers || [])
.filter(({ user: u }) => !wsUserUsernames.has(u.username))
.map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({
value: id,
@@ -140,13 +162,31 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
? `${firstName} ${lastName}`
: firstName || lastName || email || inviteEmail
}));
+ const requesterStatus = { isProjectUser: wsUserUsernames.has(requesterEmail), userLabel: "" };
+ if (!requesterStatus.isProjectUser) {
+ const userDetails = orgUsers?.find((el) => el.user.username === requesterEmail);
+ if (userDetails) {
+ const { user } = userDetails;
+ const label =
+ user.firstName && user.lastName
+ ? `${user.firstName} ${user.lastName}`
+ : user.firstName || user.lastName || (user.email as string);
+ requesterStatus.userLabel = label;
+ setValue("orgMemberships", [
+ {
+ value: userDetails.id,
+ label
+ }
+ ]);
+ }
+ }
+
+ return { list, requesterStatus };
}, [orgUsers, members]);
const selectedOrgMemberships = watch("orgMemberships");
const selectedRoleSlugs = watch("projectRoleSlugs");
- const { append } = useFieldArray({ control, name: "orgMemberships" });
-
const canInviteNewMembers = permission.can(
OrgPermissionActions.Create,
OrgPermissionSubjects.Member
@@ -155,7 +195,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
return (
handlePopUpToggle("addMember", isOpen)}
+ onOpenChange={(isOpen) => {
+ if (!isOpen)
+ navigate({
+ search: (prev) => ({ ...prev, requesterEmail: "" })
+ });
+ handlePopUpToggle("addMember", isOpen);
+ }}
>
{
noOptionsMessage={() => (
<>
- {!filteredOrgUsers.length && (
+ {!projectInviteList.list.length && (
All organization members are already assigned to this project.
)}
@@ -221,7 +267,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
placeholder="Add one or more users..."
isMulti
name="members"
- options={filteredOrgUsers}
+ options={projectInviteList.list}
value={field.value}
onChange={field.onChange}
/>
@@ -231,7 +277,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
placeholder="Add one or more users..."
isMulti
name="members"
- options={filteredOrgUsers}
+ options={projectInviteList.list}
value={field.value}
onChange={field.onChange}
/>
@@ -239,7 +285,6 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
)}
/>
-
{
)}
/>
+ {requesterEmail && projectInviteList.requesterStatus.isProjectUser && (
+
+ Requested user is part of the project.
+
+ )}
+ {requesterEmail && !projectInviteList.requesterStatus.isProjectUser && (
+
+
+ Assign a role to provide access to requesting user{" "}
+ {projectInviteList.requesterStatus.userLabel} .
+
+
+ )}
{
return {
breadcrumbs: [
diff --git a/frontend/src/pages/project/AccessControlPage/route-kms.tsx b/frontend/src/pages/project/AccessControlPage/route-kms.tsx
index c77c24b4d..4feca32b1 100644
--- a/frontend/src/pages/project/AccessControlPage/route-kms.tsx
+++ b/frontend/src/pages/project/AccessControlPage/route-kms.tsx
@@ -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: [
diff --git a/frontend/src/pages/project/AccessControlPage/route-secret-manager.tsx b/frontend/src/pages/project/AccessControlPage/route-secret-manager.tsx
index b3c0bec00..6b4221a64 100644
--- a/frontend/src/pages/project/AccessControlPage/route-secret-manager.tsx
+++ b/frontend/src/pages/project/AccessControlPage/route-secret-manager.tsx
@@ -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: [