feat: completed project layout base

This commit is contained in:
=
2024-12-18 15:34:32 +05:30
parent 7042d73410
commit f56196b820
248 changed files with 1699 additions and 860 deletions

View File

@@ -13,7 +13,7 @@ import { OrgPermissionSet } from "./types";
export const useOrgPermission = () => {
const organizationId = useRouteContext({
from: "/_authenticate/_org_details",
from: "/_authenticate/_ctx-org-details",
select: (el) => el.organizationId
});

View File

@@ -5,7 +5,7 @@ import { fetchOrganizationById, organizationKeys } from "@app/hooks/api/organiza
export const useOrganization = () => {
const organizationId = useRouteContext({
from: "/_authenticate/_org_details",
from: "/_authenticate/_ctx-org-details",
select: (el) => el.organizationId
});

View File

@@ -1,62 +1,76 @@
import { createContext, ReactNode, useContext } from "react";
import { useCallback } from "react";
import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability";
import { unpackRules } from "@casl/ability/extra";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useParams } from "@tanstack/react-router";
import { useGetUserProjectPermissions } from "@app/hooks/api";
import { TProjectMembership } from "@app/hooks/api/users/types";
import {
conditionsMatcher,
fetchUserProjectPermissions,
roleQueryKeys
} from "@app/hooks/api/roles/queries";
import { groupBy } from "@app/lib/fn/array";
import { omit } from "@app/lib/fn/object";
import { useWorkspace } from "../WorkspaceContext";
import { TProjectPermission } from "./types";
type Props = {
children: ReactNode;
};
const ProjectPermissionContext = createContext<null | {
permission: TProjectPermission;
membership: TProjectMembership;
}>(null);
export const ProjectPermissionProvider = ({ children }: Props): JSX.Element => {
const { currentWorkspace, isLoading: isWsLoading } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { data: permission, isLoading } = useGetUserProjectPermissions({ workspaceId });
if ((isLoading && currentWorkspace) || isWsLoading) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-bunker-800">
<img
src="/images/loading/loading.gif"
height={70}
width={120}
decoding="async"
loading="lazy"
alt="infisical loading indicator"
/>
</div>
);
}
if (!permission && currentWorkspace) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-bunker-800">
Failed to load user permissions
</div>
);
}
return (
<ProjectPermissionContext.Provider value={permission!}>
{children}
</ProjectPermissionContext.Provider>
);
};
import { ProjectPermissionSet } from "./types";
export const useProjectPermission = () => {
const ctx = useContext(ProjectPermissionContext);
if (!ctx) {
const projectId = useParams({
strict: false,
select: (el) => el?.projectId
});
if (!projectId) {
throw new Error("useProjectPermission to be used within <ProjectPermissionContext>");
}
const hasProjectRole = (role: string) => ctx?.membership?.roles?.includes(role) || false;
const {
data: { permission, membership }
} = useSuspenseQuery({
queryKey: roleQueryKeys.getUserProjectPermissions({ workspaceId: projectId }),
queryFn: () => fetchUserProjectPermissions({ workspaceId: projectId }),
select: (data) => {
const rule = unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(data.permissions);
const negatedRules = groupBy(
rule.filter((i) => i.inverted && i.conditions),
(i) => `${i.subject}-${JSON.stringify(i.conditions)}`
);
const ability = createMongoAbility<ProjectPermissionSet>(rule, {
// this allows in frontend to skip some rules using *
conditionsMatcher: (rules) => {
return (entity) => {
// skip validation if its negated rules
const isNegatedRule =
// eslint-disable-next-line no-underscore-dangle
negatedRules?.[`${entity.__caslSubjectType__}-${JSON.stringify(rules)}`];
if (isNegatedRule) {
const baseMatcher = conditionsMatcher(rules);
return baseMatcher(entity);
}
return { ...ctx, hasProjectRole };
const rulesStrippedOfWildcard = omit(
rules,
Object.keys(entity).filter((el) => entity[el]?.includes("*"))
);
const baseMatcher = conditionsMatcher(rulesStrippedOfWildcard);
return baseMatcher(entity);
};
}
});
return {
permission: ability,
membership: {
...data.membership,
roles: data.membership.roles.map(({ role }) => role)
}
};
}
});
const hasProjectRole = useCallback(
(role: string) => membership?.roles?.includes(role) || false,
[]
);
return { permission, membership, hasProjectRole };
};

View File

@@ -1,4 +1,4 @@
export { ProjectPermissionProvider, useProjectPermission } from "./ProjectPermissionContext";
export { useProjectPermission } from "./ProjectPermissionContext";
export type { ProjectPermissionSet, TProjectPermission } from "./types";
export {
ProjectPermissionActions,

View File

@@ -5,7 +5,7 @@ import { fetchOrgSubscription, subscriptionQueryKeys } from "@app/hooks/api/subs
export const useSubscription = () => {
const organizationId = useRouteContext({
from: "/_authenticate/_org_details",
from: "/_authenticate/_ctx-org-details",
select: (el) => el.organizationId
});

View File

@@ -1,55 +1,22 @@
import { createContext, ReactNode, useContext, useEffect, useMemo } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useParams } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { useGetUserWorkspaces } from "@app/hooks/api";
import { Workspace } from "@app/hooks/api/workspace/types";
type TWorkspaceContext = {
workspaces: Workspace[];
currentWorkspace?: Workspace;
isLoading: boolean;
};
const WorkspaceContext = createContext<TWorkspaceContext | null>(null);
type Props = {
children: ReactNode;
};
export const WorkspaceProvider = ({ children }: Props): JSX.Element => {
const { data: ws, isLoading } = useGetUserWorkspaces();
const params = useParams({ strict: false });
const workspaceId = params.id;
// memorize the workspace details for the context
const value = useMemo<TWorkspaceContext>(() => {
const wsId = workspaceId || localStorage.getItem("projectData.id");
return {
workspaces: ws || [],
currentWorkspace: (ws || []).find(({ id }) => id === wsId),
isLoading
};
}, [ws, workspaceId, isLoading]);
const shouldTriggerNoProjectAccess = !value.isLoading && !value.currentWorkspace;
if (shouldTriggerNoProjectAccess) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-bunker-800 text-primary-50">
You do not have sufficient access to this project.
</div>
);
}
return <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>;
};
import { workspaceKeys } from "@app/hooks/api";
import { fetchWorkspaceById } from "@app/hooks/api/workspace/queries";
export const useWorkspace = () => {
const ctx = useContext(WorkspaceContext);
if (!ctx) {
throw new Error("useWorkspace has to be used within <WorkspaceContext.Provider>");
const params = useParams({
strict: false
});
if (!params.projectId) {
throw new Error("Missing project id");
}
return ctx;
const { data: currentWorkspace } = useSuspenseQuery({
queryKey: workspaceKeys.getWorkspaceById(params.projectId),
queryFn: () => fetchWorkspaceById(params.projectId as string),
staleTime: Infinity
});
return { currentWorkspace };
};

View File

@@ -1 +1 @@
export { useWorkspace, WorkspaceProvider } from "./WorkspaceContext";
export { useWorkspace } from "./WorkspaceContext";

View File

@@ -11,11 +11,10 @@ export {
ProjectPermissionActions,
ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionProvider,
ProjectPermissionSub,
useProjectPermission
} from "./ProjectPermissionContext";
export { useServerConfig } from "./ServerConfigContext";
export { useSubscription } from "./SubscriptionContext";
export { useUser } from "./UserContext";
export { useWorkspace, WorkspaceProvider } from "./WorkspaceContext";
export { useWorkspace } from "./WorkspaceContext";

View File

@@ -62,7 +62,7 @@ export const initProjectHelper = async ({ projectName }: { projectName: string }
};
export const getProjectHomePage = (workspace: Workspace) => {
if (workspace.type === ProjectType.SecretManager) {
return `/${workspace.type}/$projectId/secrets/overview` as const;
return `/${workspace.type}/$projectId/overview` as const;
}
if (workspace.type === ProjectType.CertificateManager) {
return `/${workspace.type}/$projectId/certificates` as const;

View File

@@ -130,7 +130,9 @@ export const useGetUserOrgPermissions = ({ orgId }: TGetUserOrgPermissionsDTO) =
}
});
const getUserProjectPermissions = async ({ workspaceId }: TGetUserProjectPermissionDTO) => {
export const fetchUserProjectPermissions = async ({
workspaceId
}: TGetUserProjectPermissionDTO) => {
const { data } = await apiRequest.get<{
data: {
permissions: PackRule<RawRuleOf<MongoAbility<OrgPermissionSet>>>[];
@@ -144,7 +146,7 @@ const getUserProjectPermissions = async ({ workspaceId }: TGetUserProjectPermiss
export const useGetUserProjectPermissions = ({ workspaceId }: TGetUserProjectPermissionDTO) =>
useQuery({
queryKey: roleQueryKeys.getUserProjectPermissions({ workspaceId }),
queryFn: () => getUserProjectPermissions({ workspaceId }),
queryFn: () => fetchUserProjectPermissions({ workspaceId }),
enabled: Boolean(workspaceId),
select: (data) => {
const rule = unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(data.permissions);

View File

@@ -39,7 +39,7 @@ import {
Workspace
} from "./types";
const fetchWorkspaceById = async (workspaceId: string) => {
export const fetchWorkspaceById = async (workspaceId: string) => {
const { data } = await apiRequest.get<{ workspace: Workspace }>(
`/api/v1/workspace/${workspaceId}`
);

View File

@@ -0,0 +1,235 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet } from "@tanstack/react-router";
import { Mfa } from "@app/components/auth/Mfa";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { Menu, MenuItem } from "@app/components/v2";
import { useUser, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import {
useGetAccessRequestsCount,
useGetSecretApprovalRequestCount,
useSelectOrganization
} from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner";
import { SidebarFooter } from "../OrganizationLayout/components/SidebarFooter";
import { SidebarHeader } from "./components/SidebarHeader";
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 [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const { user } = useUser();
const { mutateAsync: selectOrganization } = useSelectOrganization();
const { t } = useTranslation();
const workspaceId = currentWorkspace?.id || "";
const projectSlug = currentWorkspace?.slug || "";
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId });
const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug });
const pendingRequestsCount =
(secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0);
const handleOrgChange = async (orgId: string) => {
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({
organizationId: orgId
});
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
if (mfaMethod) {
setRequiredMfaMethod(mfaMethod);
}
toggleShowMfa.on();
setMfaSuccessCallback(() => () => handleOrgChange(orgId));
}
// await navigateUserToOrg(router, orgId);
};
if (shouldShowMfa) {
return (
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<Mfa
email={user.email as string}
method={requiredMfaMethod}
successCallback={mfaSuccessCallback}
closeMfa={() => toggleShowMfa.off()}
/>
</div>
);
}
const isSecretManager = currentWorkspace?.type === ProjectType.SecretManager;
const isCertManager = currentWorkspace?.type === ProjectType.CertificateManager;
const isCmek = currentWorkspace?.type === ProjectType.KMS;
return (
<>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
{!window.isSecureContext && <InsecureConnectionBanner />}
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
<aside 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>
<SidebarHeader onChangeOrg={handleOrgChange} />
<ProjectSelect />
<div className="px-1">
<Menu>
{isSecretManager && (
<Link
to={`/${ProjectType.SecretManager}/$projectId/overview` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="system-outline-90-lock-closed">
{t("nav.menu.secrets")}
</MenuItem>
)}
</Link>
)}
{isCertManager && (
<Link
to={`/${ProjectType.CertificateManager}/$projectId/overview` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="system-outline-90-lock-closed">
Overview
</MenuItem>
)}
</Link>
)}
{isCmek && (
<Link
to={`/${ProjectType.KMS}/$projectId/overview` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="system-outline-90-lock-closed">
Overview
</MenuItem>
)}
</Link>
)}
<Link
to={`/${currentWorkspace.type}/$projectId/members` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="system-outline-96-groups">
Access Control
</MenuItem>
)}
</Link>
{isSecretManager && (
<Link
to={`/${ProjectType.SecretManager}/$projectId/integrations` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive} icon="system-outline-82-extension">
{t("nav.menu.integrations")}
</MenuItem>
)}
</Link>
)}
{isSecretManager && (
<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="system-outline-189-domain-verification"
>
Approvals
{Boolean(
secretApprovalReqCount?.open ||
accessApprovalRequestCount?.pendingCount
) && (
<span className="ml-2 rounded border border-primary-400 bg-primary-600 px-1 py-0.5 text-xs font-semibold text-black">
{pendingRequestsCount}
</span>
)}
</MenuItem>
)}
</Link>
)}
<Link
to={`/${currentWorkspace.type}/$projectId/settings` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem
isSelected={isActive}
icon="system-outline-109-slider-toggle-settings"
>
{t("nav.menu.project-settings")}
</MenuItem>
)}
</Link>
</Menu>
</div>
</div>
<SidebarFooter />
</nav>
</aside>
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 dark:[color-scheme:dark]">
<Outlet />
</main>
</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>
</>
);
};

View File

@@ -0,0 +1,219 @@
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 { 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 {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription,
useWorkspace
} from "@app/context";
import { getProjectHomePage, 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 { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
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>) => {
const { currentOrg } = useOrganization();
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg.id);
const removeProjectFromFavorites = async (projectId: string) => {
try {
await updateUserProjectFavorites({
orgId: currentOrg!.id,
projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)]
});
} catch {
createNotification({
text: "Failed to remove project from favorites.",
type: "error"
});
}
};
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({ type: currentWorkspace.type });
const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg.id!);
const { subscription } = useSubscription();
const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
: true;
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"addNewWs",
"upgradePlan"
] as const);
const { options, value } = useMemo(() => {
const projectOptions = workspaces
.map((w): Workspace & { isFavorite: boolean } => ({
...w,
isFavorite: Boolean(projectFavorites?.includes(w.id))
}))
.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
};
}, [workspaces, projectFavorites, currentWorkspace]);
return (
<div className="mb-4 mt-5 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;
localStorage.setItem("projectData.id", project.id);
// 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(getProjectHomePage(project));
}}
options={options}
components={{
Option: ProjectOption,
Menu: ProjectsMenu
}}
/>
<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)}
projectType={currentWorkspace?.type || ProjectType.SecretManager}
/>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./ProjectSelect";

View File

@@ -0,0 +1,180 @@
import {
faAngleDown,
faArrowLeft,
faArrowUpRightFromSquare,
faCheck
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate } from "@tanstack/react-router";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/v2";
import { useOrganization, useUser, useWorkspace } from "@app/context";
import { useGetOrganizations, useLogoutUser } from "@app/hooks/api";
import { AuthMethod } from "@app/hooks/api/users/types";
type Prop = {
onChangeOrg: (orgId: string) => void;
};
export const SidebarHeader = ({ onChangeOrg }: Prop) => {
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const { user } = useUser();
const navigate = useNavigate();
const { data: orgs } = useGetOrganizations();
const logout = useLogoutUser();
const logOutUser = async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
navigate({ to: "/login" });
} catch (error) {
console.error(error);
}
};
return (
<div className="flex h-12 cursor-default items-center px-3 pt-6">
<Link to={`/organization/$organizationId/${currentWorkspace.type}/overview`} params={{}}>
<div className="pl-1 pr-2 text-mineshaft-400 duration-200 hover:text-mineshaft-100">
<FontAwesomeIcon icon={faArrowLeft} />
</div>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild className="max-w-[160px] data-[state=open]:bg-mineshaft-600">
<div className="mr-auto flex items-center rounded-md py-1.5 pl-1.5 pr-2 hover:bg-mineshaft-600">
<div className="flex h-5 w-5 min-w-[20px] items-center justify-center rounded-md bg-primary text-sm">
{currentOrg?.name.charAt(0)}
</div>
<div
className="overflow-hidden truncate text-ellipsis pl-2 text-sm text-mineshaft-100"
style={{ maxWidth: "140px" }}
>
{currentOrg?.name}
</div>
<FontAwesomeIcon icon={faAngleDown} className="pl-1 pt-1 text-xs text-mineshaft-300" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<div className="px-2 py-1 text-xs text-mineshaft-400">{user?.username}</div>
{orgs?.map((org) => {
return (
<DropdownMenuItem key={org.id}>
<Button
onClick={async () => {
if (currentOrg?.id === org.id) return;
if (org.authEnforced) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
if (org.orgAuthMethod === AuthMethod.OIDC) {
window.open(`/api/v1/sso/oidc/login?orgSlug=${org.slug}`);
} else {
window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`);
}
window.close();
return;
}
onChangeOrg(org?.id);
}}
variant="plain"
colorSchema="secondary"
size="xs"
className="flex w-full items-center justify-start p-0 font-normal"
leftIcon={
currentOrg?.id === org.id && (
<FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
)
}
>
<div className="flex w-full max-w-[150px] items-center justify-between truncate">
{org.name}
</div>
</Button>
</DropdownMenuItem>
);
})}
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<button type="button" onClick={logOutUser} className="w-full">
<DropdownMenuItem>Log Out</DropdownMenuItem>
</button>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
asChild
className="p-1 hover:bg-primary-400 hover:text-black data-[state=open]:bg-primary-400 data-[state=open]:text-black"
>
<div
className="child flex items-center justify-center rounded-full bg-mineshaft pr-1 text-mineshaft-300 hover:bg-mineshaft-500"
style={{ fontSize: "11px", width: "26px", height: "26px" }}
>
{user?.firstName?.charAt(0)}
{user?.lastName && user?.lastName?.charAt(0)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<div className="px-2 py-1 text-xs text-mineshaft-400">{user?.username}</div>
<Link to="/personal-settings">
<DropdownMenuItem>Personal Settings</DropdownMenuItem>
</Link>
<a
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"
rel="noopener noreferrer"
className="mt-3 w-full text-sm font-normal leading-[1.2rem] text-mineshaft-300 hover:text-mineshaft-100"
>
<DropdownMenuItem>
Documentation
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] pl-1.5 text-xxs"
/>
</DropdownMenuItem>
</a>
<a
href="https://infisical.com/slack"
target="_blank"
rel="noopener noreferrer"
className="mt-3 w-full text-sm font-normal leading-[1.2rem] text-mineshaft-300 hover:text-mineshaft-100"
>
<DropdownMenuItem>
Join Slack Community
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] pl-1.5 text-xxs"
/>
</DropdownMenuItem>
</a>
{user?.superAdmin && (
<Link to="/admin">
<DropdownMenuItem className="mt-1 border-t border-mineshaft-600">
Server Admin Console
</DropdownMenuItem>
</Link>
)}
<Link to={`/org/${currentOrg?.id}/admin`}>
<DropdownMenuItem className="mt-1 border-t border-mineshaft-600">
Organization Admin Console
</DropdownMenuItem>
</Link>
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<button type="button" onClick={logOutUser} className="w-full">
<DropdownMenuItem>Log Out</DropdownMenuItem>
</button>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@@ -0,0 +1 @@
export { SidebarHeader } from "./SidebarHeader";

View File

@@ -0,0 +1 @@
export { ProjectLayout } from "./ProjectLayout";

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,8 @@ import { fetchOrganizationById, organizationKeys } from "@app/hooks/api/organiza
import { fetchUserOrgPermissions, roleQueryKeys } from "@app/hooks/api/roles/queries";
import { fetchOrgSubscription, subscriptionQueryKeys } from "@app/hooks/api/subscriptions/queries";
export const Route = createFileRoute("/_authenticate/_org_details")({
// Route context to fill in organization's data like details, subscription etc
export const Route = createFileRoute("/_authenticate/_ctx-org-details")({
beforeLoad: async ({ context }) => {
const organizationId = context.organizationId!;
await context.queryClient.ensureQueryData({

View File

@@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router";
import { OrganizationLayout } from "@app/layouts/OrganizationLayout";
export const Route = createFileRoute("/_authenticate/_org_details/_org-layout")({
export const Route = createFileRoute("/_authenticate/_ctx-org-details/organization/_layout-org")({
component: OrganizationLayout
});

View File

@@ -40,7 +40,7 @@ const OrgAdminPage = () => {
};
export const Route = createFileRoute(
"/_authenticate/_org_details/_org-layout/organization/$organizationId/admin/"
"/_authenticate/_ctx-org-details/organization/_layout-org/$organizationId/admin/"
)({
component: OrgAdminPage
});

View File

@@ -25,7 +25,7 @@ const AuditLogsPage = () => {
};
export const Route = createFileRoute(
"/_authenticate/_org_details/_org-layout/organization/$organizationId/audit-logs/"
"/_authenticate/_ctx-org-details/organization/_layout-org/$organizationId/audit-logs/"
)({
component: AuditLogsPage
});

View File

@@ -44,7 +44,7 @@ const BillingRoute = () => {
};
export const Route = createFileRoute(
"/_authenticate/_org_details/_org-layout/organization/$organizationId/billing/"
"/_authenticate/_ctx-org-details/organization/_layout-org/$organizationId/billing/"
)({
component: BillingRoute
});

View File

@@ -7,7 +7,7 @@ import { ProductOverview } from "../secret-manager/overview";
const CertManagerOverviewPage = () => <ProductOverview type={ProjectType.CertificateManager} />;
export const Route = createFileRoute(
"/_authenticate/_org_details/_org-layout/organization/$organizationId/cert-manager/overview"
"/_authenticate/_ctx-org-details/organization/_layout-org/$organizationId/cert-manager/overview"
)({
component: CertManagerOverviewPage
});

View File

@@ -39,7 +39,7 @@ const GroupPage = withPermission(
() => {
const navigate = useNavigate();
const params = useParams({
from: "/_authenticate/_org_details/_org-layout/organization/$organizationId/groups/$groupId/"
from: "/_authenticate/_ctx-org-details/organization/_layout-org/$organizationId/groups/$groupId/"
});
const groupId = params.groupId as string;
const { currentOrg } = useOrganization();
@@ -214,7 +214,7 @@ const GroupDetailPage = () => {
};
export const Route = createFileRoute(
"/_authenticate/_org_details/_org-layout/organization/$organizationId/groups/$groupId/"
"/_authenticate/_ctx-org-details/organization/_layout-org/$organizationId/groups/$groupId/"
)({
component: GroupDetailPage
});

View File

@@ -48,7 +48,7 @@ export const IdentitySection = withPermission(
() => {
const navigate = useNavigate();
const params = useParams({
from: "/_authenticate/_org_details/_org-layout/organization/$organizationId/identities/$identityId/"
from: "/_authenticate/_ctx-org-details/organization/_layout-org/$organizationId/identities/$identityId/"
});
const identityId = params.identityId as string;
const { currentOrg } = useOrganization();
@@ -381,7 +381,7 @@ const IdentityDetailPage = () => {
};
export const Route = createFileRoute(
"/_authenticate/_org_details/_org-layout/organization/$organizationId/identities/$identityId/"
"/_authenticate/_ctx-org-details/organization/_layout-org/$organizationId/identities/$identityId/"
)({
component: IdentityDetailPage
});

View File

@@ -7,7 +7,7 @@ import { ProductOverview } from "../secret-manager/overview";
const KeyManagerOverviewPage = () => <ProductOverview type={ProjectType.KMS} />;
export const Route = createFileRoute(
"/_authenticate/_org_details/_org-layout/organization/$organizationId/kms/overview"
"/_authenticate/_ctx-org-details/organization/_layout-org/$organizationId/kms/overview"
)({
component: KeyManagerOverviewPage
});

Some files were not shown because too many files have changed in this diff Show More