diff --git a/README.md b/README.md index b3bcd7ac6..aaafca43a 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ PRs welcome! - + git commit activity @@ -40,13 +40,15 @@ - **[Language-Agnostic CLI](https://infisical.com/docs/cli/overview)** that pulls and injects environment variables into your local workflow - **[Complete control over your data](https://infisical.com/docs/self-hosting/overview)** - host it yourself on any infrastructure - **Navigate Multiple Environments** per project (e.g. development, staging, production, etc.) -- **Personal/Shared** scoping for environment variables +- **Personal overrides** for environment variables - **[Integrations](https://infisical.com/docs/integrations/overview)** with CI/CD and production infrastructure +- **[Secret Versioning](https://infisical.com/docs/getting-started/dashboard/versioning)** - check the history of change for any secret +- **[Activity Logs](https://infisical.com/docs/getting-started/dashboard/audit-logs)** - check what user in the project is performing what actions with secrets +- **[Point-in-time Secrets Recovery](https://infisical.com/docs/getting-started/dashboard/pit-recovery)** - roll back to any snapshot of you secrets - 🔜 **1-Click Deploy** to Digital Ocean and Heroku - 🔜 **Authentication/Authorization** for projects (read/write controls soon) - 🔜 **Automatic Secret Rotation** - 🔜 **2FA** -- 🔜 **Access Logs** - 🔜 **Slack Integration & MS Teams** integrations And more. @@ -65,7 +67,7 @@ To quickly get started, visit our [get started guide](https://infisical.com/docs Infisical makes secret management simple and end-to-end encrypted by default. We're on a mission to make it more accessible to all developers, not just security teams. -According to a [report](https://www.ekransystem.com/en/blog/secrets-management) in 2019, only 10% of organizations use secret management solutions despite all using digital secrets to some extent. +According to a [report](https://www.ekransystem.com/en/blog/secrets-management), only 10% of organizations use secret management solutions despite all using digital secrets to some extent. If you care about efficiency and security, then Infisical is right for you. @@ -319,7 +321,7 @@ Looking to report a security vulnerability? Please don't post about it in GitHub ## 🚨 Stay Up-to-Date -Infisical officially launched as v.1.0 on November 21st, 2022. However, a lot of new features are coming very quickly. Watch **releases** of this repository to be notified about future updates: +Infisical officially launched as v.1.0 on November 21st, 2022. There are a lot of new features coming very frequently. Watch **releases** of this repository to be notified about future updates: ![infisical-star-github](https://github.com/Infisical/infisical/blob/main/.github/images/star-infisical.gif?raw=true) @@ -331,7 +333,7 @@ Infisical officially launched as v.1.0 on November 21st, 2022. However, a lot of - + ## 🌎 Translations diff --git a/backend/src/app.ts b/backend/src/app.ts index 1e1bbb593..000fa647d 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line @typescript-eslint/no-var-requires const { patchRouterParam } = require('./utils/patchAsyncRoutes'); -import express from 'express'; +import express, { Request, Response } from 'express'; import helmet from 'helmet'; import cors from 'cors'; import cookieParser from 'cookie-parser'; @@ -43,6 +43,8 @@ import { apiKeyData as v2APIKeyDataRouter, } from './routes/v2'; +import { healthCheck } from './routes/status'; + import { getLogger } from './utils/logger'; import { RouteNotFoundError } from './utils/errors'; import { requestErrorHandler } from './middleware/requestErrorHandler'; @@ -101,6 +103,10 @@ app.use('/api/v2/secret', v2SecretRouter); app.use('/api/v2/service-token', v2ServiceTokenDataRouter); app.use('/api/v2/api-key-data', v2APIKeyDataRouter); + +// Server status +app.use('/api', healthCheck) + //* Handle unrouted requests and respond with proper error message as well as status code app.use((req, res, next) => { if (res.headersSent) return next(); diff --git a/backend/src/controllers/v2/secretController.ts b/backend/src/controllers/v2/secretController.ts index e0cc936c4..3bb9f1720 100644 --- a/backend/src/controllers/v2/secretController.ts +++ b/backend/src/controllers/v2/secretController.ts @@ -7,6 +7,39 @@ const { ValidationError } = mongoose.Error; import { BadRequestError, InternalServerError, UnauthorizedRequestError, ValidationError as RouteValidationError } from '../../utils/errors'; import { AnyBulkWriteOperation } from 'mongodb'; import { SECRET_PERSONAL, SECRET_SHARED } from "../../variables"; +import { validateMembership } from "../../helpers/membership"; +import { ADMIN, MEMBER } from '../../variables'; + +export const createSingleSecret = async (req: Request, res: Response) => { + const secretToCreate: CreateSecretRequestBody = req.body.secret; + const { workspaceId, environmentName } = req.params + const sanitizedSecret: SanitizedSecretForCreate = { + secretKeyCiphertext: secretToCreate.secretKeyCiphertext, + secretKeyIV: secretToCreate.secretKeyIV, + secretKeyTag: secretToCreate.secretKeyTag, + secretKeyHash: secretToCreate.secretKeyHash, + secretValueCiphertext: secretToCreate.secretValueCiphertext, + secretValueIV: secretToCreate.secretValueIV, + secretValueTag: secretToCreate.secretValueTag, + secretValueHash: secretToCreate.secretValueHash, + secretCommentCiphertext: secretToCreate.secretCommentCiphertext, + secretCommentIV: secretToCreate.secretCommentIV, + secretCommentTag: secretToCreate.secretCommentTag, + secretCommentHash: secretToCreate.secretCommentHash, + workspace: new Types.ObjectId(workspaceId), + environment: environmentName, + type: secretToCreate.type, + user: new Types.ObjectId(req.user._id) + } + + + const [error, newlyCreatedSecret] = await to(Secret.create(sanitizedSecret).then()) + if (error instanceof ValidationError) { + throw RouteValidationError({ message: error.message, stack: error.stack }) + } + + res.status(200).send() +} export const batchCreateSecrets = async (req: Request, res: Response) => { const secretsToCreate: CreateSecretRequestBody[] = req.body.secrets; @@ -48,16 +81,6 @@ export const batchCreateSecrets = async (req: Request, res: Response) => { res.status(200).send() } - -export const createSingleSecret = async (req: Request, res: Response) => { - try { - const secretFromDB = await Secret.findById(req.params.secretId) - return res.status(200).send(secretFromDB); - } catch (e) { - throw BadRequestError({ message: "Unable to find the requested secret" }) - } -} - export const batchDeleteSecrets = async (req: Request, res: Response) => { const { workspaceId, environmentName } = req.params const secretIdsToDelete: string[] = req.body.secretIds @@ -90,6 +113,33 @@ export const batchDeleteSecrets = async (req: Request, res: Response) => { res.status(200).send() } +export const deleteSingleSecret = async (req: Request, res: Response) => { + const { secretId } = req.params; + + const [error, singleSecretRetrieved] = await to(Secret.findById(secretId).then()) + if (error instanceof ValidationError) { + throw RouteValidationError({ message: "Unable to get secret, please try again", stack: error.stack }) + } + + if (singleSecretRetrieved) { + const [membershipValidationError, membership] = await to(validateMembership({ + userId: req.user._id, + workspaceId: singleSecretRetrieved.workspace._id.toString(), + acceptedRoles: [ADMIN, MEMBER] + })) + + if (membershipValidationError || !membership) { + throw UnauthorizedRequestError() + } + + await Secret.findByIdAndDelete(secretId) + + res.status(200).send() + } else { + throw BadRequestError() + } +} + export const batchModifySecrets = async (req: Request, res: Response) => { const { workspaceId, environmentName } = req.params const secretsModificationsRequested: ModifySecretRequestBody[] = req.body.secrets; @@ -101,7 +151,6 @@ export const batchModifySecrets = async (req: Request, res: Response) => { const secretsUserCanModifySet: Set = new Set(secretIdsUserCanModify.map(objectId => objectId._id.toString())); const updateOperationsToPerform: any = [] - secretsModificationsRequested.forEach(userModifiedSecret => { if (secretsUserCanModifySet.has(userModifiedSecret._id.toString())) { const sanitizedSecret: SanitizedSecretModify = { @@ -138,6 +187,38 @@ export const batchModifySecrets = async (req: Request, res: Response) => { return res.status(200).send() } +export const modifySingleSecrets = async (req: Request, res: Response) => { + const { workspaceId, environmentName } = req.params + const secretModificationsRequested: ModifySecretRequestBody = req.body.secret; + + const [secretIdUserCanModifyError, secretIdUserCanModify] = await to(Secret.findOne({ workspace: workspaceId, environment: environmentName }, { _id: 1 }).then()) + if (secretIdUserCanModifyError && !secretIdUserCanModify) { + throw BadRequestError() + } + + const sanitizedSecret: SanitizedSecretModify = { + secretKeyCiphertext: secretModificationsRequested.secretKeyCiphertext, + secretKeyIV: secretModificationsRequested.secretKeyIV, + secretKeyTag: secretModificationsRequested.secretKeyTag, + secretKeyHash: secretModificationsRequested.secretKeyHash, + secretValueCiphertext: secretModificationsRequested.secretValueCiphertext, + secretValueIV: secretModificationsRequested.secretValueIV, + secretValueTag: secretModificationsRequested.secretValueTag, + secretValueHash: secretModificationsRequested.secretValueHash, + secretCommentCiphertext: secretModificationsRequested.secretCommentCiphertext, + secretCommentIV: secretModificationsRequested.secretCommentIV, + secretCommentTag: secretModificationsRequested.secretCommentTag, + secretCommentHash: secretModificationsRequested.secretCommentHash, + } + + const [error, singleModificationUpdate] = await to(Secret.updateOne({ _id: secretModificationsRequested._id, workspace: workspaceId }, { $inc: { version: 1 }, $set: sanitizedSecret }).then()) + if (error instanceof ValidationError) { + throw RouteValidationError({ message: "Unable to apply modifications, please try again", stack: error.stack }) + } + + return res.status(200).send(singleModificationUpdate) +} + export const fetchAllSecrets = async (req: Request, res: Response) => { const { environment } = req.query; const { workspaceId } = req.params; @@ -165,4 +246,31 @@ export const fetchAllSecrets = async (req: Request, res: Response) => { } return res.json(allSecrets) +} + +export const fetchSingleSecret = async (req: Request, res: Response) => { + const { secretId } = req.params; + + const [error, singleSecretRetrieved] = await to(Secret.findById(secretId).then()) + + if (error instanceof ValidationError) { + throw RouteValidationError({ message: "Unable to get secret, please try again", stack: error.stack }) + } + + if (singleSecretRetrieved) { + const [membershipValidationError, membership] = await to(validateMembership({ + userId: req.user._id, + workspaceId: singleSecretRetrieved.workspace._id.toString(), + acceptedRoles: [ADMIN, MEMBER] + })) + + if (membershipValidationError || !membership) { + throw UnauthorizedRequestError() + } + + res.json(singleSecretRetrieved) + + } else { + throw BadRequestError() + } } \ No newline at end of file diff --git a/backend/src/helpers/rateLimiter.ts b/backend/src/helpers/rateLimiter.ts index 6171559af..55ac3be0c 100644 --- a/backend/src/helpers/rateLimiter.ts +++ b/backend/src/helpers/rateLimiter.ts @@ -6,7 +6,9 @@ const apiLimiter = rateLimit({ max: 450, standardHeaders: true, legacyHeaders: false, - skip: (request) => request.path === '/healthcheck' + skip: (request) => { + return request.path === '/healthcheck' || request.path === '/api/status' + } }); // 5 requests per hour diff --git a/backend/src/routes/status/index.ts b/backend/src/routes/status/index.ts new file mode 100644 index 000000000..d3c694b92 --- /dev/null +++ b/backend/src/routes/status/index.ts @@ -0,0 +1,5 @@ +import healthCheck from './status'; + +export { + healthCheck +} \ No newline at end of file diff --git a/backend/src/routes/status/status.ts b/backend/src/routes/status/status.ts new file mode 100644 index 000000000..4ab82cca9 --- /dev/null +++ b/backend/src/routes/status/status.ts @@ -0,0 +1,15 @@ +import express, { Request, Response } from 'express'; + +const router = express.Router(); + +router.get( + '/status', + (req: Request, res: Response) => { + res.status(200).json({ + date: new Date(), + message: 'Ok', + }) + } +); + +export default router \ No newline at end of file diff --git a/backend/src/routes/v2/secret.ts b/backend/src/routes/v2/secret.ts index 52e0e316e..893af8683 100644 --- a/backend/src/routes/v2/secret.ts +++ b/backend/src/routes/v2/secret.ts @@ -4,7 +4,7 @@ import { body, param, query } from 'express-validator'; import { ADMIN, MEMBER } from '../../variables'; import { CreateSecretRequestBody, ModifySecretRequestBody } from '../../types/secret'; import { secretController } from '../../controllers/v2'; -import { fetchAllSecrets } from '../../controllers/v2/secretController'; +import { fetchAllSecrets, fetchSingleSecret } from '../../controllers/v2/secretController'; const router = express.Router(); @@ -26,6 +26,24 @@ router.post( secretController.batchCreateSecrets ); +/** + * Create single secret for a given workspace and environmentName + */ +router.post( + '/workspace/:workspaceId/environment/:environmentName', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), + param('workspaceId').exists().isMongoId().trim(), + param('environmentName').exists().trim(), + body('secret').exists().isObject(), + validateRequest, + secretController.createSingleSecret +); + /** * Get all secrets for a given environment and workspace id */ @@ -43,6 +61,18 @@ router.get( fetchAllSecrets ); +/** + * Get single secret by id + */ +router.get( + '/:secretId', + requireAuth({ + acceptedAuthModes: ['jwt', 'serviceToken'] + }), + validateRequest, + fetchSingleSecret +); + /** * Batch delete secrets in a given workspace and environment name */ @@ -62,6 +92,19 @@ router.delete( ); +/** + * delete single secret by id + */ +router.delete( + '/:secretId', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + param('secretId').isMongoId(), + validateRequest, + secretController.deleteSingleSecret +); + /** * Apply modifications to many existing secrets in a given workspace and environment */ @@ -80,4 +123,22 @@ router.patch( secretController.batchModifySecrets ); +/** + * Apply modifications to single existing secret in a given workspace and environment + */ +router.patch( + '/workspace/:workspaceId/environment/:environmentName', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + body('secret').isObject(), + param('workspaceId').exists().isMongoId().trim(), + param('environmentName').exists().trim(), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), + validateRequest, + secretController.modifySingleSecrets +); + export default router; diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 810da49c4..df77e67d4 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -313,7 +313,7 @@ func init() { secretsCmd.AddCommand(secretsGetCmd) secretsCmd.AddCommand(secretsSetCmd) secretsCmd.AddCommand(secretsDeleteCmd) - secretsCmd.PersistentFlags().String("env", "dev", "Used to define the environment name on which actions should be taken on") + secretsCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") secretsCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") secretsCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { util.RequireLogin() diff --git a/cli/packages/util/vault.go b/cli/packages/util/vault.go index 63a014af1..5561f2aee 100644 --- a/cli/packages/util/vault.go +++ b/cli/packages/util/vault.go @@ -55,12 +55,15 @@ func GetKeyRing() (keyring.Keyring, error) { func fileKeyringPassphrasePrompt(prompt string) (string, error) { if password, ok := os.LookupEnv("INFISICAL_VAULT_FILE_PASSPHRASE"); ok { return password, nil + } else { + fmt.Println("You may set the `INFISICAL_VAULT_FILE_PASSPHRASE` environment variable to avoid typing password") } - fmt.Fprintf(os.Stderr, "%s: ", prompt) + fmt.Fprintf(os.Stderr, "%s:", prompt) b, err := term.ReadPassword(int(os.Stdin.Fd())) if err != nil { return "", err } + fmt.Println("") return string(b), nil } diff --git a/docs/cli/commands/run.mdx b/docs/cli/commands/run.mdx index 4afd7586d..cbfcb4d98 100644 --- a/docs/cli/commands/run.mdx +++ b/docs/cli/commands/run.mdx @@ -31,7 +31,6 @@ Inject environment variables from the platform into an application process. | Option | Description | Default value | | -------------- | ----------------------------------------------------------------------------------------------------------- | ------------- | | `--env` | Used to set the environment that secrets are pulled from. Accepted values: `dev`, `staging`, `test`, `prod` | `dev` | -| `--projectId` | Used to link a local project to the platform (required only if injecting via the service token method) | None | | `--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` | diff --git a/docs/cli/commands/secrets.mdx b/docs/cli/commands/secrets.mdx index 32ddb2255..9b4642347 100644 --- a/docs/cli/commands/secrets.mdx +++ b/docs/cli/commands/secrets.mdx @@ -10,7 +10,7 @@ infisical secrets This command enables you to perform CRUD (create, read, update, delete) operations on secrets within your Infisical project. With it, you can view, create, update, and delete secrets in your environment. ### Sub-commands - + Use this command to print out all of the secrets in your project ``` @@ -33,6 +33,12 @@ This command enables you to perform CRUD (create, read, update, delete) operatio Default value: `true` + + Used to select the environment name on which actions should be taken on + + Default value: `dev` + + @@ -52,7 +58,11 @@ This command enables you to perform CRUD (create, read, update, delete) operatio ``` ### Flags - None + + Used to select the environment name on which actions should be taken on + + Default value: `dev` + @@ -74,7 +84,11 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb ``` ### Flags - None + + Used to select the environment name on which actions should be taken on + + Default value: `dev` + @@ -89,5 +103,9 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb ``` ### Flags - None + + Used to select the environment name on which actions should be taken on + + Default value: `dev` + \ No newline at end of file diff --git a/frontend/components/analytics/posthog.ts b/frontend/components/analytics/posthog.ts index 4735a9fa9..e0dedc7fb 100644 --- a/frontend/components/analytics/posthog.ts +++ b/frontend/components/analytics/posthog.ts @@ -14,8 +14,6 @@ export const initPostHog = () => { api_host: POSTHOG_HOST }); } - - console.log("Outside of posthog") } return posthog; diff --git a/frontend/components/basic/Error.tsx b/frontend/components/basic/Error.tsx index 79cc6e5f1..b5f0b7a4c 100644 --- a/frontend/components/basic/Error.tsx +++ b/frontend/components/basic/Error.tsx @@ -4,13 +4,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; export default function Error({ text }: { text: string }): JSX.Element { return ( -
+
{text && ( -

{text}

+

{text}

)}
); diff --git a/frontend/components/basic/EventFilter.tsx b/frontend/components/basic/EventFilter.tsx index c9b31fd43..dc5ffe109 100644 --- a/frontend/components/basic/EventFilter.tsx +++ b/frontend/components/basic/EventFilter.tsx @@ -55,7 +55,7 @@ export default function EventFilter({ {selected != '' ? (

{t("activity:event." + selected)}

) : ( -

Select an event

+

{String(t("common:select-event"))}

)} {selected != '' ? ( )}
diff --git a/frontend/components/basic/dialog/DeleteEnvVar.tsx b/frontend/components/basic/dialog/DeleteEnvVar.tsx new file mode 100644 index 000000000..4bb584039 --- /dev/null +++ b/frontend/components/basic/dialog/DeleteEnvVar.tsx @@ -0,0 +1,77 @@ +import { Fragment } from "react"; +import { useTranslation } from "react-i18next"; +import { Dialog, Transition } from "@headlessui/react"; + +// #TODO: USE THIS. Currently it's not. Kinda complicated to set up because of state. + +type Props = { + isOpen: boolean + onClose: () => void + onSubmit: () => void +} + +export const DeleteEnvVar = ({ isOpen, onClose, onSubmit }: Props) => { + const { t } = useTranslation() + return ( +
+ + {}}> +
+ +
+ +
+ + + + {t('dashboard:sidebar.delete-key-dialog.title')} + +
+

+ {t('dashboard:sidebar.delete-key-dialog.confirm-delete-message')} +

+
+
+ + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/frontend/components/basic/table/UserTable.js b/frontend/components/basic/table/UserTable.js index 449d23cc6..677b0d978 100644 --- a/frontend/components/basic/table/UserTable.js +++ b/frontend/components/basic/table/UserTable.js @@ -117,13 +117,13 @@ const UserTable = ({ return (
-
- - +
+
+ - - - + + + diff --git a/frontend/components/dashboard/DeleteActionButton.tsx b/frontend/components/dashboard/DeleteActionButton.tsx new file mode 100644 index 000000000..db595ff03 --- /dev/null +++ b/frontend/components/dashboard/DeleteActionButton.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next'; + +import Button from '../basic/buttons/Button'; +import { DeleteEnvVar } from '../basic/dialog/DeleteEnvVar'; + +type Props = { + onSubmit: () => void +} + +export function DeleteActionButton({ onSubmit }: Props) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false) + + return ( +
+
+ ) +} diff --git a/frontend/components/dashboard/DropZone.tsx b/frontend/components/dashboard/DropZone.tsx index 66be964cc..ffde34566 100644 --- a/frontend/components/dashboard/DropZone.tsx +++ b/frontend/components/dashboard/DropZone.tsx @@ -3,10 +3,11 @@ import Image from "next/image"; import { useTranslation } from "next-i18next"; import { faUpload } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { parseDocument, Scalar, YAMLMap } from 'yaml'; import Button from "../basic/buttons/Button"; import Error from "../basic/Error"; -import parse from "../utilities/file"; +import { parseDotEnv } from '../utilities/parseDotEnv'; import guidGenerator from "../utilities/randomId"; interface DropZoneProps { @@ -51,6 +52,53 @@ const DropZone = ({ const [loading, setLoading] = useState(false); + const getSecrets = (file: ArrayBuffer, fileType: string) => { + let secrets; + switch (fileType) { + case 'env': { + const keyPairs = parseDotEnv(file); + secrets = Object.keys(keyPairs).map((key, index) => { + return { + id: guidGenerator(), + pos: numCurrentRows + index, + key: key, + value: keyPairs[key as keyof typeof keyPairs].value, + comment: keyPairs[key as keyof typeof keyPairs].comments.join('\n'), + type: 'shared', + }; + }); + break; + } + case 'yml': { + const parsedFile = parseDocument(file.toString()); + const keyPairs = parsedFile.contents!.toJSON(); + + secrets = Object.keys(keyPairs).map((key, index) => { + const fileContent = parsedFile.contents as YAMLMap; + const comment = + fileContent!.items + .find((item) => item.key.value === key) + ?.key?.commentBefore?.split('\n') + .map((comment) => comment.trim()) + .join('\n') ?? ''; + return { + id: guidGenerator(), + pos: numCurrentRows + index, + key: key, + value: keyPairs[key as keyof typeof keyPairs]?.toString() ?? '', + comment, + type: 'shared', + }; + }); + break; + } + default: + secrets = ''; + break; + } + return secrets; + }; + // This function function immediately parses the file after it is dropped const handleDrop = async (e: DragEvent) => { setLoading(true); @@ -61,20 +109,12 @@ const DropZone = ({ const file = e.dataTransfer.files[0]; const reader = new FileReader(); + const fileType = file.name.split('.')[1]; reader.onload = (event) => { if (event.target === null || event.target.result === null) return; // parse function's argument looks like to be ArrayBuffer - const keyPairs = parse(event.target.result as Buffer); - const newData = Object.keys(keyPairs).map((key, index) => { - return { - id: guidGenerator(), - pos: numCurrentRows + index, - key: key, - value: keyPairs[key as keyof typeof keyPairs], - type: "shared", - }; - }); + const newData = getSecrets(event.target.result as ArrayBuffer, fileType); setData(newData); setButtonReady(true); }; @@ -95,25 +135,14 @@ const DropZone = ({ setTimeout(() => setLoading(false), 5000); if (e.currentTarget.files === null) return; const file = e.currentTarget.files[0]; + const fileType = file.name.split('.')[1]; const reader = new FileReader(); reader.onload = (event) => { if (event.target === null || event.target.result === null) return; const { result } = event.target; - if (typeof result === "string") { - const newData = result - .split("\n") - .map((line: string, index: number) => { - return { - id: guidGenerator(), - pos: numCurrentRows + index, - key: line.split("=")[0], - value: line.split("=").slice(1, line.split("=").length).join("="), - type: "shared", - }; - }); - setData(newData); - setButtonReady(true); - } + const newData = getSecrets(result as ArrayBuffer, fileType); + setData(newData); + setButtonReady(true); }; reader.readAsText(file); }; @@ -139,7 +168,7 @@ const DropZone = ({ id="fileSelect" type="file" className="opacity-0 absolute w-full h-full" - accept=".txt,.env" + accept=".txt,.env,.yml" onChange={handleFileSelect} /> {errorDragAndDrop ? ( @@ -176,7 +205,7 @@ const DropZone = ({ id="fileSelect" type="file" className="opacity-0 absolute w-full h-full" - accept=".txt,.env" + accept=".txt,.env,.yml" onChange={handleFileSelect} />
@@ -187,7 +216,7 @@ const DropZone = ({
+ deleteRow({ ids: overrideEnabled ? data.map(secret => secret.id) : [data.filter(secret => secret.type == "shared")[0]?.id], secretName: data[0]?.key })} + />
}; diff --git a/frontend/components/navigation/NavBarDashboard.tsx b/frontend/components/navigation/NavBarDashboard.tsx index 824614819..b2752f3ed 100644 --- a/frontend/components/navigation/NavBarDashboard.tsx +++ b/frontend/components/navigation/NavBarDashboard.tsx @@ -13,6 +13,7 @@ import { faGear, faPlus, faRightFromBracket, + faUpRightFromSquare, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Menu, Transition } from "@headlessui/react"; @@ -107,6 +108,14 @@ export default function Navbar() {
+ + Docs + +
diff --git a/frontend/components/signup/CodeInputStep.tsx b/frontend/components/signup/CodeInputStep.tsx new file mode 100644 index 000000000..05c4973a8 --- /dev/null +++ b/frontend/components/signup/CodeInputStep.tsx @@ -0,0 +1,136 @@ +import React, { useState } from "react"; +import ReactCodeInput from "react-code-input"; +import { useTranslation } from "next-i18next"; + +import sendVerificationEmail from "~/pages/api/auth/SendVerificationEmail"; + +import Button from "../basic/buttons/Button"; +import Error from "../basic/Error"; + + +// The style for the verification code input +const props = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "55px", + borderRadius: "5px", + fontSize: "24px", + height: "55px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + }, +} as const; +const propsPhone = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "40px", + borderRadius: "5px", + fontSize: "24px", + height: "40px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + }, +} as const; + +interface CodeInputStepProps { + email: string; + incrementStep: () => void; + setCode: (value: string) => void; + codeError: boolean; +} + +/** + * This is the second step of sign up where users need to verify their email + * @param {object} obj + * @param {string} obj.email - user's email to which we just sent a verification email + * @param {function} obj.incrementStep - goes to the next step of signup + * @param {function} obj.setCode - state updating function that set the current value of the emai verification code + * @param {boolean} obj.codeError - whether the code was inputted wrong or now + * @returns + */ +export default function CodeInputStep({ email, incrementStep, setCode, codeError }: CodeInputStepProps): JSX.Element { + const [isLoading, setIsLoading] = useState(false); + const [isResendingVerificationEmail, setIsResendingVerificationEmail] = + useState(false); + const { t } = useTranslation(); + + const resendVerificationEmail = async () => { + setIsResendingVerificationEmail(true); + setIsLoading(true); + sendVerificationEmail(email); + setTimeout(() => { + setIsLoading(false); + setIsResendingVerificationEmail(false); + }, 2000); + }; + + return ( +
+

+ {"We've"} sent a verification email to{" "} +

+

+ {email}{" "} +

+
+ +
+
+ +
+ {codeError && } +
+
+
+
+ + Not seeing an email? + + + + +
+

+ {t("signup:step2-spam-alert")} +

+
+
+ ); +} diff --git a/frontend/components/signup/DonwloadBackupPDFStep.tsx b/frontend/components/signup/DonwloadBackupPDFStep.tsx new file mode 100644 index 000000000..786c49fd2 --- /dev/null +++ b/frontend/components/signup/DonwloadBackupPDFStep.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { useTranslation } from "next-i18next"; +import { faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import Button from "../basic/buttons/Button"; +import issueBackupKey from "../utilities/cryptography/issueBackupKey"; + + +interface DownloadBackupPDFStepProps { + incrementStep: () => void; + email: string; + password: string; + name: string; +} + +/** + * This is the step of the signup flow where the user downloads the backup pdf + * @param {object} obj + * @param {function} obj.incrementStep - function that moves the user on to the next stage of signup + * @param {string} obj.email - user's email + * @param {string} obj.password - user's password + * @param {string} obj.name - user's name + * @returns + */ +export default function DonwloadBackupPDFStep({ incrementStep, email, password, name }: DownloadBackupPDFStepProps): JSX.Element { + const { t } = useTranslation(); + + return ( +
+

+ {t("signup:step4-message")} +

+
+
{t("signup:step4-description1")}
+
{t("signup:step4-description2")}
+
+
+ + {t("signup:step4-description3")} +
+
+
+
+ ); +} diff --git a/frontend/components/signup/EnterEmailStep.tsx b/frontend/components/signup/EnterEmailStep.tsx new file mode 100644 index 000000000..5f98710b7 --- /dev/null +++ b/frontend/components/signup/EnterEmailStep.tsx @@ -0,0 +1,97 @@ +import React, { useState } from "react"; +import Link from "next/link"; +import { useTranslation } from "next-i18next"; + +import sendVerificationEmail from "~/pages/api/auth/SendVerificationEmail"; + +import Button from "../basic/buttons/Button"; +import InputField from "../basic/InputField"; + + +interface DownloadBackupPDFStepProps { + incrementStep: () => void; + email: string; + setEmail: (value: string) => void; +} + +/** + * This is the first step of the sign up process - users need to enter their email + * @param {object} obj + * @param {string} obj.email - email of a user signing up + * @param {function} obj.setEmail - funciton that manages the state of the email variable + * @param {function} obj.incrementStep - function to go to the next step of the signup flow + * @returns + */ +export default function EnterEmailStep({ email, setEmail, incrementStep }: DownloadBackupPDFStepProps): JSX.Element { + const [emailError, setEmailError] = useState(false); + const [emailErrorMessage, setEmailErrorMessage] = useState(""); + const { t } = useTranslation(); + + /** + * Verifies if the entered email "looks" correct + */ + const emailCheck = () => { + let emailCheckBool = false; + if (!email) { + setEmailError(true); + setEmailErrorMessage("Please enter your email."); + emailCheckBool = true; + } else if ( + !email.includes("@") || + !email.includes(".") || + !/[a-z]/.test(email) + ) { + setEmailError(true); + setEmailErrorMessage("Please enter a valid email."); + emailCheckBool = true; + } else { + setEmailError(false); + } + + // If everything is correct, go to the next step + if (!emailCheckBool) { + sendVerificationEmail(email); + incrementStep(); + } + }; + + return ( +
+
+

+ {'Let\''}s get started +

+
+ +
+
+

+ {t("signup:step1-privacy")} +

+
+
+
+
+
+ + + +
+
+ ); +} diff --git a/frontend/components/signup/TeamInviteStep.tsx b/frontend/components/signup/TeamInviteStep.tsx new file mode 100644 index 000000000..e2639b88f --- /dev/null +++ b/frontend/components/signup/TeamInviteStep.tsx @@ -0,0 +1,72 @@ +import React, { useState } from "react"; +import { useRouter } from "next/router"; +import { useTranslation } from "next-i18next"; + +import addUserToOrg from "~/pages/api/organization/addUserToOrg"; +import getWorkspaces from "~/pages/api/workspace/getWorkspaces"; + +import Button from "../basic/buttons/Button"; + + +/** + * This is the last step of the signup flow. People can optionally invite their teammates here. + */ +export default function TeamInviteStep(): JSX.Element { + const [emails, setEmails] = useState(""); + const { t } = useTranslation(); + const router = useRouter(); + + // Redirect user to the getting started page + const redirectToHome = async () => { + const userWorkspaces = await getWorkspaces(); + const userWorkspace = userWorkspaces[0]._id; + router.push("/home/" + userWorkspace); + + } + + const inviteUsers = async ({ emails }: { emails: string; }) => { + emails + .split(',') + .map(email => email.trim()) + .map(async (email) => await addUserToOrg(email, String(localStorage.getItem('orgData.id')))); + + await redirectToHome(); + } + + return ( +
+

+ {t("signup:step5-invite-team")} +

+

+ {t("signup:step5-subtitle")} +

+
+
+
+ +
+
+
First NameLast NameEmailFIRST NAMELAST NAMEEMAIL