diff --git a/backend/src/templates/organizationInvitation.handlebars b/backend/src/templates/organizationInvitation.handlebars index f76c42ba5..045a9602b 100644 --- a/backend/src/templates/organizationInvitation.handlebars +++ b/backend/src/templates/organizationInvitation.handlebars @@ -8,7 +8,7 @@

Join your organization on Infisical

-

{{inviterFirstName}}({{inviterEmail}}) has invited you to their Infisical organization — {{organizationName}}

+

{{inviterFirstName}} ({{inviterEmail}}) has invited you to their Infisical organization — {{organizationName}}

Join now

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

diff --git a/backend/src/templates/workspaceInvitation.handlebars b/backend/src/templates/workspaceInvitation.handlebars index e899d86e6..60556555c 100644 --- a/backend/src/templates/workspaceInvitation.handlebars +++ b/backend/src/templates/workspaceInvitation.handlebars @@ -7,7 +7,7 @@

Join your team on Infisical

-

{{inviterFirstName}}({{inviterEmail}}) has invited you to their Infisical project — {{workspaceName}}

+

{{inviterFirstName}} ({{inviterEmail}}) has invited you to their Infisical project — {{workspaceName}}

Join now

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

diff --git a/frontend/public/locales/en/nav.json b/frontend/public/locales/en/nav.json index de5218619..3d0c6faca 100644 --- a/frontend/public/locales/en/nav.json +++ b/frontend/public/locales/en/nav.json @@ -1,6 +1,6 @@ { "support": { - "slack": "[NEW] Join Slack Forum", + "slack": "Join Slack Forum", "docs": "Read Docs", "issue": "Open a Github Issue", "email": "Send us an Email" diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index bc3876750..57a3f96fd 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 { useOrganization, 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 } = useOrganization(); return (
- {orgName?.charAt(0)} + {currentOrg?.name?.charAt(0)}
-
{orgName}
+
{currentOrg?.name}
{isProjectRelated && ( <> diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx index df12c25f7..af59f3d51 100644 --- a/frontend/src/components/v2/Button/Button.tsx +++ b/frontend/src/components/v2/Button/Button.tsx @@ -24,7 +24,7 @@ const buttonVariants = cva( { variants: { colorSchema: { - primary: ['bg-primary', 'text-black', 'border-primary hover:bg-opacity-80'], + primary: ['bg-primary', 'text-black', 'border-primary bg-opacity-80 hover:bg-opacity-100'], secondary: ['bg-mineshaft', 'text-gray-300', 'border-mineshaft hover:bg-opacity-80'], danger: ['bg-red', 'text-white', 'border-red hover:bg-opacity-90'] }, diff --git a/frontend/src/components/v2/Card/Card.tsx b/frontend/src/components/v2/Card/Card.tsx index 1f8448a03..4bb3c3674 100644 --- a/frontend/src/components/v2/Card/Card.tsx +++ b/frontend/src/components/v2/Card/Card.tsx @@ -8,9 +8,9 @@ export type CardTitleProps = { }; export const CardTitle = ({ children, className, subTitle }: CardTitleProps) => ( -
+
{children} - {subTitle &&

{subTitle}

} + {subTitle &&

{subTitle}

}
); diff --git a/frontend/src/components/v2/Checkbox/Checkbox.tsx b/frontend/src/components/v2/Checkbox/Checkbox.tsx index 5b30fb458..c2ecfad14 100644 --- a/frontend/src/components/v2/Checkbox/Checkbox.tsx +++ b/frontend/src/components/v2/Checkbox/Checkbox.tsx @@ -28,8 +28,9 @@ export const Checkbox = ({
- +
diff --git a/frontend/src/components/v2/Menu/Menu.tsx b/frontend/src/components/v2/Menu/Menu.tsx index 1497b01fa..262c618f9 100644 --- a/frontend/src/components/v2/Menu/Menu.tsx +++ b/frontend/src/components/v2/Menu/Menu.tsx @@ -31,18 +31,20 @@ 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/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index efc6ba3a3..c33881772 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -25,7 +25,7 @@ export const ModalContent = forwardRef( @@ -36,7 +36,7 @@ export const ModalContent = forwardRef( diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 86ccc1079..5a1aa7a35 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,11 +45,13 @@ 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)' }} > - + {isLoading ? (
    @@ -82,7 +85,7 @@ export const SelectItem = forwardRef( {...props} className={twMerge( `relative flex cursor-pointer - select-none items-center rounded-md py-2 pl-10 pr-4 text-sm + select-none items-center rounded-md py-2 pl-10 pr-4 mb-0.5 text-sm outline-none transition-all hover:bg-mineshaft-500`, isSelected && 'bg-primary', isDisabled && 'cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-gray-600', @@ -90,8 +93,8 @@ export const SelectItem = forwardRef( )} ref={forwardedRef} > - - + + {children} diff --git a/frontend/src/context/OrganizationContext/OrganizationContext.tsx b/frontend/src/context/OrganizationContext/OrganizationContext.tsx new file mode 100644 index 000000000..31a97d07c --- /dev/null +++ b/frontend/src/context/OrganizationContext/OrganizationContext.tsx @@ -0,0 +1,46 @@ +import { createContext, ReactNode, useContext, useMemo } from 'react'; + +import { useGetOrganization } from '@app/hooks/api'; +import { Organization } from '@app/hooks/api/types'; + +import { useWorkspace } from '../WorkspaceContext'; + +type TOrgContext = { + orgs?: Organization[]; + currentOrg?: Organization; + isLoading: boolean; +}; + +const OrgContext = createContext(null); + +type Props = { + children: ReactNode; +}; + +export const OrgProvider = ({ children }: Props): JSX.Element => { + const { currentWorkspace } = useWorkspace(); + const { data: userOrgs, isLoading } = useGetOrganization(); + + const currentWsOrgID = currentWorkspace?.organization; + + // memorize the workspace details for the context + const value = useMemo( + () => ({ + orgs: userOrgs, + currentOrg: (userOrgs || []).find(({ _id }) => _id === currentWsOrgID), + isLoading + }), + [currentWsOrgID, userOrgs, isLoading] + ); + + return {children}; +}; + +export const useOrganization = () => { + const ctx = useContext(OrgContext); + if (!ctx) { + throw new Error('useOrganization to be used within '); + } + + return ctx; +}; diff --git a/frontend/src/context/OrganizationContext/index.tsx b/frontend/src/context/OrganizationContext/index.tsx new file mode 100644 index 000000000..ac9091e35 --- /dev/null +++ b/frontend/src/context/OrganizationContext/index.tsx @@ -0,0 +1 @@ +export { OrgProvider, useOrganization } from './OrganizationContext'; diff --git a/frontend/src/context/UserContext/UserContext.tsx b/frontend/src/context/UserContext/UserContext.tsx new file mode 100644 index 000000000..0c6ce9549 --- /dev/null +++ b/frontend/src/context/UserContext/UserContext.tsx @@ -0,0 +1,38 @@ +import { createContext, ReactNode, useContext, useMemo } from 'react'; + +import { useGetUser } from '@app/hooks/api'; +import { User } from '@app/hooks/api/types'; + +type TUserContext = { + user: User; + isLoading: boolean; +}; + +const UserContext = createContext(null); + +type Props = { + children: ReactNode; +}; + +export const UserProvider = ({ children }: Props): JSX.Element => { + const { data, isLoading } = useGetUser(); + + // memorize the workspace details for the context + const value = useMemo(() => { + return { + user: data!, + isLoading + }; + }, [data, isLoading]); + + return {children}; +}; + +export const useUser = () => { + const ctx = useContext(UserContext); + if (!ctx) { + throw new Error('useUser has to be used within '); + } + + return ctx; +}; diff --git a/frontend/src/context/UserContext/index.tsx b/frontend/src/context/UserContext/index.tsx new file mode 100644 index 000000000..44aa26a3b --- /dev/null +++ b/frontend/src/context/UserContext/index.tsx @@ -0,0 +1 @@ +export { UserProvider, useUser } from './UserContext'; diff --git a/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx b/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx index 4edef8e6a..f2876d996 100644 --- a/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx +++ b/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx @@ -23,9 +23,10 @@ export const WorkspaceProvider = ({ children }: Props): JSX.Element => { // memorize the workspace details for the context const value = useMemo(() => { + const wsId = workspaceId || localStorage.getItem('projectData.id'); return { workspaces: ws || [], - currentWorkspace: (ws || []).find(({ _id: id }) => id === workspaceId), + currentWorkspace: (ws || []).find(({ _id: id }) => id === wsId), isLoading }; }, [ws, workspaceId, isLoading]); diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 1bf59f9ac..215802f5d 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -1,2 +1,5 @@ +export { AuthProvider } from './AuthContext'; +export { OrgProvider, useOrganization } from './OrganizationContext'; export { SubscriptionProvider, useSubscription } from './SubscriptionContext'; +export { UserProvider, useUser } from './UserContext'; export { useWorkspace, WorkspaceProvider } from './WorkspaceContext'; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 263f7e152..ad9acf0c3 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,6 +1,8 @@ export * from './auth'; export * from './keys'; +export * from './organization'; export * from './serviceTokens'; export * from './subscriptions'; export * from './tags'; +export * from './users'; export * from './workspace'; diff --git a/frontend/src/hooks/api/keys/index.tsx b/frontend/src/hooks/api/keys/index.tsx index 23ffdf8d1..7b4286b3a 100644 --- a/frontend/src/hooks/api/keys/index.tsx +++ b/frontend/src/hooks/api/keys/index.tsx @@ -1 +1 @@ -export { useGetUserWsKey } from './queries'; +export { useGetUserWsKey, useUploadWsKey } from './queries'; diff --git a/frontend/src/hooks/api/keys/queries.tsx b/frontend/src/hooks/api/keys/queries.tsx index d37c73f13..6d7819dce 100644 --- a/frontend/src/hooks/api/keys/queries.tsx +++ b/frontend/src/hooks/api/keys/queries.tsx @@ -1,8 +1,8 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { apiRequest } from '@app/config/request'; -import { UserWsKeyPair } from './types'; +import { UploadWsKeyDTO, UserWsKeyPair } from './types'; const encKeyKeys = { getUserWorkspaceKey: (workspaceID: string) => ['worksapce-key-pair', { workspaceID }] as const @@ -22,3 +22,10 @@ export const useGetUserWsKey = (workspaceID: string) => queryFn: () => fetchUserWsKey(workspaceID), enabled: Boolean(workspaceID) }); + +// mutations +export const useUploadWsKey = () => + useMutation<{}, {}, UploadWsKeyDTO>({ + mutationFn: ({ encryptedKey, nonce, userId, workspaceId }) => + apiRequest.post(`/api/v1/key/${workspaceId}`, { key: { userId, encryptedKey, nonce } }) + }); diff --git a/frontend/src/hooks/api/keys/types.ts b/frontend/src/hooks/api/keys/types.ts index bad61b5dd..ee4660aeb 100644 --- a/frontend/src/hooks/api/keys/types.ts +++ b/frontend/src/hooks/api/keys/types.ts @@ -20,3 +20,10 @@ export type Sender = { lastName: string; publicKey: string; }; + +export type UploadWsKeyDTO = { + userId: string; + encryptedKey: string; + nonce: string; + workspaceId: string; +}; diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts new file mode 100644 index 000000000..4d9dd04f8 --- /dev/null +++ b/frontend/src/hooks/api/organization/index.ts @@ -0,0 +1 @@ +export { useGetOrganization } from './queries'; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx new file mode 100644 index 000000000..b2f9c7ce6 --- /dev/null +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; + +import { apiRequest } from '@app/config/request'; + +import { Organization } from './types'; + +const organizationKeys = { + getUserOrganization: ['organization'] as const +}; + +const fetchUserOrganization = async () => { + const { data } = await apiRequest.get<{ organizations: Organization[] }>('/api/v1/organization'); + + return data.organizations; +}; + +export const useGetOrganization = () => + useQuery({ queryKey: organizationKeys.getUserOrganization, queryFn: fetchUserOrganization }); diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts new file mode 100644 index 000000000..74720320b --- /dev/null +++ b/frontend/src/hooks/api/organization/types.ts @@ -0,0 +1,6 @@ +export type Organization = { + _id: string; + name: string; + createAt: string; + updatedAt: string; +}; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index a0475e24e..699e9a81d 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -1,7 +1,9 @@ export type { GetAuthTokenAPI } from './auth/types'; export type { UserWsKeyPair } from './keys/types'; +export type { Organization } from './organization/types'; export type { CreateServiceTokenDTO, ServiceToken } from './serviceTokens/types'; export type { GetSubscriptionPlan, SubscriptionPlan } from './subscriptions/types'; +export type { User } from './users/types'; export type { CreateEnvironmentDTO, DeleteEnvironmentDTO, diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx new file mode 100644 index 000000000..69c0fc98a --- /dev/null +++ b/frontend/src/hooks/api/users/index.tsx @@ -0,0 +1,7 @@ +export { + fetchOrgUsers, + useAddUserToWs, + useGetOrgUsers, + useGetUser, + useLogoutUser +} from './queries'; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx new file mode 100644 index 000000000..1ddcf3809 --- /dev/null +++ b/frontend/src/hooks/api/users/queries.tsx @@ -0,0 +1,84 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { + decryptAssymmetric, + encryptAssymmetric +} from '@app/components/utilities/cryptography/crypto'; +import { apiRequest } from '@app/config/request'; +import { setAuthToken } from '@app/reactQuery'; + +import { useUploadWsKey } from '../keys/queries'; +import { AddUserToWsDTO, AddUserToWsRes, OrgUser, User } from './types'; + +const userKeys = { + getUser: ['user'] as const, + getOrgUsers: (orgId: string) => [{ orgId }, 'user'] +}; + +const fetchUserDetails = async () => { + const { data } = await apiRequest.get<{ user: User }>('/api/v1/user'); + + return data.user; +}; + +export const useGetUser = () => useQuery(userKeys.getUser, fetchUserDetails); + +export const fetchOrgUsers = async (orgId: string) => { + const { data } = await apiRequest.get<{ users: OrgUser[] }>( + `/api/v1/organization/${orgId}/users` + ); + + return data.users; +}; + +export const useGetOrgUsers = (orgId: string) => + useQuery(userKeys.getOrgUsers(orgId), () => fetchOrgUsers(orgId)); + +// mutation +export const useAddUserToWs = () => { + const uploadWsKey = useUploadWsKey(); + + return useMutation<{ data: AddUserToWsRes }, {}, AddUserToWsDTO>({ + mutationFn: ({ email, workspaceId }) => + apiRequest.post(`/api/v1/workspace/${workspaceId}/invite-signup`, { email }), + onSuccess: ({ data }, { workspaceId }) => { + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); + if (!PRIVATE_KEY) return; + + // assymmetrically decrypt symmetric key with local private key + const key = decryptAssymmetric({ + ciphertext: data.latestKey.encryptedKey, + nonce: data.latestKey.nonce, + publicKey: data.latestKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const { ciphertext: inviteeCipherText, nonce: inviteeNonce } = encryptAssymmetric({ + plaintext: key, + publicKey: data.invitee.publicKey, + privateKey: PRIVATE_KEY + }); + + uploadWsKey.mutate({ + encryptedKey: inviteeCipherText, + nonce: inviteeNonce, + userId: data.invitee._id, + workspaceId + }); + } + }); +}; + +export const useLogoutUser = () => + useMutation({ + mutationFn: () => apiRequest.post('/api/v1/auth/logout'), + onSuccess: () => { + setAuthToken(''); + // Delete the cookie by not setting a value; Alternatively clear the local storage + localStorage.setItem('publicKey', ''); + localStorage.setItem('encryptedPrivateKey', ''); + localStorage.setItem('iv', ''); + localStorage.setItem('tag', ''); + localStorage.setItem('PRIVATE_KEY', ''); + } + }); diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts new file mode 100644 index 000000000..6f7552d54 --- /dev/null +++ b/frontend/src/hooks/api/users/types.ts @@ -0,0 +1,39 @@ +import { UserWsKeyPair } from '../keys/types'; + +export type User = { + seenIps: string[]; + _id: string; + email: string; + createdAt: Date; + updatedAt: Date; + __v: number; + firstName: string; + lastName: string; + publicKey: string; +}; + +export type OrgUser = { + _id: string; + user: { + email: string; + firstName: string; + lastName: string; + _id: string; + publicKey: string; + }; + inviteEmail: string; + organization: string; + role: 'owner' | 'admin' | 'member'; + status: 'invited' | 'accepted'; + deniedPermissions: any[]; +}; + +export type AddUserToWsDTO = { + workspaceId: string; + email: string; +}; + +export type AddUserToWsRes = { + invitee: OrgUser['user']; + latestKey: UserWsKeyPair; +}; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 51f8c38eb..52a01029e 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -1,4 +1,5 @@ export { + useCreateWorkspace, useCreateWsEnvironment, useDeleteWorkspace, useDeleteWsEnvironment, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index bae1db874..0c4ba970b 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -4,6 +4,7 @@ import { apiRequest } from '@app/config/request'; import { CreateEnvironmentDTO, + CreateWorkspaceDTO, DeleteEnvironmentDTO, DeleteWorkspaceDTO, RenameWorkspaceDTO, @@ -40,6 +41,18 @@ export const useGetUserWorkspaces = () => useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces); // mutation +export const useCreateWorkspace = () => { + const queryClient = useQueryClient(); + + return useMutation<{ data: { workspace: Workspace } }, {}, CreateWorkspaceDTO>({ + mutationFn: async ({ organizationId, workspaceName }) => + apiRequest.post('/api/v1/workspace', { workspaceName, organizationId }), + onSuccess: () => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + } + }); +}; + export const useRenameWorkspace = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 312a811d9..738f5188c 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -11,6 +11,11 @@ export type WorkspaceEnv = { name: string; slug: string }; export type WorkspaceTag = { _id: string; name: string; slug: string }; // mutation dto +export type CreateWorkspaceDTO = { + workspaceName: string; + organizationId: string; +}; + export type RenameWorkspaceDTO = { workspaceID: string; newWorkspaceName: string }; export type ToggleAutoCapitalizationDTO = { workspaceID: string; state: boolean }; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx new file mode 100644 index 000000000..3f4e0f95e --- /dev/null +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -0,0 +1,427 @@ +/* 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 { useOrganization, 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(); + + // eslint-disable-next-line prefer-const + let { workspaces, currentWorkspace } = useWorkspace(); + const { currentOrg } = useOrganization(); + workspaces = workspaces.filter(ws => ws.organization === currentOrg?._id) + 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 (!['callback', 'create', 'authorize'].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 ( + !['callback', 'create', 'authorize'].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..c6762f5bd --- /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 { useOrganization, 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 } = useOrganization(); + 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}

    + +
    +
    + + +
    + {orgs && orgs?.length > 1 && ( +
    +
    + {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 => - - - - - - - + + + + + + + + + + + diff --git a/frontend/src/pages/home/[id].tsx b/frontend/src/pages/home/[id].tsx index 5f77fe133..44427084e 100644 --- a/frontend/src/pages/home/[id].tsx +++ b/frontend/src/pages/home/[id].tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import Head from 'next/head'; import { useRouter } from 'next/router'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { faSlack } from '@fortawesome/free-brands-svg-icons'; @@ -142,6 +143,10 @@ export default function Home() { return (
    + + Infisical Guide + +
    Your quick start guide