diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index bc3876750..68f591141 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -1,10 +1,7 @@ -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/router'; import { faAngleRight } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useWorkspace } from '@app/context'; -import getOrganization from '@app/pages/api/organization/GetOrg'; +import { useOrgnization, useWorkspace } from '@app/context'; /** * This is the component at the top of almost every page. @@ -22,28 +19,15 @@ export default function NavHeader({ pageName: string; isProjectRelated?: boolean; }): JSX.Element { - const [orgName, setOrgName] = useState(''); - const router = useRouter(); - const projectId = String(router.query.id); const { currentWorkspace } = useWorkspace(); - - useEffect(() => { - (async () => { - const orgId = localStorage.getItem('orgData.id'); - const org = await getOrganization({ - orgId: orgId || '' - }); - setOrgName(org.name); - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId]); + const { currentOrg } = useOrgnization(); return (
- {orgName?.charAt(0)} + {currentOrg?.name?.charAt(0)}
-
{orgName}
+
{currentOrg?.name}
{isProjectRelated && ( <> diff --git a/frontend/src/components/v2/Menu/Menu.tsx b/frontend/src/components/v2/Menu/Menu.tsx index 1497b01fa..cb2625384 100644 --- a/frontend/src/components/v2/Menu/Menu.tsx +++ b/frontend/src/components/v2/Menu/Menu.tsx @@ -31,18 +31,19 @@ export const MenuItem = ({ as: Item = 'button', description, // wrapping in forward ref with generic component causes the loss of ts definitions on props - inputRef + inputRef, + ...props }: MenuItemProps & ComponentPropsWithRef): JSX.Element => (
  • - - {icon && {icon}} + + {icon && {icon}} {children} {description && {description}} diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 86ccc1079..ad0319619 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -12,13 +12,14 @@ type Props = { className?: string; dropdownContainerClassName?: string; isLoading?: boolean; + position?: 'item-aligned' | 'popper'; }; export type SelectProps = SelectPrimitive.SelectProps & Props; export const Select = forwardRef( ( - { children, placeholder, className, isLoading, dropdownContainerClassName, ...props }, + { children, placeholder, className, isLoading, dropdownContainerClassName, position, ...props }, ref ): JSX.Element => { return ( @@ -44,6 +45,8 @@ export const Select = forwardRef( 'relative left-4 top-1 overflow-hidden rounded-md bg-bunker-800 font-inter text-bunker-100 shadow-md z-[100]', dropdownContainerClassName )} + position={position} + style={{ width: 'var(--radix-select-trigger-width)' }} > diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx new file mode 100644 index 000000000..ce5e362ce --- /dev/null +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -0,0 +1,431 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable no-unexpected-multiline */ +/* eslint-disable react-hooks/exhaustive-deps */ +import crypto from 'crypto'; + +import { useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import { + faBookOpen, + faFileLines, + faGear, + faKey, + faMobile, + faPlug, + faPlus, + faUser +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; + +import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider'; +import onboardingCheck from '@app/components/utilities/checks/OnboardingCheck'; +import { tempLocalStorage } from '@app/components/utilities/checks/tempLocalStorage'; +import { encryptAssymmetric } from '@app/components/utilities/cryptography/crypto'; +import { + Button, + Checkbox, + FormControl, + Input, + Menu, + MenuItem, + Modal, + ModalContent, + Select, + SelectItem +} from '@app/components/v2'; +import { useOrgnization, useUser, useWorkspace } from '@app/context'; +import { usePopUp } from '@app/hooks'; +import { fetchOrgUsers, useAddUserToWs, useCreateWorkspace, useUploadWsKey } from '@app/hooks/api'; +import getOrganizations from '@app/pages/api/organization/getOrgs'; +import getOrganizationUserProjects from '@app/pages/api/organization/GetOrgUserProjects'; + +import { Navbar } from './components/NavBar'; + +interface LayoutProps { + children: React.ReactNode; +} + +const formSchema = yup.object({ + name: yup.string().required().label('Project Name').trim(), + addMembers: yup.bool().required().label('Add Members') +}); + +type TAddProjectFormData = yup.InferType; + +export const AppLayout = ({ children }: LayoutProps) => { + const router = useRouter(); + const { createNotification } = useNotificationContext(); + + const { workspaces, currentWorkspace } = useWorkspace(); + const { currentOrg } = useOrgnization(); + const { user } = useUser(); + + const createWs = useCreateWorkspace(); + const uploadWsKey = useUploadWsKey(); + const addWsUser = useAddUserToWs(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + 'addNewWs' + ] as const); + const { + control, + formState: { isSubmitting }, + reset, + handleSubmit + } = useForm({ + resolver: yupResolver(formSchema) + }); + + const [workspaceMapping, setWorkspaceMapping] = useState[]>([]); + const [workspaceSelected, setWorkspaceSelected] = useState('∞'); + const [totalOnboardingActionsDone, setTotalOnboardingActionsDone] = useState(0); + + const { t } = useTranslation(); + + // TODO(akhilmhdh): This entire logic will be rechecked and will try to avoid + // Placing the localstorage as much as possible + // Wait till tony integrates the azure and its launched + useEffect(() => { + // Put a user in a workspace if they're not in one yet + const putUserInWorkSpace = async () => { + if (tempLocalStorage('orgData.id') === '') { + const userOrgs = await getOrganizations(); + localStorage.setItem('orgData.id', userOrgs[0]._id); + } + + const orgUserProjects = await getOrganizationUserProjects({ + orgId: tempLocalStorage('orgData.id') + }); + const userWorkspaces = orgUserProjects; + if ( + (userWorkspaces.length === 0 && + router.asPath !== '/noprojects' && + !router.asPath.includes('home') && + !router.asPath.includes('settings')) || + router.asPath === '/dashboard/undefined' + ) { + router.push('/noprojects'); + } else if (router.asPath !== '/noprojects') { + const intendedWorkspaceId = router.asPath + .split('/') + [router.asPath.split('/').length - 1].split('?')[0]; + + if (!['heroku', 'vercel', 'github', 'netlify'].includes(intendedWorkspaceId)) { + localStorage.setItem('projectData.id', intendedWorkspaceId); + } + + // If a user is not a member of a workspace they are trying to access, just push them to one of theirs + if ( + !['heroku', 'vercel', 'github', 'netlify'].includes(intendedWorkspaceId) && + !userWorkspaces + .map((workspace: { _id: string }) => workspace._id) + .includes(intendedWorkspaceId) + ) { + router.push(`/dashboard/${userWorkspaces[0]._id}`); + } else { + setWorkspaceMapping( + Object.fromEntries( + userWorkspaces.map((workspace: any) => [workspace.name, workspace._id]) + ) as any + ); + setWorkspaceSelected( + Object.fromEntries( + userWorkspaces.map((workspace: any) => [workspace._id, workspace.name]) + )[router.asPath.split('/')[router.asPath.split('/').length - 1].split('?')[0]] + ); + } + } + }; + putUserInWorkSpace(); + onboardingCheck({ setTotalOnboardingActionsDone }); + }, [router.query.id]); + + useEffect(() => { + try { + if ( + workspaceMapping[workspaceSelected as any] && + `${workspaceMapping[workspaceSelected as any]}` !== + router.asPath.split('/')[router.asPath.split('/').length - 1].split('?')[0] + ) { + localStorage.setItem('projectData.id', `${workspaceMapping[workspaceSelected as any]}`); + router.push(`/dashboard/${workspaceMapping[workspaceSelected as any]}`); + } + } catch (err) { + console.log(err); + } + }, [workspaceSelected]); + + const onCreateProject = async ({ name, addMembers }: TAddProjectFormData) => { + // type check + if (!currentOrg?._id) return; + try { + const { + data: { + workspace: { _id: newWorkspaceId } + } + } = await createWs.mutateAsync({ + organizationId: currentOrg?._id, + workspaceName: name + }); + + const randomBytes = crypto.randomBytes(16).toString('hex'); + const PRIVATE_KEY = String(localStorage.getItem('PRIVATE_KEY')); + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: randomBytes, + publicKey: user.publicKey, + privateKey: PRIVATE_KEY + }); + + await uploadWsKey.mutateAsync({ + encryptedKey: ciphertext, + nonce, + userId: user?._id, + workspaceId: newWorkspaceId + }); + + if (addMembers) { + console.log('adding other users'); + // not using hooks because need at this point only + const orgUsers = await fetchOrgUsers(currentOrg._id); + orgUsers.forEach(({ status, user: orgUser }) => { + // skip if status of org user is not accepted + // this orgUser is the person who created the ws + if (status !== 'accepted' || user.email === orgUser.email) return; + addWsUser.mutate({ email: orgUser.email, workspaceId: newWorkspaceId }); + }); + } + createNotification({ text: 'Workspace created', type: 'success' }); + handlePopUpClose('addNewWs'); + router.push(`/dashboard/${newWorkspaceId}`); + } catch (err) { + console.error(err); + createNotification({ text: 'Failed to create workspace', type: 'error' }); + } + }; + + return ( + <> +
    + +
    + + { + handlePopUpToggle('addNewWs', isModalOpen); + reset(); + }} + > + +
    + ( + + + + )} + /> +
    + ( + + Add all members of my organization to this project + + )} + /> +
    +
    + + +
    + +
    +
    +
    {children}
    +
    +
    +
    + +

    + {` ${t('common:no-mobile')} `} +

    +
    + + ); +}; diff --git a/frontend/src/layouts/AppLayout/components/NavBar/NavBar.tsx b/frontend/src/layouts/AppLayout/components/NavBar/NavBar.tsx new file mode 100644 index 000000000..4be1e4fce --- /dev/null +++ b/frontend/src/layouts/AppLayout/components/NavBar/NavBar.tsx @@ -0,0 +1,305 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +/* eslint-disable react/jsx-key */ +import { Fragment, useMemo } from 'react'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { TFunction, useTranslation } from 'next-i18next'; +import { faGithub, faSlack } from '@fortawesome/free-brands-svg-icons'; +import { faCircleQuestion } from '@fortawesome/free-regular-svg-icons'; +import { + faAngleDown, + faBook, + faCoins, + faEnvelope, + faGear, + faPlus, + faRightFromBracket +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Menu, Transition } from '@headlessui/react'; + +import guidGenerator from '@app/components/utilities/randomId'; +import { useOrgnization, useUser } from '@app/context'; +import { useLogoutUser } from '@app/hooks/api'; + +const supportOptions = (t: TFunction) => [ + [ + , + t('nav:support.slack'), + 'https://join.slack.com/t/infisical/shared_invite/zt-1dgg63ln8-G7PCNJdCymAT9YF3j1ewVA' + ], + [ + , + t('nav:support.docs'), + 'https://infisical.com/docs/getting-started/introduction' + ], + [ + , + t('nav:support.issue'), + 'https://github.com/Infisical/infisical-cli/issues' + ], + [ + , + t('nav:support.email'), + 'mailto:support@infisical.com' + ] +]; + +export interface ICurrentOrg { + name: string; +} + +export interface IUser { + firstName: string; + lastName: string; + email: string; +} + +/** + * This is the navigation bar in the main app. + * It has two main components: support options and user menu (inlcudes billing, logout, org/user settings) + * @returns NavBar + */ +export const Navbar = () => { + const router = useRouter(); + + const { currentOrg, orgs } = useOrgnization(); + const { user } = useUser(); + + const logout = useLogoutUser(); + + const { t } = useTranslation(); + + // remove this memo + const supportOptionsList = useMemo(() => supportOptions(t), [t]); + + const closeApp = async () => { + try { + console.log('Logging out...'); + await logout.mutateAsync(); + router.push('/login'); + } catch (error) { + console.error(error); + } + }; + + return ( +
    +
    +
    +
    + logo +
    + + Infisical + +
    +
    +
    + + + Docs + + +
    + + + +
    + + + {supportOptionsList.map(([icon, text, url]) => ( + +
    + {icon} +
    {text}
    +
    +
    + ))} +
    +
    +
    + +
    + + {user?.firstName} {user?.lastName} + + +
    + + +
    +
    + {t('nav:user.signed-in-as')} +
    +
    null} + role="button" + tabIndex={0} + onClick={() => router.push(`/settings/personal/${router.query.id}`)} + className="mx-1 my-1 flex cursor-pointer flex-row items-center rounded-md px-1 hover:bg-white/5" + > +
    + {user?.firstName?.charAt(0)} +
    +
    +
    +

    + {' '} + {user?.firstName} {user?.lastName} +

    +

    {user?.email}

    +
    + +
    +
    +
    +
    +
    + {t('nav:user.current-organization')} +
    +
    null} + role="button" + tabIndex={0} + onClick={() => router.push(`/settings/org/${router.query.id}`)} + className="mt-2 flex cursor-pointer flex-row items-center rounded-md px-2 py-1 hover:bg-white/5" + > +
    + {currentOrg?.name?.charAt(0)} +
    +
    +

    {currentOrg?.name}

    + +
    +
    + + +
    + {Boolean(orgs) && ( +
    +
    + {t('nav:user.other-organizations')} +
    +
    + {orgs + ?.filter((org: { _id: string }) => org._id !== currentOrg?._id) + .map((org: { _id: string; name: string }) => ( +
    null} + role="button" + tabIndex={0} + key={guidGenerator()} + onClick={() => { + localStorage.setItem('orgData.id', org._id); + router.reload(); + }} + className="flex w-full cursor-pointer flex-row items-center justify-start rounded-md p-1.5 hover:bg-white/5" + > +
    + {org.name.charAt(0)} +
    +
    +

    {org.name}

    +
    +
    + ))} +
    +
    + )} +
    + + {({ active }) => ( + + )} + +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/frontend/src/layouts/AppLayout/components/NavBar/index.tsx b/frontend/src/layouts/AppLayout/components/NavBar/index.tsx new file mode 100644 index 000000000..e3221d984 --- /dev/null +++ b/frontend/src/layouts/AppLayout/components/NavBar/index.tsx @@ -0,0 +1 @@ +export { Navbar } from './NavBar'; diff --git a/frontend/src/layouts/AppLayout/index.tsx b/frontend/src/layouts/AppLayout/index.tsx new file mode 100644 index 000000000..763c03668 --- /dev/null +++ b/frontend/src/layouts/AppLayout/index.tsx @@ -0,0 +1 @@ +export { AppLayout } from './AppLayout'; diff --git a/frontend/src/layouts/index.tsx b/frontend/src/layouts/index.tsx new file mode 100644 index 000000000..763c03668 --- /dev/null +++ b/frontend/src/layouts/index.tsx @@ -0,0 +1 @@ +export { AppLayout } from './AppLayout'; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 0d5809093..2b5dd0fc2 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -6,13 +6,17 @@ import { appWithTranslation } from 'next-i18next'; import { config } from '@fortawesome/fontawesome-svg-core'; import { QueryClientProvider } from '@tanstack/react-query'; -import Layout from '@app/components/basic/Layout'; import NotificationProvider from '@app/components/context/Notifications/NotificationProvider'; import Telemetry from '@app/components/utilities/telemetry/Telemetry'; import { publicPaths } from '@app/const'; -import { SubscriptionProvider } from '@app/context'; -import { AuthProvider } from '@app/context/AuthContext'; -import { WorkspaceProvider } from '@app/context/WorkspaceContext'; +import { + AuthProvider, + OrgProvider, + SubscriptionProvider, + UserProvider, + WorkspaceProvider +} from '@app/context'; +import { AppLayout } from '@app/layouts'; import { queryClient } from '@app/reactQuery'; import '@fortawesome/fontawesome-svg-core/styles.css'; @@ -71,13 +75,17 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element => - - - - - - - + + + + + + + + + + +