diff --git a/backend/src/app.ts b/backend/src/app.ts index 79561d00c..cf44804e9 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -50,6 +50,7 @@ import { serviceTokenData as v2ServiceTokenDataRouter, apiKeyData as v2APIKeyDataRouter, environment as v2EnvironmentRouter, + tags as v2TagsRouter, } from './routes/v2'; import { healthCheck } from './routes/status'; @@ -112,6 +113,7 @@ app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); app.use('/api/v2/users', v2UsersRouter); app.use('/api/v2/organizations', v2OrganizationsRouter); app.use('/api/v2/workspace', v2EnvironmentRouter); +app.use('/api/v2/workspace', v2TagsRouter); app.use('/api/v2/workspace', v2WorkspaceRouter); app.use('/api/v2/secret', v2SecretRouter); // deprecated app.use('/api/v2/secrets', v2SecretsRouter); diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index 936f5e281..3183ac60f 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -6,6 +6,7 @@ import * as apiKeyDataController from './apiKeyDataController'; import * as secretController from './secretController'; import * as secretsController from './secretsController'; import * as environmentController from './environmentController'; +import * as tagController from './tagController'; export { usersController, @@ -15,5 +16,6 @@ export { apiKeyDataController, secretController, secretsController, - environmentController + environmentController, + tagController } diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 5c6f5898e..5ac86e903 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -86,17 +86,28 @@ export const createSecrets = async (req: Request, res: Response) => { throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" }) } - let toAdd; + let listOfSecretsToCreate; if (Array.isArray(req.body.secrets)) { // case: create multiple secrets - toAdd = req.body.secrets; + listOfSecretsToCreate = req.body.secrets; } else if (typeof req.body.secrets === 'object') { // case: create 1 secret - toAdd = [req.body.secrets]; + listOfSecretsToCreate = [req.body.secrets]; } - const newSecrets = await Secret.insertMany( - toAdd.map(({ + type secretsToCreateType = { + type: string; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + tags: string[] + } + + const newlyCreatedSecrets = await Secret.insertMany( + listOfSecretsToCreate.map(({ type, secretKeyCiphertext, secretKeyIV, @@ -104,15 +115,8 @@ export const createSecrets = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, - }: { - type: string; - secretKeyCiphertext: string; - secretKeyIV: string; - secretKeyTag: string; - secretValueCiphertext: string; - secretValueIV: string; - secretValueTag: string; - }) => { + tags + }: secretsToCreateType) => { return ({ version: 1, workspace: new Types.ObjectId(workspaceId), @@ -124,7 +128,8 @@ export const createSecrets = async (req: Request, res: Response) => { secretKeyTag, secretValueCiphertext, secretValueIV, - secretValueTag + secretValueTag, + tags }); }) ); @@ -140,7 +145,7 @@ export const createSecrets = async (req: Request, res: Response) => { // (EE) add secret versions for new secrets await EESecretService.addSecretVersions({ - secretVersions: newSecrets.map(({ + secretVersions: newlyCreatedSecrets.map(({ _id, version, workspace, @@ -154,7 +159,8 @@ export const createSecrets = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash + secretValueHash, + tags }) => ({ _id: new Types.ObjectId(), secret: _id, @@ -171,7 +177,8 @@ export const createSecrets = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash + secretValueHash, + tags })) }); @@ -179,7 +186,7 @@ export const createSecrets = async (req: Request, res: Response) => { name: ACTION_ADD_SECRETS, userId: req.user._id, workspaceId: new Types.ObjectId(workspaceId), - secretIds: newSecrets.map((n) => n._id) + secretIds: newlyCreatedSecrets.map((n) => n._id) }); // (EE) create (audit) log @@ -201,7 +208,7 @@ export const createSecrets = async (req: Request, res: Response) => { event: 'secrets added', distinctId: req.user.email, properties: { - numberOfSecrets: toAdd.length, + numberOfSecrets: listOfSecretsToCreate.length, environment, workspaceId, channel: channel, @@ -211,7 +218,7 @@ export const createSecrets = async (req: Request, res: Response) => { } return res.status(200).send({ - secrets: newSecrets + secrets: newlyCreatedSecrets }); } @@ -294,7 +301,7 @@ export const getSecrets = async (req: Request, res: Response) => { ], type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } } - ).then()) + ).populate("tags").then()) if (err) throw ValidationError({ message: 'Failed to get secrets', stack: err.stack }); @@ -398,6 +405,7 @@ export const updateSecrets = async (req: Request, res: Response) => { secretCommentCiphertext: string; secretCommentIV: string; secretCommentTag: string; + tags: string[] } const updateOperationsToPerform = req.body.secrets.map((secret: PatchSecret) => { @@ -410,7 +418,8 @@ export const updateSecrets = async (req: Request, res: Response) => { secretValueTag, secretCommentCiphertext, secretCommentIV, - secretCommentTag + secretCommentTag, + tags } = secret; return ({ @@ -426,6 +435,7 @@ export const updateSecrets = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, + tags, ...(( secretCommentCiphertext && secretCommentIV && @@ -460,6 +470,7 @@ export const updateSecrets = async (req: Request, res: Response) => { secretCommentCiphertext, secretCommentIV, secretCommentTag, + tags } = secretModificationsBySecretId[secret._id.toString()] return ({ @@ -477,6 +488,7 @@ export const updateSecrets = async (req: Request, res: Response) => { secretCommentCiphertext: secretCommentCiphertext ? secretCommentCiphertext : secret.secretCommentCiphertext, secretCommentIV: secretCommentIV ? secretCommentIV : secret.secretCommentIV, secretCommentTag: secretCommentTag ? secretCommentTag : secret.secretCommentTag, + tags: tags ? tags : secret.tags }); }) } diff --git a/backend/src/controllers/v2/tagController.ts b/backend/src/controllers/v2/tagController.ts new file mode 100644 index 000000000..250ee08a5 --- /dev/null +++ b/backend/src/controllers/v2/tagController.ts @@ -0,0 +1,66 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; +import { + Membership, +} from '../../models'; +import Tag, { ITag } from '../../models/tag'; +import { Builder } from "builder-pattern" +import to from 'await-to-js'; +import { BadRequestError, UnauthorizedRequestError } from '../../utils/errors'; +import { MongoError } from 'mongodb'; +import { userHasWorkspaceAccess } from '../../ee/helpers/checkMembershipPermissions'; + +export const createWorkspaceTag = async (req: Request, res: Response) => { + const { workspaceId } = req.params + const { name, slug } = req.body + const sanitizedTagToCreate = Builder() + .name(name) + .workspace(new Types.ObjectId(workspaceId)) + .slug(slug) + .user(new Types.ObjectId(req.user._id)) + .build(); + + const [err, createdTag] = await to(Tag.create(sanitizedTagToCreate)) + + if (err) { + if ((err as MongoError).code === 11000) { + throw BadRequestError({ message: "Tags must be unique in a workspace" }) + } + + throw err + } + + res.json(createdTag) +} + +export const deleteWorkspaceTag = async (req: Request, res: Response) => { + const { tagId } = req.params + + const tagFromDB = await Tag.findById(tagId) + if (!tagFromDB) { + throw BadRequestError() + } + + // can only delete if the request user is one that belongs to the same workspace as the tag + const membership = await Membership.findOne({ + user: req.user, + workspace: tagFromDB.workspace + }); + + if (!membership) { + UnauthorizedRequestError({ message: 'Failed to validate membership' }); + } + + await Tag.findByIdAndDelete(tagId) + + res.sendStatus(200) +} + +export const getWorkspaceTags = async (req: Request, res: Response) => { + const { workspaceId } = req.params + const workspaceTags = await Tag.find({ workspace: workspaceId }) + return res.json({ + workspaceTags + }) +} \ No newline at end of file diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index 1af4aff2c..efa042765 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -21,6 +21,7 @@ export interface ISecretVersion { secretValueIV: string; secretValueTag: string; secretValueHash: string; + tags?: string[]; } const secretVersionSchema = new Schema( @@ -88,7 +89,12 @@ const secretVersionSchema = new Schema( }, secretValueHash: { type: String - } + }, + tags: { + ref: 'Tag', + type: [Schema.Types.ObjectId], + default: [] + }, }, { timestamps: true diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index 6887c8b0f..4ac6c768d 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -23,6 +23,7 @@ export interface ISecret { secretCommentIV?: string; secretCommentTag?: string; secretCommentHash?: string; + tags?: string[]; } const secretSchema = new Schema( @@ -47,6 +48,11 @@ const secretSchema = new Schema( type: Schema.Types.ObjectId, ref: 'User' }, + tags: { + ref: 'Tag', + type: [Schema.Types.ObjectId], + default: [] + }, environment: { type: String, required: true diff --git a/backend/src/models/tag.ts b/backend/src/models/tag.ts new file mode 100644 index 000000000..6b02c8b1b --- /dev/null +++ b/backend/src/models/tag.ts @@ -0,0 +1,49 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface ITag { + _id: Types.ObjectId; + name: string; + slug: string; + user: Types.ObjectId; + workspace: Types.ObjectId; +} + +const tagSchema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + slug: { + type: String, + required: true, + trim: true, + lowercase: true, + validate: [ + function (value: any) { + return value.indexOf(' ') === -1; + }, + 'slug cannot contain spaces' + ] + }, + user: { + type: Schema.Types.ObjectId, + ref: 'User' + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace' + }, + }, + { + timestamps: true + } +); + +tagSchema.index({ slug: 1, workspace: 1 }, { unique: true }) +tagSchema.index({ workspace: 1 }) + +const Tag = model('Tag', tagSchema); + +export default Tag; diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index 6f698e316..dfc9ee617 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -6,6 +6,7 @@ import secrets from './secrets'; import serviceTokenData from './serviceTokenData'; import apiKeyData from './apiKeyData'; import environment from "./environment" +import tags from "./tags" export { users, @@ -15,5 +16,6 @@ export { secrets, serviceTokenData, apiKeyData, - environment + environment, + tags } \ No newline at end of file diff --git a/backend/src/routes/v2/tags.ts b/backend/src/routes/v2/tags.ts new file mode 100644 index 000000000..d78e1e0f1 --- /dev/null +++ b/backend/src/routes/v2/tags.ts @@ -0,0 +1,50 @@ +import express, { Response, Request } from 'express'; +const router = express.Router(); +import { body, param } from 'express-validator'; +import { tagController } from '../../controllers/v2'; +import { + requireAuth, + requireWorkspaceAuth, + validateRequest, +} from '../../middleware'; +import { ADMIN, MEMBER } from '../../variables'; + +router.get( + '/:workspaceId/tags', + requireAuth({ + acceptedAuthModes: ['jwt'], + }), + requireWorkspaceAuth({ + acceptedRoles: [MEMBER, ADMIN], + }), + param('workspaceId').exists().trim(), + validateRequest, + tagController.getWorkspaceTags +); + +router.delete( + '/tags/:tagId', + requireAuth({ + acceptedAuthModes: ['jwt'], + }), + param('tagId').exists().trim(), + validateRequest, + tagController.deleteWorkspaceTag +); + +router.post( + '/:workspaceId/tags', + requireAuth({ + acceptedAuthModes: ['jwt'], + }), + requireWorkspaceAuth({ + acceptedRoles: [MEMBER, ADMIN], + }), + param('workspaceId').exists().trim(), + body('name').exists().trim(), + body('slug').exists().trim(), + validateRequest, + tagController.createWorkspaceTag +); + +export default router; diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index 089c407b0..c2963f6bd 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -96,6 +96,7 @@ func init() { exportCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") exportCmd.Flags().StringP("format", "f", "dotenv", "Set the format of the output file (dotenv, json, csv)") exportCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") + exportCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") } // Format according to the format flag diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index 6a1dc2313..c37bdda08 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -101,6 +101,9 @@ var loginCmd = &cobra.Command{ util.HandleError(err, "Unable to write write to Infisical Config file. Please try again") } + // clear backed up secrets from prev account + util.DeleteBackupSecrets() + color.Green("Nice! You are logged in as: %v", email) }, diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 5c40e23f1..67b89b27f 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -34,9 +34,9 @@ type WorkspaceConfigFile struct { } type SymmetricEncryptionResult struct { - CipherText []byte - Nonce []byte - AuthTag []byte + CipherText []byte `json:"CipherText"` + Nonce []byte `json:"Nonce"` + AuthTag []byte `json:"AuthTag"` } type GetAllSecretsParameters struct { diff --git a/cli/packages/util/common.go b/cli/packages/util/common.go index 2d420ac6f..13e76046d 100644 --- a/cli/packages/util/common.go +++ b/cli/packages/util/common.go @@ -2,6 +2,7 @@ package util import ( "fmt" + "net/http" "os" ) @@ -19,3 +20,11 @@ func WriteToFile(fileName string, dataToWrite []byte, filePerm os.FileMode) erro return nil } + +func CheckIsConnectedToInternet() (ok bool) { + _, err := http.Get("http://clients3.google.com/generate_204") + if err != nil { + return false + } + return true +} diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index c20bd94e7..86f4a5e05 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -2,6 +2,8 @@ package util import ( "encoding/base64" + "encoding/json" + "errors" "fmt" "os" "regexp" @@ -105,10 +107,18 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters) ([]models infisicalToken = params.InfisicalToken } + isConnected := CheckIsConnectedToInternet() + var secretsToReturn []models.SingleEnvironmentVariable + var errorToReturn error + if infisicalToken == "" { - RequireLocalWorkspaceFile() - RequireLogin() - log.Debug("Trying to fetch secrets using logged in details") + if isConnected { + log.Debug("GetAllEnvironmentVariables: Connected to internet, checking logged in creds") + RequireLocalWorkspaceFile() + RequireLogin() + } + + log.Debug("GetAllEnvironmentVariables: Trying to fetch secrets using logged in details") loggedInUserDetails, err := GetCurrentLoggedInUserDetails() if err != nil { @@ -120,13 +130,30 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters) ([]models return nil, err } - secrets, err := GetPlainTextSecretsViaJTW(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceFile.WorkspaceId, params.Environment) - return secrets, err + secretsToReturn, errorToReturn = GetPlainTextSecretsViaJTW(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceFile.WorkspaceId, params.Environment) + log.Debugf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", errorToReturn) + + backupSecretsEncryptionKey := []byte(loggedInUserDetails.UserCredentials.PrivateKey)[0:32] + if errorToReturn == nil { + WriteBackupSecrets(workspaceFile.WorkspaceId, params.Environment, backupSecretsEncryptionKey, secretsToReturn) + } + + // only attempt to serve cached secrets if no internet connection and if at least one secret cached + if !isConnected { + backedSecrets, err := ReadBackupSecrets(workspaceFile.WorkspaceId, params.Environment, backupSecretsEncryptionKey) + if len(backedSecrets) > 0 { + PrintWarning("Unable to fetch latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug") + secretsToReturn = backedSecrets + errorToReturn = err + } + } } else { log.Debug("Trying to fetch secrets using service token") - return GetPlainTextSecretsViaServiceToken(infisicalToken) + secretsToReturn, errorToReturn = GetPlainTextSecretsViaServiceToken(infisicalToken) } + + return secretsToReturn, errorToReturn } func getExpandedEnvVariable(secrets []models.SingleEnvironmentVariable, variableWeAreLookingFor string, hashMapOfCompleteVariables map[string]string, hashMapOfSelfRefs map[string]string) string { @@ -300,3 +327,100 @@ func GetPlainTextSecrets(key []byte, encryptedSecrets api.GetEncryptedSecretsV2R return plainTextSecrets, nil } + +func WriteBackupSecrets(workspace string, environment string, encryptionKey []byte, secrets []models.SingleEnvironmentVariable) error { + fileName := fmt.Sprintf("secrets_%s_%s", workspace, environment) + secrets_backup_folder_name := "secrets-backup" + + _, fullConfigFileDirPath, err := GetFullConfigFilePath() + if err != nil { + return fmt.Errorf("WriteBackupSecrets: unable to get full config folder path [err=%s]", err) + } + + // create secrets backup directory + fullPathToSecretsBackupFolder := fmt.Sprintf("%s/%s", fullConfigFileDirPath, secrets_backup_folder_name) + if _, err := os.Stat(fullPathToSecretsBackupFolder); errors.Is(err, os.ErrNotExist) { + err := os.Mkdir(fullPathToSecretsBackupFolder, os.ModePerm) + if err != nil { + return err + } + } + + var encryptedSecrets []models.SymmetricEncryptionResult + for _, secret := range secrets { + marshaledSecrets, _ := json.Marshal(secret) + result, err := crypto.EncryptSymmetric(marshaledSecrets, encryptionKey) + if err != nil { + return err + } + + encryptedSecrets = append(encryptedSecrets, result) + } + + listOfSecretsMarshalled, _ := json.Marshal(encryptedSecrets) + err = os.WriteFile(fmt.Sprintf("%s/%s", fullPathToSecretsBackupFolder, fileName), listOfSecretsMarshalled, os.ModePerm) + if err != nil { + return fmt.Errorf("WriteBackupSecrets: Unable to write backup secrets to file [err=%s]", err) + } + + return nil +} + +func ReadBackupSecrets(workspace string, environment string, encryptionKey []byte) ([]models.SingleEnvironmentVariable, error) { + fileName := fmt.Sprintf("secrets_%s_%s", workspace, environment) + secrets_backup_folder_name := "secrets-backup" + + _, fullConfigFileDirPath, err := GetFullConfigFilePath() + if err != nil { + return nil, fmt.Errorf("ReadBackupSecrets: unable to write config file because an error occurred when getting config file path [err=%s]", err) + } + + fullPathToSecretsBackupFolder := fmt.Sprintf("%s/%s", fullConfigFileDirPath, secrets_backup_folder_name) + if _, err := os.Stat(fullPathToSecretsBackupFolder); errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + encryptedBackupSecretsFilePath := fmt.Sprintf("%s/%s", fullPathToSecretsBackupFolder, fileName) + + encryptedBackupSecretsAsBytes, err := os.ReadFile(encryptedBackupSecretsFilePath) + if err != nil { + return nil, err + } + + var listOfEncryptedBackupSecrets []models.SymmetricEncryptionResult + + _ = json.Unmarshal(encryptedBackupSecretsAsBytes, &listOfEncryptedBackupSecrets) + + var plainTextSecrets []models.SingleEnvironmentVariable + for _, encryptedSecret := range listOfEncryptedBackupSecrets { + result, err := crypto.DecryptSymmetric(encryptionKey, encryptedSecret.CipherText, encryptedSecret.AuthTag, encryptedSecret.Nonce) + if err != nil { + return nil, err + } + + var plainTextSecret models.SingleEnvironmentVariable + + err = json.Unmarshal(result, &plainTextSecret) + if err != nil { + return nil, err + } + + plainTextSecrets = append(plainTextSecrets, plainTextSecret) + } + + return plainTextSecrets, nil + +} + +func DeleteBackupSecrets() error { + secrets_backup_folder_name := "secrets-backup" + + _, fullConfigFileDirPath, err := GetFullConfigFilePath() + if err != nil { + return fmt.Errorf("ReadBackupSecrets: unable to write config file because an error occurred when getting config file path [err=%s]", err) + } + + fullPathToSecretsBackupFolder := fmt.Sprintf("%s/%s", fullConfigFileDirPath, secrets_backup_folder_name) + + return os.RemoveAll(fullPathToSecretsBackupFolder) +} diff --git a/docs/cli/commands/init.mdx b/docs/cli/commands/init.mdx index d477541b8..ef33e1d04 100644 --- a/docs/cli/commands/init.mdx +++ b/docs/cli/commands/init.mdx @@ -9,6 +9,8 @@ infisical init ## Description -Link a local project to the platform +Link a local project to your Infisical project. Once connected, you can then access the secrets locally from the connected Infisical project. -The command creates a `infisical.json` file containing your Project ID. + +This command creates a `infisical.json` file containing your Project ID. + diff --git a/docs/cli/commands/run.mdx b/docs/cli/commands/run.mdx index a3f02cf44..fcc9be549 100644 --- a/docs/cli/commands/run.mdx +++ b/docs/cli/commands/run.mdx @@ -25,13 +25,58 @@ description: "The command that injects your secrets into local environment" ## Description -Inject environment variables from the platform into an application process. +Inject secrets from Infisical into your application process. -## Options -| Option | Description | Default value | -| -------------- | ----------------------------------------------------------------------------------------------------------- | ------------- | -| `--env` | Used to set the environment that secrets are pulled from. Accepted values: `dev`, `staging`, `test`, `prod` | `dev` | -| `--expand` | Parse shell parameter expansions in your secrets (e.g., `${DOMAIN}`) | `true` | -| `--command` | Pass secrets into chained commands (e.g., `"first-command && second-command; more-commands..."`) | None | -| `--secret-overriding`| Prioritizes personal secrets with the same name over shared secrets | `true` | +## Subcommands & flags + + + Use this command to inject secrets into your applications process + + ```bash + $ infisical run -- + + # Example + $ infisical run -- npm run dev + ``` + + ### flags + + Pass secrets into multiple commands at once + + ```bash + # Example + infisical run --command="npm run build && npm run dev; more-commands..." + ``` + + + + If you are using a [service token](../../getting-started/dashboard/token) to authenticate, you can pass the token as a flag + + ```bash + # Example + infisical run --token="st.63e03c4a97cb4a747186c71e.ed5b46a34c078a8f94e8228f4ab0ff97.4f7f38034811995997d72badf44b42ec" -- npm run start + ``` + + You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the run command. This will have the same effect as setting the token with `--token` flag + + + + Turn on or off the shell parameter expansion in your secrets. If you have used shell parameters in your secret(s), activating this feature will populate them before injecting them into your application process. + + Default value: `true` + + + + This is used to specify the environment from which secrets should be retrieved. The accepted values are the environment slugs defined for your project, such as `dev`, `staging`, `test`, and `prod`. + + Default value: `dev` + + + + Prioritizes personal secrets with the same name over shared secrets + + Default value: `true` + + + diff --git a/docs/cli/commands/secrets.mdx b/docs/cli/commands/secrets.mdx index 0556e1866..0e4190291 100644 --- a/docs/cli/commands/secrets.mdx +++ b/docs/cli/commands/secrets.mdx @@ -14,17 +14,8 @@ This command enables you to perform CRUD (create, read, update, delete) operatio Use this command to print out all of the secrets in your project - ``` + ```bash $ infisical secrets - - ## Example - $ infisical secrets - ┌─────────────┬──────────────┬─────────────┐ - │ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ - ├─────────────┼──────────────┼─────────────┤ - │ DOMAIN │ example.com │ shared │ - │ HASH │ jebhfbwe │ shared │ - └─────────────┴──────────────┴─────────────┘ ``` ### flags @@ -45,16 +36,11 @@ This command enables you to perform CRUD (create, read, update, delete) operatio This command allows you selectively print the requested secrets by name - ``` + ```bash $ infisical secrets get ... # Example $ infisical secrets get DOMAIN - ┌─────────────┬──────────────┬─────────────┐ - │ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ - ├─────────────┼──────────────┼─────────────┤ - │ DOMAIN │ example.com │ shared │ - └─────────────┴──────────────┴─────────────┘ ``` @@ -70,18 +56,11 @@ This command enables you to perform CRUD (create, read, update, delete) operatio This command allows you to set or update secrets in your environment. If the secret key provided already exists, its value will be updated with the new value. If the secret key does not exist, a new secret will be created using both the key and value provided. -``` +```bash $ infisical secrets set ... ## Example $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jebhfbwe -┌────────────────┬───────────────┬────────────────────────┐ -│ SECRET NAME │ SECRET VALUE │ STATUS │ -├────────────────┼───────────────┼────────────────────────┤ -│ STRIPE_API_KEY │ sjdgwkeudyjwe │ SECRET VALUE UNCHANGED │ -│ DOMAIN │ example.com │ SECRET VALUE MODIFIED │ -│ HASH │ jebhfbwe │ SECRET CREATED │ -└────────────────┴───────────────┴────────────────────────┘ ``` ### Flags @@ -95,12 +74,11 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb This command allows you to delete secrets by their name(s). - ``` + ```bash $ infisical secrets delete ... ## Example $ infisical secrets delete STRIPE_API_KEY DOMAIN HASH - secret name(s) [STRIPE_API_KEY, DOMAIN, HASH] have been deleted from your project ``` ### Flags diff --git a/docs/cli/faq.mdx b/docs/cli/faq.mdx index 6a98d8dc3..7eb73258e 100644 --- a/docs/cli/faq.mdx +++ b/docs/cli/faq.mdx @@ -13,4 +13,9 @@ If none of the available stores work for you, you can try using the `file` store If you are still experiencing trouble, please seek support. [Learn more about vault command](./commands/vault) + + + +Yes. If you have previously retrieved secrets for a specific project and environment (such as dev, staging, or prod), the `run`/`secret` command will utilize the saved secrets, even when offline, on subsequent fetch attempts. + \ No newline at end of file diff --git a/docs/integrations/cicd/gitlab.mdx b/docs/integrations/cicd/gitlab.mdx new file mode 100644 index 000000000..b84d90e07 --- /dev/null +++ b/docs/integrations/cicd/gitlab.mdx @@ -0,0 +1,34 @@ +--- +title: "Gitlab Pipeline" +--- + +To integrate Infisical secrets into your Gitlab CI/CD setup, three steps are required. + +## Generate service token +To expose Infisical secrets in Gitlab CI/CD, you must generate a service token for the specific project and environment in Infisical. For instructions on how to generate a service token, refer to [this page](../../getting-started/dashboard/token) + +## Set Infisical service token in Gitlab +To provide Infisical CLI with the service token generated in the previous step, go to **Settings > CI/CD > Variables** in Gitlab and create a new **INFISICAL_TOKEN** variable. Enter the generated service token as its value. + +## Configure Infisical in your pipeline +Edit your .gitlab-ci.yml to include the installation of the Infisical CLI. This will allow you to use the CLI for fetching and injecting secrets into any script or command within your Gitlab CI/CD process. + +#### Example +```yaml +image: ubuntu + +stages: + - build + - test + - deploy + +build-job: + stage: build + script: + - apt update && apt install -y curl + - curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash + - apt-get update && apt-get install -y infisical + - infisical run -- npm run build + +... +``` \ No newline at end of file diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 36423e787..eb99ca79d 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -37,7 +37,7 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi | GCP | Cloud | Coming soon | | Azure | Cloud | Coming soon | | DigitalOcean | Cloud | Coming soon | -| GitLab | CI/CD | Coming soon | +| [GitLab Pipeline](/integrations/cicd/gitlab) | CI/CD | Available | | [CircleCI](/integrations/cicd/circleci) | CI/CD | Coming soon | | TravisCI | CI/CD | Coming soon | | GitHub Actions | CI/CD | Coming soon | diff --git a/docs/mint.json b/docs/mint.json index 441769152..fd334b5ab 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -227,6 +227,7 @@ "group": "CI/CD", "pages": [ "integrations/cicd/githubactions", + "integrations/cicd/gitlab", "integrations/cicd/circleci" ] }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 283c69b53..7f52e04ae 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@radix-ui/react-checkbox": "^1.0.1", "@radix-ui/react-dialog": "^1.0.2", "@radix-ui/react-dropdown-menu": "^2.0.2", + "@radix-ui/react-hover-card": "^1.0.3", "@radix-ui/react-label": "^2.0.0", "@radix-ui/react-popover": "^1.0.3", "@radix-ui/react-progress": "^1.0.1", @@ -3858,6 +3859,27 @@ "react-dom": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.0.3.tgz", + "integrity": "sha512-rr2+DxPlMhR57IPcNvZ85X8chytdfj7kyVToyR5Ge0r4IJEFiyPs0Cs8/K8oe5zt+yo0F8f29vtC8tNNK+ZIkA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-dismissable-layer": "1.0.2", + "@radix-ui/react-popper": "1.1.0", + "@radix-ui/react-portal": "1.0.1", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, "node_modules/@radix-ui/react-id": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", @@ -25050,6 +25072,23 @@ "@radix-ui/react-use-callback-ref": "1.0.0" } }, + "@radix-ui/react-hover-card": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.0.3.tgz", + "integrity": "sha512-rr2+DxPlMhR57IPcNvZ85X8chytdfj7kyVToyR5Ge0r4IJEFiyPs0Cs8/K8oe5zt+yo0F8f29vtC8tNNK+ZIkA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-dismissable-layer": "1.0.2", + "@radix-ui/react-popper": "1.1.0", + "@radix-ui/react-portal": "1.0.1", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.0" + } + }, "@radix-ui/react-id": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 322e60f31..db8153a0a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@radix-ui/react-checkbox": "^1.0.1", "@radix-ui/react-dialog": "^1.0.2", "@radix-ui/react-dropdown-menu": "^2.0.2", + "@radix-ui/react-hover-card": "^1.0.3", "@radix-ui/react-label": "^2.0.0", "@radix-ui/react-popover": "^1.0.3", "@radix-ui/react-progress": "^1.0.1", diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index e02c4f474..3d840c663 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -14,7 +14,7 @@ "save-changes": "Save Changes", "saved": "Saved", "drop-zone": "Drag and drop a .env or .yml file here.", - "drop-zone-keys": "Drag and drop a .env or .yml file here to add more keys.", + "drop-zone-keys": "Drag and drop a .env or .yml file here to add more secrets.", "role": "Role", "role_admin": "admin", "display-name": "Display Name", diff --git a/frontend/src/components/basic/Listbox.tsx b/frontend/src/components/basic/Listbox.tsx index a2f08d9b9..19bdd1dd5 100644 --- a/frontend/src/components/basic/Listbox.tsx +++ b/frontend/src/components/basic/Listbox.tsx @@ -58,7 +58,7 @@ const ListBox = ({ leaveFrom="opacity-100" leaveTo="opacity-0" > - + {data.map((person, personIdx) => ( { return (
- {}}> + {}}>
>; + selectedEnv: WorkspaceEnv; + workspaceEnvs: WorkspaceEnv[]; + workspaceId: string; + currentSecret: { + key: string; + value: string; + }; +} + +const CompareSecretsModal = ({ + compareModal, + setCompareModal, + selectedEnv, + workspaceEnvs, + workspaceId, + currentSecret +}: CompareSecretsModalProps) => { + const [secrets, setSecrets] = useState([]); + + const getEnvSecrets = async () => { + const workspaceEnvironments = workspaceEnvs?.filter((env) => env !== selectedEnv); + const newSecrets = await Promise.all( + workspaceEnvironments.map(async (env) => { + // #TODO: optimize this query somehow... + const allSecrets = await getSecretsForProject({ env: env.slug, workspaceId }); + const secret = + allSecrets.find((item) => item.key === currentSecret.key)?.value ?? 'Not found'; + return { label: env.name, secret }; + }) + ); + setSecrets([{ label: selectedEnv.name, secret: currentSecret.value }, ...newSecrets]); + }; + + useEffect(() => { + if (compareModal) { + (async () => { + await getEnvSecrets(); + })(); + } + }, [compareModal]); + + return ( + + e.preventDefault()}> +
+ {secrets.length === 0 ? ( +
+ infisical loading indicator +
+ ) : ( + secrets.map((item) => ( +
+

{item.label}

+ +
+ )) + )} +
+
+
+ ); +}; +export default CompareSecretsModal; diff --git a/frontend/src/components/dashboard/DashboardInputField.tsx b/frontend/src/components/dashboard/DashboardInputField.tsx index 0e292d65d..c3f7e9a65 100644 --- a/frontend/src/components/dashboard/DashboardInputField.tsx +++ b/frontend/src/components/dashboard/DashboardInputField.tsx @@ -1,8 +1,10 @@ import { memo, SyntheticEvent, useRef } from 'react'; -import { faCircle } from '@fortawesome/free-solid-svg-icons'; +import { faCircle, faExclamationCircle, faEye, faLayerGroup } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import guidGenerator from '../utilities/randomId'; +import { Button } from '../v2'; +import { HoverObject } from '../v2/HoverCard'; const REGEX = /([$]{.*?})/g; @@ -10,10 +12,12 @@ interface DashboardInputFieldProps { position: number; onChangeHandler: (value: string, position: number) => void; value: string | undefined; - type: 'varName' | 'value'; + type: 'varName' | 'value' | 'comment'; blurred?: boolean; isDuplicate?: boolean; - override?: boolean; + overrideEnabled?: boolean; + modifyValueOverride?: (value: string | undefined, position: number) => void; + isSideBarOpen?: boolean; } /** @@ -26,6 +30,8 @@ interface DashboardInputFieldProps { * @param {boolean} obj.blurred - whether the input field should be blurred (behind the gray dots) or not; this can be turned on/off in the dashboard * @param {boolean} obj.isDuplicate - if the key name is duplicated * @param {boolean} obj.override - whether a secret/row should be displalyed as overriden + * + * * @returns */ @@ -36,7 +42,9 @@ const DashboardInputField = ({ value, blurred, isDuplicate, - override + overrideEnabled, + modifyValueOverride, + isSideBarOpen }: DashboardInputFieldProps) => { const ref = useRef(null); const syncScroll = (e: SyntheticEvent) => { @@ -51,41 +59,97 @@ const DashboardInputField = ({ const error = startsWithNumber || isDuplicate; return ( -
+
onChangeHandler(e.target.value.toUpperCase(), position)} type={type} value={value} - className={`z-10 peer font-mono ph-no-capture bg-bunker-800 rounded-md caret-white text-gray-400 text-md px-2 py-1.5 w-full min-w-16 outline-none focus:ring-2 ${ - error ? 'focus:ring-red/50' : 'focus:ring-primary/50' + className={`z-10 peer font-mono ph-no-capture bg-transparent h-full caret-bunker-200 text-sm px-2 w-full min-w-16 outline-none ${ + error ? 'text-red-600 focus:text-red-500' : 'text-bunker-300 focus:text-bunker-100' } duration-200`} spellCheck="false" />
{startsWithNumber && ( -

- Should not start with a number -

+
+ +
)} - {isDuplicate && !startsWithNumber && ( -

- Secret names should be unique -

+ {isDuplicate && value !== '' && !startsWithNumber && ( +
+ +
)} + {!error &&
+ +
} +
+ ); + } + if (type === 'comment') { + const startsWithNumber = !Number.isNaN(Number(value?.charAt(0))) && value !== ''; + const error = startsWithNumber || isDuplicate; + + return ( +
+
+ onChangeHandler(e.target.value, position)} + type={type} + value={value} + className='z-10 peer font-mono ph-no-capture bg-transparent py-2.5 caret-bunker-200 text-sm px-2 w-full min-w-16 outline-none text-bunker-300 focus:text-bunker-100 placeholder:text-bunker-400 placeholder:focus:text-transparent placeholder duration-200' + spellCheck="false" + placeholder='–' + /> +
); } if (type === 'value') { return (
-
- {override === true && ( -
+
+ {overrideEnabled === true && ( +
Override enabled
)} @@ -95,19 +159,19 @@ const DashboardInputField = ({ onScroll={syncScroll} className={`${ blurred - ? 'text-transparent group-hover:text-transparent focus:text-transparent active:text-transparent' + ? 'text-transparent focus:text-transparent active:text-transparent' : '' - } z-10 peer font-mono ph-no-capture bg-transparent rounded-md caret-white text-transparent text-md px-2 py-1.5 w-full min-w-16 outline-none focus:ring-2 focus:ring-primary/50 duration-200 no-scrollbar no-scrollbar::-webkit-scrollbar`} + } z-10 peer font-mono ph-no-capture bg-transparent caret-white text-transparent text-sm px-2 py-2 w-full min-w-16 outline-none duration-200 no-scrollbar no-scrollbar::-webkit-scrollbar`} spellCheck="false" />
{value?.split(REGEX).map((word, id) => { if (word.match(REGEX) !== null) { @@ -137,7 +201,9 @@ const DashboardInputField = ({ })}
{blurred && ( -
+
{value?.split('').map(() => ( ))} + {value?.split('').length === 0 && EMPTY}
+
)}
@@ -163,8 +231,8 @@ function inputPropsAreEqual(prev: DashboardInputFieldProps, next: DashboardInput prev.type === next.type && prev.position === next.position && prev.blurred === next.blurred && - prev.override === next.override && - prev.isDuplicate === next.isDuplicate + prev.overrideEnabled === next.overrideEnabled && + prev.isDuplicate === next.isDuplicate ); } diff --git a/frontend/src/components/dashboard/DeleteActionButton.tsx b/frontend/src/components/dashboard/DeleteActionButton.tsx index e4d56dd95..d0708f239 100644 --- a/frontend/src/components/dashboard/DeleteActionButton.tsx +++ b/frontend/src/components/dashboard/DeleteActionButton.tsx @@ -1,26 +1,42 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import Button from '../basic/buttons/Button'; import { DeleteEnvVar } from '../basic/dialog/DeleteEnvVar'; type Props = { - onSubmit: () => void + onSubmit: () => void; + isPlain?: boolean; } -export const DeleteActionButton = ({ onSubmit }: Props) => { +export const DeleteActionButton = ({ onSubmit, isPlain }: Props) => { const { t } = useTranslation(); const [open, setOpen] = useState(false) return ( -
-
+
+
); diff --git a/frontend/src/components/utilities/secrets/getSecretsForProject.ts b/frontend/src/components/utilities/secrets/getSecretsForProject.ts index c00f13d04..62c1e2e2d 100644 --- a/frontend/src/components/utilities/secrets/getSecretsForProject.ts +++ b/frontend/src/components/utilities/secrets/getSecretsForProject.ts @@ -29,8 +29,8 @@ interface SecretProps { interface FunctionProps { env: string; - setIsKeyAvailable: any; - setData: any; + setIsKeyAvailable?: any; + setData?: any; workspaceId: string; } @@ -58,7 +58,9 @@ const getSecretsForProject = async ({ const latestKey = await getLatestFileKey({ workspaceId }); // This is called isKeyAvailable but what it really means is if a person is able to create new key pairs - setIsKeyAvailable(!latestKey ? encryptedSecrets.length === 0 : true); + if (typeof setIsKeyAvailable === 'function') { + setIsKeyAvailable(!latestKey ? encryptedSecrets.length === 0 : true); + } const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; @@ -131,7 +133,10 @@ const getSecretsForProject = async ({ )[0]?.comment })); - setData(result); + if (typeof setData === 'function') { + setData(result); + } + return result; } catch (error) { console.log('Something went wrong during accessing or decripting secrets.'); diff --git a/frontend/src/components/v2/HoverCard/HoverCard.tsx b/frontend/src/components/v2/HoverCard/HoverCard.tsx new file mode 100644 index 000000000..703e7dbd0 --- /dev/null +++ b/frontend/src/components/v2/HoverCard/HoverCard.tsx @@ -0,0 +1,42 @@ +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import * as HoverCard from '@radix-ui/react-hover-card'; + +type Props = { + text: string; + icon: IconProp; + color: string; +}; + +export type HoverCardProps = Props; + +export const HoverObject = ({ + text, + icon, + color + }: Props): JSX.Element => ( + + + + + + + + +
+
+
+
{text}
+
+
+
+ + +
+
+
+); + +HoverObject.displayName = 'HoverCard'; diff --git a/frontend/src/components/v2/HoverCard/index.tsx b/frontend/src/components/v2/HoverCard/index.tsx new file mode 100644 index 000000000..fec7b1538 --- /dev/null +++ b/frontend/src/components/v2/HoverCard/index.tsx @@ -0,0 +1,2 @@ +export type { HoverCardProps } from './HoverCard'; +export { HoverObject } from './HoverCard'; diff --git a/frontend/src/components/v2/IconButton/IconButton.tsx b/frontend/src/components/v2/IconButton/IconButton.tsx index d0500ab80..98e81b089 100644 --- a/frontend/src/components/v2/IconButton/IconButton.tsx +++ b/frontend/src/components/v2/IconButton/IconButton.tsx @@ -80,7 +80,7 @@ const iconButtonVariants = cva( { colorSchema: ['danger', 'primary', 'secondary'], variant: ['plain'], - className: 'bg-transparent py-1 px-1' + className: 'bg-transparent py-1 px-1 text-bunker-300' } ] } diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index 8c979473b..efc6ba3a3 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -18,14 +18,14 @@ export const ModalContent = forwardRef( ({ children, title, subTitle, className, footerContent, onClose, ...props }, forwardedRef) => ( diff --git a/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx b/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx index e32ea2598..4edef8e6a 100644 --- a/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx +++ b/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx @@ -1,4 +1,4 @@ -import { createContext, ReactNode, useContext, useEffect, useMemo } from 'react'; +import { createContext, ReactNode, useContext, useMemo } from 'react'; import { useRouter } from 'next/router'; import { useGetUserWorkspaces } from '@app/hooks/api'; @@ -30,15 +30,6 @@ export const WorkspaceProvider = ({ children }: Props): JSX.Element => { }; }, [ws, workspaceId, isLoading]); - useEffect(() => { - // not loading and current workspace is empty - // ws empty means user has no access to the ws - // push to the first workspace - if (!isLoading && !value?.currentWorkspace?._id) { - // router.push(`/dashboard/${value.workspaces?.[0]?._id}`); - } - }, [value?.currentWorkspace?._id, isLoading, value.workspaces?.[0]?._id, router.pathname]); - return {children}; }; diff --git a/frontend/src/ee/components/PITRecoverySidebar.tsx b/frontend/src/ee/components/PITRecoverySidebar.tsx index d9cc47a7e..b13e3b66a 100644 --- a/frontend/src/ee/components/PITRecoverySidebar.tsx +++ b/frontend/src/ee/components/PITRecoverySidebar.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; -import { faX } from '@fortawesome/free-solid-svg-icons'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import Button from '@app/components/basic/buttons/Button'; @@ -155,7 +155,7 @@ const PITRecoverySidebar = ({ toggleSidebar, setSnapshotData, chosenSnapshot }:
{isLoading ? (
@@ -177,7 +177,7 @@ const PITRecoverySidebar = ({ toggleSidebar, setSnapshotData, chosenSnapshot }: className="p-1" onClick={() => toggleSidebar(false)} > - +
diff --git a/frontend/src/ee/components/SecretVersionList.tsx b/frontend/src/ee/components/SecretVersionList.tsx index 4df9c38a7..fd4a3916c 100644 --- a/frontend/src/ee/components/SecretVersionList.tsx +++ b/frontend/src/ee/components/SecretVersionList.tsx @@ -89,7 +89,7 @@ const SecretVersionList = ({ secretId }: { secretId: string }) => { />
) : ( -
+
{secretVersions ? ( secretVersions ?.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) diff --git a/frontend/src/pages/dashboard/[id].tsx b/frontend/src/pages/dashboard/[id].tsx index 99114a765..d5b26c2f5 100644 --- a/frontend/src/pages/dashboard/[id].tsx +++ b/frontend/src/pages/dashboard/[id].tsx @@ -5,9 +5,9 @@ import Image from 'next/image'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { - faArrowDownAZ, - faArrowDownZA, + faArrowDown, faArrowLeft, + faArrowUp, faCheck, faClockRotateLeft, faEye, @@ -31,6 +31,7 @@ import guidGenerator from '@app/components/utilities/randomId'; import encryptSecrets from '@app/components/utilities/secrets/encryptSecrets'; import getSecretsForProject from '@app/components/utilities/secrets/getSecretsForProject'; import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps'; +import { IconButton } from '@app/components/v2'; import getProjectSercetSnapshotsCount from '@app/ee/api/secrets/GetProjectSercetSnapshotsCount'; import performSecretRollback from '@app/ee/api/secrets/PerformSecretRollback'; import PITRecoverySidebar from '@app/ee/components/PITRecoverySidebar'; @@ -113,7 +114,7 @@ export default function Dashboard() { const [blurred, setBlurred] = useState(true); const [isKeyAvailable, setIsKeyAvailable] = useState(true); const [isNew, setIsNew] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [searchKeys, setSearchKeys] = useState(''); const [errorDragAndDrop, setErrorDragAndDrop] = useState(false); const [sortMethod, setSortMethod] = useState('alphabetical'); @@ -133,11 +134,7 @@ export default function Dashboard() { const [workspaceEnvs, setWorkspaceEnvs] = useState([]); const [selectedSnapshotEnv, setSelectedSnapshotEnv] = useState(); - const [selectedEnv, setSelectedEnv] = useState({ - name: '', - slug: '', - isWriteDenied: false - }); + const [selectedEnv, setSelectedEnv] = useState(); const [atSecretAreaTop, setAtSecretsAreaTop] = useState(true); const secretsTop = useRef(null); @@ -193,15 +190,18 @@ export default function Dashboard() { })); setData(sortedData); + setIsLoading(false); }; /** * Reorder rows alphabetically or in the opprosite order */ const reorderRows = (dataToReorder: SecretDataProps[] | 1) => { - setSortMethod((prevSort) => (prevSort === 'alphabetical' ? '-alphabetical' : 'alphabetical')); + if (dataToReorder) { + setSortMethod((prevSort) => (prevSort === 'alphabetical' ? '-alphabetical' : 'alphabetical')); - sortValuesHandler(dataToReorder, undefined); + sortValuesHandler(dataToReorder, undefined); + } }; useEffect(() => { @@ -246,16 +246,17 @@ export default function Dashboard() { setIsLoading(true); setBlurred(true); // ENV - const dataToSort = await getSecretsForProject({ - env: selectedEnv.slug, - setIsKeyAvailable, - setData, - workspaceId - }); - setInitialData(dataToSort); - reorderRows(dataToSort); - - setTimeout(() => setIsLoading(false), 700); + let dataToSort; + if (selectedEnv) { + dataToSort = await getSecretsForProject({ + env: selectedEnv.slug, + setIsKeyAvailable, + setData, + workspaceId + }); + setInitialData(dataToSort); + reorderRows(dataToSort); + } } catch (error) { console.log('Error', error); setData(undefined); @@ -283,6 +284,22 @@ export default function Dashboard() { } }; + const addRowToBottom = () => { + setIsNew(false); + setData([ + ...data!, + { + id: guidGenerator(), + idOverride: guidGenerator(), + pos: data!.length, + key: '', + value: '', + valueOverride: undefined, + comment: '' + }, + ]); + }; + const deleteRow = ({ ids, secretName }: { ids: string[]; secretName: string }) => { setButtonReady(true); toggleSidebar('None'); @@ -370,7 +387,7 @@ export default function Dashboard() { }); } - if (selectedEnv.isWriteDenied) { + if (selectedEnv?.isWriteDenied) { setSaveLoading(false); return createNotification({ text: 'You are not allowed to edit this environment', @@ -473,7 +490,7 @@ export default function Dashboard() { if (secretsToBeDeleted.concat(overridesToBeDeleted).length > 0) { await deleteSecrets({ secretIds: secretsToBeDeleted.concat(overridesToBeDeleted) }); } - if (secretsToBeAdded.concat(overridesToBeAdded).length > 0) { + if (selectedEnv && secretsToBeAdded.concat(overridesToBeAdded).length > 0) { const secrets = await encryptSecrets({ secretsToEncrypt: secretsToBeAdded.concat(overridesToBeAdded), workspaceId, @@ -481,7 +498,7 @@ export default function Dashboard() { }); if (secrets) await addSecrets({ secrets, env: selectedEnv.slug, workspaceId }); } - if (secretsToBeUpdated.concat(overridesToBeUpdated).length > 0) { + if (selectedEnv && secretsToBeUpdated.concat(overridesToBeUpdated).length > 0) { const secrets = await encryptSecrets({ secretsToEncrypt: secretsToBeUpdated.concat(overridesToBeUpdated), workspaceId, @@ -518,7 +535,7 @@ export default function Dashboard() { }; return data ? ( -
+
{t('common:head-title', { title: t('dashboard:title') })} @@ -526,7 +543,7 @@ export default function Dashboard() { -
+
{sidebarSecretId !== 'None' && ( )} -
+
{checkDocsPopUpVisible && ( )} -
+
{snapshotData && (
- {!snapshotData && data?.length === 0 && ( + {!snapshotData && data?.length === 0 && selectedEnv && ( name)} @@ -626,7 +646,7 @@ export default function Dashboard() { />
)} - {snapshotData && ( + {snapshotData && selectedEnv && (
-
+
- {(snapshotData || data?.length !== 0) && ( + {(snapshotData || data?.length !== 0) && selectedEnv && ( <> {!snapshotData ? ( )} -
+
setSearchKeys(e.target.value)} placeholder={String(t('dashboard:search-keys'))} />
- {!snapshotData && ( -
-
- )} {!snapshotData && (
@@ -762,13 +773,30 @@ export default function Dashboard() { />
) : data?.length !== 0 ? ( -
+
-
+
+
+
+ Key + {!snapshotData && reorderRows(1)} + > + {sortMethod === 'alphabetical' ? : } + } +
+
Value
+
Comment
+ {!snapshotData &&
} +
+
{!snapshotData && data ?.filter((row) => row.key?.toUpperCase().includes(searchKeys.toUpperCase())) @@ -780,6 +808,7 @@ export default function Dashboard() { modifyValue={listenChangeValue} modifyValueOverride={listenChangeValueOverride} modifyKey={listenChangeKey} + modifyComment={listenChangeComment} isBlurred={blurred} isDuplicate={findDuplicates(data?.map((item) => item.key))?.includes( keyPair.key @@ -787,6 +816,7 @@ export default function Dashboard() { toggleSidebar={toggleSidebar} sidebarSecretId={sidebarSecretId} isSnapshot={false} + deleteRow={deleteCertainRow} /> ))} {snapshotData && @@ -817,6 +847,7 @@ export default function Dashboard() { modifyValue={listenChangeValue} modifyValueOverride={listenChangeValueOverride} modifyKey={listenChangeKey} + modifyComment={listenChangeComment} isBlurred={blurred} isDuplicate={findDuplicates(data?.map((item) => item.key))?.includes( keyPair.key @@ -826,9 +857,20 @@ export default function Dashboard() { isSnapshot /> ))} +
+
+ +
{!snapshotData && ( -
+
) : ( -
+
{isKeyAvailable && !snapshotData && ( ) : (
-
loading animation
); diff --git a/frontend/src/pages/users/[id].tsx b/frontend/src/pages/users/[id].tsx index abd1cfc6f..27b531444 100644 --- a/frontend/src/pages/users/[id].tsx +++ b/frontend/src/pages/users/[id].tsx @@ -207,7 +207,6 @@ export default function Users() {
) : (
-
loading animation
); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index e6e8f317d..953a3d140 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -2,6 +2,7 @@ import crypto from 'crypto'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useRouter } from 'next/router'; import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider'; import NavHeader from '@app/components/navigation/NavHeader'; @@ -37,7 +38,8 @@ import { export const ProjectSettingsPage = () => { const { t } = useTranslation(); - const { currentWorkspace } = useWorkspace(); + const { currentWorkspace, workspaces } = useWorkspace(); + const router = useRouter(); const { data: serviceTokens } = useGetUserWsServiceTokens({ workspaceID: currentWorkspace?._id || '' }); @@ -64,7 +66,6 @@ export const ProjectSettingsPage = () => { const host = window.location.origin; const isEnvServiceAllowed = subscriptionPlan !== plans.starter || host !== 'https://app.infisical.com'; - const onRenameWorkspace = async (name: string) => { try { @@ -86,6 +87,9 @@ export const ProjectSettingsPage = () => { setIsDeleting.on(); try { await deleteWorkspace.mutateAsync({ workspaceID }); + // redirect user to first workspace user is part of + const ws = workspaces.find(({ _id }) => _id !== workspaceID); + router.push(`/dashboard/${ws?._id}`); createNotification({ text: 'Successfully deleted workspace', type: 'success'