diff --git a/.github/values.yaml b/.github/values.yaml index 62afb494f..819df5066 100644 --- a/.github/values.yaml +++ b/.github/values.yaml @@ -2,7 +2,8 @@ frontend: enabled: true name: frontend podAnnotations: {} - deploymentAnnotations: {} + deploymentAnnotations: + secrets.infisical.com/auto-reload: "true" replicaCount: 2 image: repository: infisical/frontend @@ -20,7 +21,8 @@ backend: enabled: true name: backend podAnnotations: {} - deploymentAnnotations: {} + deploymentAnnotations: + secrets.infisical.com/auto-reload: "true" replicaCount: 2 image: repository: infisical/backend diff --git a/README.md b/README.md index 9f2590080..131d055b4 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ - **[Activity Logs](https://infisical.com/docs/getting-started/dashboard/audit-logs)** to record every action taken in a project - **[Point-in-time Secrets Recovery](https://infisical.com/docs/getting-started/dashboard/pit-recovery)** for rolling back to any snapshot of your secrets - **Role-based Access Controls** per environment -- 🔜 **2FA** (next week) +- **2FA** (more options coming soon) - 🔜 **1-Click Deploy** to AWS - 🔜 **Automatic Secret Rotation** - 🔜 **Smart Security Alerts** diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts index fa41eda73..95c359dd4 100644 --- a/backend/src/controllers/v2/authController.ts +++ b/backend/src/controllers/v2/authController.ts @@ -4,7 +4,7 @@ import jwt from 'jsonwebtoken'; import * as Sentry from '@sentry/node'; import * as bigintConversion from 'bigint-conversion'; const jsrp = require('jsrp'); -import { User } from '../../models'; +import { User, LoginSRPDetail } from '../../models'; import { issueAuthTokens, createToken } from '../../helpers/auth'; import { sendMail } from '../../helpers/nodemailer'; import { TokenService } from '../../services'; @@ -13,6 +13,7 @@ import { JWT_MFA_LIFETIME, JWT_MFA_SECRET } from '../../config'; +import { BadRequestError } from '../../utils/errors'; import { TOKEN_EMAIL_MFA } from '../../variables'; @@ -50,13 +51,15 @@ export const login1 = async (req: Request, res: Response) => { salt: user.salt, verifier: user.verifier }, - () => { + async () => { // generate server-side public key const serverPublicKey = server.getPublicKey(); - clientPublicKeys[email] = { - clientPublicKey, - serverBInt: bigintConversion.bigintToBuf(server.bInt) - }; + + await LoginSRPDetail.findOneAndReplace({ email: email }, { + email: email, + clientPublicKey: clientPublicKey, + serverBInt: bigintConversion.bigintToBuf(server.bInt), + }, { upsert: true, returnNewDocument: false }); return res.status(200).send({ serverPublicKey, @@ -89,15 +92,21 @@ export const login2 = async (req: Request, res: Response) => { if (!user) throw new Error('Failed to find user'); + const loginSRPDetail = await LoginSRPDetail.findOneAndDelete({ email: email }) + + if (!loginSRPDetail) { + return BadRequestError(Error("Failed to find login details for SRP")) + } + const server = new jsrp.server(); server.init( { salt: user.salt, verifier: user.verifier, - b: clientPublicKeys[email].serverBInt + b: loginSRPDetail.serverBInt }, async () => { - server.setClientPublicKey(clientPublicKeys[email].clientPublicKey); + server.setClientPublicKey(loginSRPDetail.clientPublicKey); // compare server and client shared keys if (server.checkClientProof(clientProof)) { diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 1e08a701f..301259198 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -138,7 +138,7 @@ const exchangeCodeAzure = async ({ try { res = (await axios.post( INTEGRATION_AZURE_TOKEN_URL, - new URLSearchParams({ + new URLSearchParams({ grant_type: 'authorization_code', code: code, scope: 'https://vault.azure.net/.default openid offline_access', @@ -147,16 +147,16 @@ const exchangeCodeAzure = async ({ redirect_uri: `${SITE_URL}/integrations/azure-key-vault/oauth2/callback` } as any) )).data; - + accessExpiresAt.setSeconds( - accessExpiresAt.getSeconds() + res.expires_in + accessExpiresAt.getSeconds() + res.expires_in ); } catch (err: any) { Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed OAuth2 code-token exchange with Azure'); } - + return ({ accessToken: res.access_token, refreshToken: res.refresh_token, @@ -175,36 +175,36 @@ const exchangeCodeAzure = async ({ * @returns {Date} obj2.accessExpiresAt - date of expiration for access token */ const exchangeCodeHeroku = async ({ - code + code }: { - code: string; + code: string; }) => { - let res: ExchangeCodeHerokuResponse; - const accessExpiresAt = new Date(); - try { - res = (await axios.post( - INTEGRATION_HEROKU_TOKEN_URL, - new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - client_secret: CLIENT_SECRET_HEROKU - } as any) - )).data; - - accessExpiresAt.setSeconds( - accessExpiresAt.getSeconds() + res.expires_in - ); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed OAuth2 code-token exchange with Heroku'); - } - - return ({ - accessToken: res.access_token, - refreshToken: res.refresh_token, - accessExpiresAt - }); + let res: ExchangeCodeHerokuResponse; + const accessExpiresAt = new Date(); + try { + res = (await axios.post( + INTEGRATION_HEROKU_TOKEN_URL, + new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_secret: CLIENT_SECRET_HEROKU + } as any) + )).data; + + accessExpiresAt.setSeconds( + accessExpiresAt.getSeconds() + res.expires_in + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Heroku'); + } + + return ({ + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt + }); } /** @@ -234,7 +234,7 @@ const exchangeCodeVercel = async ({ code }: { code: string }) => { } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed OAuth2 code-token exchange with Vercel'); + throw new Error(`Failed OAuth2 code-token exchange with Vercel [err=${err}]`); } return { diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index e4bdc116c..3309e5813 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -30,7 +30,6 @@ import { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, } from "../variables"; -import { access, appendFile } from "fs"; /** * Sync/push [secrets] to [app] in integration named [integration] @@ -181,7 +180,8 @@ const syncSecretsAzureKeyVault = async ({ while (url) { const res = await axios.get(url, { headers: { - Authorization: `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' } }); @@ -202,7 +202,8 @@ const syncSecretsAzureKeyVault = async ({ const azureKeyVaultSecret = await axios.get(`${getAzureKeyVaultSecret.id}?api-version=7.3`, { headers: { - 'Authorization': `Bearer ${accessToken}` + 'Authorization': `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' } }); @@ -259,7 +260,8 @@ const syncSecretsAzureKeyVault = async ({ }, { headers: { - Authorization: `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' } } ); @@ -270,7 +272,8 @@ const syncSecretsAzureKeyVault = async ({ deleteSecrets.forEach(async (secret) => { await axios.delete(`${integration.app}/secrets/${secret.key}?api-version=7.3`, { headers: { - 'Authorization': `Bearer ${accessToken}` + 'Authorization': `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' } }); }); @@ -488,6 +491,7 @@ const syncSecretsHeroku = async ({ headers: { Accept: "application/vnd.heroku+json; version=3", Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ) @@ -506,6 +510,7 @@ const syncSecretsHeroku = async ({ headers: { Accept: "application/vnd.heroku+json; version=3", Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -552,7 +557,7 @@ const syncSecretsVercel = async ({ } : {}), }; - + const res = ( await Promise.all( ( @@ -561,7 +566,8 @@ const syncSecretsVercel = async ({ { params, headers: { - Authorization: `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' } } )) @@ -573,7 +579,8 @@ const syncSecretsVercel = async ({ { params, headers: { - Authorization: `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' } } )).data) @@ -633,6 +640,7 @@ const syncSecretsVercel = async ({ params, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -649,6 +657,7 @@ const syncSecretsVercel = async ({ params, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -664,6 +673,7 @@ const syncSecretsVercel = async ({ params, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -723,6 +733,7 @@ const syncSecretsNetlify = async ({ params: getParams, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ) @@ -837,6 +848,7 @@ const syncSecretsNetlify = async ({ params: syncParams, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -854,6 +866,7 @@ const syncSecretsNetlify = async ({ params: syncParams, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -868,6 +881,7 @@ const syncSecretsNetlify = async ({ params: syncParams, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -882,6 +896,7 @@ const syncSecretsNetlify = async ({ params: syncParams, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -1035,6 +1050,7 @@ const syncSecretsRender = async ({ { headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -1088,6 +1104,7 @@ const syncSecretsFlyio = async ({ method: "post", headers: { Authorization: "Bearer " + accessToken, + 'Accept-Encoding': 'application/json' }, data: { query: SetSecrets, @@ -1167,6 +1184,7 @@ const syncSecretsFlyio = async ({ headers: { Authorization: "Bearer " + accessToken, "Content-Type": "application/json", + 'Accept-Encoding': 'application/json' }, data: { query: DeleteSecrets, diff --git a/frontend/public/data/frequentInterfaces.ts b/frontend/public/data/frequentInterfaces.ts index fa6c73a57..953096e1d 100644 --- a/frontend/public/data/frequentInterfaces.ts +++ b/frontend/public/data/frequentInterfaces.ts @@ -13,6 +13,7 @@ export interface SecretDataProps { value: string | undefined; valueOverride: string | undefined; id: string; + idOverride?: string; comment: string; tags: Tag[]; } \ No newline at end of file diff --git a/frontend/src/components/basic/table/ProjectUsersTable.tsx b/frontend/src/components/basic/table/ProjectUsersTable.tsx index 27346195c..438517aa5 100644 --- a/frontend/src/components/basic/table/ProjectUsersTable.tsx +++ b/frontend/src/components/basic/table/ProjectUsersTable.tsx @@ -30,7 +30,7 @@ type Props = { type EnvironmentProps = { name: string; slug: string; -} +}; /** * This is the component that shows the users of a certin project @@ -91,26 +91,38 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { }); }; - const handlePermissionUpdate = (index: number, val: string, membershipId: string, slug: string ) => { - let denials: { ability: string; environmentSlug: string; }[]; - if (val === "Read Only") { - denials = [{ - ability: "write", - environmentSlug: slug - }]; - } else if (val === "No Access") { - denials = [{ - ability: "write", - environmentSlug: slug - }, { - ability: "read", - environmentSlug: slug - }]; - } else if (val === "Add Only") { - denials = [{ - ability: "read", - environmentSlug: slug - }]; + const handlePermissionUpdate = ( + index: number, + val: string, + membershipId: string, + slug: string + ) => { + let denials: { ability: string; environmentSlug: string }[]; + if (val === 'Read Only') { + denials = [ + { + ability: 'write', + environmentSlug: slug + } + ]; + } else if (val === 'No Access') { + denials = [ + { + ability: 'write', + environmentSlug: slug + }, + { + ability: 'read', + environmentSlug: slug + } + ]; + } else if (val === 'Add Only') { + denials = [ + { + ability: 'read', + environmentSlug: slug + } + ]; } else { denials = []; } @@ -118,8 +130,12 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { if (currentPlan !== plans.professional && host === 'https://app.infisical.com') { setIsUpgradeModalOpen(true); } else { - const allDenials = userData[index].deniedPermissions.filter((perm: { ability: string; environmentSlug: string; }) => perm.environmentSlug !== slug).concat(denials); - updateUserProjectPermission({ membershipId, denials: allDenials}); + const allDenials = userData[index].deniedPermissions + .filter( + (perm: { ability: string; environmentSlug: string }) => perm.environmentSlug !== slug + ) + .concat(denials); + updateUserProjectPermission({ membershipId, denials: allDenials }); changeData([ ...userData.slice(0, index), ...[ @@ -156,7 +172,7 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { orgId }); if (subscriptions) { - setCurrentPlan(subscriptions.data[0].plan.product) + setCurrentPlan(subscriptions.data[0].plan.product); } })(); }, [userData, myUser]); @@ -186,25 +202,28 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { const closeUpgradeModal = () => { setIsUpgradeModalOpen(false); - } + }; return ( -
-
+
+
- - +
+ - - - - {workspaceEnvs.map(env => ( - + + + {workspaceEnvs.map((env) => ( + ))} @@ -227,28 +246,28 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { ) .map((row, index) => ( - - - - {workspaceEnvs.map((env) => )} - + ))} + diff --git a/frontend/src/components/dashboard/DashboardInputField.tsx b/frontend/src/components/dashboard/DashboardInputField.tsx index 3e171b722..f9fb39d7e 100644 --- a/frontend/src/components/dashboard/DashboardInputField.tsx +++ b/frontend/src/components/dashboard/DashboardInputField.tsx @@ -99,8 +99,8 @@ const DashboardInputField = ({ /> )} - {!error &&
diff --git a/frontend/src/components/navigation/NavBarDashboard.tsx b/frontend/src/components/navigation/NavBarDashboard.tsx index e31b04ed0..404df9abc 100644 --- a/frontend/src/components/navigation/NavBarDashboard.tsx +++ b/frontend/src/components/navigation/NavBarDashboard.tsx @@ -92,7 +92,7 @@ export default function Navbar() { }; return ( -
+
@@ -165,7 +165,7 @@ export default function Navbar() { leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - +
{t('nav:user.signed-in-as')} diff --git a/frontend/src/components/utilities/cryptography/changePassword.ts b/frontend/src/components/utilities/cryptography/changePassword.ts index bdc741b3d..aeb138ec6 100644 --- a/frontend/src/components/utilities/cryptography/changePassword.ts +++ b/frontend/src/components/utilities/cryptography/changePassword.ts @@ -13,8 +13,6 @@ import { deriveArgonKey } from './crypto'; const clientOldPassword = new jsrp.client(); const clientNewPassword = new jsrp.client(); -// TODO: modify this function - /** * This function loggs in the user (whether it's right after signup, or a normal login) * @param {*} email @@ -105,9 +103,8 @@ const changePassword = async ( secret: Buffer.from(derivedKey.hash) }); - let res; try { - res = await changePassword2({ + await changePassword2({ clientProof, protectedKey, protectedKeyIV, @@ -118,23 +115,19 @@ const changePassword = async ( salt: result.salt, verifier: result.verifier }); - + saveTokenToLocalStorage({ protectedKey, protectedKeyIV, protectedKeyTag, encryptedPrivateKey, iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, + tag: encryptedPrivateKeyTag }); - if (res && res.status === 400) { - setCurrentPasswordError(true); - } else if (res && res.status === 200) { - setPasswordChanged(true); - setCurrentPassword(''); - setNewPassword(''); - } + setPasswordChanged(true); + setCurrentPassword(''); + setNewPassword(''); } catch (error) { setCurrentPasswordError(true); console.log(error); diff --git a/frontend/src/components/utilities/saveTokenToLocalStorage.ts b/frontend/src/components/utilities/saveTokenToLocalStorage.ts index 90cf5bd7d..c47241ed0 100644 --- a/frontend/src/components/utilities/saveTokenToLocalStorage.ts +++ b/frontend/src/components/utilities/saveTokenToLocalStorage.ts @@ -3,9 +3,9 @@ interface Props { protectedKeyIV?: string; protectedKeyTag?: string; publicKey?: string; - encryptedPrivateKey: string; - iv: string; - tag: string; + encryptedPrivateKey?: string; + iv?: string; + tag?: string; privateKey?: string; } @@ -20,38 +20,46 @@ export const saveTokenToLocalStorage = ({ privateKey, }: Props) => { try { - localStorage.removeItem("protectedKey"); - localStorage.removeItem("protectedKeyIV"); - localStorage.removeItem("protectedKeyTag"); - localStorage.removeItem("publicKey"); - localStorage.removeItem("encryptedPrivateKey"); - localStorage.removeItem("iv"); - localStorage.removeItem("tag"); - localStorage.removeItem("PRIVATE_KEY"); if (protectedKey) { + localStorage.removeItem("protectedKey"); localStorage.setItem("protectedKey", protectedKey); } if (protectedKeyIV) { + localStorage.removeItem("protectedKeyIV"); localStorage.setItem("protectedKeyIV", protectedKeyIV); } if (protectedKeyTag) { + localStorage.removeItem("protectedKeyTag"); localStorage.setItem("protectedKeyTag", protectedKeyTag); } if (publicKey) { + localStorage.removeItem("publicKey"); localStorage.setItem("publicKey", publicKey); } - if (privateKey) { - localStorage.setItem("PRIVATE_KEY", privateKey); + if (encryptedPrivateKey) { + localStorage.removeItem("encryptedPrivateKey"); + localStorage.setItem("encryptedPrivateKey", encryptedPrivateKey); } - localStorage.setItem("encryptedPrivateKey", encryptedPrivateKey); - localStorage.setItem("iv", iv); - localStorage.setItem("tag", tag); + if (iv) { + localStorage.removeItem("iv"); + localStorage.setItem("iv", iv); + } + + if (tag) { + localStorage.removeItem("tag"); + localStorage.setItem("tag", tag); + } + + if (privateKey) { + localStorage.removeItem("PRIVATE_KEY"); + localStorage.setItem("PRIVATE_KEY", privateKey); + } } catch (err) { if (err instanceof Error) { throw new Error( diff --git a/frontend/src/components/v2/Card/Card.tsx b/frontend/src/components/v2/Card/Card.tsx index 4bb3c3674..095018614 100644 --- a/frontend/src/components/v2/Card/Card.tsx +++ b/frontend/src/components/v2/Card/Card.tsx @@ -3,14 +3,19 @@ import { twMerge } from 'tailwind-merge'; export type CardTitleProps = { children: ReactNode; - subTitle?: string; + subTitle?: ReactNode; className?: string; }; export const CardTitle = ({ children, className, subTitle }: CardTitleProps) => ( -
+
{children} - {subTitle &&

{subTitle}

} + {subTitle &&

{subTitle}

}
); diff --git a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx index 1d072cc89..31dcc611a 100644 --- a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx +++ b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx @@ -5,7 +5,7 @@ import { useToggle } from '@app/hooks'; import { Button } from '../Button'; import { FormControl } from '../FormControl'; import { Input } from '../Input'; -import { Modal, ModalContent } from '../Modal'; +import { Modal, ModalClose, ModalContent } from '../Modal'; type Props = { isOpen?: boolean; @@ -64,9 +64,11 @@ export const DeleteActionModal = ({ > Delete - + + + {' '}
} onClose={onClose} diff --git a/frontend/src/components/v2/Input/Input.tsx b/frontend/src/components/v2/Input/Input.tsx index 28524c72d..e6524c839 100644 --- a/frontend/src/components/v2/Input/Input.tsx +++ b/frontend/src/components/v2/Input/Input.tsx @@ -12,7 +12,7 @@ type Props = { }; const inputVariants = cva( - 'input w-full py-2 text-gray-400 placeholder-gray-500 placeholder-opacity-50 outline-none focus:ring-2', + 'input w-full py-[0.375rem] text-gray-400 placeholder:text-sm placeholder-gray-500 placeholder-opacity-50 outline-none focus:ring-2', { variants: { size: { @@ -84,19 +84,19 @@ export const Input = forwardRef( ): JSX.Element => { return (
- {leftIcon && {leftIcon}} + {leftIcon && {leftIcon}} - {rightIcon && {rightIcon}} + {rightIcon && {rightIcon}}
); } diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index c33881772..fc9a90642 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -9,7 +9,7 @@ import { IconButton } from '../IconButton'; export type ModalContentProps = DialogPrimitive.DialogContentProps & { title?: ReactNode; - subTitle?: string; + subTitle?: ReactNode; footerContent?: ReactNode; onClose?: () => void; }; diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index f8d559abe..ffeaf3c3f 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -14,18 +14,28 @@ type Props = { dropdownContainerClassName?: string; isLoading?: boolean; position?: 'item-aligned' | 'popper'; + isDisabled?: boolean; icon?: IconProp; }; -export type SelectProps = SelectPrimitive.SelectProps & Props; +export type SelectProps = Omit & Props; export const Select = forwardRef( ( - { children, placeholder, className, isLoading, dropdownContainerClassName, position, ...props }, + { + children, + placeholder, + className, + isLoading, + isDisabled, + dropdownContainerClassName, + position, + ...props + }, ref ): JSX.Element => { return ( - + ( className )} > - + {props.icon ? : placeholder} - {!props.disabled && ( + {!isDisabled && ( @@ -46,7 +56,7 @@ export const Select = forwardRef( ( ; + +const tagVariants = cva('inline-flex whitespace-nowrap text-sm rounded-sm mr-1.5 text-bunker-200 ', { + variants: { + colorSchema: { + gray: 'bg-mineshaft-500', + red: 'bg-red/80 text-bunker-100' + }, + size: { + sm: 'px-1 py-0.5' + } + } +}); + +export const Tag = ({ children, className, colorSchema = 'gray', size = 'sm' }: Props) => ( +
{children}
+); diff --git a/frontend/src/components/v2/Tag/index.tsx b/frontend/src/components/v2/Tag/index.tsx new file mode 100644 index 000000000..ba2338b7b --- /dev/null +++ b/frontend/src/components/v2/Tag/index.tsx @@ -0,0 +1 @@ +export { Tag } from './Tag'; diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 0f89a3a2b..d69b0bfb4 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -12,5 +12,6 @@ export * from './Select'; export * from './Spinner'; export * from './Switch'; export * from './Table'; +export * from './Tag'; export * from './TextArea'; export * from './UpgradePlanModal'; diff --git a/frontend/src/ee/components/PITRecoverySidebar.tsx b/frontend/src/ee/components/PITRecoverySidebar.tsx index 24b58eb0f..e72f05cc9 100644 --- a/frontend/src/ee/components/PITRecoverySidebar.tsx +++ b/frontend/src/ee/components/PITRecoverySidebar.tsx @@ -102,7 +102,9 @@ const PITRecoverySidebar = ({ toggleSidebar, setSnapshotData, chosenSnapshot }: }); } - const decryptedSecretVersions = secretSnapshotData.secretVersions.map( + const decryptedSecretVersions = secretSnapshotData.secretVersions.filter( + (sv: EncrypetedSecretVersionListProps) => (sv.type !== undefined && sv.environment !== undefined) + ).map( (encryptedSecretVersion: EncrypetedSecretVersionListProps, pos: number) => ({ id: encryptedSecretVersion._id, pos, @@ -125,10 +127,13 @@ const PITRecoverySidebar = ({ toggleSidebar, setSnapshotData, chosenSnapshot }: ); const secretKeys = [ - ...new Set(decryptedSecretVersions.map((secret: SecretDataProps) => secret.key)) + ...new Set(decryptedSecretVersions.filter((dsv: any) => dsv.type !== undefined || dsv.environemnt !== undefined) + .map((secret: SecretDataProps) => secret.key)) ]; - const result = secretKeys.map((key, index) => ({ + const result = secretKeys.map((key, index) => (decryptedSecretVersions.filter( + (secret: SecretDataProps) => secret.key === key && secret.type === 'shared' + )[0]?.id ? { id: decryptedSecretVersions.filter( (secret: SecretDataProps) => secret.key === key && secret.type === 'shared' )[0].id, @@ -146,6 +151,24 @@ const PITRecoverySidebar = ({ toggleSidebar, setSnapshotData, chosenSnapshot }: valueOverride: decryptedSecretVersions.filter( (secret: SecretDataProps) => secret.key === key && secret.type === 'personal' )[0]?.value + } : { + id: decryptedSecretVersions.filter( + (secret: SecretDataProps) => secret.key === key && secret.type === 'personal' + )[0].id, + pos: index, + key, + environment: decryptedSecretVersions.filter( + (secret: SecretDataProps) => secret.key === key && secret.type === 'personal' + )[0].environment, + tags: decryptedSecretVersions.filter( + (secret: SecretDataProps) => secret.key === key && secret.type === 'personal' + )[0].tags, + value: decryptedSecretVersions.filter( + (secret: SecretDataProps) => secret.key === key && secret.type === 'shared' + )[0]?.value, + valueOverride: decryptedSecretVersions.filter( + (secret: SecretDataProps) => secret.key === key && secret.type === 'personal' + )[0]?.value })); setSnapshotData({ @@ -161,7 +184,7 @@ const PITRecoverySidebar = ({ toggleSidebar, setSnapshotData, chosenSnapshot }:
{isLoading ? (
diff --git a/frontend/src/hooks/api/incidentContacts/index.tsx b/frontend/src/hooks/api/incidentContacts/index.tsx new file mode 100644 index 000000000..6c53db28e --- /dev/null +++ b/frontend/src/hooks/api/incidentContacts/index.tsx @@ -0,0 +1,5 @@ +export { + useAddIncidentContact, + useDeleteIncidentContact, + useGetOrgIncidentContact +} from './queries'; diff --git a/frontend/src/hooks/api/incidentContacts/queries.tsx b/frontend/src/hooks/api/incidentContacts/queries.tsx new file mode 100644 index 000000000..4f028f16d --- /dev/null +++ b/frontend/src/hooks/api/incidentContacts/queries.tsx @@ -0,0 +1,57 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { apiRequest } from '@app/config/request'; + +import { AddIncidentContactDTO, DeleteIncidentContactDTO, IncidentContact } from './types'; + +const incidentContactKeys = { + getAllContact: (orgId: string) => ['org-incident-contacts', { orgId }] as const +}; + +const fetchOrgIncidentContacts = async (orgId: string) => { + const { data } = await apiRequest.get<{ incidentContactsOrg: IncidentContact[] }>( + `/api/v1/organization/${orgId}/incidentContactOrg` + ); + + return data.incidentContactsOrg; +}; + +export const useGetOrgIncidentContact = (orgId: string) => + useQuery({ + queryKey: incidentContactKeys.getAllContact(orgId), + queryFn: () => fetchOrgIncidentContacts(orgId), + enabled: Boolean(orgId) + }); + +// mutation +export const useAddIncidentContact = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, AddIncidentContactDTO>({ + mutationFn: async ({ orgId, email }) => { + const { data } = await apiRequest.post(`/api/v1/organization/${orgId}/incidentContactOrg`, { + email + }); + return data; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(incidentContactKeys.getAllContact(orgId)); + } + }); +}; + +export const useDeleteIncidentContact = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, DeleteIncidentContactDTO>({ + mutationFn: async ({ orgId, email }) => { + const { data } = await apiRequest.delete(`/api/v1/organization/${orgId}/incidentContactOrg`, { + data: { email } + }); + return data; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(incidentContactKeys.getAllContact(orgId)); + } + }); +}; diff --git a/frontend/src/hooks/api/incidentContacts/types.ts b/frontend/src/hooks/api/incidentContacts/types.ts new file mode 100644 index 000000000..a1ef8ab95 --- /dev/null +++ b/frontend/src/hooks/api/incidentContacts/types.ts @@ -0,0 +1,18 @@ +export type IncidentContact = { + _id: string; + email: string; + organization: string; + __v: number; + createdAt: Date; + updatedAt: Date; +}; + +export type DeleteIncidentContactDTO = { + orgId: string; + email: string; +}; + +export type AddIncidentContactDTO = { + orgId: string; + email: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index ad9acf0c3..c2035bbba 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,4 +1,5 @@ export * from './auth'; +export * from './incidentContacts'; export * from './keys'; export * from './organization'; export * from './serviceTokens'; diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts index 4d9dd04f8..a11a7dd74 100644 --- a/frontend/src/hooks/api/organization/index.ts +++ b/frontend/src/hooks/api/organization/index.ts @@ -1 +1 @@ -export { useGetOrganization } from './queries'; +export { useGetOrganization, useRenameOrg } from './queries'; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index b2f9c7ce6..0770fc413 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -1,8 +1,8 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiRequest } from '@app/config/request'; -import { Organization } from './types'; +import { Organization, RenameOrgDTO } from './types'; const organizationKeys = { getUserOrganization: ['organization'] as const @@ -16,3 +16,16 @@ const fetchUserOrganization = async () => { export const useGetOrganization = () => useQuery({ queryKey: organizationKeys.getUserOrganization, queryFn: fetchUserOrganization }); + +// mutation +export const useRenameOrg = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, RenameOrgDTO>({ + mutationFn: ({ newOrgName, orgId }) => + apiRequest.patch(`/api/v1/organization/${orgId}/name`, { name: newOrgName }), + onSuccess: () => { + queryClient.invalidateQueries(organizationKeys.getUserOrganization); + } + }); +}; diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 74720320b..92a5c5f1d 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -4,3 +4,8 @@ export type Organization = { createAt: string; updatedAt: string; }; + +export type RenameOrgDTO = { + orgId: string; + newOrgName: string; +}; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index 699e9a81d..a8821e5a5 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -1,14 +1,17 @@ export type { GetAuthTokenAPI } from './auth/types'; +export type { IncidentContact } from './incidentContacts/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 { AddUserToWsDTO, AddUserToWsRes, OrgUser, User } from './users/types'; export type { CreateEnvironmentDTO, + CreateWorkspaceDTO, DeleteEnvironmentDTO, DeleteWorkspaceDTO, RenameWorkspaceDTO, + ToggleAutoCapitalizationDTO, UpdateEnvironmentDTO, Workspace, WorkspaceEnv, diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 69c0fc98a..0980d4108 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,7 +1,10 @@ export { fetchOrgUsers, + useAddUserToOrg, useAddUserToWs, + useDeleteOrgMembership, useGetOrgUsers, useGetUser, - useLogoutUser + useLogoutUser, + useUpdateOrgUserRole } from './queries'; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 1ddcf3809..c6b35960d 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { decryptAssymmetric, @@ -8,7 +8,15 @@ import { apiRequest } from '@app/config/request'; import { setAuthToken } from '@app/reactQuery'; import { useUploadWsKey } from '../keys/queries'; -import { AddUserToWsDTO, AddUserToWsRes, OrgUser, User } from './types'; +import { + AddUserToOrgDTO, + AddUserToWsDTO, + AddUserToWsRes, + DeletOrgMembershipDTO, + OrgUser, + UpdateOrgUserRoleDTO, + User +} from './types'; const userKeys = { getUser: ['user'] as const, @@ -32,7 +40,11 @@ export const fetchOrgUsers = async (orgId: string) => { }; export const useGetOrgUsers = (orgId: string) => - useQuery(userKeys.getOrgUsers(orgId), () => fetchOrgUsers(orgId)); + useQuery({ + queryKey: userKeys.getOrgUsers(orgId), + queryFn: () => fetchOrgUsers(orgId), + enabled: Boolean(orgId) + }); // mutation export const useAddUserToWs = () => { @@ -69,6 +81,47 @@ export const useAddUserToWs = () => { }); }; +export const useAddUserToOrg = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, AddUserToOrgDTO>({ + mutationFn: (dto) => apiRequest.post(`/api/v1/invite-org/signup`, dto), + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId)); + } + }); +}; + +export const useDeleteOrgMembership = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, DeletOrgMembershipDTO>({ + mutationFn: ({ membershipId, orgId }) => + apiRequest.delete(`/api/v2/organizations/${orgId}/memberships/${membershipId}`), + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(userKeys.getOrgUsers(orgId)); + } + }); +}; + +export const useUpdateOrgUserRole = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, UpdateOrgUserRoleDTO>({ + mutationFn: ({ organizationId, membershipId, role }) => + apiRequest.patch(`/api/v2/organizations/${organizationId}/memberships/${membershipId}`, { + role + }), + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId)); + }, + // to remove old states + onError: (_, { organizationId }) => { + queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId)); + } + }); +}; + export const useLogoutUser = () => useMutation({ mutationFn: () => apiRequest.post('/api/v1/auth/logout'), diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 1b17cd023..b3fb5cf6b 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -32,7 +32,7 @@ export type OrgUser = { inviteEmail: string; organization: string; role: 'owner' | 'admin' | 'member'; - status: 'invited' | 'accepted'; + status: 'invited' | 'accepted' | 'verified' | 'completed'; deniedPermissions: any[]; }; @@ -45,3 +45,19 @@ export type AddUserToWsRes = { invitee: OrgUser['user']; latestKey: UserWsKeyPair; }; + +export type UpdateOrgUserRoleDTO = { + organizationId: string; + membershipId: string; + role: string; +}; + +export type DeletOrgMembershipDTO = { + membershipId: string; + orgId: string; +}; + +export type AddUserToOrgDTO = { + inviteeEmail: string; + organizationId: string; +}; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 52a01029e..cab8617bd 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -3,9 +3,9 @@ export { useCreateWsEnvironment, useDeleteWorkspace, useDeleteWsEnvironment, + useGetUserWorkspaceMemberships, useGetUserWorkspaces, useGetWorkspaceById, useRenameWorkspace, useToggleAutoCapitalization, useUpdateWsEnvironment} from './queries'; - \ No newline at end of file diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 0c4ba970b..2bca2e9a5 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -13,16 +13,18 @@ import { Workspace } from './types'; - const workspaceKeys = { getWorkspaceById: (workspaceId: string) => [{ workspaceId }, 'workspace'] as const, + getWorkspaceMemberships: (orgId: string) => [{ orgId }, 'workspace-memberships'], getAllUserWorkspace: ['workspaces'] as const }; const fetchWorkspaceById = async (workspaceId: string) => { - const { data } = await apiRequest.get<{ workspace: Workspace }>(`/api/v1/workspace/${workspaceId}`); - return data.workspace; -} + const { data } = await apiRequest.get<{ workspace: Workspace }>( + `/api/v1/workspace/${workspaceId}` + ); + return data.workspace; +}; const fetchUserWorkspaces = async () => { const { data } = await apiRequest.get<{ workspaces: Workspace[] }>('/api/v1/workspace'); @@ -40,6 +42,21 @@ export const useGetWorkspaceById = (workspaceId: string) => { export const useGetUserWorkspaces = () => useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces); +const fetchUserWorkspaceMemberships = async (orgId: string) => { + const { data } = await apiRequest.get>( + `/api/v1/organization/${orgId}/workspace-memberships` + ); + return data; +}; + +// to get all userids in an org with the workspace they are part of +export const useGetUserWorkspaceMemberships = (orgId: string) => + useQuery({ + queryKey: workspaceKeys.getWorkspaceMemberships(orgId), + queryFn: () => fetchUserWorkspaceMemberships(orgId), + enabled: Boolean(orgId) + }); + // mutation export const useCreateWorkspace = () => { const queryClient = useQueryClient(); @@ -70,7 +87,9 @@ export const useToggleAutoCapitalization = () => { return useMutation<{}, {}, ToggleAutoCapitalizationDTO>({ mutationFn: ({ workspaceID, state }) => - apiRequest.patch(`/api/v2/workspace/${workspaceID}/auto-capitalization`, { autoCapitalization: state }), + apiRequest.patch(`/api/v2/workspace/${workspaceID}/auto-capitalization`, { + autoCapitalization: state + }), onSuccess: () => { queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); } diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 06fc6d5f7..b071c3272 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -227,7 +227,7 @@ export const AppLayout = ({ children }: LayoutProps) => { setSearchUsers(e.target.value)} - placeholder={t('section-members:search-members') as string} - /> -
-
-
-
- {userList && ( -
- -
- )} -
- -
-
-
-

- {t('section-incident:incident-contacts')} -

-

- {t('section-incident:incident-contacts-description')} -

-
-
-
-
-
- - setSearchIncidentContact(e.target.value)} - placeholder={t('common:search') as string} - /> -
- {incidentContacts?.filter((incidentEmail) => - incidentEmail.includes(searchIncidentContact) - ).length > 0 ? ( - incidentContacts - .filter((incidentEmail) => incidentEmail.includes(searchIncidentContact)) - .map((contact) => ( -
-

{contact}

-
-
-
- )) - ) : ( -
-

{t('section-incident:no-incident-contacts')}

-
- )} -
- - {/*
-

- Danger Zone -

-

- As soon as you delete an organization, you will - not be able to undo it. This will immediately - remove all organization members and cancel your - subscription. If you still want to do that, - please enter the name of the organization below. -

-
- -
- -

- Note: You can only delete a project in case you - have more than one. -

-
*/} -
-
-
-
+ + ); } diff --git a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx new file mode 100644 index 000000000..26c06a9d2 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx @@ -0,0 +1,308 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useTranslation } from 'next-i18next'; +import { plans } from 'public/data/frequentConstants'; + +import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider'; +import NavHeader from '@app/components/navigation/NavHeader'; +import { + decryptAssymmetric, + encryptAssymmetric +} from '@app/components/utilities/cryptography/crypto'; +import { useOrganization, useSubscription, useUser, useWorkspace } from '@app/context'; +import { + useAddIncidentContact, + useAddUserToOrg, + useDeleteIncidentContact, + useDeleteOrgMembership, + useGetOrgIncidentContact, + useGetOrgUsers, + useGetUserWorkspaceMemberships, + useGetUserWsKey, + useRenameOrg, + useUpdateOrgUserRole, + useUploadWsKey +} from '@app/hooks/api'; + +import { OrgIncidentContactsTable, OrgMembersTable, OrgNameChangeSection } from './components'; + +export const OrgSettingsPage = () => { + const host = window.location.origin; + + const { t } = useTranslation(); + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + const { user } = useUser(); + const { subscriptionPlan } = useSubscription(); + const { createNotification } = useNotificationContext(); + + const orgId = currentOrg?._id || ''; + const { data: orgUsers } = useGetOrgUsers(orgId); + const { data: workspaceMemberships } = useGetUserWorkspaceMemberships(orgId); + const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || ''); + const { data: incidentContact } = useGetOrgIncidentContact(orgId); + + const renameOrg = useRenameOrg(); + const removeUserOrgMembership = useDeleteOrgMembership(); + const addUserToOrg = useAddUserToOrg(); + const updateOrgUserRole = useUpdateOrgUserRole(); + const uploadWsKey = useUploadWsKey(); + const addIncidentContact = useAddIncidentContact(); + const removeIncidentContact = useDeleteIncidentContact(); + + const isMoreUsersNotAllowed = + (orgUsers || []).length >= 5 && + subscriptionPlan === plans.starter && + host === 'https://app.infisical.com'; + + const onRenameOrg = async (name: string) => { + if (!currentOrg?._id) return; + + try { + await renameOrg.mutateAsync({ orgId: currentOrg?._id, newOrgName: name }); + createNotification({ + text: 'Successfully renamed organization', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to rename organization', + type: 'error' + }); + } + }; + + const onRemoveUserOrgMembership = async (membershipId: string) => { + if (!currentOrg?._id) return; + + try { + await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId }); + createNotification({ + text: 'Successfully removed used from org', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to remove user from org', + type: 'error' + }); + } + }; + const onAddUserToOrg = async (email: string) => { + if (!currentOrg?._id) return; + + try { + await addUserToOrg.mutateAsync({ organizationId: currentOrg?._id, inviteeEmail: email }); + createNotification({ + text: 'Successfully invited user to org', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to invite user to org', + type: 'error' + }); + } + }; + + const onUpdateOrgUserRole = async (membershipId: string, role: string) => { + if (!currentOrg?._id) return; + + try { + await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role }); + createNotification({ + text: 'Successfully updated user role', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to update user role', + type: 'error' + }); + } + }; + + const onGrantUserAccess = async (userId: string, publicKey: string) => { + try { + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; + if (!PRIVATE_KEY || !wsKey) return; + + // assymmetrically decrypt symmetric key with local private key + const key = decryptAssymmetric({ + ciphertext: wsKey.encryptedKey, + nonce: wsKey.nonce, + publicKey: wsKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: key, + publicKey, + privateKey: PRIVATE_KEY + }); + + await uploadWsKey.mutateAsync({ + userId, + nonce, + encryptedKey: ciphertext, + workspaceId: currentWorkspace?._id || '' + }); + } catch (err) { + console.error(err); + createNotification({ + text: 'Failed to grant access to user', + type: 'error' + }); + } + }; + + const onAddIncidentContact = async (email: string) => { + if (!currentOrg?._id) return; + + try { + await addIncidentContact.mutateAsync({ orgId, email }); + createNotification({ + text: 'Successfully added incident contact', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to add incident contact', + type: 'error' + }); + } + }; + + const onRemoveIncidentContact = async (email: string) => { + if (!currentOrg?._id) return; + + try { + await removeIncidentContact.mutateAsync({ orgId, email }); + createNotification({ + text: 'Successfully removed incident contact', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to remove incident contact', + type: 'error' + }); + } + }; + + /** + * This function deleted a workspace. + * It first checks if there is more than one workspace aviable. Otherwise, it doesn't delete + * It then checks if the name of the workspace to be deleted is correct. Otherwise, it doesn't delete. + * It then deletes the workspace and forwards the user to another aviable workspace. + */ + // const executeDeletingWorkspace = async () => { + // const userWorkspaces = await getWorkspaces(); + // + // if (userWorkspaces.length > 1) { + // if ( + // userWorkspaces.filter((workspace) => workspace._id === workspaceId)[0].name === + // workspaceToBeDeletedName + // ) { + // await deleteWorkspace(workspaceId); + // const ws = await getWorkspaces(); + // router.push(`/dashboard/${ws[0]._id}`); + // } + // } + // }; + // + return ( +
+ +
+
+

{t('settings-org:title')}

+

+ {t('settings-org:description')} +

+
+
+
+ +
+

+ {t('section-members:org-members')} +

+

+ {t('section-members:org-members-description')} +

+ +
+
+
+
+

+ {t('section-incident:incident-contacts')} +

+

+ {t('section-incident:incident-contacts-description')} +

+
+
+
+ +
+
+ {/*
+

+ Danger Zone +

+

+ As soon as you delete an organization, you will + not be able to undo it. This will immediately + remove all organization members and cancel your + subscription. If you still want to do that, + please enter the name of the organization below. +

+
+ +
+ +

+ Note: You can only delete a project in case you + have more than one. +

+
*/} +
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx new file mode 100644 index 000000000..970ca8774 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx @@ -0,0 +1,176 @@ +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { faMagnifyingGlass, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; + +import { + Button, + DeleteActionModal, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from '@app/components/v2'; +import { usePopUp } from '@app/hooks'; +import { IncidentContact } from '@app/hooks/api/types'; + +type Props = { + contacts?: IncidentContact[]; + onRemoveContact: (email: string) => Promise; + onAddContact: (email: string) => Promise; +}; + +const addContactFormSchema = yup.object({ + email: yup.string().email().required().label('Email').trim() +}); + +type TAddContactForm = yup.InferType; + +export const OrgIncidentContactsTable = ({ + contacts = [], + onAddContact, + onRemoveContact +}: Props) => { + const [searchContact, setSearchContact] = useState(''); + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + 'addContact', + 'removeContact' + ] as const); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ resolver: yupResolver(addContactFormSchema) }); + + const onAddIncidentContact = ({ email }: TAddContactForm) => { + onAddContact(email); + handlePopUpClose('addContact'); + reset(); + }; + + const onRemoveIncidentContact = async () => { + const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email; + await onRemoveContact(incidentContactEmail); + handlePopUpClose('removeContact'); + }; + + return ( +
+
+
+ setSearchContact(e.target.value)} + leftIcon={} + placeholder="Search incident contact by email..." + /> +
+
+ +
+
+
+ +
NAMEEMAILROLE - {env.slug.toUpperCase()}
+
NAMEEMAILROLE + + {env.slug.toUpperCase()} +
+
{/* PERMISSION */}
+ {row.firstName} {row.lastName} + {row.email} -
- handleRoleUpdate(index, e)} value={row.role} - disabled={myRole !== 'admin' || myUser === row.email} + isDisabled={myRole !== 'admin' || myUser === row.email} // onOpenChange={(open) => setIsOpen(open)} > Admin Member {row.status === 'completed' && myUser !== row.email && ( -
+
- - No Access - Read Only - Add Only - Read & Write - - + + {myUser !== row.email && // row.role !== "admin" && myRole !== 'member' ? ( -
+
) : ( -
+
)}
+ + + + + + + {contacts + ?.filter(({ email }) => email.toLocaleLowerCase().includes(searchContact)) + ?.map(({ email }) => ( + + + + + ))} + +
Email +
{email} + handlePopUpOpen('removeContact', { email })} + > + + +
+ {contacts + ?.filter(({ email }) => email.toLocaleLowerCase().includes(searchContact)) + ?.length === 0 && ( +
No incident contacts found
+ )} + +
+ { + handlePopUpToggle('addContact', isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ handlePopUpToggle('removeContact', isOpen)} + onDeleteApproved={onRemoveIncidentContact} + /> +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/index.tsx new file mode 100644 index 000000000..8fbc24611 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/index.tsx @@ -0,0 +1 @@ +export { OrgIncidentContactsTable } from './OrgIncidentContactsTable'; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx new file mode 100644 index 000000000..2911ae603 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx @@ -0,0 +1,275 @@ +import { useMemo, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { faMagnifyingGlass, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; + +import { + Button, + DeleteActionModal, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Select, + SelectItem, + Table, + TableContainer, + Tag, + TBody, + Td, + Th, + THead, + Tr, + UpgradePlanModal +} from '@app/components/v2'; +import { usePopUp } from '@app/hooks'; +import { OrgUser, Workspace } from '@app/hooks/api/types'; + +type Props = { + members?: OrgUser[]; + workspaceMemberships?: Record; + orgName: string; + isMoreUserNotAllowed: boolean; + onRemoveMember: (userId: string) => Promise; + onInviteMember: (email: string) => Promise; + onRoleChange: (membershipId: string, role: string) => Promise; + onGrantAccess: (userId: string, publicKey: string) => Promise; + // the current user id to block remove org button + userId: string; +}; + +const addMemberFormSchema = yup.object({ + email: yup.string().email().required().label('Email').trim() +}); + +type TAddMemberForm = yup.InferType; + +export const OrgMembersTable = ({ + members = [], + workspaceMemberships = {}, + orgName, + isMoreUserNotAllowed, + onRemoveMember, + onInviteMember, + onGrantAccess, + onRoleChange, + userId +}: Props) => { + const [searchMemberFilter, setSearchMemberFilter] = useState(''); + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + 'addMember', + 'removeMember', + 'upgradePlan' + ] as const); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ resolver: yupResolver(addMemberFormSchema) }); + + const onAddMember = ({ email }: TAddMemberForm) => { + onInviteMember(email); + handlePopUpClose('addMember'); + reset(); + }; + + const onRemoveOrgMemberApproved = async () => { + const orgMembershipId = (popUp?.removeMember?.data as { id: string })?.id; + await onRemoveMember(orgMembershipId); + handlePopUpClose('removeMember'); + }; + + const isIamOwner = useMemo( + () => members.find(({ user }) => userId === user?._id)?.role === 'owner', + [userId, members] + ); + + const filterdUser = useMemo( + () => + members.filter( + ({ user, inviteEmail }) => + user?.firstName?.toLowerCase().includes(searchMemberFilter) || + user?.lastName?.toLowerCase().includes(searchMemberFilter) || + user?.email?.toLowerCase().includes(searchMemberFilter) || + inviteEmail?.includes(searchMemberFilter) + ), + [members, searchMemberFilter] + ); + + return ( +
+
+
+ setSearchMemberFilter(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> +
+
+ +
+
+
+ + + + + + + + + + + + {filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => { + const name = user ? `${user.firstName} ${user.lastName}` : '-'; + const email = user?.email || inviteEmail; + const userWs = workspaceMemberships?.[user?._id]; + + return ( + + + + + + + + ); + })} + +
NameEmailRoleProjects +
{name}{email} + {status === 'accepted' && ( + + )} + {(status === 'invited' || status === 'verified') && ( + + )} + {status === 'completed' && ( + + )} + + {userWs ? ( + userWs?.map(({ name: wsName, _id }) => ( + + {wsName} + + )) + ) : ( + This user isn't part of any projects yet + )} + + {userId !== user?._id && handlePopUpOpen('removeMember', { id: orgMembershipId })} + > + + } +
+ {filterdUser.length === 0 && No project members found} +
+
+ { + handlePopUpToggle('addMember', isOpen); + reset(); + }} + > + + An invite is specific to an email address and expires after 1 day. +
+ For security reasons, you will need to separately add members to projects. + + } + > +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ handlePopUpToggle('removeMember', isOpen)} + onDeleteApproved={onRemoveOrgMemberApproved} + /> + handlePopUpToggle('upgradePlan', isOpen)} + text="You can add custom environments if you switch to Infisical's Team plan." + /> +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/index.tsx new file mode 100644 index 000000000..729d06592 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/index.tsx @@ -0,0 +1 @@ +export { OrgMembersTable } from './OrgMembersTable'; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx new file mode 100644 index 000000000..9b18285c0 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx @@ -0,0 +1,68 @@ +import { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { faCheck } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; + +import { Button, FormControl, Input } from '@app/components/v2'; + +type Props = { + orgName?: string; + onOrgNameChange: (name: string) => Promise; +}; + +const formSchema = yup.object({ + name: yup.string().required().label('Project Name') +}); + +type FormData = yup.InferType; + +export const OrgNameChangeSection = ({ onOrgNameChange, orgName }: Props): JSX.Element => { + const { + handleSubmit, + control, + reset, + formState: { isDirty, isSubmitting } + } = useForm({ resolver: yupResolver(formSchema) }); + const { t } = useTranslation(); + + useEffect(() => { + reset({ name: orgName }); + }, [orgName]); + + const onFormSubmit = async ({ name }: FormData) => { + await onOrgNameChange(name); + }; + + return ( +
+
+

{t('common:display-name')}

+
+ ( + + + + )} + control={control} + name="name" + /> +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/index.tsx new file mode 100644 index 000000000..b668d3732 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/index.tsx @@ -0,0 +1 @@ +export { OrgNameChangeSection } from './OrgNameChangeSection'; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx new file mode 100644 index 000000000..15a09b0d2 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx @@ -0,0 +1,3 @@ +export { OrgIncidentContactsTable } from './OrgIncidentContactsTable'; +export { OrgMembersTable } from './OrgMembersTable'; +export { OrgNameChangeSection } from './OrgNameChangeSection'; diff --git a/frontend/src/views/Settings/OrgSettingsPage/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/index.tsx new file mode 100644 index 000000000..3ea2e3b0f --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/index.tsx @@ -0,0 +1 @@ +export { OrgSettingsPage } from './OrgSettingsPage'; diff --git a/frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/SecuritySection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/SecuritySection.tsx index 5a1e965a1..0d7902c60 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/SecuritySection.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/SecuritySection/SecuritySection.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; +import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider'; import { Checkbox } from '@app/components/v2'; import { useGetUser } from '../../../../hooks/api'; @@ -9,6 +10,7 @@ import updateMyMfaEnabled from '../../../../pages/api/user/updateMyMfaEnabled'; export const SecuritySection = () => { const [isMfaEnabled, setIsMfaEnabled] = useState(false); const { data: user } = useGetUser(); + const { createNotification } = useNotificationContext(); useEffect(() => { if (user && typeof user.isMfaEnabled !== 'undefined') { @@ -21,18 +23,27 @@ export const SecuritySection = () => { const newUser: User = await updateMyMfaEnabled({ isMfaEnabled: state }); - + if (newUser) { setIsMfaEnabled(newUser.isMfaEnabled); } + + createNotification({ + text: `${newUser.isMfaEnabled ? 'Successfully turned on two-factor authentication.' : 'Successfully turned off two-factor authentication.'}`, + type: 'success' + }); } catch (err) { + createNotification({ + text: 'Something went wrong while toggling the two-factor authentication.', + type: 'error' + }); console.error(err); } } return (
-
+

Two-factor Authentication

diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index f6fc92111..8c1a2ddd1 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -58,7 +58,7 @@ export const ProjectSettingsPage = () => { const renameWorkspace = useRenameWorkspace(); const toggleAutoCapitalization = useToggleAutoCapitalization(); - + const deleteWorkspace = useDeleteWorkspace(); // env crud operation const createWsEnv = useCreateWsEnvironment(); @@ -97,7 +97,7 @@ export const ProjectSettingsPage = () => { } }; - const onAutoCapitalizationToggle = async (state: boolean) => { + const onAutoCapitalizationToggle = async (state: boolean) => { try { await toggleAutoCapitalization.mutateAsync({ workspaceID, @@ -123,6 +123,9 @@ export const ProjectSettingsPage = () => { await deleteWorkspace.mutateAsync({ workspaceID }); // redirect user to first workspace user is part of const ws = workspaces.find(({ _id }) => _id !== workspaceID); + if (!ws) { + router.push('/noprojects'); + } router.push(`/dashboard/${ws?._id}`); createNotification({ text: 'Successfully deleted workspace', @@ -247,7 +250,7 @@ export const ProjectSettingsPage = () => { const res = await createWsTag.mutateAsync({ workspaceID, tagName: name, - tagSlug: name.replace(" ", "_") + tagSlug: name.replace(' ', '_') }); createNotification({ text: 'Successfully created a tag', diff --git a/helm-charts/infisical/Chart.yaml b/helm-charts/infisical/Chart.yaml index d0d293b71..80fe48fcc 100644 --- a/helm-charts/infisical/Chart.yaml +++ b/helm-charts/infisical/Chart.yaml @@ -7,7 +7,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.14 +version: 0.1.15 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to @@ -23,4 +23,4 @@ dependencies: - name: mailhog version: "~5.2.3" repository: "https://codecentric.github.io/helm-charts" - condition: mailhog.enabled \ No newline at end of file + condition: mailhog.enabled diff --git a/helm-charts/infisical/values.yaml b/helm-charts/infisical/values.yaml index f670028b3..d569e7c31 100644 --- a/helm-charts/infisical/values.yaml +++ b/helm-charts/infisical/values.yaml @@ -209,6 +209,8 @@ mongodb: ## databases: - "infisical" + rootPassword: root + rootUser: root ## MongoDB persistence configuration ## persistence: @@ -250,7 +252,7 @@ ingress: kubernetes.io/ingress.class: "nginx" # cert-manager.io/issuer: letsencrypt-nginx ## @param ingress.hostName Ingress hostname (your custom domain name) - ## Replace with your own domain + ## Replace with your own domain ## hostName: infisical.local ## @skip ingress.frontend @@ -264,9 +266,10 @@ ingress: path: /api pathType: Prefix ## @param ingress.tls Ingress TLS hosts (matching above hostName) - ## Replace with your own domain + ## Replace with your own domain ## - tls: [] + tls: + [] # - secretName: letsencrypt-nginx # hosts: # - infisical.local @@ -338,7 +341,8 @@ mailhog: ingressClassName: nginx ## @param mailhog.ingress.annotations Ingress annotations ## - annotations: {} + annotations: + {} # kubernetes.io/ingress.class: nginx # kubernetes.io/tls-acme: "true" ## @param mailhog.ingress.labels Ingress labels @@ -352,4 +356,4 @@ mailhog: ## paths: - path: "/" - pathType: Prefix \ No newline at end of file + pathType: Prefix