From 3e9ce79398a448be24bef55f2410bf8e0556f939 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 28 May 2024 12:38:57 +0800 Subject: [PATCH 01/53] fix: resolved trailing slash issue with additional privileges --- frontend/src/helpers/string.ts | 5 +++++ .../MemberRoleForm/SpecificPrivilegeSection.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 frontend/src/helpers/string.ts diff --git a/frontend/src/helpers/string.ts b/frontend/src/helpers/string.ts new file mode 100644 index 000000000..8f581a280 --- /dev/null +++ b/frontend/src/helpers/string.ts @@ -0,0 +1,5 @@ +export const removeTrailingSlash = (str: string) => { + if (str === "/") return str; + + return str.endsWith("/") ? str.slice(0, -1) : str; +}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 6fb97063b..7894d0e78 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -43,6 +43,7 @@ import { useProjectPermission, useWorkspace } from "@app/context"; +import { removeTrailingSlash } from "@app/helpers/string"; import { usePopUp } from "@app/hooks"; import { TProjectUserPrivilege, @@ -104,7 +105,9 @@ export const SpecificPrivilegeSecretForm = ({ ? { environmentSlug: privilege.permissions?.[0]?.conditions?.environment, // secret path will be inside $glob operator - secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", + secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob + ? removeTrailingSlash(privilege.permissions?.[0]?.conditions?.secretPath?.$glob) + : "", read: privilege.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read) ), @@ -183,7 +186,7 @@ export const SpecificPrivilegeSecretForm = ({ ]; const conditions: Record = { environment: data.environmentSlug }; if (data.secretPath) { - conditions.secretPath = { $glob: data.secretPath }; + conditions.secretPath = { $glob: removeTrailingSlash(data.secretPath) }; } await updateUserPrivilege.mutateAsync({ privilegeId: privilege.id, From f11c2d6b3e1b548f27b1768bcc32b78bc158bb5e Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Wed, 29 May 2024 00:10:15 +0000 Subject: [PATCH 02/53] fix: backend/package.json & backend/package-lock.json to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-MYSQL2-6861580 --- backend/package-lock.json | 9 +++++---- backend/package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index cdaee75f4..b6f9a37c1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -51,7 +51,7 @@ "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", - "mysql2": "^3.9.7", + "mysql2": "^3.9.8", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", "ora": "^7.0.1", @@ -10290,9 +10290,10 @@ } }, "node_modules/mysql2": { - "version": "3.9.7", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz", - "integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==", + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.8.tgz", + "integrity": "sha512-+5JKNjPuks1FNMoy9TYpl77f+5frbTklz7eb3XDwbpsERRLEeXiW2PDEkakYF50UuKU2qwfGnyXpKYvukv8mGA==", + "license": "MIT", "dependencies": { "denque": "^2.1.0", "generate-function": "^2.3.1", diff --git a/backend/package.json b/backend/package.json index 85538d45a..3d1937964 100644 --- a/backend/package.json +++ b/backend/package.json @@ -112,7 +112,7 @@ "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", - "mysql2": "^3.9.7", + "mysql2": "^3.9.8", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", "ora": "^7.0.1", From c097c918edc56c7e4c3231bf7a876da7231e5e69 Mon Sep 17 00:00:00 2001 From: ShubhamPalriwala Date: Thu, 30 May 2024 10:06:14 +0530 Subject: [PATCH 03/53] fix: docs to open in new tab --- frontend/src/views/ShareSecretPage/ShareSecretPage.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/ShareSecretPage/ShareSecretPage.tsx b/frontend/src/views/ShareSecretPage/ShareSecretPage.tsx index e1636446e..95861ec61 100644 --- a/frontend/src/views/ShareSecretPage/ShareSecretPage.tsx +++ b/frontend/src/views/ShareSecretPage/ShareSecretPage.tsx @@ -13,14 +13,16 @@ export const ShareSecretPage = () => {

Share secrets securely using a shareable link

- - + + +
Documentation{" "} - +
+
From d79d7ca5e8301d0dcf6eae7bea1d6d57e1781d4b Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 30 May 2024 21:40:09 +0800 Subject: [PATCH 04/53] feat: added support for tagging existing aws params --- .../integration-sync-secret.ts | 47 ++++++++++++++----- .../cloud/aws-parameter-store.mdx | 1 + 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 587f6c6a8..12c691085 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -27,6 +27,7 @@ import { z } from "zod"; import { SecretType, TIntegrationAuths, TIntegrations, TSecrets } from "@app/db/schemas"; import { request } from "@app/lib/config/request"; import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/secret/secret-types"; import { TIntegrationDALFactory } from "../integration/integration-dal"; @@ -521,18 +522,42 @@ const syncSecretsAWSParameterStore = async ({ .promise(); } // case: secret exists in AWS parameter store - } else if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) { - // case: secret value doesn't match one in AWS parameter store + } else { // -> update secret - await ssm - .putParameter({ - Name: `${integration.path}${key}`, - Type: "SecureString", - Value: secrets[key].value, - Overwrite: true - // Tags: metadata.secretAWSTag ? [{ Key: metadata.secretAWSTag.key, Value: metadata.secretAWSTag.value }] : [] - }) - .promise(); + if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) { + await ssm + .putParameter({ + Name: `${integration.path}${key}`, + Type: "SecureString", + Value: secrets[key].value, + Overwrite: true + }) + .promise(); + } + + if (awsParameterStoreSecretsObj[key].Name) { + try { + await ssm + .addTagsToResource({ + ResourceType: "Parameter", + ResourceId: awsParameterStoreSecretsObj[key].Name as string, + Tags: metadata.secretAWSTag + ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ + Key: tag.key, + Value: tag.value + })) + : [] + }) + .promise(); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((err as any).code === "AccessDeniedException") { + logger.error( + `AWS Parameter Store Error [integration=${integration.id}]: double check AWS account permissions (refer to the Infisical docs)` + ); + } + } + } } await new Promise((resolve) => { diff --git a/docs/integrations/cloud/aws-parameter-store.mdx b/docs/integrations/cloud/aws-parameter-store.mdx index fdad8b638..d53c557fb 100644 --- a/docs/integrations/cloud/aws-parameter-store.mdx +++ b/docs/integrations/cloud/aws-parameter-store.mdx @@ -28,6 +28,7 @@ Prerequisites: "Action": [ "ssm:PutParameter", "ssm:DeleteParameter", + "ssm:GetParameters", "ssm:GetParametersByPath", "ssm:DeleteParameters", "ssm:AddTagsToResource", // if you need to add tags to secrets From 3438dbc70d9fabf16da6acf242f57d4bbfba6ba6 Mon Sep 17 00:00:00 2001 From: ShubhamPalriwala Date: Fri, 31 May 2024 00:00:48 +0530 Subject: [PATCH 05/53] feat: secret sharing supports expiry on view count + multi-line secret value --- ...044702_universal-text-in-secret-sharing.ts | 35 ++++ backend/src/db/schemas/secret-sharing.ts | 6 +- backend/src/db/schemas/users.ts | 4 +- .../server/routes/v1/secret-sharing-router.ts | 27 +-- .../secret-sharing/secret-sharing-service.ts | 34 +++- .../secret-sharing/secret-sharing-types.ts | 4 +- frontend/src/hooks/api/secretSharing/types.ts | 17 +- .../components/AddShareSecretModal.tsx | 167 ++++++++++-------- .../components/ShareSecretsRow.tsx | 73 +++++--- .../components/ShareSecretsTable.tsx | 14 +- 10 files changed, 251 insertions(+), 130 deletions(-) create mode 100644 backend/src/db/migrations/20240530044702_universal-text-in-secret-sharing.ts diff --git a/backend/src/db/migrations/20240530044702_universal-text-in-secret-sharing.ts b/backend/src/db/migrations/20240530044702_universal-text-in-secret-sharing.ts new file mode 100644 index 000000000..63d9dd81c --- /dev/null +++ b/backend/src/db/migrations/20240530044702_universal-text-in-secret-sharing.ts @@ -0,0 +1,35 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews"); + const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name"); + + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + if (!hasExpiresAfterViewsColumn) { + t.integer("expiresAfterViews").nullable(); + t.timestamp("expiresAt").nullable().alter(); + } + + if (hasSecretNameColumn) { + t.dropColumn("name"); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews"); + const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name"); + + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + if (hasExpiresAfterViewsColumn) { + t.dropColumn("expiresAfterViews"); + t.timestamp("expiresAt").notNullable().alter(); + } + + if (!hasSecretNameColumn) { + t.string("name").notNullable(); + } + }); +} diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index a412221b2..541cd1fc0 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -9,16 +9,16 @@ import { TImmutableDBKeys } from "./models"; export const SecretSharingSchema = z.object({ id: z.string().uuid(), - name: z.string(), encryptedValue: z.string(), iv: z.string(), tag: z.string(), hashedHex: z.string(), - expiresAt: z.date(), + expiresAt: z.date().nullable().optional(), userId: z.string().uuid(), orgId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + expiresAfterViews: z.number().nullable().optional() }); export type TSecretSharing = z.infer; diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index c10af4ba4..9e0b9a3b5 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -23,8 +23,8 @@ export const UsersSchema = z.object({ isGhost: z.boolean().default(false), username: z.string(), isEmailVerified: z.boolean().default(false).nullable().optional(), - consecutiveFailedMfaAttempts: z.number().optional(), - isLocked: z.boolean().optional(), + consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(), + isLocked: z.boolean().default(false).nullable().optional(), temporaryLockDateEnd: z.date().nullable().optional() }); diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts index 67751395b..f1016576c 100644 --- a/backend/src/server/routes/v1/secret-sharing-router.ts +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -45,7 +45,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => hashedHex: z.string() }), response: { - 200: SecretSharingSchema.pick({ name: true, encryptedValue: true, iv: true, tag: true, expiresAt: true }) + 200: SecretSharingSchema.pick({ + encryptedValue: true, + iv: true, + tag: true, + expiresAt: true, + expiresAfterViews: true + }) } }, handler: async (req) => { @@ -55,11 +61,11 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => ); if (!sharedSecret) return undefined; return { - name: sharedSecret.name, encryptedValue: sharedSecret.encryptedValue, iv: sharedSecret.iv, tag: sharedSecret.tag, - expiresAt: sharedSecret.expiresAt + expiresAt: sharedSecret.expiresAt, + expiresAfterViews: sharedSecret.expiresAfterViews }; } }); @@ -72,14 +78,15 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => }, schema: { body: z.object({ - name: z.string(), encryptedValue: z.string(), iv: z.string(), tag: z.string(), hashedHex: z.string(), - expiresAt: z.string().refine((date) => new Date(date) > new Date(), { - message: "Expires at should be a future date" - }) + expiresAt: z + .string() + .optional() + .refine((date) => date === undefined || new Date(date) > new Date(), "Expires at should be a future date"), + expiresAfterViews: z.number().optional() }), response: { 200: z.object({ @@ -89,19 +96,19 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const { name, encryptedValue, iv, tag, hashedHex, expiresAt } = req.body; + const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body; const sharedSecret = await req.server.services.secretSharing.createSharedSecret({ actor: req.permission.type, actorId: req.permission.id, orgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - name, encryptedValue, iv, tag, hashedHex, - expiresAt: new Date(expiresAt) + expiresAt: expiresAt ? new Date(expiresAt) : undefined, + expiresAfterViews }); return { id: sharedSecret.id }; } diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index 85cfe97f6..23d39ea9e 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -16,17 +16,28 @@ export const secretSharingServiceFactory = ({ secretSharingDAL }: TSecretSharingServiceFactoryDep) => { const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => { - const { actor, actorId, orgId, actorAuthMethod, actorOrgId, name, encryptedValue, iv, tag, hashedHex, expiresAt } = - createSharedSecretInput; - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - if (!permission) throw new UnauthorizedError({ name: "User not in org" }); - const newSharedSecret = await secretSharingDAL.create({ - name, + const { + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, encryptedValue, iv, tag, hashedHex, expiresAt, + expiresAfterViews + } = createSharedSecretInput; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (!permission) throw new UnauthorizedError({ name: "User not in org" }); + const newSharedSecret = await secretSharingDAL.create({ + encryptedValue, + iv, + tag, + hashedHex, + expiresAt, + expiresAfterViews, userId: actorId, orgId }); @@ -43,9 +54,18 @@ export const secretSharingServiceFactory = ({ const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => { const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex }); - if (sharedSecret && sharedSecret.expiresAt < new Date()) { + if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) { return; } + if (sharedSecret.expiresAfterViews != null && sharedSecret.expiresAfterViews >= 0) { + if (sharedSecret.expiresAfterViews === 0) { + await secretSharingDAL.deleteById(sharedSecretId); + return; + } + await secretSharingDAL.updateById(sharedSecretId, { + expiresAfterViews: sharedSecret.expiresAfterViews - 1 + }); + } return sharedSecret; }; diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts index 2d14ed12c..5af475c52 100644 --- a/backend/src/services/secret-sharing/secret-sharing-types.ts +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -9,12 +9,12 @@ export type TSharedSecretPermission = { }; export type TCreateSharedSecretDTO = { - name: string; encryptedValue: string; iv: string; tag: string; hashedHex: string; - expiresAt: Date; + expiresAt?: Date; + expiresAfterViews?: number; } & TSharedSecretPermission; export type TDeleteSharedSecretDTO = { diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts index 2fbddfb35..04bd19de7 100644 --- a/frontend/src/hooks/api/secretSharing/types.ts +++ b/frontend/src/hooks/api/secretSharing/types.ts @@ -1,31 +1,26 @@ export type TSharedSecret = { id: string; - name: string; - encryptedValue: string; - iv: string; - tag: string; - hashedHex: string; userId: string; - expiresAt: Date; + orgId: string; createdAt: Date; updatedAt: Date; -}; +} & TCreateSharedSecretRequest; export type TCreateSharedSecretRequest = { - name: string; encryptedValue: string; iv: string; tag: string; hashedHex: string; - expiresAt: Date; + expiresAt?: Date; + expiresAfterViews?: number; }; export type TViewSharedSecretResponse = { - name: string; encryptedValue: string; iv: string; tag: string; - expiresAt: Date; + expiresAt?: Date; + expiresAfterViews?: number; }; export type TDeleteSharedSecretRequest = { diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx index 00ee27b44..dcaf91b46 100644 --- a/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx @@ -9,9 +9,7 @@ import { AxiosError } from "axios"; import * as yup from "yup"; import { createNotification } from "@app/components/notifications"; -import { - encryptSymmetric, -} from "@app/components/utilities/cryptography/crypto"; +import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto"; import { Button, FormControl, @@ -22,7 +20,8 @@ import { ModalContent, SecretInput, Select, - SelectItem + SelectItem, + Switch } from "@app/components/v2"; import { useOrganization } from "@app/context"; import { useTimedReset } from "@app/hooks"; @@ -63,10 +62,10 @@ const expirationUnitsAndActions = [ ]; const schema = yup.object({ - name: yup.string().max(100).required().label("Shared Secret Name"), value: yup.string().max(1000).required().label("Shared Secret Value"), - expiresInValue: yup.number().min(1).required().label("Expiration Value"), - expiresInUnit: yup.string().required().label("Expiration Unit") + expiresAfterViews: yup.number().min(1).optional().label("Expires After Views"), + expiresInValue: yup.number().min(1).optional().label("Expiration Value"), + expiresInUnit: yup.string().optional().label("Expiration Unit") }); export type FormData = yup.InferType; @@ -91,9 +90,10 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => { const createSharedSecret = useCreateSharedSecret(); const { currentOrg } = useOrganization(); const [newSharedSecret, setnewSharedSecret] = useState(""); + const [expiryOption, setExpiryOption] = useState<"time" | "views">("time"); const hasSharedSecret = Boolean(newSharedSecret); const [isUrlCopied, , setIsUrlCopied] = useTimedReset({ - initialState: false, + initialState: false }); const copyUrlToClipboard = () => { @@ -106,10 +106,14 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => { } }, [isUrlCopied]); - const onFormSubmit = async ({ name, value, expiresInValue, expiresInUnit }: FormData) => { + const onFormSubmit = async ({ + value, + expiresInValue, + expiresInUnit, + expiresAfterViews + }: FormData) => { try { if (!currentOrg?.id) return; - const key = crypto.randomBytes(16).toString("hex"); const hashedHex = crypto.createHash("sha256").update(key).digest("hex"); const { ciphertext, iv, tag } = encryptSymmetric({ @@ -117,25 +121,26 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => { key }); - const expiresAt = new Date(); const updateExpiresAt = expirationUnitsAndActions.find( (item) => item.unit === expiresInUnit )?.action; - if (updateExpiresAt) { + if (updateExpiresAt && expiresInValue) { updateExpiresAt(expiresAt, expiresInValue); } const { id } = await createSharedSecret.mutateAsync({ - name, encryptedValue: ciphertext, iv, tag, hashedHex, - expiresAt, + expiresAt: expiryOption === "time" ? expiresAt : undefined, + expiresAfterViews: expiryOption === "views" ? expiresAfterViews : undefined }); setnewSharedSecret( - `${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(hashedHex)}-${encodeURIComponent(key)}` + `${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent( + hashedHex + )}-${encodeURIComponent(key)}` ); createNotification({ @@ -168,90 +173,114 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => { setnewSharedSecret(""); }} > - + {!hasSharedSecret ? (
- ( - - - - )} - /> ( )} /> -
-
+
+

+ Set Expiry Based On +

+
+

+ Time +

+ setExpiryOption(value ? "views" : "time")} + isChecked={expiryOption === "views"} + /> +

+ Views +

+
+
+
+ {expiryOption === "views" ? ( ( - + )} /> -
-
- ( - - - - )} - /> -
+ ) : ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ )}
-
+