diff --git a/backend/src/db/migrations/20240627142223_member-project-favorite.ts b/backend/src/db/migrations/20240627142223_member-project-favorite.ts new file mode 100644 index 0000000000..0021cca4cb --- /dev/null +++ b/backend/src/db/migrations/20240627142223_member-project-favorite.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.OrgMembership, "projectFavorites"))) { + await knex.schema.alterTable(TableName.OrgMembership, (tb) => { + tb.specificType("projectFavorites", "text[]"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.OrgMembership, "projectFavorites")) { + await knex.schema.alterTable(TableName.OrgMembership, (t) => { + t.dropColumn("projectFavorites"); + }); + } +} diff --git a/backend/src/db/schemas/org-memberships.ts b/backend/src/db/schemas/org-memberships.ts index 585addb7ce..b1858e5be6 100644 --- a/backend/src/db/schemas/org-memberships.ts +++ b/backend/src/db/schemas/org-memberships.ts @@ -16,7 +16,8 @@ export const OrgMembershipsSchema = z.object({ updatedAt: z.date(), userId: z.string().uuid().nullable().optional(), orgId: z.string().uuid(), - roleId: z.string().uuid().nullable().optional() + roleId: z.string().uuid().nullable().optional(), + projectFavorites: z.string().array().nullable().optional() }); export type TOrgMemberships = z.infer; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index dfcbf82cfa..6b7b18f5c1 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -415,8 +415,10 @@ export const registerRoutes = async ( userAliasDAL, orgMembershipDAL, tokenService, - smtpService + smtpService, + projectMembershipDAL }); + const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL }); const passwordService = authPaswordServiceFactory({ tokenService, diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index b9269d66e1..d3c0db242f 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; -import { authRateLimit, readLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -90,4 +90,48 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { return res.redirect(`${appCfg.SITE_URL}/login`); } }); + + server.route({ + method: "GET", + url: "/me/project-favorites", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + orgId: z.string().trim() + }), + response: { + 200: z.object({ + projectFavorites: z.string().array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + return server.services.user.getUserProjectFavorites(req.permission.id, req.query.orgId); + } + }); + + server.route({ + method: "PUT", + url: "/me/project-favorites", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + orgId: z.string().trim(), + projectFavorites: z.string().array() + }) + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + return server.services.user.updateUserProjectFavorites( + req.permission.id, + req.body.orgId, + req.body.projectFavorites + ); + } + }); }; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 4ee8bdc1f7..5f01066044 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -8,6 +8,7 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { AuthMethod } from "../auth/auth-type"; +import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TUserDALFactory } from "./user-dal"; type TUserServiceFactoryDep = { @@ -26,8 +27,9 @@ type TUserServiceFactoryDep = { | "delete" >; userAliasDAL: Pick; - orgMembershipDAL: Pick; + orgMembershipDAL: Pick; tokenService: Pick; + projectMembershipDAL: Pick; smtpService: Pick; }; @@ -37,6 +39,7 @@ export const userServiceFactory = ({ userDAL, userAliasDAL, orgMembershipDAL, + projectMembershipDAL, tokenService, smtpService }: TUserServiceFactoryDep) => { @@ -247,6 +250,51 @@ export const userServiceFactory = ({ return privateKey; }; + const getUserProjectFavorites = async (userId: string, orgId: string) => { + const orgMembership = await orgMembershipDAL.findOne({ + userId, + orgId + }); + + if (!orgMembership) { + throw new BadRequestError({ + message: "User does not belong in the organization." + }); + } + + return { projectFavorites: orgMembership.projectFavorites || [] }; + }; + + const updateUserProjectFavorites = async (userId: string, orgId: string, projectIds: string[]) => { + const orgMembership = await orgMembershipDAL.findOne({ + userId, + orgId + }); + + if (!orgMembership) { + throw new BadRequestError({ + message: "User does not belong in the organization." + }); + } + + const matchingUserProjectMemberships = await projectMembershipDAL.find({ + userId, + $in: { + projectId: projectIds + } + }); + + const memberProjectFavorites = matchingUserProjectMemberships.map( + (projectMembership) => projectMembership.projectId + ); + + const updatedOrgMembership = await orgMembershipDAL.updateById(orgMembership.id, { + projectFavorites: memberProjectFavorites + }); + + return updatedOrgMembership.projectFavorites; + }; + return { sendEmailVerificationCode, verifyEmailVerificationCode, @@ -258,6 +306,8 @@ export const userServiceFactory = ({ createUserAction, getUserAction, unlockUser, - getUserPrivateKey + getUserPrivateKey, + getUserProjectFavorites, + updateUserProjectFavorites }; }; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 20e986aab3..26e932ac6d 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -7,6 +7,7 @@ import { import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace/queries"; +import { userKeys } from "./queries"; import { AddUserToWsDTOE2EE, AddUserToWsDTONonE2EE } from "./types"; export const useAddUserToWsE2EE = () => { @@ -88,3 +89,26 @@ export const useVerifyEmailVerificationCode = () => { } }); }; + +export const useUpdateUserProjectFavorites = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + orgId, + projectFavorites + }: { + orgId: string; + projectFavorites: string[]; + }) => { + await apiRequest.put("/api/v1/user/me/project-favorites", { + orgId, + projectFavorites + }); + + return {}; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(userKeys.userProjectFavorites(orgId)); + } + }); +}; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index fa0b932eaa..1a49e8d7c5 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -22,11 +22,13 @@ export const userKeys = { getUser: ["user"] as const, getPrivateKey: ["user"] as const, userAction: ["user-action"] as const, + userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const, getOrgUsers: (orgId: string) => [{ orgId }, "user"], myIp: ["ip"] as const, myAPIKeys: ["api-keys"] as const, myAPIKeysV2: ["api-keys-v2"] as const, mySessions: ["sessions"] as const, + myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const }; @@ -74,6 +76,14 @@ export const fetchUserAction = async (action: string) => { return data.userAction || ""; }; +export const fetchUserProjectFavorites = async (orgId: string) => { + const { data } = await apiRequest.get<{ projectFavorites: string[] }>( + `/api/v1/user/me/project-favorites?orgId=${orgId}` + ); + + return data.projectFavorites; +}; + export const useRenameUser = () => { const queryClient = useQueryClient(); @@ -122,6 +132,12 @@ export const fetchOrgUsers = async (orgId: string) => { return data.users; }; +export const useGetUserProjectFavorites = (orgId: string) => + useQuery({ + queryKey: userKeys.userProjectFavorites(orgId), + queryFn: () => fetchUserProjectFavorites(orgId) + }); + export const useGetOrgUsers = (orgId: string) => useQuery({ queryKey: userKeys.getOrgUsers(orgId), diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 72351df88e..acf899a7ee 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -12,6 +12,7 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; +import { faStar } from "@fortawesome/free-regular-svg-icons"; import { faAngleDown, faArrowLeft, @@ -23,7 +24,8 @@ import { faInfo, faMobile, faPlus, - faQuestion + faQuestion, + faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; @@ -72,6 +74,9 @@ import { useRegisterUserAction, useSelectOrganization } from "@app/hooks/api"; +import { Workspace } from "@app/hooks/api/types"; +import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation"; +import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; import { navigateUserToOrg } from "@app/views/Login/Login.utils"; import { CreateOrgModal } from "@app/views/Org/components"; @@ -122,6 +127,20 @@ export const AppLayout = ({ children }: LayoutProps) => { const { workspaces, currentWorkspace } = useWorkspace(); const { orgs, currentOrg } = useOrganization(); + const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); + const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); + + const workspacesWithFaveProp = useMemo( + () => + workspaces + .map((w): Workspace & { isFavorite: boolean } => ({ + ...w, + isFavorite: Boolean(projectFavorites?.includes(w.id)) + })) + .sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite)), + [workspaces, projectFavorites] + ); + const { user } = useUser(); const { subscription } = useSubscription(); const workspaceId = currentWorkspace?.id || ""; @@ -271,6 +290,38 @@ export const AppLayout = ({ children }: LayoutProps) => { } }; + const addProjectToFavorites = async (projectId: string) => { + try { + if (currentOrg?.id) { + await updateUserProjectFavorites({ + orgId: currentOrg?.id, + projectFavorites: [...(projectFavorites || []), projectId] + }); + } + } catch (err) { + 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 (err) { + createNotification({ + text: "Failed to remove project from favorites.", + type: "error" + }); + } + }; + return ( <>
@@ -451,19 +502,47 @@ export const AppLayout = ({ children }: LayoutProps) => { dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50 max-h-96 border-gray-700" >
- {workspaces + {workspacesWithFaveProp .filter((ws) => ws.orgId === currentOrg?.id) - .map(({ id, name }) => ( - ( +
- {name} - +
+ + {name} + +
+
+ {isFavorite ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(id); + }} + /> + )} +
+
))}

diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 177a6586b1..d443b35b1e 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -1,6 +1,6 @@ // REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Head from "next/head"; @@ -8,7 +8,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { faSlack } from "@fortawesome/free-brands-svg-icons"; -import { faFolderOpen } from "@fortawesome/free-regular-svg-icons"; +import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons"; import { faArrowRight, faArrowUpRightFromSquare, @@ -24,6 +24,7 @@ import { faNetworkWired, faPlug, faPlus, + faStar as faSolidStar, faUserPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -62,6 +63,9 @@ import { } from "@app/hooks/api"; // import { fetchUserWsKey } from "@app/hooks/api/keys/queries"; 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 { usePopUp } from "@app/hooks/usePopUp"; const features = [ @@ -485,7 +489,11 @@ const OrganizationPage = withPermission( const { currentOrg } = useOrganization(); const routerOrgId = String(router.query.id); const orgWorkspaces = workspaces?.filter((workspace) => workspace.orgId === routerOrgId) || []; + const { data: projectFavorites, isLoading: isProjectFavoritesLoading } = + useGetUserProjectFavorites(currentOrg?.id!); + const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); + const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading; const addUsersToProject = useAddUserToWsNonE2EE(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ @@ -570,56 +578,187 @@ const OrganizationPage = withPermission( ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()) ); + const { workspacesWithFaveProp, favoriteWorkspaces, nonFavoriteWorkspaces } = 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)); + + const favWorkspaces = workspacesWithFav.filter((w) => w.isFavorite); + const nonFavWorkspaces = workspacesWithFav.filter((w) => !w.isFavorite); + + return { + workspacesWithFaveProp: workspacesWithFav, + favoriteWorkspaces: favWorkspaces, + nonFavoriteWorkspaces: nonFavWorkspaces + }; + }, [filteredWorkspaces, projectFavorites]); + + const addProjectToFavorites = async (projectId: string) => { + try { + if (currentOrg?.id) { + await updateUserProjectFavorites({ + orgId: currentOrg?.id, + projectFavorites: [...(projectFavorites || []), projectId] + }); + } + } catch (err) { + 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 (err) { + 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 +
{ + router.push(`/project/${workspace.id}/secrets/overview`); + localStorage.setItem("projectData.id", workspace.id); + }} + key={workspace.id} + className="min-w-72 flex h-40 cursor-pointer flex-col justify-between 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.environments?.length || 0} environments +
+ +
+ ); + + const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + router.push(`/project/${workspace.id}/secrets/overview`); + localStorage.setItem("projectData.id", workspace.id); + }} + key={workspace.id} + className={`min-w-72 group grid h-14 cursor-pointer grid-cols-6 border-t border-l border-r border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ + index === 0 && "rounded-t-md" + } ${index === filteredWorkspaces.length - 1 && "rounded-b-md border-b"}`} + > +
+ +
{workspace.name}
+
+
+
+ {workspace.environments?.length || 0} environments +
+ {isFavorite ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(workspace.id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(workspace.id); + }} + /> + )} +
+
+ ); + const projectsGridView = ( -
- {isWorkspaceLoading && - Array.apply(0, Array(3)).map((_x, i) => ( + <> + {favoriteWorkspaces.length > 0 && ( + <> +

Favorites

0 && "border-b border-mineshaft-600" + } py-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4`} > -
- -
-
- -
-
- -
-
- ))} - {filteredWorkspaces.map((workspace) => ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
{ - router.push(`/project/${workspace.id}/secrets/overview`); - localStorage.setItem("projectData.id", workspace.id); - }} - key={workspace.id} - className="min-w-72 group flex h-40 cursor-pointer flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4" - > -
{workspace.name}
-
- {workspace.environments?.length || 0} environments + {favoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, true))}
- -
- ))} -
+ ))} + {!isProjectViewLoading && + nonFavoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, false))} +
+ ); const projectsListView = (
- {isWorkspaceLoading && + {isProjectViewLoading && Array.apply(0, Array(3)).map((_x, i) => (
))} - {filteredWorkspaces.map((workspace, ind) => ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
{ - router.push(`/project/${workspace.id}/secrets/overview`); - localStorage.setItem("projectData.id", workspace.id); - }} - key={workspace.id} - className={`min-w-72 group grid h-14 cursor-pointer grid-cols-6 border-t border-l border-r border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ - ind === 0 && "rounded-t-md" - } ${ind === filteredWorkspaces.length - 1 && "rounded-b-md border-b"}`} - > -
- -
{workspace.name}
-
-
-
- {workspace.environments?.length || 0} environments -
-
-
- ))} + {!isProjectViewLoading && + workspacesWithFaveProp.map((workspace, ind) => + renderProjectListItem(workspace, workspace.isFavorite, ind) + )}
);