feat: added project layout and project select in breadcrumb

This commit is contained in:
=
2025-07-02 01:16:48 +05:30
parent e028b4e26d
commit 09cdd5ec91
165 changed files with 6253 additions and 5600 deletions
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Outlet } from "@tanstack/react-router";
import { Outlet, useLocation, useParams, useSearch } from "@tanstack/react-router";
import { CreateOrgModal } from "@app/components/organization/CreateOrgModal";
import { Banner } from "@app/components/page-frames/Banner";
@@ -12,10 +12,17 @@ import { usePopUp } from "@app/hooks";
import { InsecureConnectionBanner } from "./components/InsecureConnectionBanner";
import { OrgSidebar } from "./components/OrgSidebar";
import { Navbar } from "./components/NavBar";
import { twMerge } from "tailwind-merge";
export const OrganizationLayout = () => {
const { config } = useServerConfig();
const { permission } = useOrgPermission();
const projectId = useParams({
strict: false,
select: (el) => el?.projectId
});
const isInsideProject = Boolean(projectId);
console.log(isInsideProject);
const shouldShowProductsSidebar = permission.can(
OrgPermissionSecretShareAction.ManageSettings,
@@ -37,8 +44,13 @@ export const OrganizationLayout = () => {
<Navbar />
{!window.isSecureContext && <InsecureConnectionBanner />}
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
<OrgSidebar />
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 py-4 dark:[color-scheme:dark]">
<OrgSidebar isHidden={isInsideProject} />
<main
className={twMerge(
"flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 py-4 dark:[color-scheme:dark]",
isInsideProject && "p-0"
)}
>
<Outlet />
</main>
</div>
@@ -4,6 +4,7 @@ import {
faArrowUpRightFromSquare,
faBook,
faCheck,
faCubes,
faEnvelope,
faInfo,
faSignOut,
@@ -26,6 +27,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Lottie,
TBreadcrumbFormat
} from "@app/components/v2";
@@ -151,13 +153,28 @@ export const Navbar = () => {
style={{ fontSize: "10px" }}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Link to="/organization/projects">
<div className="flex cursor-pointer items-center gap-2 text-sm text-white">
<div>
<FontAwesomeIcon icon={faCubes} className="text-xs" />
</div>
<div className="max-w-32 overflow-hidden text-ellipsis">{currentOrg?.name}</div>
<div className="rounded border border-mineshaft-500 p-1 text-xs text-bunker-300">
{getPlan(subscription)}
</div>
<FontAwesomeIcon icon={faSort} className="text-xs text-bunker-300" />
</div>
</Link>
<DropdownMenuTrigger asChild>
<div>
<IconButton
variant="plain"
colorSchema="secondary"
ariaLabel="switch-org"
className="px-2 py-1"
>
<FontAwesomeIcon icon={faSort} className="text-xs text-bunker-300" />
</IconButton>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -20,8 +20,13 @@ import { usePopUp } from "@app/hooks";
import { useGetOrgTrialUrl } from "@app/hooks/api";
import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel";
import { AnimatePresence, motion } from "framer-motion";
export const OrgSidebar = () => {
type Props = {
isHidden?: boolean;
};
export const OrgSidebar = ({ isHidden }: Props) => {
const { subscription } = useSubscription();
const [showAdminsModal, setShowAdminsModal] = useState(false);
@@ -34,158 +39,175 @@ export const OrgSidebar = () => {
return (
<>
<aside className="dark z-10 w-60 border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 transition-all duration-150">
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]">
<Menu>
<MenuGroup title="Overview">
<Link to="/organization/projects">
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="notification-bell">
Projects
</MenuItem>
)}
</Link>
<Link to="/organization/access-management">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faUsers} className="mr-4" />
Access Control
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/billing">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faMoneyBill} className="mr-4" />
Usage & Billing
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/audit-logs">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faBook} className="mr-4" />
Audit Logs
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/sso">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faCheckCircle} className="mr-4" />
SSO Settings
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/settings">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faCog} className="mr-4" />
Organization Settings
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Resources">
<Link to="/organization/app-connections">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faPlug} className="mr-4" />
App Connections
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/gateways">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faDoorClosed} className="mr-4" />
Gateways
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Admin Panels">
{user?.superAdmin && (
<Link to="/admin">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faUserCog} className="mr-4" />
Server Admin Console
</div>
</MenuItem>
)}
</Link>
)}
<Link to="/organization/admin">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faCog} className="mr-4" />
Org Admin Console
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Others">
<Link to="/organization/secret-sharing">
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="lock-closed">
Share Secret
</MenuItem>
)}
</Link>
</MenuGroup>
</Menu>
<div
className={`relative mt-10 ${
subscription && subscription.slug === "starter" && !subscription.has_used_trial
? "mb-2"
: "mb-4"
} flex w-full cursor-default flex-col items-center px-1 text-sm text-mineshaft-400`}
<AnimatePresence mode="popLayout">
{!isHidden && (
<motion.aside
key="org-sidebar"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: -240 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -240 }}
layout
className="dark z-10 w-60 border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900"
>
{subscription && subscription.slug === "starter" && !subscription.has_used_trial && (
<Tooltip content="Start Free Pro Trial" side="right">
<button
type="button"
onClick={async () => {
if (!subscription || !currentOrg) return;
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]">
<Menu>
<MenuGroup title="Overview">
<Link to="/organization/projects">
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="notification-bell">
Projects
</MenuItem>
)}
</Link>
<Link to="/organization/access-management">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faUsers} className="mr-4" />
Access Control
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/billing">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faMoneyBill} className="mr-4" />
Usage & Billing
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/audit-logs">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faBook} className="mr-4" />
Audit Logs
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/sso">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faCheckCircle} className="mr-4" />
SSO Settings
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/settings">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faCog} className="mr-4" />
Organization Settings
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Resources">
<Link to="/organization/app-connections">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faPlug} className="mr-4" />
App Connections
</div>
</MenuItem>
)}
</Link>
<Link to="/organization/gateways">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faDoorClosed} className="mr-4" />
Gateways
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Admin Panels">
{user?.superAdmin && (
<Link to="/admin">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faUserCog} className="mr-4" />
Server Admin Console
</div>
</MenuItem>
)}
</Link>
)}
<Link to="/organization/admin">
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<div className="mx-1">
<FontAwesomeIcon icon={faCog} className="mr-4" />
Org Admin Console
</div>
</MenuItem>
)}
</Link>
</MenuGroup>
<MenuGroup title="Others">
<Link to="/organization/secret-sharing">
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="lock-closed">
Share Secret
</MenuItem>
)}
</Link>
</MenuGroup>
</Menu>
<div
className={`relative mt-10 ${
subscription && subscription.slug === "starter" && !subscription.has_used_trial
? "mb-2"
: "mb-4"
} flex w-full cursor-default flex-col items-center px-1 text-sm text-mineshaft-400`}
>
{subscription &&
subscription.slug === "starter" &&
!subscription.has_used_trial && (
<Tooltip content="Start Free Pro Trial" side="right">
<button
type="button"
onClick={async () => {
if (!subscription || !currentOrg) return;
// direct user to start pro trial
const url = await mutateAsync({
orgId: currentOrg.id,
success_url: window.location.href
});
// direct user to start pro trial
const url = await mutateAsync({
orgId: currentOrg.id,
success_url: window.location.href
});
window.location.href = url;
}}
className="mt-1.5 w-full"
>
<div className="justify-left mb-1.5 mt-1.5 flex w-full flex-col items-center rounded-md p-1 text-xs text-mineshaft-300 transition-all duration-150 hover:bg-mineshaft-500 hover:text-primary-400">
<FontAwesomeIcon icon={faInfinity} className="py-2 text-lg text-primary" />
Pro Trial
</div>
</button>
</Tooltip>
)}
</div>
</nav>
</aside>
window.location.href = url;
}}
className="mt-1.5 w-full"
>
<div className="justify-left mb-1.5 mt-1.5 flex w-full flex-col items-center rounded-md p-1 text-xs text-mineshaft-300 transition-all duration-150 hover:bg-mineshaft-500 hover:text-primary-400">
<FontAwesomeIcon
icon={faInfinity}
className="py-2 text-lg text-primary"
/>
Pro Trial
</div>
</button>
</Tooltip>
)}
</div>
</nav>
</motion.aside>
)}
</AnimatePresence>
<Modal isOpen={showAdminsModal} onOpenChange={setShowAdminsModal}>
<ModalContent title="Server Administrators" subTitle="View all server administrators">
<div className="mb-2">
+411
View File
@@ -0,0 +1,411 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
BreadcrumbContainer,
Menu,
MenuGroup,
MenuItem,
TBreadcrumbFormat
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import { ProjectPermissionSecretScanningFindingActions } from "@app/context/ProjectPermissionContext/types";
import {
useGetAccessRequestsCount,
useGetSecretApprovalRequestCount,
useGetSecretRotations
} from "@app/hooks/api";
import { useGetSecretScanningUnresolvedFindingCount } from "@app/hooks/api/secretScanningV2";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { AssumePrivilegeModeBanner } from "./components/AssumePrivilegeModeBanner";
import { ProjectSelect } from "./components/ProjectSelect";
// This is a generic layout shared by all types of projects.
// If the product layout differs significantly, create a new layout as needed.
export const ProjectLayout = () => {
const { currentWorkspace } = useWorkspace();
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
const { permission } = useProjectPermission();
const { t } = useTranslation();
const { assumedPrivilegeDetails } = useProjectPermission();
const workspaceId = currentWorkspace?.id || "";
const projectSlug = currentWorkspace?.slug || "";
const { subscription } = useSubscription();
const isSecretManager = currentWorkspace?.type === ProjectType.SecretManager;
const isCertManager = currentWorkspace?.type === ProjectType.CertificateManager;
const isCmek = currentWorkspace?.type === ProjectType.KMS;
const isSSH = currentWorkspace?.type === ProjectType.SSH;
const isSecretScanning = currentWorkspace?.type === ProjectType.SecretScanning;
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({
workspaceId,
options: { enabled: isSecretManager }
});
const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({
projectSlug,
options: { enabled: isSecretManager }
});
// we only show the secret rotations v1 tab if they have existing rotations
const { data: secretRotations } = useGetSecretRotations({
workspaceId,
options: {
enabled: isSecretManager && Boolean(subscription.secretRotation),
refetchOnMount: false
}
});
const pendingRequestsCount =
(secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0);
const { data: unresolvedFindings } = useGetSecretScanningUnresolvedFindingCount(workspaceId, {
enabled:
isSecretScanning &&
subscription.secretScanning &&
permission.can(
ProjectPermissionSecretScanningFindingActions.Read,
ProjectPermissionSub.SecretScanningFindings
),
refetchInterval: 30000
});
return (
<>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
<motion.div
key="menu-project-items"
initial={{ x: -150 }}
animate={{ x: 0 }}
exit={{ x: -150 }}
transition={{ duration: 0.2 }}
className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60"
>
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]">
<div>
<ProjectSelect />
<div className="px-1">
<Menu>
<MenuGroup title="Main Menu">
{isSecretManager && (
<Link
to={`/${ProjectType.SecretManager}/$projectId/overview` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="lock-closed">
{t("nav.menu.secrets")}
</MenuItem>
)}
</Link>
)}
{isCertManager && (
<>
<Link
to={
`/${ProjectType.CertificateManager}/$projectId/subscribers` as const
}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="pki-subscriber">
Subscribers
</MenuItem>
)}
</Link>
<Link
to={
`/${ProjectType.CertificateManager}/$projectId/certificate-templates` as const
}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem
iconMode="reverse"
isSelected={isActive}
icon="pki-template"
>
Certificate Templates
</MenuItem>
)}
</Link>
<Link
to={
`/${ProjectType.CertificateManager}/$projectId/certificates` as const
}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="certificate">
Certificates
</MenuItem>
)}
</Link>
<Link
to={
`/${ProjectType.CertificateManager}/$projectId/certificate-authorities` as const
}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="certificate-authority">
Certificate Authorities
</MenuItem>
)}
</Link>
<Link
to={`/${ProjectType.CertificateManager}/$projectId/alerting` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="notification-bell">
Alerting
</MenuItem>
)}
</Link>
</>
)}
{isCmek && (
<Link
to={`/${ProjectType.KMS}/$projectId/overview` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="lock-closed">
Overview
</MenuItem>
)}
</Link>
)}
{isCmek && (
<Link
to={`/${ProjectType.KMS}/$projectId/kmip` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="key-user" iconMode="reverse">
KMIP
</MenuItem>
)}
</Link>
)}
{isSSH && (
<>
<Link
to={`/${ProjectType.SSH}/$projectId/overview` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="server">
Hosts
</MenuItem>
)}
</Link>
{/* <Link
to={`/${ProjectType.SSH}/$projectId/certificates` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="certificate" iconMode="reverse">
Certificates
</MenuItem>
)}
</Link> */}
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SshCertificateAuthorities}
>
{(isAllowed) =>
isAllowed && (
<Link
to={`/${ProjectType.SSH}/$projectId/cas` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem
isSelected={isActive}
icon="certificate-authority"
iconMode="reverse"
>
Certificate Authorities
</MenuItem>
)}
</Link>
)
}
</ProjectPermissionCan>
</>
)}
{isSecretScanning && (
<Link
to={`/${ProjectType.SecretScanning}/$projectId/data-sources` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="blocks">
Data Sources
</MenuItem>
)}
</Link>
)}
{isSecretScanning && (
<Link
to={`/${ProjectType.SecretScanning}/$projectId/findings` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="search">
<div className="flex w-full items-center justify-between">
<span>Findings</span>
{Boolean(unresolvedFindings) && (
<Badge variant="primary" className="mr-2">
{unresolvedFindings}
</Badge>
)}
</div>
</MenuItem>
)}
</Link>
)}
{isSecretManager && (
<Link
to={`/${ProjectType.SecretManager}/$projectId/integrations` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="jigsaw-puzzle">
{t("nav.menu.integrations")}
</MenuItem>
)}
</Link>
)}
{isSecretManager && Boolean(secretRotations?.length) && (
<Link
to={`/${ProjectType.SecretManager}/$projectId/secret-rotation` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="rotation">
Secret Rotation
</MenuItem>
)}
</Link>
)}
{isSecretManager && (
<Link
to={`/${ProjectType.SecretManager}/$projectId/approval` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="circular-check">
Approvals
{Boolean(
secretApprovalReqCount?.open ||
accessApprovalRequestCount?.pendingCount
) && (
<Badge variant="primary" className="ml-1.5">
{pendingRequestsCount}
</Badge>
)}
</MenuItem>
)}
</Link>
)}
</MenuGroup>
<MenuGroup title="Other">
<Link
to={`/${currentWorkspace.type}/$projectId/access-management` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="groups">
Access Control
</MenuItem>
)}
</Link>
<Link
to={`/${currentWorkspace.type}/$projectId/settings` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="toggle-settings">
{t("nav.menu.project-settings")}
</MenuItem>
)}
</Link>
</MenuGroup>
</Menu>
</div>
</div>
</nav>
</motion.div>
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 pb-4 dark:[color-scheme:dark]">
{breadcrumbs ? (
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
) : null}
<Outlet />
</div>
</div>
</div>
<div className="z-[200] flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}
</p>
</div>
</>
);
};
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
import { Link, Outlet } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { ProjectPermissionCan } from "@app/components/permissions";
@@ -36,8 +36,6 @@ import { ProjectSelect } from "./components/ProjectSelect";
// If the product layout differs significantly, create a new layout as needed.
export const ProjectLayout = () => {
const { currentWorkspace } = useWorkspace();
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
const { permission } = useProjectPermission();
@@ -392,10 +390,7 @@ export const ProjectLayout = () => {
</div>
</nav>
</motion.div>
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 pb-4 dark:[color-scheme:dark]">
{breadcrumbs ? (
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
) : null}
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 py-4 dark:[color-scheme:dark]">
<Outlet />
</div>
</div>
@@ -1,15 +1,26 @@
import { useMemo } from "react";
import { components, MenuProps, OptionProps } from "react-select";
import { faStar } from "@fortawesome/free-regular-svg-icons";
import { faChevronRight, faPlus, faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons";
import {
faCheck,
faCube,
faPlus,
faStar as faSolidStar,
faSort
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
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, FilterableSelect } from "@app/components/v2";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
@@ -17,49 +28,39 @@ import {
useSubscription,
useWorkspace
} from "@app/context";
import { getProjectTitle } from "@app/helpers/project";
import { usePopUp } from "@app/hooks";
import { useGetUserWorkspaces } from "@app/hooks/api";
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
import { Workspace } from "@app/hooks/api/workspace/types";
import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
import { Link, linkOptions } from "@tanstack/react-router";
import { getCurrentProductFromUrl, getProjectHomePage } from "@app/helpers/project";
type TWorkspaceWithFaveProp = Workspace & { isFavorite: boolean };
const ProjectsMenu = ({ children, ...props }: MenuProps<TWorkspaceWithFaveProp>) => {
return (
<components.Menu {...props}>
{children}
<hr className="mb-2 h-px border-0 bg-mineshaft-500" />
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Workspace}>
{(isAllowed) => (
<Button
className="w-full bg-mineshaft-700 pt-2 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
onClick={() => props.clearValue()}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Project
</Button>
)}
</OrgPermissionCan>
</components.Menu>
);
};
const ProjectOption = ({
isSelected,
children,
data,
...props
}: OptionProps<TWorkspaceWithFaveProp>) => {
// TODO(pta): add search to project select
export const ProjectSelect = () => {
const { currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization();
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
const { data: workspaces = [] } = useGetUserWorkspaces();
const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg.id);
const { subscription } = useSubscription();
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
const addProjectToFavorites = async (projectId: string) => {
try {
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 {
await updateUserProjectFavorites({
@@ -74,63 +75,6 @@ const ProjectOption = ({
}
};
const addProjectToFavorites = async (projectId: string) => {
try {
await updateUserProjectFavorites({
orgId: currentOrg!.id,
projectFavorites: [...(projectFavorites || []), projectId]
});
} catch {
createNotification({
text: "Failed to add project to favorites.",
type: "error"
});
}
};
return (
<components.Option
isSelected={isSelected}
data={data}
{...props}
className={twMerge(props.className, isSelected && "bg-mineshaft-500")}
>
<div className="flex w-full items-center">
{isSelected && (
<FontAwesomeIcon className="mr-2 text-primary" icon={faChevronRight} size="xs" />
)}
<p className="truncate">{children}</p>
{data.isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="ml-auto text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={async (e) => {
e.stopPropagation();
await removeProjectFromFavorites(data.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="ml-auto text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={async (e) => {
e.stopPropagation();
await addProjectToFavorites(data.id);
}}
/>
)}
</div>
</components.Option>
);
};
export const ProjectSelect = () => {
const { currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization();
const { data: workspaces = [] } = useGetUserWorkspaces();
const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg.id!);
const { subscription } = useSubscription();
const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
: true;
@@ -140,7 +84,7 @@ export const ProjectSelect = () => {
"upgradePlan"
] as const);
const { options, value } = useMemo(() => {
const projects = useMemo(() => {
const projectOptions = workspaces
.map((w): Workspace & { isFavorite: boolean } => ({
...w,
@@ -148,66 +92,107 @@ export const ProjectSelect = () => {
}))
.sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite));
const currentOption = projectOptions.find((option) => option.id === currentWorkspace?.id);
if (!currentOption) {
return {
options: projectOptions,
value: null
};
}
return {
options: [
currentOption,
...projectOptions.filter((option) => option.id !== currentOption.id)
],
value: currentOption
};
return projectOptions;
}, [workspaces, projectFavorites, currentWorkspace]);
return (
<div className="mt-2 w-full p-3">
<p className="mb-1 ml-1.5 text-xs font-semibold uppercase text-gray-400">
{currentWorkspace?.type ? getProjectTitle(currentWorkspace?.type) : "Project"}
</p>
<FilterableSelect
className="text-sm"
value={value}
filterOption={(option, inputValue) =>
option.data.name.toLowerCase().includes(inputValue.toLowerCase())
}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
onChange={(newValue) => {
// hacky use of null as indication to create project
if (!newValue) {
if (isAddingProjectsAllowed) {
handlePopUpOpen("addNewWs");
} else {
handlePopUpOpen("upgradePlan");
}
return;
}
const project = newValue as TWorkspaceWithFaveProp;
// todo(akhi): this is not using react query because react query in overview is throwing error when envs are not exact same count
// to reproduce change this back to router.push and switch between two projects with different env count
// look into this on dashboard revamp
window.location.assign(`/${project.type}/${project.id}/overview`);
}}
options={options}
components={{
Option: ProjectOption,
Menu: ProjectsMenu
}}
/>
<div className="flex w-full items-center gap-2">
<DropdownMenu modal={false}>
<Link
to={getProjectHomePage(
getCurrentProductFromUrl(window.location.href) || ProjectType.SecretManager
)}
params={{
projectId: currentWorkspace.id
}}
>
<div className="flex cursor-pointer items-center gap-2 text-sm text-white">
<div>
<FontAwesomeIcon icon={faCube} className="text-xs" />
</div>
<div className="max-w-32 overflow-hidden text-ellipsis">{currentWorkspace?.name}</div>
</div>
</Link>
<DropdownMenuTrigger asChild>
<div>
<IconButton
variant="plain"
colorSchema="secondary"
ariaLabel="switch-org"
className="px-2 py-1"
>
<FontAwesomeIcon icon={faSort} className="text-xs text-bunker-300" />
</IconButton>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
style={{ minWidth: "220px" }}
>
<div className="px-2 py-1 text-xs capitalize text-mineshaft-400">Projects</div>
{projects?.map((workspace) => {
return (
<DropdownMenuItem
key={workspace.id}
onClick={async () => {
// todo(akhi): this is not using react query because react query in overview is throwing error when envs are not exact same count
// to reproduce change this back to router.push and switch between two projects with different env count
// look into this on dashboard revamp
const url = linkOptions({
to: getProjectHomePage(workspace.defaultType),
params: {
projectId: workspace.id
}
});
window.location.assign(url.to.replaceAll("$projectId", workspace.id));
}}
icon={
currentWorkspace?.id === workspace.id && (
<FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
)
}
>
<div className="flex items-center">
<div className="flex max-w-[150px] flex-grow items-center justify-between truncate">
{workspace.name}
</div>
<FontAwesomeIcon
icon={workspace.isFavorite ? faSolidStar : faStar}
className="text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={async (e) => {
e.stopPropagation();
await (
workspace.isFavorite ? removeProjectFromFavorites : addProjectToFavorites
)(workspace.id);
}}
/>
</div>
</DropdownMenuItem>
);
})}
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Workspace}>
{(isAllowed) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faPlus} />}
onClick={() =>
handlePopUpOpen(isAddingProjectsAllowed ? "addNewWs" : "upgradePlan")
}
>
New Project
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You have exceeded the number of projects allowed on the free plan."
/>
<NewProjectModal
isOpen={popUp.addNewWs.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addNewWs", isOpen)}