From 3ad3e19bcf202de57c812555a902204f02aa6a0f Mon Sep 17 00:00:00 2001 From: akhilmhdh Date: Wed, 28 Dec 2022 23:42:17 +0530 Subject: [PATCH 1/7] feat(#31): implemented api for environment crud operations --- backend/src/app.ts | 2 + .../v1/integrationAuthController.ts | 8 +- .../src/controllers/v1/secretController.ts | 10 +- .../controllers/v1/serviceTokenController.ts | 4 +- .../controllers/v2/environmentController.ts | 167 ++++++++++++++++++ backend/src/controllers/v2/index.ts | 4 +- .../src/controllers/v2/workspaceController.ts | 9 +- backend/src/helpers/integration.ts | 8 +- backend/src/models/integration.ts | 5 - backend/src/models/secret.ts | 5 - backend/src/models/serviceToken.ts | 4 - backend/src/models/workspace.ts | 32 +++- backend/src/routes/v2/environment.ts | 54 ++++++ backend/src/routes/v2/index.ts | 6 +- backend/src/services/IntegrationService.ts | 12 +- 15 files changed, 293 insertions(+), 37 deletions(-) create mode 100644 backend/src/controllers/v2/environmentController.ts create mode 100644 backend/src/routes/v2/environment.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index aa7ac9b28..571b84cd4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -46,6 +46,7 @@ import { workspace as v2WorkspaceRouter, serviceTokenData as v2ServiceTokenDataRouter, apiKeyData as v2APIKeyDataRouter, + environment as v2EnvironmentRouter, } from './routes/v2'; import { healthCheck } from './routes/status'; @@ -108,6 +109,7 @@ app.use('/api/v2/secret', v2SecretRouter); // stop supporting, TODO: revise app.use('/api/v2/secrets', v2SecretsRouter); app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route app.use('/api/v2/api-key-data', v2APIKeyDataRouter); +app.use('/api/v2/environments', v2EnvironmentRouter); // api docs app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile)) diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 95d0066ae..b2f93a8c2 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -31,11 +31,17 @@ export const oAuthExchange = async ( if (!INTEGRATION_SET.has(integration)) throw new Error('Failed to validate integration'); + + const environments = req.membership.workspace?.environments || []; + if(environments.length === 0){ + throw new Error("Failed to get environments") + } await IntegrationService.handleOAuthExchange({ workspaceId, integration, - code + code, + environment: environments[0].slug, }); } catch (err) { Sentry.setUser(null); diff --git a/backend/src/controllers/v1/secretController.ts b/backend/src/controllers/v1/secretController.ts index 1b756ecc7..c76e5e883 100644 --- a/backend/src/controllers/v1/secretController.ts +++ b/backend/src/controllers/v1/secretController.ts @@ -9,7 +9,6 @@ import { import { pushKeys } from '../../helpers/key'; import { eventPushSecrets } from '../../events'; import { EventService } from '../../services'; -import { ENV_SET } from '../../variables'; import { postHogClient } from '../../services'; interface PushSecret { @@ -44,7 +43,8 @@ export const pushSecrets = async (req: Request, res: Response) => { const { workspaceId } = req.params; // validate environment - if (!ENV_SET.has(environment)) { + const workspaceEnvs = req.membership.workspace.environments; + if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) { throw new Error('Failed to validate environment'); } @@ -116,7 +116,8 @@ export const pullSecrets = async (req: Request, res: Response) => { const { workspaceId } = req.params; // validate environment - if (!ENV_SET.has(environment)) { + const workspaceEnvs = req.membership.workspace.environments; + if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) { throw new Error('Failed to validate environment'); } @@ -183,7 +184,8 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => { const { workspaceId } = req.params; // validate environment - if (!ENV_SET.has(environment)) { + const workspaceEnvs = req.membership.workspace.environments; + if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) { throw new Error('Failed to validate environment'); } diff --git a/backend/src/controllers/v1/serviceTokenController.ts b/backend/src/controllers/v1/serviceTokenController.ts index 244a58783..3fafb9043 100644 --- a/backend/src/controllers/v1/serviceTokenController.ts +++ b/backend/src/controllers/v1/serviceTokenController.ts @@ -1,7 +1,6 @@ import { Request, Response } from 'express'; import { ServiceToken } from '../../models'; import { createToken } from '../../helpers/auth'; -import { ENV_SET } from '../../variables'; import { JWT_SERVICE_SECRET } from '../../config'; /** @@ -36,7 +35,8 @@ export const createServiceToken = async (req: Request, res: Response) => { } = req.body; // validate environment - if (!ENV_SET.has(environment)) { + const workspaceEnvs = req.membership.workspace.environments; + if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) { throw new Error('Failed to validate environment'); } diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts new file mode 100644 index 000000000..77c4fcb59 --- /dev/null +++ b/backend/src/controllers/v2/environmentController.ts @@ -0,0 +1,167 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { Secret, ServiceToken, Workspace, Integration } from '../../models'; + +/** + * Create new workspace environment named [environmentName] under workspace with id + * @param req + * @param res + * @returns + */ +export const createWorkspaceEnvironment = async ( + req: Request, + res: Response +) => { + const { workspaceId, environmentName, environmentSlug } = req.body; + try { + // atomic create the environment + const workspace = await Workspace.findOneAndUpdate( + { + _id: workspaceId, + 'environments.slug': { $ne: environmentSlug }, + 'environments.name': { $ne: environmentName }, + }, + { + $addToSet: { + environments: { name: environmentName, slug: environmentSlug }, + }, + } + ); + + if (!workspace) { + throw new Error('Failed to update workspace environment'); + } + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to create new workspace environment', + }); + } + + return res.status(200).send({ + message: 'Successfully created new environment', + workspace: workspaceId, + environment: { + name: environmentName, + slug: environmentSlug, + }, + }); +}; + +/** + * Rename workspace environment with new name and slug of a workspace with [workspaceId] + * Old slug [oldEnvironmentSlug] must be provided + * @param req + * @param res + * @returns + */ +export const renameWorkspaceEnvironment = async ( + req: Request, + res: Response +) => { + const { workspaceId, environmentName, environmentSlug, oldEnvironmentSlug } = + req.body; + try { + // user should pass both new slug and env name + if (!environmentSlug || !environmentName) { + throw new Error('Invalid environment given.'); + } + + // atomic update the env to avoid conflict + const workspace = await Workspace.findOneAndUpdate( + { _id: workspaceId, 'environments.slug': oldEnvironmentSlug }, + { + 'environments.$.name': environmentName, + 'environments.$.slug': environmentSlug, + } + ); + if (!workspace) { + throw new Error('Failed to update workspace'); + } + + await Secret.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + await ServiceToken.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + await Integration.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to update workspace environment', + }); + } + + return res.status(200).send({ + message: 'Successfully update environment', + workspace: workspaceId, + environment: { + name: environmentName, + slug: environmentSlug, + }, + }); +}; + +/** + * Delete workspace environment by [environmentSlug] of workspace [workspaceId] and do the clean up + * @param req + * @param res + * @returns + */ +export const deleteWorkspaceEnvironment = async ( + req: Request, + res: Response +) => { + const { workspaceId, environmentSlug } = req.body; + try { + // atomic delete the env in the workspacce + const workspace = await Workspace.findOneAndUpdate( + { _id: workspaceId }, + { + $pull: { + environments: { + slug: environmentSlug, + }, + }, + } + ); + if (!workspace) { + throw new Error('Failed to delete workspace environment'); + } + + // clean up + await Secret.deleteMany({ + workspace: workspaceId, + environment: environmentSlug, + }); + await ServiceToken.deleteMany({ + workspace: workspaceId, + environment: environmentSlug, + }); + await Integration.deleteMany({ + workspace: workspaceId, + environment: environmentSlug, + }); + + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to delete workspace environment', + }); + } + + return res.status(200).send({ + message: 'Successfully deleted environment', + workspace: workspaceId, + environment: environmentSlug, + }); +}; diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index 1651c09ee..601314a2a 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -3,11 +3,13 @@ import * as serviceTokenDataController from './serviceTokenDataController'; import * as apiKeyDataController from './apiKeyDataController'; import * as secretController from './secretController'; import * as secretsController from './secretsController'; +import * as environmentController from './environmentController'; export { workspaceController, serviceTokenDataController, apiKeyDataController, secretController, - secretsController + secretsController, + environmentController } diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index 0dbdfa076..efac159a5 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -19,7 +19,6 @@ import { import { pushKeys } from '../../helpers/key'; import { postHogClient, EventService } from '../../services'; import { eventPushSecrets } from '../../events'; -import { ENV_SET } from '../../variables'; interface V2PushSecret { type: string; // personal or shared @@ -52,7 +51,8 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { const { workspaceId } = req.params; // validate environment - if (!ENV_SET.has(environment)) { + const workspaceEnvs = req.membership.workspace.environments; + if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) { throw new Error('Failed to validate environment'); } @@ -129,6 +129,11 @@ export const pullSecrets = async (req: Request, res: Response) => { } else if (req.serviceTokenData) { userId = req.serviceTokenData.user._id } + // validate environment + const workspaceEnvs = req.membership.workspace.environments; + if (!workspaceEnvs.find(({ slug }: { slug: string }) => slug === environment)) { + throw new Error('Failed to validate environment'); + } secrets = await pull({ userId, diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index d92156ece..f602f1729 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -7,8 +7,6 @@ import { import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations'; import { BotService } from '../services'; import { - ENV_DEV, - EVENT_PUSH_SECRETS, INTEGRATION_VERCEL, INTEGRATION_NETLIFY } from '../variables'; @@ -36,11 +34,13 @@ interface Update { const handleOAuthExchangeHelper = async ({ workspaceId, integration, - code + code, + environment }: { workspaceId: string; integration: string; code: string; + environment: string; }) => { let action; let integrationAuth; @@ -102,9 +102,9 @@ const handleOAuthExchangeHelper = async ({ // initialize new integration after exchange await new Integration({ workspace: workspaceId, - environment: ENV_DEV, isActive: false, app: null, + environment, integration, integrationAuth: integrationAuth._id }).save(); diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 6da699216..0b184ed67 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -1,9 +1,5 @@ import { Schema, model, Types } from 'mongoose'; import { - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -32,7 +28,6 @@ const integrationSchema = new Schema( }, environment: { type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], required: true }, isActive: { diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index a01b92d80..6887c8b0f 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -2,10 +2,6 @@ import { Schema, model, Types } from 'mongoose'; import { SECRET_SHARED, SECRET_PERSONAL, - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD } from '../variables'; export interface ISecret { @@ -53,7 +49,6 @@ const secretSchema = new Schema( }, environment: { type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], required: true }, secretKeyCiphertext: { diff --git a/backend/src/models/serviceToken.ts b/backend/src/models/serviceToken.ts index b5a2f4ec9..9d91b076e 100644 --- a/backend/src/models/serviceToken.ts +++ b/backend/src/models/serviceToken.ts @@ -1,7 +1,4 @@ import { Schema, model, Types } from 'mongoose'; -import { ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD } from '../variables'; - -// TODO: deprecate export interface IServiceToken { _id: Types.ObjectId; name: string; @@ -33,7 +30,6 @@ const serviceTokenSchema = new Schema( }, environment: { type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], required: true }, expiresAt: { diff --git a/backend/src/models/workspace.ts b/backend/src/models/workspace.ts index 1e886c523..fa7dbc8b5 100644 --- a/backend/src/models/workspace.ts +++ b/backend/src/models/workspace.ts @@ -4,6 +4,10 @@ export interface IWorkspace { _id: Types.ObjectId; name: string; organization: Types.ObjectId; + environments: Array<{ + name: string; + slug: string; + }>; } const workspaceSchema = new Schema({ @@ -15,7 +19,33 @@ const workspaceSchema = new Schema({ type: Schema.Types.ObjectId, ref: 'Organization', required: true - } + }, + environments: { + type: [ + { + name: String, + slug: String, + }, + ], + default: [ + { + name: "development", + slug: "dev" + }, + { + name: "test", + slug: "test" + }, + { + name: "staging", + slug: "staging" + }, + { + name: "production", + slug: "prod" + } + ], + }, }); const Workspace = model('Workspace', workspaceSchema); diff --git a/backend/src/routes/v2/environment.ts b/backend/src/routes/v2/environment.ts new file mode 100644 index 000000000..592a51248 --- /dev/null +++ b/backend/src/routes/v2/environment.ts @@ -0,0 +1,54 @@ +import express from 'express'; +const router = express.Router(); +import { body, param } from 'express-validator'; +import { environmentController } from '../../controllers/v2'; +import { + requireAuth, + requireWorkspaceAuth, + validateRequest, +} from '../../middleware'; +import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../../variables'; + +router.post( + '/:workspaceId', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED], + }), + param('workspaceId').exists().trim(), + body('environmentSlug').exists().trim(), + body('environmentName').exists().trim(), + validateRequest, + environmentController.createWorkspaceEnvironment +); + +router.put( + '/:workspaceId', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + acceptedStatuses: [COMPLETED, GRANTED], + }), + param('workspaceId').exists().trim(), + body('environmentSlug').exists().trim(), + body('environmentName').exists().trim(), + body('oldEnvironmentSlug').exists().trim(), + validateRequest, + environmentController.renameWorkspaceEnvironment +); + +router.delete( + '/:workspaceId', + requireAuth, + requireWorkspaceAuth({ + acceptedRoles: [ADMIN], + acceptedStatuses: [GRANTED], + }), + param('workspaceId').exists().trim(), + body('environmentSlug').exists().trim(), + validateRequest, + environmentController.deleteWorkspaceEnvironment +); + +export default router; diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index 8bea42620..2740c3530 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -3,11 +3,13 @@ import secrets from './secrets'; import workspace from './workspace'; import serviceTokenData from './serviceTokenData'; import apiKeyData from './apiKeyData'; +import environment from "./environment" export { secret, secrets, workspace, serviceTokenData, - apiKeyData -} + apiKeyData, + environment +} \ No newline at end of file diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 32f5f5a88..43746aee4 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -11,10 +11,6 @@ import { setIntegrationAuthAccessHelper, } from '../helpers/integration'; import { exchangeCode } from '../integrations'; -import { - ENV_DEV, - EVENT_PUSH_SECRETS -} from '../variables'; // should sync stuff be here too? Probably. // TODO: move bot functions to IntegrationService. @@ -32,22 +28,26 @@ class IntegrationService { * - Create bot sequence for integration * @param {Object} obj * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.environment - workspace environment * @param {String} obj.integration - name of integration * @param {String} obj.code - code */ static async handleOAuthExchange({ workspaceId, integration, - code + code, + environment }: { workspaceId: string; integration: string; code: string; + environment: string; }) { await handleOAuthExchangeHelper({ workspaceId, integration, - code + code, + environment }); } From 9116bf33441422deee8fe1d2b9b0a33f1cc36c8b Mon Sep 17 00:00:00 2001 From: akhilmhdh Date: Tue, 10 Jan 2023 19:39:19 +0530 Subject: [PATCH 2/7] feat(ui): implemented ui for env management table --- .../basic/dialog/AddEnvironmentDialog.tsx | 127 +++++++++ .../basic/dialog/DeleteActionModal.tsx | 100 +++++++ .../basic/table/EnvironmentsTable.tsx | 118 +++++++++ frontend/hooks/index.ts | 1 + frontend/hooks/usePopUp.tsx | 69 +++++ frontend/pages/settings/project/[id].tsx | 245 ++++++++++++++++++ frontend/tsconfig.json | 1 + 7 files changed, 661 insertions(+) create mode 100644 frontend/components/basic/dialog/AddEnvironmentDialog.tsx create mode 100644 frontend/components/basic/dialog/DeleteActionModal.tsx create mode 100644 frontend/components/basic/table/EnvironmentsTable.tsx create mode 100644 frontend/hooks/index.ts create mode 100644 frontend/hooks/usePopUp.tsx create mode 100644 frontend/pages/settings/project/[id].tsx diff --git a/frontend/components/basic/dialog/AddEnvironmentDialog.tsx b/frontend/components/basic/dialog/AddEnvironmentDialog.tsx new file mode 100644 index 000000000..83b578d81 --- /dev/null +++ b/frontend/components/basic/dialog/AddEnvironmentDialog.tsx @@ -0,0 +1,127 @@ +import { FormEventHandler, Fragment, useEffect, useState } from 'react'; +import { Dialog, Transition } from '@headlessui/react'; + +import Button from '../buttons/Button'; +import InputField from '../InputField'; + +type FormFields = { name: string; slug: string }; + +type Props = { + isOpen?: boolean; + isEditMode?: boolean; + // on edit mode load up initial values + initialValues?: FormFields; + onClose: () => void; + onSubmit: (envName: string, envSlug: string) => void; +}; + +/** + * The dialog modal for when the user wants to create a new workspace + * @param {*} param0 + * @returns + */ +export const AddEnvironmentDialog = ({ isOpen, onClose, onSubmit, initialValues, isEditMode }: Props) => { + const [formInput, setFormInput] = useState({ + name: '', + slug: '', + }); + + // This use effect can be removed when the unmount is happening from outside the component + // When unmount happens outside state gets unmounted also + useEffect(() => { + setFormInput(initialValues || { name: '', slug: '' }); + }, [isOpen]); + + // REFACTOR: Move to react-hook-form with yup for better form management + const onInputChange = (fieldName: string, fieldValue: string) => { + setFormInput((state) => ({ ...state, [fieldName]: fieldValue })); + }; + + const onFormSubmit: FormEventHandler = (e) => { + e.preventDefault(); + console.log(formInput); + }; + + return ( +
+ + + +
+ + +
+
+ + + + {isEditMode + ? 'Update environment' + : 'Create a new environment'} + +
+
+ onInputChange('name', val)} + type='varName' + value={formInput.name} + placeholder='' + isRequired + // error={error.length > 0} + // errorText={error} + /> +
+
+ onInputChange('slug', val)} + type='varName' + value={formInput.slug} + placeholder='' + isRequired + // error={error.length > 0} + // errorText={error} + /> +
+

+ Slugs are shorthands used in cli to access environment +

+
+
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/frontend/components/basic/dialog/DeleteActionModal.tsx b/frontend/components/basic/dialog/DeleteActionModal.tsx new file mode 100644 index 000000000..cafd832ae --- /dev/null +++ b/frontend/components/basic/dialog/DeleteActionModal.tsx @@ -0,0 +1,100 @@ +import { Fragment, useState } from 'react'; +import { Dialog, Transition } from '@headlessui/react'; + +import InputField from '../InputField'; + +// REFACTOR: Move all these modals into one reusable one +type Props = { + isOpen?: boolean; + onClose: ()=>void; + title: string; + onSubmit:()=>void; + deleteKey?:string; +} + +const DeleteActionModal = ({ + isOpen, + onClose, + title, + onSubmit, + deleteKey +}:Props) => { + const [deleteInputField, setDeleteInputField] = useState("") + + return ( +
+ + + +
+ +
+
+ + + + {title} + +
+

+ This action is irrevertible. +

+
+
+ setDeleteInputField(val)} + value={deleteInputField} + type='text' + /> +
+
+ + +
+
+
+
+
+
+
+
+ ); +}; + +export default DeleteActionModal; diff --git a/frontend/components/basic/table/EnvironmentsTable.tsx b/frontend/components/basic/table/EnvironmentsTable.tsx new file mode 100644 index 000000000..501b7f9bc --- /dev/null +++ b/frontend/components/basic/table/EnvironmentsTable.tsx @@ -0,0 +1,118 @@ +import { faPencil,faPlus,faX } from '@fortawesome/free-solid-svg-icons'; + +import { usePopUp } from '../../../hooks/usePopUp'; +import Button from '../buttons/Button'; +import {AddEnvironmentDialog} from '../dialog/AddEnvironmentDialog'; +import DeleteActionModal from '../dialog/DeleteActionModal'; + +const EnvironmentTable = ({ data = [] }) => { + const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + 'createUpdateEnv', + 'deleteEnv', + ] as const); + + return ( + <> +
+
+

Project Environments

+

+ Choose which environments will show up in your dashboard like + development, staging, production +

+

+ Note: the text in slugs shows how these environmant should be + accessed in CLI. +

+
+
+
+
+
+
+ + + + + + + + + + {data?.length > 0 ? ( + data.map(({ name, slug }) => { + return ( + + + + + + ); + }) + ) : ( + + + + )} + +
NameSlug
+ {name} + + {slug} + +
+
+
+
+
+ No environmants found +
+ handlePopUpClose('deleteEnv')} + onSubmit={() => handlePopUpClose('deleteEnv')} + /> + handlePopUpClose('createUpdateEnv')} + onSubmit={() => null} + /> +
+ + ); +}; + +export default EnvironmentTable; diff --git a/frontend/hooks/index.ts b/frontend/hooks/index.ts new file mode 100644 index 000000000..dcea2eb7c --- /dev/null +++ b/frontend/hooks/index.ts @@ -0,0 +1 @@ +export { usePopUp } from './usePopUp'; diff --git a/frontend/hooks/usePopUp.tsx b/frontend/hooks/usePopUp.tsx new file mode 100644 index 000000000..eb0835b49 --- /dev/null +++ b/frontend/hooks/usePopUp.tsx @@ -0,0 +1,69 @@ +import { useCallback, useState } from 'react'; + +interface usePopUpProps { + name: Readonly; + isOpen: boolean; +} + +/** + * to provide better intellisense + * checks which type of inputProps were given and converts them into key-names + * SIDENOTE: On inputting give it as const and not string with (as const) + */ +type usePopUpState | usePopUpProps[]> = { + [P in T extends usePopUpProps[] ? T[number]['name'] : T[number]]: { + isOpen: boolean; + data?: unknown; + }; +}; + +interface usePopUpReturn | usePopUpProps[]> { + popUp: usePopUpState; + handlePopUpOpen: (popUpName: keyof usePopUpState, data?: unknown) => void; + handlePopUpClose: (popUpName: keyof usePopUpState) => void; + handlePopUpToggle: (popUpName: keyof usePopUpState) => void; +} + +/** + * This hook is used to manage multiple popUps/modal/dialog in a page + * Provides api to open,close,toggle and also store temporary data for the popUp + * @param popUpNames: the names of popUp containers eg: ["popUp1","second"] or [{name:"popUp2",isOpen:bool}] + */ +export const usePopUp = | usePopUpProps[]>( + popUpNames: T +): usePopUpReturn => { + const [popUp, setPopUp] = useState>( + Object.fromEntries( + popUpNames.map((popUpName) => + typeof popUpName === 'string' + ? [popUpName, { isOpen: false }] + : [popUpName.name, { isOpen: popUpName.isOpen }] + ) // convert into an array of [[popUpName,state]] then into Object + ) as usePopUpState // to override generic string return type of the function + ); + + const handlePopUpOpen = useCallback( + (popUpName: keyof usePopUpState, data?: unknown) => { + setPopUp((popUp) => ({ ...popUp, [popUpName]: { isOpen: true, data } })); + }, + [] + ); + + const handlePopUpClose = useCallback((popUpName: keyof usePopUpState) => { + setPopUp((popUp) => ({ ...popUp, [popUpName]: { isOpen: false } })); + }, []); + + const handlePopUpToggle = useCallback((popUpName: keyof usePopUpState) => { + setPopUp((popUp) => ({ + ...popUp, + [popUpName]: { isOpen: !popUp[popUpName].isOpen }, + })); + }, []); + + return { + popUp, + handlePopUpOpen, + handlePopUpClose, + handlePopUpToggle, + }; +}; diff --git a/frontend/pages/settings/project/[id].tsx b/frontend/pages/settings/project/[id].tsx new file mode 100644 index 000000000..4a64c3e65 --- /dev/null +++ b/frontend/pages/settings/project/[id].tsx @@ -0,0 +1,245 @@ +import { useEffect, useRef, useState } from "react"; +import Head from "next/head"; +import { useRouter } from "next/router"; +import { useTranslation } from "next-i18next"; +import { faCheck, faPlus } from "@fortawesome/free-solid-svg-icons"; + +import Button from "~/components/basic/buttons/Button"; +import AddServiceTokenDialog from "~/components/basic/dialog/AddServiceTokenDialog"; +import InputField from "~/components/basic/InputField"; +import EnvironmentTable from '~/components/basic/table/EnvironmentsTable'; +import ServiceTokenTable from "~/components/basic/table/ServiceTokenTable"; +import NavHeader from "~/components/navigation/NavHeader"; +import { getTranslatedServerSideProps } from "~/utilities/withTranslateProps"; + +import getServiceTokens from "../../api/serviceToken/getServiceTokens"; +import deleteWorkspace from "../../api/workspace/deleteWorkspace"; +import getWorkspaces from "../../api/workspace/getWorkspaces"; +import renameWorkspace from "../../api/workspace/renameWorkspace"; + +export default function SettingsBasic() { + const [buttonReady, setButtonReady] = useState(false); + const router = useRouter(); + const [workspaceName, setWorkspaceName] = useState(""); + const [serviceTokens, setServiceTokens] = useState([]); + const [environments,setEnvironments] = useState([]); + const [workspaceToBeDeletedName, setWorkspaceToBeDeletedName] = useState(""); + const [isAddOpen, setIsAddOpen] = useState(false); + const [isAddServiceTokenDialogOpen, setIsAddServiceTokenDialogOpen] = useState(false); + + const { t } = useTranslation(); + + useEffect(async () => { + const userWorkspaces = await getWorkspaces(); + userWorkspaces.forEach((userWorkspace) => { + if (userWorkspace._id == router.query.id) { + setWorkspaceName(userWorkspace.name); + setEnvironments(userWorkspace.environments); + } + }); + const tempServiceTokens = await getServiceTokens({ + workspaceId: router.query.id, + }); + setServiceTokens(tempServiceTokens); + }, []); + + const modifyWorkspaceName = (newName) => { + setButtonReady(true); + setWorkspaceName(newName); + }; + + const submitChanges = (newWorkspaceName) => { + renameWorkspace(router.query.id, newWorkspaceName); + setButtonReady(false); + }; + + const closeAddServiceTokenModal = () => { + setIsAddServiceTokenDialogOpen(false); + }; + + /** + * 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 == router.query.id + )[0].name == workspaceToBeDeletedName + ) { + await deleteWorkspace(router.query.id); + const userWorkspaces = await getWorkspaces(); + router.push("/dashboard/" + userWorkspaces[0]._id); + } + } + }; + + return ( +
+ + + {t('common:head-title', { title: t('settings-project:title') })} + + + + +
+
+ +
+
+

+ {t('settings-project:title')} +

+

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

+
+
+
+
+
+
+

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

+
+ +
+
+
+
+
+
+
+

+ {t('common:project-id')} +

+

+ {t('settings-project:project-id-description')} +

+

+ {t('settings-project:project-id-description2')} + {/* eslint-disable-next-line react/jsx-no-target-blank */} + + {t('settings-project:docs')} + +

+
+ +
+
+
+
+
+

+ {t('section-token:service-tokens')} +

+

+ {t('section-token:service-tokens-description')} +

+
+
+
+
+ +
+
+ +
+
+
+
+

+ {t('settings-project:danger-zone')} +

+

+ {t('settings-project:danger-zone-note')} +

+
+ +
+ +

+ {t('settings-project:delete-project-note')} +

+
+
+
+
+
+ ); +} + +SettingsBasic.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps([ + "settings", + "settings-project", + "section-token", +]); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index e936a69a2..f51378ec3 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -3,6 +3,7 @@ "baseUrl": ".", "paths": { "~/components/*": ["components/*"], + "~/hooks/*": ["hooks/*"], "~/utilities/*": ["components/utilities/*"], "~/*": ["const"], "~/pages/*": ["pages/*"] From 84700308f5201b70c3680727de0d93eedf7ab32d Mon Sep 17 00:00:00 2001 From: akhilmhdh Date: Wed, 11 Jan 2023 23:09:34 +0530 Subject: [PATCH 3/7] feat(#31): implemented ui for multi env and integrated api with backend fix(#31): fixed all v2 release conflict --- backend/src/app.ts | 2 +- .../v1/integrationAuthController.ts | 2 +- .../controllers/v2/environmentController.ts | 121 ++- backend/src/ee/models/secretVersion.ts | 5 - backend/src/models/integration.ts | 2 +- backend/src/routes/v2/environment.ts | 25 +- backend/src/routes/v2/secrets.ts | 4 +- frontend/components/basic/Listbox.tsx | 2 +- .../basic/dialog/AddServiceTokenDialog.js | 164 ++-- ...log.tsx => AddUpdateEnvironmentDialog.tsx} | 26 +- .../basic/dialog/DeleteActionModal.tsx | 6 +- .../basic/table/EnvironmentsTable.tsx | 73 +- .../basic/table/ServiceTokenTable.tsx | 3 +- .../components/integrations/Integration.tsx | 10 +- .../utilities/secrets/downloadDotEnv.ts | 3 +- .../utilities/secrets/getSecretsForProject.ts | 6 +- .../api/environments/createEnvironment.ts | 29 + .../api/environments/deleteEnvironment.ts | 26 + .../api/environments/updateEnvironment.ts | 33 + frontend/pages/api/workspace/getWorkspaces.ts | 1 + frontend/pages/dashboard/[id].tsx | 819 +++++++++++------- frontend/pages/settings/project/[id].js | 306 ------- frontend/pages/settings/project/[id].tsx | 234 +++-- 23 files changed, 1051 insertions(+), 851 deletions(-) rename frontend/components/basic/dialog/{AddEnvironmentDialog.tsx => AddUpdateEnvironmentDialog.tsx} (87%) create mode 100644 frontend/pages/api/environments/createEnvironment.ts create mode 100644 frontend/pages/api/environments/deleteEnvironment.ts create mode 100644 frontend/pages/api/environments/updateEnvironment.ts delete mode 100644 frontend/pages/settings/project/[id].js diff --git a/backend/src/app.ts b/backend/src/app.ts index 571b84cd4..82275b9c9 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -104,12 +104,12 @@ app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); // v2 routes +app.use('/api/v2/workspace', v2EnvironmentRouter); app.use('/api/v2/workspace', v2WorkspaceRouter); // TODO: turn into plural route app.use('/api/v2/secret', v2SecretRouter); // stop supporting, TODO: revise app.use('/api/v2/secrets', v2SecretsRouter); app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route app.use('/api/v2/api-key-data', v2APIKeyDataRouter); -app.use('/api/v2/environments', v2EnvironmentRouter); // api docs app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile)) diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index b2f93a8c2..5df8b4e91 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -3,7 +3,7 @@ import * as Sentry from '@sentry/node'; import axios from 'axios'; import { readFileSync } from 'fs'; import { IntegrationAuth, Integration } from '../../models'; -import { INTEGRATION_SET, INTEGRATION_OPTIONS, ENV_DEV } from '../../variables'; +import { INTEGRATION_SET, INTEGRATION_OPTIONS } from '../../variables'; import { IntegrationService } from '../../services'; import { getApps, revokeAccess } from '../../integrations'; diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts index 77c4fcb59..1d1ffb6a6 100644 --- a/backend/src/controllers/v2/environmentController.ts +++ b/backend/src/controllers/v2/environmentController.ts @@ -1,6 +1,13 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; -import { Secret, ServiceToken, Workspace, Integration } from '../../models'; +import { + Secret, + ServiceToken, + Workspace, + Integration, + ServiceTokenData, +} from '../../models'; +import { SecretVersion } from '../../ee/models'; /** * Create new workspace environment named [environmentName] under workspace with id @@ -12,25 +19,24 @@ export const createWorkspaceEnvironment = async ( req: Request, res: Response ) => { - const { workspaceId, environmentName, environmentSlug } = req.body; + const { workspaceId } = req.params; + const { environmentName, environmentSlug } = req.body; try { - // atomic create the environment - const workspace = await Workspace.findOneAndUpdate( - { - _id: workspaceId, - 'environments.slug': { $ne: environmentSlug }, - 'environments.name': { $ne: environmentName }, - }, - { - $addToSet: { - environments: { name: environmentName, slug: environmentSlug }, - }, - } - ); - - if (!workspace) { - throw new Error('Failed to update workspace environment'); + const workspace = await Workspace.findById(workspaceId).exec(); + if ( + !workspace || + workspace?.environments.find( + ({ name, slug }) => slug === environmentSlug || environmentName === name + ) + ) { + throw new Error('Failed to create workspace environment'); } + + workspace?.environments.push({ + name: environmentName.toLowerCase(), + slug: environmentSlug.toLowerCase(), + }); + await workspace.save(); } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); @@ -60,8 +66,8 @@ export const renameWorkspaceEnvironment = async ( req: Request, res: Response ) => { - const { workspaceId, environmentName, environmentSlug, oldEnvironmentSlug } = - req.body; + const { workspaceId } = req.params; + const { environmentName, environmentSlug, oldEnvironmentSlug } = req.body; try { // user should pass both new slug and env name if (!environmentSlug || !environmentName) { @@ -69,25 +75,47 @@ export const renameWorkspaceEnvironment = async ( } // atomic update the env to avoid conflict - const workspace = await Workspace.findOneAndUpdate( - { _id: workspaceId, 'environments.slug': oldEnvironmentSlug }, - { - 'environments.$.name': environmentName, - 'environments.$.slug': environmentSlug, - } - ); + const workspace = await Workspace.findById(workspaceId).exec(); if (!workspace) { - throw new Error('Failed to update workspace'); + throw new Error('Failed to create workspace environment'); } + const isEnvExist = workspace.environments.some( + ({ name, slug }) => + slug !== oldEnvironmentSlug && + (name === environmentName || slug === environmentSlug) + ); + if (isEnvExist) { + throw new Error('Invalid environment given'); + } + + const envIndex = workspace?.environments.findIndex( + ({ slug }) => slug === oldEnvironmentSlug + ); + if (envIndex === -1) { + throw new Error('Invalid environment given'); + } + + workspace.environments[envIndex].name = environmentName.toLowerCase(); + workspace.environments[envIndex].slug = environmentSlug.toLowerCase(); + + await workspace.save(); await Secret.updateMany( { workspace: workspaceId, environment: oldEnvironmentSlug }, { environment: environmentSlug } ); + await SecretVersion.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); await ServiceToken.updateMany( { workspace: workspaceId, environment: oldEnvironmentSlug }, { environment: environmentSlug } ); + await ServiceTokenData.updateMany( + { workspace: workspaceId, environment: oldEnvironmentSlug }, + { environment: environmentSlug } + ); await Integration.updateMany( { workspace: workspaceId, environment: oldEnvironmentSlug }, { environment: environmentSlug } @@ -120,37 +148,46 @@ export const deleteWorkspaceEnvironment = async ( req: Request, res: Response ) => { - const { workspaceId, environmentSlug } = req.body; + const { workspaceId } = req.params; + const { environmentSlug } = req.body; try { - // atomic delete the env in the workspacce - const workspace = await Workspace.findOneAndUpdate( - { _id: workspaceId }, - { - $pull: { - environments: { - slug: environmentSlug, - }, - }, - } - ); + // atomic update the env to avoid conflict + const workspace = await Workspace.findById(workspaceId).exec(); if (!workspace) { - throw new Error('Failed to delete workspace environment'); + throw new Error('Failed to create workspace environment'); } + const envIndex = workspace?.environments.findIndex( + ({ slug }) => slug === environmentSlug + ); + if (envIndex === -1) { + throw new Error('Invalid environment given'); + } + + workspace.environments.splice(envIndex, 1); + await workspace.save(); + // clean up await Secret.deleteMany({ workspace: workspaceId, environment: environmentSlug, }); + await SecretVersion.deleteMany({ + workspace: workspaceId, + environment: environmentSlug, + }); await ServiceToken.deleteMany({ workspace: workspaceId, environment: environmentSlug, }); + await ServiceTokenData.deleteMany({ + workspace: workspaceId, + environment: environmentSlug, + }); await Integration.deleteMany({ workspace: workspaceId, environment: environmentSlug, }); - } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index 616d44fbd..a4fec0a39 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -2,10 +2,6 @@ import { Schema, model, Types } from 'mongoose'; import { SECRET_SHARED, SECRET_PERSONAL, - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD } from '../../variables'; export interface ISecretVersion { @@ -56,7 +52,6 @@ const secretVersionSchema = new Schema( }, environment: { type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], required: true }, isDeleted: { // consider removing field diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 0b184ed67..98e4934d9 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -9,7 +9,7 @@ import { export interface IIntegration { _id: Types.ObjectId; workspace: Types.ObjectId; - environment: 'dev' | 'test' | 'staging' | 'prod'; + environment: string; isActive: boolean; app: string; target: string; diff --git a/backend/src/routes/v2/environment.ts b/backend/src/routes/v2/environment.ts index 592a51248..924db18fc 100644 --- a/backend/src/routes/v2/environment.ts +++ b/backend/src/routes/v2/environment.ts @@ -1,4 +1,4 @@ -import express from 'express'; +import express, { Response, Request } from 'express'; const router = express.Router(); import { body, param } from 'express-validator'; import { environmentController } from '../../controllers/v2'; @@ -7,14 +7,15 @@ import { requireWorkspaceAuth, validateRequest, } from '../../middleware'; -import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../../variables'; +import { ADMIN, MEMBER } from '../../variables'; router.post( - '/:workspaceId', - requireAuth, + '/:workspaceId/environments', + requireAuth({ + acceptedAuthModes: ['jwt'], + }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], - acceptedStatuses: [COMPLETED, GRANTED], }), param('workspaceId').exists().trim(), body('environmentSlug').exists().trim(), @@ -24,11 +25,12 @@ router.post( ); router.put( - '/:workspaceId', - requireAuth, + '/:workspaceId/environments', + requireAuth({ + acceptedAuthModes: ['jwt'], + }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], - acceptedStatuses: [COMPLETED, GRANTED], }), param('workspaceId').exists().trim(), body('environmentSlug').exists().trim(), @@ -39,11 +41,12 @@ router.put( ); router.delete( - '/:workspaceId', - requireAuth, + '/:workspaceId/environments', + requireAuth({ + acceptedAuthModes: ['jwt'], + }), requireWorkspaceAuth({ acceptedRoles: [ADMIN], - acceptedStatuses: [GRANTED], }), param('workspaceId').exists().trim(), body('environmentSlug').exists().trim(), diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index 29eac042f..4196cd819 100644 --- a/backend/src/routes/v2/secrets.ts +++ b/backend/src/routes/v2/secrets.ts @@ -18,7 +18,7 @@ import { router.post( '/', body('workspaceId').exists().isString().trim(), - body('environment').exists().isString().trim().isIn(['dev', 'staging', 'prod', 'test']), + body('environment').exists().isString().trim(), body('secrets') .exists() .custom((value) => { @@ -73,7 +73,7 @@ router.post( router.get( '/', query('workspaceId').exists().trim(), - query('environment').exists().trim().isIn(['dev', 'staging', 'prod', 'test']), + query('environment').exists().trim(), validateRequest, requireAuth({ acceptedAuthModes: ['jwt', 'serviceToken'] diff --git a/frontend/components/basic/Listbox.tsx b/frontend/components/basic/Listbox.tsx index 95e3f33c6..d315df2e6 100644 --- a/frontend/components/basic/Listbox.tsx +++ b/frontend/components/basic/Listbox.tsx @@ -69,7 +69,7 @@ export default function ListBox({ - `my-0.5 relative cursor-default select-none py-2 pl-10 pr-4 rounded-md ${ + `my-0.5 relative cursor-default select-none py-2 pl-10 pr-4 rounded-md capitalize ${ selected ? 'bg-white/10 text-gray-400 font-bold' : '' } ${ active && !selected diff --git a/frontend/components/basic/dialog/AddServiceTokenDialog.js b/frontend/components/basic/dialog/AddServiceTokenDialog.js index 177911973..a8c5c15a7 100644 --- a/frontend/components/basic/dialog/AddServiceTokenDialog.js +++ b/frontend/components/basic/dialog/AddServiceTokenDialog.js @@ -8,7 +8,6 @@ import nacl from "tweetnacl"; import addServiceToken from "~/pages/api/serviceToken/addServiceToken"; import getLatestFileKey from "~/pages/api/workspace/getLatestFileKey"; -import { envMapping } from "../../../public/data/frequentConstants"; import { decryptAssymmetric, encryptAssymmetric, @@ -34,11 +33,12 @@ const AddServiceTokenDialog = ({ workspaceId, workspaceName, serviceTokens, + environments, setServiceTokens }) => { const [serviceToken, setServiceToken] = useState(""); const [serviceTokenName, setServiceTokenName] = useState(""); - const [serviceTokenEnv, setServiceTokenEnv] = useState("Development"); + const [selectedServiceTokenEnv, setSelectedServiceTokenEnv] = useState(environments?.[0]); const [serviceTokenExpiresIn, setServiceTokenExpiresIn] = useState("1 day"); const [serviceTokenCopied, setServiceTokenCopied] = useState(false); const { t } = useTranslation(); @@ -66,7 +66,7 @@ const AddServiceTokenDialog = ({ let newServiceToken = await addServiceToken({ name: serviceTokenName, workspaceId, - environment: envMapping[serviceTokenEnv], + environment: selectedServiceTokenEnv.slug, expiresIn: expiryMapping[serviceTokenExpiresIn], encryptedKey: ciphertext, iv, @@ -101,155 +101,159 @@ const AddServiceTokenDialog = ({ }; return ( -
+
- + -
+
-
-
+
+
- {serviceToken == "" ? ( - + {serviceToken == '' ? ( + - {t("section-token:add-dialog.title", { + {t('section-token:add-dialog.title', { target: workspaceName, })} -
-
-

- {t("section-token:add-dialog.description")} +

+
+

+ {t('section-token:add-dialog.description')}

-
+
-
+
name)} + onChange={(envName) => + setSelectedServiceTokenEnv( + environments.find( + ({ name }) => envName === name + ) || { + name: 'unknown', + slug: 'unknown', + } + ) + } isFull={true} - text={`${t("common:environment")}: `} + text={`${t('common:environment')}: `} />
-
+
-
-
+
+
) : ( - + - {t("section-token:add-dialog.copy-service-token")} + {t('section-token:add-dialog.copy-service-token')} -
-
-

+

+
+

{t( - "section-token:add-dialog.copy-service-token-description" + 'section-token:add-dialog.copy-service-token-description' )}

-
-
+
+
-
+
{serviceToken}
-
+
- - {t("common:click-to-copy")} + + {t('common:click-to-copy')}
-
+
diff --git a/frontend/components/basic/dialog/AddEnvironmentDialog.tsx b/frontend/components/basic/dialog/AddUpdateEnvironmentDialog.tsx similarity index 87% rename from frontend/components/basic/dialog/AddEnvironmentDialog.tsx rename to frontend/components/basic/dialog/AddUpdateEnvironmentDialog.tsx index 83b578d81..9476dd8f6 100644 --- a/frontend/components/basic/dialog/AddEnvironmentDialog.tsx +++ b/frontend/components/basic/dialog/AddUpdateEnvironmentDialog.tsx @@ -12,15 +12,24 @@ type Props = { // on edit mode load up initial values initialValues?: FormFields; onClose: () => void; - onSubmit: (envName: string, envSlug: string) => void; + onCreateSubmit: (data: FormFields) => void; + onEditSubmit: (data: FormFields) => void; }; +// TODO: Migrate to better form management and validation. Preferable react-hook-form + yup /** * The dialog modal for when the user wants to create a new workspace * @param {*} param0 * @returns */ -export const AddEnvironmentDialog = ({ isOpen, onClose, onSubmit, initialValues, isEditMode }: Props) => { +export const AddUpdateEnvironmentDialog = ({ + isOpen, + onClose, + onCreateSubmit, + onEditSubmit, + initialValues, + isEditMode, +}: Props) => { const [formInput, setFormInput] = useState({ name: '', slug: '', @@ -39,7 +48,15 @@ export const AddEnvironmentDialog = ({ isOpen, onClose, onSubmit, initialValues, const onFormSubmit: FormEventHandler = (e) => { e.preventDefault(); - console.log(formInput); + const data = { + name: formInput.name.toLowerCase(), + slug: formInput.slug.toLowerCase(), + }; + if (isEditMode) { + onEditSubmit(data); + return; + } + onCreateSubmit(data); }; return ( @@ -81,7 +98,7 @@ export const AddEnvironmentDialog = ({ isOpen, onClose, onSubmit, initialValues,
onInputChange('name', val)} type='varName' value={formInput.name} @@ -112,6 +129,7 @@ export const AddEnvironmentDialog = ({ isOpen, onClose, onSubmit, initialValues, type='submit' color='mineshaft' text={isEditMode ? 'Update' : 'Create'} + active={formInput.name !== '' && formInput.slug !== ''} size='md' />
diff --git a/frontend/components/basic/dialog/DeleteActionModal.tsx b/frontend/components/basic/dialog/DeleteActionModal.tsx index cafd832ae..9a1b7a46a 100644 --- a/frontend/components/basic/dialog/DeleteActionModal.tsx +++ b/frontend/components/basic/dialog/DeleteActionModal.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import { Dialog, Transition } from '@headlessui/react'; import InputField from '../InputField'; @@ -21,6 +21,10 @@ const DeleteActionModal = ({ }:Props) => { const [deleteInputField, setDeleteInputField] = useState("") + useEffect(() => { + setDeleteInputField(""); + }, [isOpen]); + return (
diff --git a/frontend/components/basic/table/EnvironmentsTable.tsx b/frontend/components/basic/table/EnvironmentsTable.tsx index 501b7f9bc..56536b679 100644 --- a/frontend/components/basic/table/EnvironmentsTable.tsx +++ b/frontend/components/basic/table/EnvironmentsTable.tsx @@ -1,15 +1,61 @@ -import { faPencil,faPlus,faX } from '@fortawesome/free-solid-svg-icons'; +import { faPencil, faPlus, faX } from '@fortawesome/free-solid-svg-icons'; -import { usePopUp } from '../../../hooks/usePopUp'; +import { usePopUp } from '../../../hooks/usePopUp'; import Button from '../buttons/Button'; -import {AddEnvironmentDialog} from '../dialog/AddEnvironmentDialog'; +import { AddUpdateEnvironmentDialog } from '../dialog/AddUpdateEnvironmentDialog'; import DeleteActionModal from '../dialog/DeleteActionModal'; -const EnvironmentTable = ({ data = [] }) => { - const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ - 'createUpdateEnv', - 'deleteEnv', - ] as const); +type Env = { name: string; slug: string }; + +type Props = { + data: Env[]; + onCreateEnv: (arg0: Env) => Promise; + onUpdateEnv: (oldSlug: string, arg0: Env) => Promise; + onDeleteEnv: (slug: string) => Promise; +}; + +const EnvironmentTable = ({ + data = [], + onCreateEnv, + onDeleteEnv, + onUpdateEnv, +}: Props) => { + const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + 'createUpdateEnv', + 'deleteEnv', + ] as const); + + const onEnvCreateCB = async (env: Env) => { + try { + await onCreateEnv(env); + handlePopUpClose('createUpdateEnv'); + } catch (error) { + console.error(error); + } + }; + + const onEnvUpdateCB = async (env: Env) => { + try { + await onUpdateEnv( + (popUp.createUpdateEnv?.data as Pick)?.slug, + env + ); + handlePopUpClose('createUpdateEnv'); + } catch (error) { + console.error(error); + } + }; + + const onEnvDeleteCB = async () => { + try { + await onDeleteEnv( + (popUp.deleteEnv?.data as Pick)?.slug + ); + handlePopUpClose('deleteEnv'); + } catch (error) { + console.error(error); + } + }; return ( <> @@ -62,7 +108,9 @@ const EnvironmentTable = ({ data = [] }) => {
diff --git a/frontend/components/basic/table/ServiceTokenTable.tsx b/frontend/components/basic/table/ServiceTokenTable.tsx index 412a0dbfb..4d30b3013 100644 --- a/frontend/components/basic/table/ServiceTokenTable.tsx +++ b/frontend/components/basic/table/ServiceTokenTable.tsx @@ -3,7 +3,6 @@ import { faX } from '@fortawesome/free-solid-svg-icons'; import { useNotificationContext } from '~/components/context/Notifications/NotificationProvider'; import deleteServiceToken from "../../../pages/api/serviceToken/deleteServiceToken"; -import { reverseEnvMapping } from '../../../public/data/frequentConstants'; import guidGenerator from '../../utilities/randomId'; import Button from '../buttons/Button'; @@ -60,7 +59,7 @@ const ServiceTokenTable = ({ data, workspaceName, setServiceTokens }: ServiceTok {workspaceName} - {reverseEnvMapping[row.environment]} + {row.environment} {new Date(row.expiresAt).toUTCString()} diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx index 11edf7cb7..73d6ee0dc 100644 --- a/frontend/components/integrations/Integration.tsx +++ b/frontend/components/integrations/Integration.tsx @@ -15,9 +15,7 @@ import getIntegrationApps from "../../pages/api/integrations/GetIntegrationApps" import updateIntegration from "../../pages/api/integrations/updateIntegration" import { contextNetlifyMapping, - envMapping, reverseContextNetlifyMapping, - reverseEnvMapping, } from "../../public/data/frequentConstants"; interface Integration { @@ -41,9 +39,7 @@ const Integration = ({ }: { integration: Integration; }) => { - const [integrationEnvironment, setIntegrationEnvironment] = useState( - reverseEnvMapping[integration.environment] - ); + const [integrationEnvironment, setIntegrationEnvironment] = useState(integration.environment); const [fileState, setFileState] = useState([]); const router = useRouter(); const [apps, setApps] = useState([]); // integration app objects @@ -199,9 +195,9 @@ const Integration = ({ const siteApp = apps.find((app) => app.name === integrationApp); // obj or undefined const siteId = siteApp?.siteId ? siteApp.siteId : null; - const result = await updateIntegration({ + await updateIntegration({ integrationId: integration._id, - environment: envMapping[integrationEnvironment], + environment: integrationEnvironment, app: integrationApp, isActive: true, target: integrationTarget ? integrationTarget.toLowerCase() : null, diff --git a/frontend/components/utilities/secrets/downloadDotEnv.ts b/frontend/components/utilities/secrets/downloadDotEnv.ts index dbb29497b..4a84aa17a 100644 --- a/frontend/components/utilities/secrets/downloadDotEnv.ts +++ b/frontend/components/utilities/secrets/downloadDotEnv.ts @@ -1,4 +1,3 @@ -import { envMapping } from "../../../public/data/frequentConstants"; import checkOverrides from './checkOverrides'; @@ -39,7 +38,7 @@ const downloadDotEnv = async ({ data, env }: { data: SecretDataProps[]; env: str const fileDownloadUrl = URL.createObjectURL(blob); const alink = document.createElement('a'); alink.href = fileDownloadUrl; - alink.download = envMapping[env] + '.env'; + alink.download = env + '.env'; alink.click(); } diff --git a/frontend/components/utilities/secrets/getSecretsForProject.ts b/frontend/components/utilities/secrets/getSecretsForProject.ts index 81de27be0..629ac98ee 100644 --- a/frontend/components/utilities/secrets/getSecretsForProject.ts +++ b/frontend/components/utilities/secrets/getSecretsForProject.ts @@ -1,8 +1,6 @@ import getSecrets from '~/pages/api/files/GetSecrets'; import getLatestFileKey from '~/pages/api/workspace/getLatestFileKey'; -import { envMapping } from '../../../public/data/frequentConstants'; - const { decryptAssymmetric, decryptSymmetric @@ -35,7 +33,7 @@ interface SecretProps { } interface FunctionProps { - env: keyof typeof envMapping; + env: string; setIsKeyAvailable: any; setData: any; workspaceId: string; @@ -58,7 +56,7 @@ const getSecretsForProject = async ({ try { let encryptedSecrets; try { - encryptedSecrets = await getSecrets(workspaceId, envMapping[env]); + encryptedSecrets = await getSecrets(workspaceId, env); } catch (error) { console.log('ERROR: Not able to access the latest version of secrets'); } diff --git a/frontend/pages/api/environments/createEnvironment.ts b/frontend/pages/api/environments/createEnvironment.ts new file mode 100644 index 000000000..2d0ecfa37 --- /dev/null +++ b/frontend/pages/api/environments/createEnvironment.ts @@ -0,0 +1,29 @@ +import SecurityClient from '~/utilities/SecurityClient'; + +type NewEnvironmentInfo = { + environmentSlug: string; + environmentName: string; +}; + +/** + * This route deletes a specified workspace. + * @param {*} workspaceId + * @returns + */ +const createEnvironment = (workspaceId:string, newEnv: NewEnvironmentInfo) => { + return SecurityClient.fetchCall(`/api/v2/workspace/${workspaceId}/environments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(newEnv) + }).then(async (res) => { + if (res && res.status == 200) { + return res; + } else { + console.log('Failed to create environment'); + } + }); +}; + +export default createEnvironment; diff --git a/frontend/pages/api/environments/deleteEnvironment.ts b/frontend/pages/api/environments/deleteEnvironment.ts new file mode 100644 index 000000000..de8611532 --- /dev/null +++ b/frontend/pages/api/environments/deleteEnvironment.ts @@ -0,0 +1,26 @@ +import SecurityClient from '~/utilities/SecurityClient'; +/** + * This route deletes a specified env. + * @param {*} workspaceId + * @returns + */ +const deleteEnvironment = (workspaceId: string, environmentSlug: string) => { + return SecurityClient.fetchCall( + `/api/v2/workspace/${workspaceId}/environments`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ environmentSlug }), + } + ).then(async (res) => { + if (res && res.status == 200) { + return res; + } else { + console.log('Failed to delete environment'); + } + }); +}; + +export default deleteEnvironment; diff --git a/frontend/pages/api/environments/updateEnvironment.ts b/frontend/pages/api/environments/updateEnvironment.ts new file mode 100644 index 000000000..65fb449a8 --- /dev/null +++ b/frontend/pages/api/environments/updateEnvironment.ts @@ -0,0 +1,33 @@ +import SecurityClient from '~/utilities/SecurityClient'; + +type EnvironmentInfo = { + oldEnvironmentSlug: string; + environmentSlug: string; + environmentName: string; +}; + +/** + * This route updates a specified environment. + * @param {*} workspaceId + * @returns + */ +const updateEnvironment = (workspaceId: string, env: EnvironmentInfo) => { + return SecurityClient.fetchCall( + `/api/v2/workspace/${workspaceId}/environments`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(env), + } + ).then(async (res) => { + if (res && res.status == 200) { + return res; + } else { + console.log('Failed to update environment'); + } + }); +}; + +export default updateEnvironment; diff --git a/frontend/pages/api/workspace/getWorkspaces.ts b/frontend/pages/api/workspace/getWorkspaces.ts index 1bbb42c7a..d77c4c431 100644 --- a/frontend/pages/api/workspace/getWorkspaces.ts +++ b/frontend/pages/api/workspace/getWorkspaces.ts @@ -5,6 +5,7 @@ interface Workspace { _id: string; name: string; organization: string; + environments: Array<{name:string, slug:string}> } /** diff --git a/frontend/pages/dashboard/[id].tsx b/frontend/pages/dashboard/[id].tsx index 4ce91002d..e20ee73c7 100644 --- a/frontend/pages/dashboard/[id].tsx +++ b/frontend/pages/dashboard/[id].tsx @@ -2,7 +2,7 @@ import { Fragment, useCallback, useEffect, useState } from 'react'; import Head from 'next/head'; import Image from 'next/image'; import { useRouter } from 'next/router'; -import { useTranslation } from "next-i18next"; +import { useTranslation } from 'next-i18next'; import { faArrowDownAZ, faArrowDownZA, @@ -34,7 +34,6 @@ import getSecretsForProject from '~/components/utilities/secrets/getSecretsForPr import { getTranslatedServerSideProps } from '~/components/utilities/withTranslateProps'; import guidGenerator from '~/utilities/randomId'; -import { envMapping, reverseEnvMapping } from '../../public/data/frequentConstants'; import addSecrets from '../api/files/AddSecrets'; import deleteSecrets from '../api/files/DeleteSecrets'; import updateSecrets from '../api/files/UpdateSecrets'; @@ -43,6 +42,10 @@ import checkUserAction from '../api/userActions/checkUserAction'; import registerUserAction from '../api/userActions/registerUserAction'; import getWorkspaces from '../api/workspace/getWorkspaces'; +type WorkspaceEnv = { + name: string; + slug: string; +}; interface SecretDataProps { type: 'personal' | 'shared'; @@ -68,7 +71,7 @@ interface SnapshotProps { secretVersions: { id: string; pos: number; - type: "personal" | "shared"; + type: 'personal' | 'shared'; environment: string; key: string; value: string; @@ -99,14 +102,11 @@ function findDuplicates(arr: any[]) { */ export default function Dashboard() { const [data, setData] = useState(); - const [initialData, setInitialData] = useState([]); + const [initialData, setInitialData] = useState([]); const [buttonReady, setButtonReady] = useState(false); const router = useRouter(); - const [workspaceId, setWorkspaceId] = useState(''); const [blurred, setBlurred] = useState(true); const [isKeyAvailable, setIsKeyAvailable] = useState(true); - const [env, setEnv] = useState('Development'); - const [snapshotEnv, setSnapshotEnv] = useState('Development'); const [isNew, setIsNew] = useState(false); const [isLoading, setIsLoading] = useState(false); const [searchKeys, setSearchKeys] = useState(''); @@ -114,7 +114,7 @@ export default function Dashboard() { const [sortMethod, setSortMethod] = useState('alphabetical'); const [checkDocsPopUpVisible, setCheckDocsPopUpVisible] = useState(false); const [hasUserEverPushed, setHasUserEverPushed] = useState(false); - const [sidebarSecretId, toggleSidebar] = useState("None"); + const [sidebarSecretId, toggleSidebar] = useState('None'); const [PITSidebarOpen, togglePITSidebar] = useState(false); const [sharedToHide, setSharedToHide] = useState([]); const [snapshotData, setSnapshotData] = useState(); @@ -123,6 +123,16 @@ export default function Dashboard() { const { t } = useTranslation(); const { createNotification } = useNotificationContext(); + const workspaceId = router.query.id as string; + const [workspaceEnvs, setWorkspaceEnvs] = useState([]); + + const [selectedSnapshotEnv, setSelectedSnapshotEnv] = + useState(); + const [selectedEnv, setSelectedEnv] = useState({ + name: '', + slug: '', + }); + // #TODO: fix save message for changing reroutes // const beforeRouteHandler = (url) => { // const warningText = @@ -169,25 +179,37 @@ export default function Dashboard() { useEffect(() => { (async () => { try { - const tempNumSnapshots = await getProjectSercetSnapshotsCount({ workspaceId: String(router.query.id) }) + const tempNumSnapshots = await getProjectSercetSnapshotsCount({ + workspaceId, + }); setNumSnapshots(tempNumSnapshots); const userWorkspaces = await getWorkspaces(); - const listWorkspaces = userWorkspaces.map((workspace) => workspace._id); - if ( - !listWorkspaces.includes(router.asPath.split('/')[2]) - ) { - router.push('/dashboard/' + listWorkspaces[0]); + const workspace = userWorkspaces.find( + (workspace) => workspace._id === workspaceId + ); + if (!workspace) { + router.push('/dashboard/' + userWorkspaces?.[0]?._id); } + setWorkspaceEnvs(workspace?.environments || []); + // set env + const env = workspace?.environments?.[0] || { + name: 'unknown', + slug: 'unkown', + }; + setSelectedEnv(env); + setSelectedSnapshotEnv(env); const user = await getUser(); setIsNew( - (Date.parse(String(new Date())) - Date.parse(user.createdAt)) / 60000 < 3 + (Date.parse(String(new Date())) - Date.parse(user.createdAt)) / + 60000 < + 3 ? true : false ); const userAction = await checkUserAction({ - action: 'first_time_secrets_pushed' + action: 'first_time_secrets_pushed', }); setHasUserEverPushed(userAction ? true : false); } catch (error) { @@ -202,13 +224,12 @@ export default function Dashboard() { try { setIsLoading(true); setBlurred(true); - setWorkspaceId(String(router.query.id)); - + // ENV const dataToSort = await getSecretsForProject({ - env, + env: selectedEnv.slug, setIsKeyAvailable, setData, - workspaceId: String(router.query.id) + workspaceId, }); setInitialData(dataToSort); reorderRows(dataToSort); @@ -230,7 +251,7 @@ export default function Dashboard() { } })(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [env]); + }, [selectedEnv]); const addRow = () => { setIsNew(false); @@ -243,17 +264,17 @@ export default function Dashboard() { value: '', type: 'shared', comment: '', - } + }, ]); }; /** * This function add an ovverrided version of a certain secret to the current user - * @param {object} obj + * @param {object} obj * @param {string} obj.id - if of this secret that is about to be overriden * @param {string} obj.keyName - key name of this secret * @param {string} obj.value - value of this secret - * @param {string} obj.pos - position of this secret on the dashboard + * @param {string} obj.pos - position of this secret on the dashboard */ const addOverride = ({ id, keyName, value, pos, comment }: overrideProps) => { setIsNew(false); @@ -265,20 +286,32 @@ export default function Dashboard() { key: keyName, value: value, type: 'personal', - comment: comment - } + comment: comment, + }, ]; - sortValuesHandler(tempdata, sortMethod == "alhpabetical" ? "-alphabetical" : "alphabetical"); + sortValuesHandler( + tempdata, + sortMethod == 'alhpabetical' ? '-alphabetical' : 'alphabetical' + ); }; - const deleteRow = ({ ids, secretName }: { ids: string[]; secretName: string; }) => { + const deleteRow = ({ + ids, + secretName, + }: { + ids: string[]; + secretName: string; + }) => { setButtonReady(true); - toggleSidebar("None"); + toggleSidebar('None'); createNotification({ text: `${secretName} has been deleted. Remember to save changes.`, - type: 'error' + type: 'error', }); - sortValuesHandler(data!.filter((row: SecretDataProps) => !ids.includes(row.id)), sortMethod == "alhpabetical" ? "-alphabetical" : "alphabetical"); + sortValuesHandler( + data!.filter((row: SecretDataProps) => !ids.includes(row.id)), + sortMethod == 'alhpabetical' ? '-alphabetical' : 'alphabetical' + ); }; /** @@ -289,15 +322,30 @@ export default function Dashboard() { setButtonReady(true); // find which shared secret corresponds to the overriden version - const sharedVersionOfOverride = data!.filter(secret => secret.type == "shared" && secret.key == data!.filter(row => row.id == id)[0]?.key)[0]?.id; - + const sharedVersionOfOverride = data!.filter( + (secret) => + secret.type == 'shared' && + secret.key == data!.filter((row) => row.id == id)[0]?.key + )[0]?.id; + // change the sidebar to this shared secret; and unhide it - toggleSidebar(sharedVersionOfOverride) - setSharedToHide(sharedToHide!.filter(tempId => tempId != sharedVersionOfOverride)) + toggleSidebar(sharedVersionOfOverride); + setSharedToHide( + sharedToHide!.filter((tempId) => tempId != sharedVersionOfOverride) + ); // resort secrets - const tempData = data!.filter((row: SecretDataProps) => !(row.key == data!.filter(row => row.id == id)[0]?.key && row.type == 'personal')) - sortValuesHandler(tempData, sortMethod == "alhpabetical" ? "-alphabetical" : "alphabetical") + const tempData = data!.filter( + (row: SecretDataProps) => + !( + row.key == data!.filter((row) => row.id == id)[0]?.key && + row.type == 'personal' + ) + ); + sortValuesHandler( + tempData, + sortMethod == 'alhpabetical' ? '-alphabetical' : 'alphabetical' + ); }; const modifyValue = (value: string, pos: number) => { @@ -351,59 +399,97 @@ export default function Dashboard() { const obj = Object.assign( {}, - ...newData!.map((row: SecretDataProps) => ({ [row.type.charAt(0) + row.key]: [row.value, row.comment ?? ''] })) + ...newData!.map((row: SecretDataProps) => ({ + [row.type.charAt(0) + row.key]: [row.value, row.comment ?? ''], + })) ); // Checking if any of the secret keys start with a number - if so, don't do anything const nameErrors = !Object.keys(obj) .map((key) => !isNaN(Number(key[0].charAt(0)))) .every((v) => v === false); - const duplicatesExist = findDuplicates(data!.map((item: SecretDataProps) => item.key + item.type)).length > 0; + const duplicatesExist = + findDuplicates(data!.map((item: SecretDataProps) => item.key + item.type)) + .length > 0; if (nameErrors) { return createNotification({ text: 'Solve all name errors before saving secrets.', - type: 'error' + type: 'error', }); } if (duplicatesExist) { return createNotification({ text: 'Remove duplicated secret names before saving.', - type: 'error' + type: 'error', }); } // Once "Save changes" is clicked, disable that button setButtonReady(false); - const secretsToBeDeleted - = initialData - .filter(initDataPoint => !newData!.map(newDataPoint => newDataPoint.id).includes(initDataPoint.id)) - .map(secret => secret.id); + const secretsToBeDeleted = initialData + .filter( + (initDataPoint) => + !newData! + .map((newDataPoint) => newDataPoint.id) + .includes(initDataPoint.id) + ) + .map((secret) => secret.id); - const secretsToBeAdded - = newData! - .filter(newDataPoint => !initialData.map(initDataPoint => initDataPoint.id).includes(newDataPoint.id)); + const secretsToBeAdded = newData!.filter( + (newDataPoint) => + !initialData + .map((initDataPoint) => initDataPoint.id) + .includes(newDataPoint.id) + ); + + const secretsToBeUpdated = newData!.filter((newDataPoint) => + initialData + .filter( + (initDataPoint) => + newData! + .map((newDataPoint) => newDataPoint.id) + .includes(initDataPoint.id) && + (newData!.filter( + (newDataPoint) => newDataPoint.id == initDataPoint.id + )[0].value != initDataPoint.value || + newData!.filter( + (newDataPoint) => newDataPoint.id == initDataPoint.id + )[0].key != initDataPoint.key || + newData!.filter( + (newDataPoint) => newDataPoint.id == initDataPoint.id + )[0].comment != initDataPoint.comment) + ) + .map((secret) => secret.id) + .includes(newDataPoint.id) + ); - const secretsToBeUpdated - = newData!.filter(newDataPoint => initialData - .filter(initDataPoint => newData!.map(newDataPoint => newDataPoint.id).includes(initDataPoint.id) - && (newData!.filter(newDataPoint => newDataPoint.id == initDataPoint.id)[0].value != initDataPoint.value - || newData!.filter(newDataPoint => newDataPoint.id == initDataPoint.id)[0].key != initDataPoint.key - || newData!.filter(newDataPoint => newDataPoint.id == initDataPoint.id)[0].comment != initDataPoint.comment)) - .map(secret => secret.id).includes(newDataPoint.id)); - if (secretsToBeDeleted.length > 0) { await deleteSecrets({ secretIds: secretsToBeDeleted }); } + // ENV if (secretsToBeAdded.length > 0) { - const secrets = await encryptSecrets({ secretsToEncrypt: secretsToBeAdded, workspaceId, env: envMapping[env] }) - secrets && await addSecrets({ secrets, env: envMapping[env], workspaceId }); + const secrets = await encryptSecrets({ + secretsToEncrypt: secretsToBeAdded, + workspaceId, + env: selectedEnv.slug, + }); + secrets && + (await addSecrets({ + secrets, + env: selectedEnv.slug, + workspaceId, + })); } if (secretsToBeUpdated.length > 0) { - const secrets = await encryptSecrets({ secretsToEncrypt: secretsToBeUpdated, workspaceId, env: envMapping[env] }) - secrets && await updateSecrets({ secrets }); + const secrets = await encryptSecrets({ + secretsToEncrypt: secretsToBeUpdated, + workspaceId, + env: selectedEnv.slug, + }); + secrets && (await updateSecrets({ secrets })); } // If this user has never saved environment variables before, show them a prompt to read docs @@ -425,299 +511,417 @@ export default function Dashboard() { setBlurred(!blurred); }; - const sortValuesHandler = (dataToSort: SecretDataProps[] | 1, specificSortMethod?: 'alphabetical' | '-alphabetical') => { - const howToSort = specificSortMethod == undefined ? sortMethod : specificSortMethod; + const sortValuesHandler = ( + dataToSort: SecretDataProps[] | 1, + specificSortMethod?: 'alphabetical' | '-alphabetical' + ) => { + const howToSort = + specificSortMethod == undefined ? sortMethod : specificSortMethod; const sortedData = (dataToSort != 1 ? dataToSort : data)! - .sort((a, b) => - howToSort == 'alphabetical' - ? a.key.localeCompare(b.key) - : b.key.localeCompare(a.key) - ) - .map((item: SecretDataProps, index: number) => { - return { - ...item, - pos: index - }; - }); + .sort((a, b) => + howToSort == 'alphabetical' + ? a.key.localeCompare(b.key) + : b.key.localeCompare(a.key) + ) + .map((item: SecretDataProps, index: number) => { + return { + ...item, + pos: index, + }; + }); setData(sortedData); }; - - const deleteCertainRow = ({ ids, secretName }: { ids: string[]; secretName: string; }) => { - deleteRow({ids, secretName}); + + const deleteCertainRow = ({ + ids, + secretName, + }: { + ids: string[]; + secretName: string; + }) => { + deleteRow({ ids, secretName }); }; return data ? ( -
+
- {t("common:head-title", { title: t("dashboard:title") })} - - - - + {t('common:head-title', { title: t('dashboard:title') })} + + + + -
- {sidebarSecretId != "None" && row.key == data.filter(row => row.id == sidebarSecretId)[0]?.key)} - modifyKey={listenChangeKey} - modifyValue={listenChangeValue} - modifyComment={listenChangeComment} - addOverride={addOverride} - deleteOverride={deleteOverride} - buttonReady={buttonReady} - savePush={savePush} - sharedToHide={sharedToHide} - setSharedToHide={setSharedToHide} - deleteRow={deleteCertainRow} - />} - {PITSidebarOpen && } -
- +
+ {sidebarSecretId != 'None' && ( + + row.key == + data.filter((row) => row.id == sidebarSecretId)[0]?.key + )} + modifyKey={listenChangeKey} + modifyValue={listenChangeValue} + modifyComment={listenChangeComment} + addOverride={addOverride} + deleteOverride={deleteOverride} + buttonReady={buttonReady} + savePush={savePush} + sharedToHide={sharedToHide} + setSharedToHide={setSharedToHide} + deleteRow={deleteCertainRow} + /> + )} + {PITSidebarOpen && ( + + )} +
+ {checkDocsPopUpVisible && ( )} -
- {snapshotData && -
-
} -
-
-

{snapshotData ? "Secret Snapshot" : t("dashboard:title")}

- {snapshotData && {new Date(snapshotData.createdAt).toLocaleString()}} +
+ {snapshotData && ( +
+
+ )} +
+
+

{snapshotData ? 'Secret Snapshot' : t('dashboard:title')}

+ {snapshotData && ( + + {new Date(snapshotData.createdAt).toLocaleString()} + + )}
{!snapshotData && data?.length == 0 && ( name)} + onChange={(envName) => + setSelectedEnv( + workspaceEnvs.find(({ name }) => envName === name) || { + name: 'unknown', + slug: 'unknown', + } + ) + } /> )}
-
+
{(data?.length !== 0 || buttonReady) && !snapshotData && (
)} - {snapshotData &&
-
} + setSnapshotData(undefined); + createNotification({ + text: `Rollback has been performed successfully.`, + type: 'success', + }); + }} + color='primary' + size='md' + active={buttonReady} + /> +
+ )}
-
-
-
+
+
+
{(snapshotData || data?.length !== 0) && ( <> - {!snapshotData - ? - : } -
+ {!snapshotData ? ( + name)} + onChange={(envName) => + setSelectedEnv( + workspaceEnvs.find( + ({ name }) => envName === name + ) || { + name: 'unknown', + slug: 'unknown', + } + ) + } + /> + ) : ( + name)} + onChange={(envName) => + setSelectedSnapshotEnv( + workspaceEnvs.find( + ({ name }) => envName === name + ) || { + name: 'unknown', + slug: 'unknown', + } + ) + } + /> + )} +
setSearchKeys(e.target.value)} - placeholder={String(t("dashboard:search-keys"))} + placeholder={String(t('dashboard:search-keys'))} />
- {!snapshotData &&
-
} - {!snapshotData &&
- -
} -
+ {!snapshotData && ( +
+
+ )} + {!snapshotData && ( +
+ +
+ )} +
- {!snapshotData &&
-
} + {!snapshotData && ( +
+
+ )} )}
{isLoading ? ( -
- infisical loading indicator -
- ) : ( - data?.length !== 0 ? ( -
+
+ infisical loading indicator +
+ ) : data?.length !== 0 ? ( +
-
- {!snapshotData && data?.filter(row => row.key?.toUpperCase().includes(searchKeys.toUpperCase())) - .filter(row => !(sharedToHide.includes(row.id) && row.type == 'shared')).map((keyPair) => ( - item.key + item.type) - )?.includes(keyPair.key + keyPair.type)} - toggleSidebar={toggleSidebar} - sidebarSecretId={sidebarSecretId} - isSnapshot={false} - /> - ))} - {snapshotData && snapshotData.secretVersions?.sort((a, b) => a.key.localeCompare(b.key)) - .filter(row => reverseEnvMapping[row.environment] == snapshotEnv) - .filter(row => row.key.toUpperCase().includes(searchKeys.toUpperCase())) - .filter(row => !(snapshotData.secretVersions?.filter(row => (snapshotData.secretVersions - ?.map((item) => item.key) - .filter( - (item, index) => - index !== - snapshotData.secretVersions?.map((item) => item.key).indexOf(item) - ).includes(row.key) && row.type == 'shared'))?.map((item) => item.id).includes(row.id) && row.type == 'shared')).map((keyPair) => ( - item.key + item.type) - )?.includes(keyPair.key + keyPair.type)} - toggleSidebar={toggleSidebar} - sidebarSecretId={sidebarSecretId} - isSnapshot={true} - /> - ))} +
+ {!snapshotData && + data + ?.filter((row) => + row.key + ?.toUpperCase() + .includes(searchKeys.toUpperCase()) + ) + .filter( + (row) => + !( + sharedToHide.includes(row.id) && + row.type == 'shared' + ) + ) + .map((keyPair) => ( + item.key + item.type) + )?.includes(keyPair.key + keyPair.type)} + toggleSidebar={toggleSidebar} + sidebarSecretId={sidebarSecretId} + isSnapshot={false} + /> + ))} + {snapshotData && + snapshotData.secretVersions + ?.sort((a, b) => a.key.localeCompare(b.key)) + .filter( + (row) => + row.environment == selectedSnapshotEnv?.slug + ) + .filter((row) => + row.key + .toUpperCase() + .includes(searchKeys.toUpperCase()) + ) + .filter( + (row) => + !( + snapshotData.secretVersions + ?.filter( + (row) => + snapshotData.secretVersions + ?.map((item) => item.key) + .filter( + (item, index) => + index !== + snapshotData.secretVersions + ?.map((item) => item.key) + .indexOf(item) + ) + .includes(row.key) && row.type == 'shared' + ) + ?.map((item) => item.id) + .includes(row.id) && row.type == 'shared' + ) + ) + .map((keyPair) => ( + item.key + item.type) + )?.includes(keyPair.key + keyPair.type)} + toggleSidebar={toggleSidebar} + sidebarSecretId={sidebarSecretId} + isSnapshot={true} + /> + ))}
- {!snapshotData &&
- -
} + {!snapshotData && ( +
+ +
+ )}
) : ( -
+
{isKeyAvailable && !snapshotData && ( )} - { - (!isKeyAvailable && ( - <> - -

- To view this file, contact your administrator for - permission. -

-

- They need to grant you access in the team tab. -

- - ))} + {!isKeyAvailable && ( + <> + +

+ To view this file, contact your administrator for + permission. +

+

+ They need to grant you access in the team tab. +

+ + )}
- ))} + )}
) : ( -
-
+
+
loading animation
); @@ -766,4 +969,4 @@ export default function Dashboard() { Dashboard.requireAuth = true; -export const getServerSideProps = getTranslatedServerSideProps(["dashboard"]); +export const getServerSideProps = getTranslatedServerSideProps(['dashboard']); diff --git a/frontend/pages/settings/project/[id].js b/frontend/pages/settings/project/[id].js deleted file mode 100644 index 3449df346..000000000 --- a/frontend/pages/settings/project/[id].js +++ /dev/null @@ -1,306 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import Head from "next/head"; -import { useRouter } from "next/router"; -import { useTranslation } from "next-i18next"; -import { faCheck, faCopy, faPlus } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; - -import Button from "~/components/basic/buttons/Button"; -import AddServiceTokenDialog from "~/components/basic/dialog/AddServiceTokenDialog"; -import InputField from "~/components/basic/InputField"; -import ServiceTokenTable from "~/components/basic/table/ServiceTokenTable.tsx"; -import NavHeader from "~/components/navigation/NavHeader"; -import { getTranslatedServerSideProps } from "~/utilities/withTranslateProps"; - -import getServiceTokens from "../../api/serviceToken/getServiceTokens"; -import deleteWorkspace from "../../api/workspace/deleteWorkspace"; -import getWorkspaces from "../../api/workspace/getWorkspaces"; -import renameWorkspace from "../../api/workspace/renameWorkspace"; - - -export default function SettingsBasic() { - const [buttonReady, setButtonReady] = useState(false); - const router = useRouter(); - const [workspaceName, setWorkspaceName] = useState(""); - const [serviceTokens, setServiceTokens] = useState([]); - const [workspaceToBeDeletedName, setWorkspaceToBeDeletedName] = useState(""); - const [workspaceId, setWorkspaceId] = useState(""); - const [isAddOpen, setIsAddOpen] = useState(false); - let [isAddServiceTokenDialogOpen, setIsAddServiceTokenDialogOpen] = - useState(false); - const [projectIdCopied, setProjectIdCopied] = useState(false); - - const { t } = useTranslation(); - - /** - * This function copies the project id to the clipboard - */ - function copyToClipboard() { - // const copyText = document.getElementById('myInput') as HTMLInputElement; - const copyText = document.getElementById('myInput') - - if (copyText) { - copyText.select(); - copyText.setSelectionRange(0, 99999); // For mobile devices - - navigator.clipboard.writeText(copyText.value); - - setProjectIdCopied(true); - setTimeout(() => setProjectIdCopied(false), 2000); - } - } - - useEffect(async () => { - let userWorkspaces = await getWorkspaces(); - userWorkspaces.map((userWorkspace) => { - if (userWorkspace._id == router.query.id) { - setWorkspaceName(userWorkspace.name); - } - }); - let tempServiceTokens = await getServiceTokens({ - workspaceId: router.query.id, - }); - setServiceTokens(tempServiceTokens); - }, []); - - const modifyWorkspaceName = (newName) => { - setButtonReady(true); - setWorkspaceName(newName); - }; - - const submitChanges = (newWorkspaceName) => { - renameWorkspace(router.query.id, newWorkspaceName); - setButtonReady(false); - }; - - useEffect(async () => { - setWorkspaceId(router.query.id); - }, []); - - function closeAddModal() { - setIsAddOpen(false); - } - - function openAddModal() { - setIsAddOpen(true); - } - - const closeAddServiceTokenModal = () => { - setIsAddServiceTokenDialogOpen(false); - }; - - /** - * 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 () => { - let userWorkspaces = await getWorkspaces(); - - if (userWorkspaces.length > 1) { - if ( - userWorkspaces.filter( - (workspace) => workspace._id == router.query.id - )[0].name == workspaceToBeDeletedName - ) { - await deleteWorkspace(router.query.id); - let userWorkspaces = await getWorkspaces(); - router.push("/dashboard/" + userWorkspaces[0]._id); - } - } - }; - - return ( -
- - - {t("common:head-title", { title: t("settings-project:title") })} - - - - -
-
- -
-
-

- {t("settings-project:title")} -

-

- {t("settings-project:description")} -

-
-
-
-
-
-
-

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

-
- -
-
-
-
-
-
-
-

- {t("common:project-id")} -

-

- {t("settings-project:project-id-description")} -

-

- {t("settings-project:project-id-description2")} - {/* eslint-disable-next-line react/jsx-no-target-blank */} - - {t("settings-project:docs")} - -

-

{t("settings-project:auto-generated")}

-
-

{`${t( - "common:project-id" - )}:`}

- -
- - - {t("common:click-to-copy")} - -
-
-
-
-
-
-

- {t("section-token:service-tokens")} -

-

- {t("section-token:service-tokens-description")} -

-

- Please, make sure you are on the - - latest version of CLI - . -

-
-
-
-
- -
-
-
-
-

- {t("settings-project:danger-zone")} -

-

- {t("settings-project:danger-zone-note")} -

-
- -
- -

- {t("settings-project:delete-project-note")} -

-
-
-
-
-
- ); -} - -SettingsBasic.requireAuth = true; - -export const getServerSideProps = getTranslatedServerSideProps([ - "settings", - "settings-project", - "section-token", -]); diff --git a/frontend/pages/settings/project/[id].tsx b/frontend/pages/settings/project/[id].tsx index 4a64c3e65..619ef2e6b 100644 --- a/frontend/pages/settings/project/[id].tsx +++ b/frontend/pages/settings/project/[id].tsx @@ -1,55 +1,87 @@ -import { useEffect, useRef, useState } from "react"; -import Head from "next/head"; -import { useRouter } from "next/router"; -import { useTranslation } from "next-i18next"; -import { faCheck, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { useEffect, useState } from 'react'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import { faCheck, faCopy, faPlus } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import Button from "~/components/basic/buttons/Button"; -import AddServiceTokenDialog from "~/components/basic/dialog/AddServiceTokenDialog"; -import InputField from "~/components/basic/InputField"; +import Button from '~/components/basic/buttons/Button'; +import AddServiceTokenDialog from '~/components/basic/dialog/AddServiceTokenDialog'; +import InputField from '~/components/basic/InputField'; import EnvironmentTable from '~/components/basic/table/EnvironmentsTable'; -import ServiceTokenTable from "~/components/basic/table/ServiceTokenTable"; -import NavHeader from "~/components/navigation/NavHeader"; -import { getTranslatedServerSideProps } from "~/utilities/withTranslateProps"; +import ServiceTokenTable from '~/components/basic/table/ServiceTokenTable'; +import NavHeader from '~/components/navigation/NavHeader'; +import deleteEnvironment from '~/pages/api/environments/deleteEnvironment'; +import updateEnvironment from '~/pages/api/environments/updateEnvironment'; +import { getTranslatedServerSideProps } from '~/utilities/withTranslateProps'; -import getServiceTokens from "../../api/serviceToken/getServiceTokens"; -import deleteWorkspace from "../../api/workspace/deleteWorkspace"; -import getWorkspaces from "../../api/workspace/getWorkspaces"; -import renameWorkspace from "../../api/workspace/renameWorkspace"; +import createEnvironment from '../../api/environments/createEnvironment'; +import getServiceTokens from '../../api/serviceToken/getServiceTokens'; +import deleteWorkspace from '../../api/workspace/deleteWorkspace'; +import getWorkspaces from '../../api/workspace/getWorkspaces'; +import renameWorkspace from '../../api/workspace/renameWorkspace'; +type EnvData = { + name: string; + slug: string; +}; export default function SettingsBasic() { const [buttonReady, setButtonReady] = useState(false); const router = useRouter(); - const [workspaceName, setWorkspaceName] = useState(""); + const [workspaceName, setWorkspaceName] = useState(''); const [serviceTokens, setServiceTokens] = useState([]); - const [environments,setEnvironments] = useState([]); - const [workspaceToBeDeletedName, setWorkspaceToBeDeletedName] = useState(""); + const [environments, setEnvironments] = useState>([]); + const [workspaceToBeDeletedName, setWorkspaceToBeDeletedName] = useState(''); const [isAddOpen, setIsAddOpen] = useState(false); - const [isAddServiceTokenDialogOpen, setIsAddServiceTokenDialogOpen] = useState(false); + const [isAddServiceTokenDialogOpen, setIsAddServiceTokenDialogOpen] = + useState(false); + const [projectIdCopied, setProjectIdCopied] = useState(false); + const workspaceId = router.query.id as string; const { t } = useTranslation(); - useEffect(async () => { - const userWorkspaces = await getWorkspaces(); - userWorkspaces.forEach((userWorkspace) => { - if (userWorkspace._id == router.query.id) { - setWorkspaceName(userWorkspace.name); - setEnvironments(userWorkspace.environments); - } - }); - const tempServiceTokens = await getServiceTokens({ - workspaceId: router.query.id, - }); - setServiceTokens(tempServiceTokens); + /** + * This function copies the project id to the clipboard + */ + function copyToClipboard() { + const copyText = document.getElementById('myInput') as HTMLInputElement; + + if (copyText) { + copyText.select(); + copyText.setSelectionRange(0, 99999); // For mobile devices + + navigator.clipboard.writeText(copyText.value); + + setProjectIdCopied(true); + setTimeout(() => setProjectIdCopied(false), 2000); + } + } + + useEffect(() => { + const load = async () => { + const userWorkspaces = await getWorkspaces(); + userWorkspaces.forEach((userWorkspace) => { + if (userWorkspace._id == workspaceId) { + setWorkspaceName(userWorkspace.name); + setEnvironments(userWorkspace.environments); + } + }); + const tempServiceTokens = await getServiceTokens({ + workspaceId, + }); + setServiceTokens(tempServiceTokens); + }; + + load(); }, []); - const modifyWorkspaceName = (newName) => { + const modifyWorkspaceName = (newName: string) => { setButtonReady(true); setWorkspaceName(newName); }; - const submitChanges = (newWorkspaceName) => { - renameWorkspace(router.query.id, newWorkspaceName); + const submitChanges = (newWorkspaceName: string) => { + renameWorkspace(workspaceId, newWorkspaceName); setButtonReady(false); }; @@ -69,16 +101,54 @@ export default function SettingsBasic() { if (userWorkspaces.length > 1) { if ( userWorkspaces.filter( - (workspace) => workspace._id == router.query.id + (workspace) => workspace._id === workspaceId )[0].name == workspaceToBeDeletedName ) { - await deleteWorkspace(router.query.id); + await deleteWorkspace(workspaceId); const userWorkspaces = await getWorkspaces(); - router.push("/dashboard/" + userWorkspaces[0]._id); + router.push('/dashboard/' + userWorkspaces[0]._id); } } }; + const onCreateEnvironment = async ({ name, slug }: EnvData) => { + const res = await createEnvironment(workspaceId, { + environmentName: name, + environmentSlug: slug, + }); + if (res) { + // TODO: on react-query migration do an api call to resync + setEnvironments((env) => [...env, { name, slug }]); + } + }; + + const onUpdateEnvironment = async ( + oldSlug: string, + { name, slug }: EnvData + ) => { + const res = await updateEnvironment(workspaceId, { + oldEnvironmentSlug: oldSlug, + environmentName: name, + environmentSlug: slug, + }); + // TODO: on react-query migration do an api call to resync + if (res) { + setEnvironments((env) => + env.map((el) => (el.slug === oldSlug ? { name, slug } : el)) + ); + } + }; + + const onDeleteEnvironment = async (slugToBeDelete: string) => { + const res = await deleteEnvironment(workspaceId, slugToBeDelete); + // TODO: on react-query migration do an api call to resync + if (res) { + setEnvironments((env) => + env.filter(({ slug }) => slug !== slugToBeDelete) + ); + } + }; + return (
@@ -89,9 +159,12 @@ export default function SettingsBasic() {
@@ -112,12 +185,13 @@ export default function SettingsBasic() {
-
-

+

+

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

-
+

{t('common:project-id')}

@@ -158,30 +232,70 @@ export default function SettingsBasic() { {t('settings-project:docs')}

-
- +

+ {t('settings-project:auto-generated')} +

+
+

{`${t( + 'common:project-id' + )}:`}

+ +
+ + + {t('common:click-to-copy')} + +
+ +
+

{t('section-token:service-tokens')}

-

+

{t('section-token:service-tokens-description')}

+

+ Please, make sure you are on the + + latest version of CLI + + . +

-
+
-
- -
-
+

{t('settings-project:danger-zone')}

@@ -239,7 +351,7 @@ export default function SettingsBasic() { SettingsBasic.requireAuth = true; export const getServerSideProps = getTranslatedServerSideProps([ - "settings", - "settings-project", - "section-token", + 'settings', + 'settings-project', + 'section-token', ]); From b67abf94d4e4e265686735e35d406856216cffaf Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Fri, 13 Jan 2023 01:02:43 -0800 Subject: [PATCH 4/7] Fixing minor bugs for custom environments --- frontend/components/basic/Listbox.tsx | 2 +- .../basic/dialog/AddServiceTokenDialog.js | 4 +- frontend/pages/dashboard/[id].tsx | 75 ++----------------- frontend/pages/settings/project/[id].tsx | 1 + 4 files changed, 12 insertions(+), 70 deletions(-) diff --git a/frontend/components/basic/Listbox.tsx b/frontend/components/basic/Listbox.tsx index d315df2e6..c67735d6e 100644 --- a/frontend/components/basic/Listbox.tsx +++ b/frontend/components/basic/Listbox.tsx @@ -48,7 +48,7 @@ export default function ListBox({ {text} {' '} - {selected} + {selected?.charAt(0).toUpperCase() + selected?.slice(1)}
{data && ( diff --git a/frontend/components/basic/dialog/AddServiceTokenDialog.js b/frontend/components/basic/dialog/AddServiceTokenDialog.js index a8c5c15a7..0be330708 100644 --- a/frontend/components/basic/dialog/AddServiceTokenDialog.js +++ b/frontend/components/basic/dialog/AddServiceTokenDialog.js @@ -66,7 +66,7 @@ const AddServiceTokenDialog = ({ let newServiceToken = await addServiceToken({ name: serviceTokenName, workspaceId, - environment: selectedServiceTokenEnv.slug, + environment: selectedServiceTokenEnv?.slug ? selectedServiceTokenEnv.slug : environments[0]?.name, expiresIn: expiryMapping[serviceTokenExpiresIn], encryptedKey: ciphertext, iv, @@ -156,7 +156,7 @@ const AddServiceTokenDialog = ({
name)} onChange={(envName) => setSelectedServiceTokenEnv( diff --git a/frontend/pages/dashboard/[id].tsx b/frontend/pages/dashboard/[id].tsx index 8f6d21c13..395ed2924 100644 --- a/frontend/pages/dashboard/[id].tsx +++ b/frontend/pages/dashboard/[id].tsx @@ -237,7 +237,9 @@ export default function Dashboard() { setInitialData(dataToSort); reorderRows(dataToSort); - setIsLoading(false); + setTimeout( + () => setIsLoading(false) + , 700); } catch (error) { console.log('Error', error); setData(undefined); @@ -361,67 +363,6 @@ export default function Dashboard() { // Once "Save changes" is clicked, disable that button setButtonReady(false); - const secretsToBeDeleted = initialData - .filter( - (initDataPoint) => - !newData! - .map((newDataPoint) => newDataPoint.id) - .includes(initDataPoint.id) - ) - .map((secret) => secret.id); - - const secretsToBeAdded = newData!.filter( - (newDataPoint) => - !initialData - .map((initDataPoint) => initDataPoint.id) - .includes(newDataPoint.id) - ); - - const secretsToBeUpdated = newData!.filter((newDataPoint) => - initialData - .filter( - (initDataPoint) => - newData! - .map((newDataPoint) => newDataPoint.id) - .includes(initDataPoint.id) && - (newData!.filter( - (newDataPoint) => newDataPoint.id == initDataPoint.id - )[0].value != initDataPoint.value || - newData!.filter( - (newDataPoint) => newDataPoint.id == initDataPoint.id - )[0].key != initDataPoint.key || - newData!.filter( - (newDataPoint) => newDataPoint.id == initDataPoint.id - )[0].comment != initDataPoint.comment) - ) - .map((secret) => secret.id) - .includes(newDataPoint.id) - ); - - if (secretsToBeDeleted.length > 0) { - await deleteSecrets({ secretIds: secretsToBeDeleted }); - } - // ENV - if (secretsToBeAdded.length > 0) { - const secrets = await encryptSecrets({ - secretsToEncrypt: secretsToBeAdded, - workspaceId, - env: selectedEnv.slug, - }); - secrets && - (await addSecrets({ - secrets, - env: selectedEnv.slug, - workspaceId, - })); - } - if (secretsToBeUpdated.length > 0) { - const secrets = await encryptSecrets({ - secretsToEncrypt: secretsToBeUpdated, - workspaceId, - env: selectedEnv.slug, - }); - secrets && (await updateSecrets({ secrets })); const secretsToBeDeleted = initialData! .filter(initDataPoint => !newData!.map(newDataPoint => newDataPoint.id).includes(initDataPoint.id)) @@ -471,11 +412,11 @@ export default function Dashboard() { await deleteSecrets({ secretIds: secretsToBeDeleted.concat(overridesToBeDeleted) }); } if (secretsToBeAdded.concat(overridesToBeAdded).length > 0) { - const secrets = await encryptSecrets({ secretsToEncrypt: secretsToBeAdded.concat(overridesToBeAdded), workspaceId, env: envMapping[env] }); - secrets && await addSecrets({ secrets, env: envMapping[env], workspaceId }); + const secrets = await encryptSecrets({ secretsToEncrypt: secretsToBeAdded.concat(overridesToBeAdded), workspaceId, env: selectedEnv.slug }); + secrets && await addSecrets({ secrets, env: selectedEnv.slug, workspaceId }); } if (secretsToBeUpdated.concat(overridesToBeUpdated).length > 0) { - const secrets = await encryptSecrets({ secretsToEncrypt: secretsToBeUpdated.concat(overridesToBeUpdated), workspaceId, env: envMapping[env] }); + const secrets = await encryptSecrets({ secretsToEncrypt: secretsToBeUpdated.concat(overridesToBeUpdated), workspaceId, env: selectedEnv.slug }); secrets && await updateSecrets({ secrets }); } @@ -643,7 +584,7 @@ export default function Dashboard() { onButtonPressed={async () => { // Update secrets in the state only for the current environment const rolledBackSecrets = snapshotData.secretVersions - .filter(row => reverseEnvMapping[row.environment] == env) + .filter(row => row.environment == selectedEnv.slug) .map((sv, position) => { return { id: sv.id, idOverride: sv.id, pos: position, valueOverride: sv.valueOverride, key: sv.key, value: sv.value, comment: '' @@ -799,7 +740,7 @@ export default function Dashboard() { /> ))} {snapshotData && snapshotData.secretVersions?.sort((a, b) => a.key.localeCompare(b.key)) - .filter(row => reverseEnvMapping[row.environment] == snapshotEnv) + .filter(row => row.environment == selectedSnapshotEnv?.slug) .filter(row => row.key.toUpperCase().includes(searchKeys.toUpperCase())) .filter( row => !(snapshotData.secretVersions?.filter(row => (snapshotData.secretVersions diff --git a/frontend/pages/settings/project/[id].tsx b/frontend/pages/settings/project/[id].tsx index 619ef2e6b..5b9e3f5de 100644 --- a/frontend/pages/settings/project/[id].tsx +++ b/frontend/pages/settings/project/[id].tsx @@ -25,6 +25,7 @@ type EnvData = { name: string; slug: string; }; + export default function SettingsBasic() { const [buttonReady, setButtonReady] = useState(false); const router = useRouter(); From 036d32aeba2f899f9c999a85ec2aed0015388770 Mon Sep 17 00:00:00 2001 From: akhilmhdh Date: Fri, 13 Jan 2023 21:09:59 +0530 Subject: [PATCH 5/7] feat(#31): added multi env support in integration --- .../components/integrations/Integration.tsx | 164 ++++++++++-------- .../integrations/IntegrationSection.tsx | 7 +- frontend/pages/api/workspace/getAWorkspace.ts | 31 ++++ frontend/pages/integrations/[id].js | 20 ++- 4 files changed, 139 insertions(+), 83 deletions(-) create mode 100644 frontend/pages/api/workspace/getAWorkspace.ts diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx index 73d6ee0dc..4dba86841 100644 --- a/frontend/components/integrations/Integration.tsx +++ b/frontend/components/integrations/Integration.tsx @@ -34,12 +34,24 @@ interface IntegrationApp { siteId: string; } -const Integration = ({ - integration -}: { +type Props = { integration: Integration; -}) => { - const [integrationEnvironment, setIntegrationEnvironment] = useState(integration.environment); + environments: Array<{ name: string; slug: string }>; +}; + +const Integration = ({ + integration, + environments = [] +}:Props ) => { + // set initial environment. This find will only execute when component is mounting + const [integrationEnvironment, setIntegrationEnvironment] = useState< + Props['environments'][0] + >( + environments.find(({ slug }) => slug === integration.environment) || { + name: '', + slug: '', + } + ); const [fileState, setFileState] = useState([]); const router = useRouter(); const [apps, setApps] = useState([]); // integration app objects @@ -132,42 +144,47 @@ const Integration = ({ if (!integrationApp || apps.length === 0) return
return ( -
-
+
+
-

ENVIRONMENT

- { - setIntegrationEnvironment(environment); - }} +

+ ENVIRONMENT +

+ name) + : null + } + selected={integrationEnvironment.name} + onChange={(envName) => + setIntegrationEnvironment( + environments.find(({ name }) => envName === name) || { + name: 'unknown', + slug: 'unknown', + } + ) + } isFull={true} />
-
+
+ className='mx-4 text-gray-400 mt-8' + />
-
-

+

+

INTEGRATION

-
+
{integration.integration.charAt(0).toUpperCase() + integration.integration.slice(1)}
-
-
- APP -
+
+
APP
app.name) : null} selected={integrationApp} @@ -178,52 +195,55 @@ const Integration = ({
{renderIntegrationSpecificParams(integration)}
-
- {integration.isActive ? ( -
- -
In Sync
-
- ) : ( -
); diff --git a/frontend/components/integrations/IntegrationSection.tsx b/frontend/components/integrations/IntegrationSection.tsx index 52d5565ff..633a747af 100644 --- a/frontend/components/integrations/IntegrationSection.tsx +++ b/frontend/components/integrations/IntegrationSection.tsx @@ -5,7 +5,8 @@ import guidGenerator from "~/utilities/randomId"; import Integration from "./Integration"; interface Props { - integrations: any + integrations: any; + environments: Array<{ name: string; slug: string }>; } interface IntegrationType { @@ -19,7 +20,8 @@ interface IntegrationType { } const ProjectIntegrationSection = ({ - integrations + integrations, + environments = [], }: Props) => { return integrations.length > 0 ? (
@@ -33,6 +35,7 @@ const ProjectIntegrationSection = ({ ))}
diff --git a/frontend/pages/api/workspace/getAWorkspace.ts b/frontend/pages/api/workspace/getAWorkspace.ts new file mode 100644 index 000000000..71c5036eb --- /dev/null +++ b/frontend/pages/api/workspace/getAWorkspace.ts @@ -0,0 +1,31 @@ +import SecurityClient from '~/utilities/SecurityClient'; + +interface Workspace { + __v: number; + _id: string; + name: string; + organization: string; + environments: Array<{ name: string; slug: string }>; +} + +/** + * This route lets us get the workspaces of a certain user + * @returns + */ +const getAWorkspace = (workspaceID:string) => { + return SecurityClient.fetchCall(`/api/v1/workspace/${workspaceID}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }).then(async (res) => { + if (res?.status == 200) { + const data = (await res.json()) as unknown as { workspace: Workspace }; + return data.workspace; + } + + throw new Error('Failed to get workspace'); + }); +}; + +export default getAWorkspace; diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index 2a5460dfb..8a11bc3c4 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -24,6 +24,7 @@ import setBotActiveStatus from "../api/bot/setBotActiveStatus"; import getIntegrationOptions from "../api/integrations/GetIntegrationOptions"; import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations"; import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations"; +import getAWorkspace from "../api/workspace/getAWorkspace"; import getLatestFileKey from "../api/workspace/getLatestFileKey"; const { decryptAssymmetric, @@ -34,6 +35,7 @@ const crypto = require("crypto"); export default function Integrations() { const [cloudIntegrationOptions, setCloudIntegrationOptions] = useState([]); const [integrationAuths, setIntegrationAuths] = useState([]); + const [environments,setEnvironments] = useState([]) const [integrations, setIntegrations] = useState([]); const [bot, setBot] = useState(null); const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false); @@ -41,11 +43,15 @@ export default function Integrations() { const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null); const router = useRouter(); + const workspaceId = router.query.id; const { t } = useTranslation(); useEffect(async () => { try { + const workspace = await getAWorkspace(workspaceId); + setEnvironments(workspace.environments); + // get cloud integration options setCloudIntegrationOptions( await getIntegrationOptions() @@ -54,23 +60,19 @@ export default function Integrations() { // get project integration authorizations setIntegrationAuths( await getWorkspaceAuthorizations({ - workspaceId: router.query.id, + workspaceId }) ); // get project integrations setIntegrations( await getWorkspaceIntegrations({ - workspaceId: router.query.id, + workspaceId, }) ); // get project bot - setBot( - await getBot({ - workspaceId: router.query.id - } - )); + setBot(await getBot({ workspaceId })); } catch (err) { console.log(err); @@ -90,7 +92,7 @@ export default function Integrations() { if (bot) { // case: there is a bot - const key = await getLatestFileKey({ workspaceId: router.query.id }); + const key = await getLatestFileKey({ workspaceId }); const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); const WORKSPACE_KEY = decryptAssymmetric({ @@ -214,7 +216,7 @@ export default function Integrations() { handleBotActivate={handleBotActivate} handleIntegrationOption={handleIntegrationOption} /> */} - + {(cloudIntegrationOptions.length > 0 && bot) ? ( Date: Fri, 13 Jan 2023 16:05:50 -0800 Subject: [PATCH 6/7] Minor style changes to integrations --- frontend/components/integrations/CloudIntegration.tsx | 2 +- frontend/components/integrations/Integration.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/components/integrations/CloudIntegration.tsx b/frontend/components/integrations/CloudIntegration.tsx index 75a8019a5..dd1b13f47 100644 --- a/frontend/components/integrations/CloudIntegration.tsx +++ b/frontend/components/integrations/CloudIntegration.tsx @@ -74,7 +74,7 @@ const CloudIntegration = ({ integrationAuths .map((authorization) => authorization.integration) .includes(cloudIntegrationOption.name.toLowerCase()) && ( -
+
{ event.stopPropagation(); diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx index 4dba86841..bbc8861dc 100644 --- a/frontend/components/integrations/Integration.tsx +++ b/frontend/components/integrations/Integration.tsx @@ -101,7 +101,7 @@ const Integration = ({ case "vercel": return (
-
+
ENVIRONMENT
); From 71fbf519ce86af02b5321c0d646e88ff96a12639 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Fri, 13 Jan 2023 16:17:40 -0800 Subject: [PATCH 7/7] Minor style changes - capitalization --- frontend/components/basic/Listbox.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/components/basic/Listbox.tsx b/frontend/components/basic/Listbox.tsx index c67735d6e..a65135dfa 100644 --- a/frontend/components/basic/Listbox.tsx +++ b/frontend/components/basic/Listbox.tsx @@ -46,9 +46,9 @@ export default function ListBox({ >
{text} - + {' '} - {selected?.charAt(0).toUpperCase() + selected?.slice(1)} + {selected}
{data && (