mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 02:27:39 +00:00
Merge pull request #2040 from Infisical/feat/mark-projects-as-favourite
feat: allow org members to mark projects as favorites
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
if (await knex.schema.hasColumn(TableName.OrgMembership, "projectFavorites")) {
|
||||||
|
await knex.schema.alterTable(TableName.OrgMembership, (t) => {
|
||||||
|
t.dropColumn("projectFavorites");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ export const OrgMembershipsSchema = z.object({
|
|||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
userId: z.string().uuid().nullable().optional(),
|
userId: z.string().uuid().nullable().optional(),
|
||||||
orgId: z.string().uuid(),
|
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<typeof OrgMembershipsSchema>;
|
export type TOrgMemberships = z.infer<typeof OrgMembershipsSchema>;
|
||||||
|
|||||||
@@ -415,8 +415,10 @@ export const registerRoutes = async (
|
|||||||
userAliasDAL,
|
userAliasDAL,
|
||||||
orgMembershipDAL,
|
orgMembershipDAL,
|
||||||
tokenService,
|
tokenService,
|
||||||
smtpService
|
smtpService,
|
||||||
|
projectMembershipDAL
|
||||||
});
|
});
|
||||||
|
|
||||||
const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL });
|
const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL });
|
||||||
const passwordService = authPaswordServiceFactory({
|
const passwordService = authPaswordServiceFactory({
|
||||||
tokenService,
|
tokenService,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
|
import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { logger } from "@app/lib/logger";
|
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 { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
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`);
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
|||||||
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
|
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
|
||||||
|
|
||||||
import { AuthMethod } from "../auth/auth-type";
|
import { AuthMethod } from "../auth/auth-type";
|
||||||
|
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||||
import { TUserDALFactory } from "./user-dal";
|
import { TUserDALFactory } from "./user-dal";
|
||||||
|
|
||||||
type TUserServiceFactoryDep = {
|
type TUserServiceFactoryDep = {
|
||||||
@@ -26,8 +27,9 @@ type TUserServiceFactoryDep = {
|
|||||||
| "delete"
|
| "delete"
|
||||||
>;
|
>;
|
||||||
userAliasDAL: Pick<TUserAliasDALFactory, "find" | "insertMany">;
|
userAliasDAL: Pick<TUserAliasDALFactory, "find" | "insertMany">;
|
||||||
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "insertMany">;
|
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "insertMany" | "findOne" | "updateById">;
|
||||||
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
|
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
|
||||||
smtpService: Pick<TSmtpService, "sendMail">;
|
smtpService: Pick<TSmtpService, "sendMail">;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ export const userServiceFactory = ({
|
|||||||
userDAL,
|
userDAL,
|
||||||
userAliasDAL,
|
userAliasDAL,
|
||||||
orgMembershipDAL,
|
orgMembershipDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
tokenService,
|
tokenService,
|
||||||
smtpService
|
smtpService
|
||||||
}: TUserServiceFactoryDep) => {
|
}: TUserServiceFactoryDep) => {
|
||||||
@@ -247,6 +250,51 @@ export const userServiceFactory = ({
|
|||||||
return privateKey;
|
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 {
|
return {
|
||||||
sendEmailVerificationCode,
|
sendEmailVerificationCode,
|
||||||
verifyEmailVerificationCode,
|
verifyEmailVerificationCode,
|
||||||
@@ -258,6 +306,8 @@ export const userServiceFactory = ({
|
|||||||
createUserAction,
|
createUserAction,
|
||||||
getUserAction,
|
getUserAction,
|
||||||
unlockUser,
|
unlockUser,
|
||||||
getUserPrivateKey
|
getUserPrivateKey,
|
||||||
|
getUserProjectFavorites,
|
||||||
|
updateUserProjectFavorites
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
import { workspaceKeys } from "../workspace/queries";
|
import { workspaceKeys } from "../workspace/queries";
|
||||||
|
import { userKeys } from "./queries";
|
||||||
import { AddUserToWsDTOE2EE, AddUserToWsDTONonE2EE } from "./types";
|
import { AddUserToWsDTOE2EE, AddUserToWsDTONonE2EE } from "./types";
|
||||||
|
|
||||||
export const useAddUserToWsE2EE = () => {
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ export const userKeys = {
|
|||||||
getUser: ["user"] as const,
|
getUser: ["user"] as const,
|
||||||
getPrivateKey: ["user"] as const,
|
getPrivateKey: ["user"] as const,
|
||||||
userAction: ["user-action"] as const,
|
userAction: ["user-action"] as const,
|
||||||
|
userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const,
|
||||||
getOrgUsers: (orgId: string) => [{ orgId }, "user"],
|
getOrgUsers: (orgId: string) => [{ orgId }, "user"],
|
||||||
myIp: ["ip"] as const,
|
myIp: ["ip"] as const,
|
||||||
myAPIKeys: ["api-keys"] as const,
|
myAPIKeys: ["api-keys"] as const,
|
||||||
myAPIKeysV2: ["api-keys-v2"] as const,
|
myAPIKeysV2: ["api-keys-v2"] as const,
|
||||||
mySessions: ["sessions"] as const,
|
mySessions: ["sessions"] as const,
|
||||||
|
|
||||||
myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const
|
myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,6 +76,14 @@ export const fetchUserAction = async (action: string) => {
|
|||||||
return data.userAction || "";
|
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 = () => {
|
export const useRenameUser = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -122,6 +132,12 @@ export const fetchOrgUsers = async (orgId: string) => {
|
|||||||
return data.users;
|
return data.users;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useGetUserProjectFavorites = (orgId: string) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: userKeys.userProjectFavorites(orgId),
|
||||||
|
queryFn: () => fetchUserProjectFavorites(orgId)
|
||||||
|
});
|
||||||
|
|
||||||
export const useGetOrgUsers = (orgId: string) =>
|
export const useGetOrgUsers = (orgId: string) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: userKeys.getOrgUsers(orgId),
|
queryKey: userKeys.getOrgUsers(orgId),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons";
|
import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons";
|
||||||
|
import { faStar } from "@fortawesome/free-regular-svg-icons";
|
||||||
import {
|
import {
|
||||||
faAngleDown,
|
faAngleDown,
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
@@ -23,7 +24,8 @@ import {
|
|||||||
faInfo,
|
faInfo,
|
||||||
faMobile,
|
faMobile,
|
||||||
faPlus,
|
faPlus,
|
||||||
faQuestion
|
faQuestion,
|
||||||
|
faStar as faSolidStar
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
@@ -72,6 +74,9 @@ import {
|
|||||||
useRegisterUserAction,
|
useRegisterUserAction,
|
||||||
useSelectOrganization
|
useSelectOrganization
|
||||||
} from "@app/hooks/api";
|
} 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 { navigateUserToOrg } from "@app/views/Login/Login.utils";
|
||||||
import { CreateOrgModal } from "@app/views/Org/components";
|
import { CreateOrgModal } from "@app/views/Org/components";
|
||||||
|
|
||||||
@@ -122,6 +127,20 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
|||||||
const { workspaces, currentWorkspace } = useWorkspace();
|
const { workspaces, currentWorkspace } = useWorkspace();
|
||||||
const { orgs, currentOrg } = useOrganization();
|
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 { user } = useUser();
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
|
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
|
||||||
@@ -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"
|
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50 max-h-96 border-gray-700"
|
||||||
>
|
>
|
||||||
<div className="no-scrollbar::-webkit-scrollbar h-full no-scrollbar">
|
<div className="no-scrollbar::-webkit-scrollbar h-full no-scrollbar">
|
||||||
{workspaces
|
{workspacesWithFaveProp
|
||||||
.filter((ws) => ws.orgId === currentOrg?.id)
|
.filter((ws) => ws.orgId === currentOrg?.id)
|
||||||
.map(({ id, name }) => (
|
.map(({ id, name, isFavorite }) => (
|
||||||
<SelectItem
|
<div
|
||||||
key={`ws-layout-list-${id}`}
|
|
||||||
value={id}
|
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
currentWorkspace?.id === id && "bg-mineshaft-600",
|
"mb-1 grid grid-cols-7 rounded-md hover:bg-mineshaft-500",
|
||||||
"truncate"
|
id === currentWorkspace?.id && "bg-mineshaft-500"
|
||||||
)}
|
)}
|
||||||
|
key={id}
|
||||||
>
|
>
|
||||||
{name}
|
<div className="col-span-6">
|
||||||
</SelectItem>
|
<SelectItem
|
||||||
|
key={`ws-layout-list-${id}`}
|
||||||
|
value={id}
|
||||||
|
className="transition-none data-[highlighted]:bg-mineshaft-500"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</SelectItem>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 flex items-center">
|
||||||
|
{isFavorite ? (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faSolidStar}
|
||||||
|
className="text-sm text-mineshaft-300 hover:text-mineshaft-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeProjectFromFavorites(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faStar}
|
||||||
|
className="text-sm text-mineshaft-400 hover:text-mineshaft-300"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
addProjectToFavorites(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<hr className="mt-1 mb-1 h-px border-0 bg-gray-700" />
|
<hr className="mt-1 mb-1 h-px border-0 bg-gray-700" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
|
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
@@ -8,7 +8,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||||
import { faSlack } from "@fortawesome/free-brands-svg-icons";
|
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 {
|
import {
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
faArrowUpRightFromSquare,
|
faArrowUpRightFromSquare,
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
faNetworkWired,
|
faNetworkWired,
|
||||||
faPlug,
|
faPlug,
|
||||||
faPlus,
|
faPlus,
|
||||||
|
faStar as faSolidStar,
|
||||||
faUserPlus
|
faUserPlus
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
@@ -62,6 +63,9 @@ import {
|
|||||||
} from "@app/hooks/api";
|
} from "@app/hooks/api";
|
||||||
// import { fetchUserWsKey } from "@app/hooks/api/keys/queries";
|
// import { fetchUserWsKey } from "@app/hooks/api/keys/queries";
|
||||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||||
|
import { Workspace } from "@app/hooks/api/types";
|
||||||
|
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
|
||||||
|
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
|
||||||
import { usePopUp } from "@app/hooks/usePopUp";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
@@ -485,7 +489,11 @@ const OrganizationPage = withPermission(
|
|||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
const routerOrgId = String(router.query.id);
|
const routerOrgId = String(router.query.id);
|
||||||
const orgWorkspaces = workspaces?.filter((workspace) => workspace.orgId === routerOrgId) || [];
|
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 addUsersToProject = useAddUserToWsNonE2EE();
|
||||||
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||||
@@ -570,56 +578,187 @@ const OrganizationPage = withPermission(
|
|||||||
ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())
|
ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
const projectsGridView = (
|
const { workspacesWithFaveProp, favoriteWorkspaces, nonFavoriteWorkspaces } = useMemo(() => {
|
||||||
<div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
const workspacesWithFav = filteredWorkspaces
|
||||||
{isWorkspaceLoading &&
|
.map((w): Workspace & { isFavorite: boolean } => ({
|
||||||
Array.apply(0, Array(3)).map((_x, i) => (
|
...w,
|
||||||
<div
|
isFavorite: Boolean(projectFavorites?.includes(w.id))
|
||||||
key={`workspace-cards-loading-${i + 1}`}
|
}))
|
||||||
className="min-w-72 flex h-40 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
|
.sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite));
|
||||||
>
|
|
||||||
<div className="mt-0 text-lg text-mineshaft-100">
|
const favWorkspaces = workspacesWithFav.filter((w) => w.isFavorite);
|
||||||
<Skeleton className="w-3/4 bg-mineshaft-600" />
|
const nonFavWorkspaces = workspacesWithFav.filter((w) => !w.isFavorite);
|
||||||
</div>
|
|
||||||
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
|
return {
|
||||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
workspacesWithFaveProp: workspacesWithFav,
|
||||||
</div>
|
favoriteWorkspaces: favWorkspaces,
|
||||||
<div className="flex justify-end">
|
nonFavoriteWorkspaces: nonFavWorkspaces
|
||||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
};
|
||||||
</div>
|
}, [filteredWorkspaces, projectFavorites]);
|
||||||
</div>
|
|
||||||
))}
|
const addProjectToFavorites = async (projectId: string) => {
|
||||||
{filteredWorkspaces.map((workspace) => (
|
try {
|
||||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
if (currentOrg?.id) {
|
||||||
<div
|
await updateUserProjectFavorites({
|
||||||
onClick={() => {
|
orgId: currentOrg?.id,
|
||||||
router.push(`/project/${workspace.id}/secrets/overview`);
|
projectFavorites: [...(projectFavorites || []), projectId]
|
||||||
localStorage.setItem("projectData.id", workspace.id);
|
});
|
||||||
}}
|
}
|
||||||
key={workspace.id}
|
} catch (err) {
|
||||||
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"
|
createNotification({
|
||||||
>
|
text: "Failed to add project to favorites.",
|
||||||
<div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
|
type: "error"
|
||||||
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
|
});
|
||||||
{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 py-2 px-4 text-sm text-mineshaft-300 transition-all group-hover:border-primary-500/80 group-hover:bg-primary-800/20 group-hover:text-mineshaft-200">
|
const removeProjectFromFavorites = async (projectId: string) => {
|
||||||
Explore{" "}
|
try {
|
||||||
<FontAwesomeIcon
|
if (currentOrg?.id) {
|
||||||
icon={faArrowRight}
|
await updateUserProjectFavorites({
|
||||||
className="pl-1.5 pr-0.5 duration-200 group-hover:pl-2 group-hover:pr-0"
|
orgId: currentOrg?.id,
|
||||||
/>
|
projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)]
|
||||||
</div>
|
});
|
||||||
</button>
|
}
|
||||||
|
} 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
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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-mineshaft-300 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="mt-0 pb-6 text-sm text-mineshaft-300">
|
||||||
|
{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 py-2 px-4 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>
|
</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={() => {
|
||||||
|
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"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center sm:col-span-3 lg:col-span-4">
|
||||||
|
<FontAwesomeIcon icon={faFileShield} className="text-sm text-primary/70" />
|
||||||
|
<div className="ml-5 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-mineshaft-300 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectsGridView = (
|
||||||
|
<>
|
||||||
|
{favoriteWorkspaces.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="mt-6 text-xl font-semibold text-white">Favorites</p>
|
||||||
|
<div
|
||||||
|
className={`b grid w-full grid-cols-1 gap-4 ${
|
||||||
|
nonFavoriteWorkspaces.length > 0 && "border-b border-mineshaft-600"
|
||||||
|
} py-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4`}
|
||||||
|
>
|
||||||
|
{favoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, true))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<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="min-w-72 flex h-40 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 &&
|
||||||
|
nonFavoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, false))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const projectsListView = (
|
const projectsListView = (
|
||||||
<div className="mt-4 w-full rounded-md">
|
<div className="mt-4 w-full rounded-md">
|
||||||
{isWorkspaceLoading &&
|
{isProjectViewLoading &&
|
||||||
Array.apply(0, Array(3)).map((_x, i) => (
|
Array.apply(0, Array(3)).map((_x, i) => (
|
||||||
<div
|
<div
|
||||||
key={`workspace-cards-loading-${i + 1}`}
|
key={`workspace-cards-loading-${i + 1}`}
|
||||||
@@ -630,29 +769,10 @@ const OrganizationPage = withPermission(
|
|||||||
<Skeleton className="w-full bg-mineshaft-600" />
|
<Skeleton className="w-full bg-mineshaft-600" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{filteredWorkspaces.map((workspace, ind) => (
|
{!isProjectViewLoading &&
|
||||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
workspacesWithFaveProp.map((workspace, ind) =>
|
||||||
<div
|
renderProjectListItem(workspace, workspace.isFavorite, ind)
|
||||||
onClick={() => {
|
)}
|
||||||
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"}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center sm:col-span-3 lg:col-span-4">
|
|
||||||
<FontAwesomeIcon icon={faFileShield} className="text-sm text-primary/70" />
|
|
||||||
<div className="ml-5 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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user