diff --git a/backend/src/app.ts b/backend/src/app.ts index aa7ac9b28..82275b9c9 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'; @@ -103,6 +104,7 @@ 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); diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 95d0066ae..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'; @@ -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..1d1ffb6a6 --- /dev/null +++ b/backend/src/controllers/v2/environmentController.ts @@ -0,0 +1,204 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { + Secret, + ServiceToken, + Workspace, + Integration, + ServiceTokenData, +} from '../../models'; +import { SecretVersion } from '../../ee/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 } = req.params; + const { environmentName, environmentSlug } = req.body; + try { + 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); + 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 } = req.params; + const { 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.findById(workspaceId).exec(); + if (!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 } + ); + } 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 } = req.params; + const { environmentSlug } = req.body; + try { + // atomic update the env to avoid conflict + const workspace = await Workspace.findById(workspaceId).exec(); + if (!workspace) { + 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); + 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/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index 391c0faec..1af4aff2c 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/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..98e4934d9 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, @@ -13,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; @@ -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..924db18fc --- /dev/null +++ b/backend/src/routes/v2/environment.ts @@ -0,0 +1,57 @@ +import express, { Response, Request } 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 } from '../../variables'; + +router.post( + '/:workspaceId/environments', + requireAuth({ + acceptedAuthModes: ['jwt'], + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + }), + param('workspaceId').exists().trim(), + body('environmentSlug').exists().trim(), + body('environmentName').exists().trim(), + validateRequest, + environmentController.createWorkspaceEnvironment +); + +router.put( + '/:workspaceId/environments', + requireAuth({ + acceptedAuthModes: ['jwt'], + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + }), + param('workspaceId').exists().trim(), + body('environmentSlug').exists().trim(), + body('environmentName').exists().trim(), + body('oldEnvironmentSlug').exists().trim(), + validateRequest, + environmentController.renameWorkspaceEnvironment +); + +router.delete( + '/:workspaceId/environments', + requireAuth({ + acceptedAuthModes: ['jwt'], + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN], + }), + 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/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index 085d487cd..ccdce5321 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/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 }); } diff --git a/frontend/components/basic/Listbox.tsx b/frontend/components/basic/Listbox.tsx index 95e3f33c6..a65135dfa 100644 --- a/frontend/components/basic/Listbox.tsx +++ b/frontend/components/basic/Listbox.tsx @@ -46,7 +46,7 @@ export default function ListBox({ >
{text} - + {' '} {selected} @@ -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..0be330708 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 ? selectedServiceTokenEnv.slug : environments[0]?.name, 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/AddUpdateEnvironmentDialog.tsx b/frontend/components/basic/dialog/AddUpdateEnvironmentDialog.tsx new file mode 100644 index 000000000..9476dd8f6 --- /dev/null +++ b/frontend/components/basic/dialog/AddUpdateEnvironmentDialog.tsx @@ -0,0 +1,145 @@ +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; + 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 AddUpdateEnvironmentDialog = ({ + isOpen, + onClose, + onCreateSubmit, + onEditSubmit, + 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(); + const data = { + name: formInput.name.toLowerCase(), + slug: formInput.slug.toLowerCase(), + }; + if (isEditMode) { + onEditSubmit(data); + return; + } + onCreateSubmit(data); + }; + + 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..9a1b7a46a --- /dev/null +++ b/frontend/components/basic/dialog/DeleteActionModal.tsx @@ -0,0 +1,104 @@ +import { Fragment, useEffect, 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("") + + useEffect(() => { + setDeleteInputField(""); + }, [isOpen]); + + 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..56536b679 --- /dev/null +++ b/frontend/components/basic/table/EnvironmentsTable.tsx @@ -0,0 +1,167 @@ +import { faPencil, faPlus, faX } from '@fortawesome/free-solid-svg-icons'; + +import { usePopUp } from '../../../hooks/usePopUp'; +import Button from '../buttons/Button'; +import { AddUpdateEnvironmentDialog } from '../dialog/AddUpdateEnvironmentDialog'; +import DeleteActionModal from '../dialog/DeleteActionModal'; + +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 ( + <> +
+
+

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={onEnvDeleteCB} + /> + handlePopUpClose('createUpdateEnv')} + onCreateSubmit={onEnvCreateCB} + onEditSubmit={onEnvUpdateCB} + /> +
+ + ); +}; + +export default EnvironmentTable; 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/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 11edf7cb7..bbc8861dc 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 { @@ -36,13 +34,23 @@ interface IntegrationApp { siteId: string; } -const Integration = ({ - integration -}: { +type Props = { integration: Integration; -}) => { - const [integrationEnvironment, setIntegrationEnvironment] = useState( - reverseEnvMapping[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(); @@ -93,7 +101,7 @@ const Integration = ({ case "vercel": return (
-
+
ENVIRONMENT
); @@ -136,42 +145,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} @@ -182,52 +196,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/components/utilities/secrets/downloadDotEnv.ts b/frontend/components/utilities/secrets/downloadDotEnv.ts index 93ac774ba..8770d098b 100644 --- a/frontend/components/utilities/secrets/downloadDotEnv.ts +++ b/frontend/components/utilities/secrets/downloadDotEnv.ts @@ -32,7 +32,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 d2c4ddf54..86f6db0e0 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/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/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/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/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 949db52c0..9438cf115 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 { pos: number; @@ -104,11 +107,8 @@ export default function Dashboard() { 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(''); @@ -116,7 +116,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(); @@ -126,6 +126,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 = @@ -172,25 +182,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) { @@ -205,25 +227,26 @@ 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); - setIsLoading(false); + setTimeout( + () => setIsLoading(false) + , 700); } catch (error) { console.log('Error', error); setData(undefined); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [env]); + }, [selectedEnv]); const addRow = () => { setIsNew(false); @@ -237,37 +260,22 @@ export default function Dashboard() { value: '', valueOverride: undefined, comment: '', - } + }, ]); }; + 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"); - }; - - /** - * This function deleted the override of a certain secrer - * @param {string} id - id of a shared secret; the override with the same key should be deleted - */ - const deleteOverride = (id: string) => { - 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; - - // change the sidebar to this shared secret; and unhide it - // 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") + sortValuesHandler( + data!.filter((row: SecretDataProps) => !ids.includes(row.id)), + sortMethod == 'alhpabetical' ? '-alphabetical' : 'alphabetical' + ); }; const modifyValue = (value: string, pos: number) => { @@ -341,14 +349,14 @@ export default function Dashboard() { 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', }); } @@ -404,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 }); } @@ -434,36 +442,49 @@ 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" && {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 || 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 ? ( +
@@ -679,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 @@ -707,21 +768,23 @@ export default function Dashboard() { /> ))}
- {!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
); @@ -770,4 +832,4 @@ export default function Dashboard() { Dashboard.requireAuth = true; -export const getServerSideProps = getTranslatedServerSideProps(["dashboard"]); +export const getServerSideProps = getTranslatedServerSideProps(['dashboard']); 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) ? ( 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 new file mode 100644 index 000000000..5b9e3f5de --- /dev/null +++ b/frontend/pages/settings/project/[id].tsx @@ -0,0 +1,358 @@ +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 EnvironmentTable from '~/components/basic/table/EnvironmentsTable'; +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 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 [serviceTokens, setServiceTokens] = useState([]); + const [environments, setEnvironments] = useState>([]); + const [workspaceToBeDeletedName, setWorkspaceToBeDeletedName] = useState(''); + const [isAddOpen, setIsAddOpen] = useState(false); + const [isAddServiceTokenDialogOpen, setIsAddServiceTokenDialogOpen] = + useState(false); + const [projectIdCopied, setProjectIdCopied] = useState(false); + const workspaceId = router.query.id as string; + + const { t } = useTranslation(); + + /** + * 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: string) => { + setButtonReady(true); + setWorkspaceName(newName); + }; + + const submitChanges = (newWorkspaceName: string) => { + renameWorkspace(workspaceId, 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 === workspaceId + )[0].name == workspaceToBeDeletedName + ) { + await deleteWorkspace(workspaceId); + const userWorkspaces = await getWorkspaces(); + 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 ( +
+ + + {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/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/*"]