From c041e443991d282092c1cb8f14243454abca72a4 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 29 Jul 2024 14:32:11 -0700 Subject: [PATCH] Continue secret sharing --- .../20240728010334_secret-sharing-name.ts | 46 ++++ .../db/schemas/access-approval-policies.ts | 4 +- backend/src/db/schemas/kms-keys.ts | 4 +- backend/src/db/schemas/org-memberships.ts | 2 +- .../db/schemas/secret-approval-requests.ts | 4 +- backend/src/db/schemas/secret-sharing.ts | 9 +- .../server/routes/v1/secret-sharing-router.ts | 45 ++-- .../secret-sharing/secret-sharing-dal.ts | 26 +- .../secret-sharing/secret-sharing-service.ts | 116 +++++--- .../secret-sharing/secret-sharing-types.ts | 9 +- frontend/src/hooks/api/index.tsx | 1 + .../src/hooks/api/secretSharing/mutations.ts | 9 +- .../src/hooks/api/secretSharing/queries.ts | 36 ++- frontend/src/hooks/api/secretSharing/types.ts | 17 +- frontend/src/pages/share-secret/index.tsx | 2 +- .../src/pages/shared/secret/[id]/index.tsx | 6 +- .../SecretListView/SecretListView.tsx | 9 +- .../components/AddShareSecretForm.tsx | 1 - .../components/AddShareSecretModal.tsx | 120 --------- .../components/AddShareSecretModal2.tsx | 32 +++ .../components/ShareSecretSection.tsx | 9 +- .../components/ShareSecretsRow.tsx | 91 +++++-- .../components/ShareSecretsTable.tsx | 39 ++- .../ShareSecretPublicPage.tsx | 192 ++----------- .../components/SecretTable.tsx | 110 -------- .../components/ShareSecretForm.tsx | 252 ++++++++++++++++++ .../components/index.tsx | 2 +- .../ViewSecretPublicPage.tsx | 50 ++++ .../components/SecretContainer.tsx | 87 ++++++ .../components/SecretErrorContainer.tsx | 13 + .../ViewSecretPublicPage/components/index.tsx | 2 + .../src/views/ViewSecretPublicPage/index.tsx | 1 + 32 files changed, 797 insertions(+), 549 deletions(-) create mode 100644 backend/src/db/migrations/20240728010334_secret-sharing-name.ts delete mode 100644 frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx create mode 100644 frontend/src/views/ShareSecretPage/components/AddShareSecretModal2.tsx delete mode 100644 frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx create mode 100644 frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx create mode 100644 frontend/src/views/ViewSecretPublicPage/ViewSecretPublicPage.tsx create mode 100644 frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx create mode 100644 frontend/src/views/ViewSecretPublicPage/components/SecretErrorContainer.tsx create mode 100644 frontend/src/views/ViewSecretPublicPage/components/index.tsx create mode 100644 frontend/src/views/ViewSecretPublicPage/index.tsx diff --git a/backend/src/db/migrations/20240728010334_secret-sharing-name.ts b/backend/src/db/migrations/20240728010334_secret-sharing-name.ts new file mode 100644 index 000000000..d051d5e08 --- /dev/null +++ b/backend/src/db/migrations/20240728010334_secret-sharing-name.ts @@ -0,0 +1,46 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + const doesNameExist = await knex.schema.hasColumn(TableName.SecretSharing, "name"); + if (!doesNameExist) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.string("name").nullable(); + }); + } + + const doesLastViewedAtExist = await knex.schema.hasColumn(TableName.SecretSharing, "lastViewedAt"); + if (!doesLastViewedAtExist) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.timestamp("lastViewedAt").nullable(); + }); + } + + const doesHashedHexExist = await knex.schema.hasColumn(TableName.SecretSharing, "hashedHex"); + if (doesHashedHexExist) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.dropColumn("hashedHex"); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + const doesNameExist = await knex.schema.hasColumn(TableName.SecretSharing, "name"); + if (doesNameExist) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.dropColumn("name"); + }); + } + + const doesLastViewedAtExist = await knex.schema.hasColumn(TableName.SecretSharing, "lastViewedAt"); + if (doesLastViewedAtExist) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.dropColumn("lastViewedAt"); + }); + } + } +} diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts index c05f22b31..f4c525a4f 100644 --- a/backend/src/db/schemas/access-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -5,8 +5,6 @@ import { z } from "zod"; -import { EnforcementLevel } from "@app/lib/types"; - import { TImmutableDBKeys } from "./models"; export const AccessApprovalPoliciesSchema = z.object({ @@ -17,7 +15,7 @@ export const AccessApprovalPoliciesSchema = z.object({ envId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard) + enforcementLevel: z.string().default("hard") }); export type TAccessApprovalPolicies = z.infer; diff --git a/backend/src/db/schemas/kms-keys.ts b/backend/src/db/schemas/kms-keys.ts index 99df71f8d..60cbb2e3b 100644 --- a/backend/src/db/schemas/kms-keys.ts +++ b/backend/src/db/schemas/kms-keys.ts @@ -13,9 +13,9 @@ export const KmsKeysSchema = z.object({ isDisabled: z.boolean().default(false).nullable().optional(), isReserved: z.boolean().default(true).nullable().optional(), orgId: z.string().uuid(), + slug: z.string(), createdAt: z.date(), - updatedAt: z.date(), - slug: z.string() + updatedAt: z.date() }); export type TKmsKeys = z.infer; diff --git a/backend/src/db/schemas/org-memberships.ts b/backend/src/db/schemas/org-memberships.ts index 7fc6f46eb..e77b6e9c9 100644 --- a/backend/src/db/schemas/org-memberships.ts +++ b/backend/src/db/schemas/org-memberships.ts @@ -18,7 +18,7 @@ export const OrgMembershipsSchema = z.object({ orgId: z.string().uuid(), roleId: z.string().uuid().nullable().optional(), projectFavorites: z.string().array().nullable().optional(), - isActive: z.boolean() + isActive: z.boolean().default(true) }); export type TOrgMemberships = z.infer; diff --git a/backend/src/db/schemas/secret-approval-requests.ts b/backend/src/db/schemas/secret-approval-requests.ts index 7ca0b71d9..218a0f922 100644 --- a/backend/src/db/schemas/secret-approval-requests.ts +++ b/backend/src/db/schemas/secret-approval-requests.ts @@ -15,12 +15,12 @@ export const SecretApprovalRequestsSchema = z.object({ conflicts: z.unknown().nullable().optional(), slug: z.string(), folderId: z.string().uuid(), - bypassReason: z.string().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), isReplicated: z.boolean().nullable().optional(), committerUserId: z.string().uuid(), - statusChangedByUserId: z.string().uuid().nullable().optional() + statusChangedByUserId: z.string().uuid().nullable().optional(), + bypassReason: z.string().nullable().optional() }); export type TSecretApprovalRequests = z.infer; diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index 4406ad493..3701e9f7a 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -5,8 +5,6 @@ import { z } from "zod"; -import { SecretSharingAccessType } from "@app/lib/types"; - import { TImmutableDBKeys } from "./models"; export const SecretSharingSchema = z.object({ @@ -14,14 +12,15 @@ export const SecretSharingSchema = z.object({ encryptedValue: z.string(), iv: z.string(), tag: z.string(), - hashedHex: z.string(), expiresAt: z.date(), userId: z.string().uuid().nullable().optional(), orgId: z.string().uuid().nullable().optional(), - accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization), createdAt: z.date(), updatedAt: z.date(), - expiresAfterViews: z.number().nullable().optional() + expiresAfterViews: z.number().nullable().optional(), + accessType: z.string().default("anyone"), + name: z.string().nullable().optional(), + lastViewedAt: z.date().nullable().optional() }); export type TSecretSharing = z.infer; diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts index a53f879dd..1cb6eb307 100644 --- a/backend/src/server/routes/v1/secret-sharing-router.ts +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -19,21 +19,31 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => rateLimit: readLimit }, schema: { + querystring: z.object({ + offset: z.coerce.number().min(0).max(100).default(0), + limit: z.coerce.number().min(1).max(100).default(25) + }), response: { - 200: z.array(SecretSharingSchema) + 200: z.object({ + secrets: z.array(SecretSharingSchema), + totalCount: z.number() + }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const sharedSecrets = await req.server.services.secretSharing.getSharedSecrets({ + const { secrets, totalCount } = await req.server.services.secretSharing.getSharedSecrets({ actor: req.permission.type, actorId: req.permission.id, - orgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId + actorOrgId: req.permission.orgId, + ...req.query }); - return sharedSecrets; + return { + secrets, + totalCount + }; } }); @@ -47,9 +57,6 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => params: z.object({ id: z.string().uuid() }), - querystring: z.object({ - hashedHex: z.string() - }), response: { 200: SecretSharingSchema.pick({ encryptedValue: true, @@ -64,9 +71,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => } }, handler: async (req) => { - const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretByIdAndHashedHex( + const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretById( req.params.id, - req.query.hashedHex, req.permission?.orgId ); if (!sharedSecret) return undefined; @@ -93,9 +99,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => encryptedValue: z.string(), iv: z.string(), tag: z.string(), - hashedHex: z.string(), expiresAt: z.string(), - expiresAfterViews: z.number().optional() + expiresAfterViews: z.number().min(1).optional() }), response: { 200: z.object({ @@ -104,12 +109,11 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => } }, handler: async (req) => { - const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body; + const { encryptedValue, iv, tag, expiresAt, expiresAfterViews } = req.body; const sharedSecret = await req.server.services.secretSharing.createPublicSharedSecret({ encryptedValue, iv, tag, - hashedHex, expiresAt, expiresAfterViews, accessType: SecretSharingAccessType.Anyone @@ -126,12 +130,12 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => }, schema: { body: z.object({ + name: z.string().max(50).optional(), encryptedValue: z.string(), iv: z.string(), tag: z.string(), - hashedHex: z.string(), expiresAt: z.string(), - expiresAfterViews: z.number().optional(), + expiresAfterViews: z.number().min(1).optional(), accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization) }), response: { @@ -142,20 +146,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - 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, - encryptedValue, - iv, - tag, - hashedHex, - expiresAt, - expiresAfterViews, - accessType: req.body.accessType + ...req.body }); return { id: sharedSecret.id }; } diff --git a/backend/src/services/secret-sharing/secret-sharing-dal.ts b/backend/src/services/secret-sharing/secret-sharing-dal.ts index 16b66d871..d1b4edd4b 100644 --- a/backend/src/services/secret-sharing/secret-sharing-dal.ts +++ b/backend/src/services/secret-sharing/secret-sharing-dal.ts @@ -10,6 +10,25 @@ export type TSecretSharingDALFactory = ReturnType { const sharedSecretOrm = ormify(db, TableName.SecretSharing); + const countAllUserOrgSharedSecrets = async ({ orgId, userId }: { orgId: string; userId: string }) => { + try { + interface CountResult { + count: string; + } + + const count = await db + .replicaNode()(TableName.SecretSharing) + .where(`${TableName.SecretSharing}.orgId`, orgId) + .where(`${TableName.SecretSharing}.userId`, userId) + .count("*") + .first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count all user-org shared secrets" }); + } + }; + const pruneExpiredSharedSecrets = async (tx?: Knex) => { try { const today = new Date(); @@ -19,8 +38,7 @@ export const secretSharingDALFactory = (db: TDbClient) => { .update({ encryptedValue: "", tag: "", - iv: "", - hashedHex: "" + iv: "" }); return docs; } catch (error) { @@ -50,8 +68,7 @@ export const secretSharingDALFactory = (db: TDbClient) => { await sharedSecretOrm.updateById(id, { encryptedValue: "", iv: "", - tag: "", - hashedHex: "" + tag: "" }); } catch (error) { throw new DatabaseError({ @@ -63,6 +80,7 @@ export const secretSharingDALFactory = (db: TDbClient) => { return { ...sharedSecretOrm, + countAllUserOrgSharedSecrets, pruneExpiredSharedSecrets, softDeleteById, findActiveSharedSecrets diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index da2f52534..17f1d8493 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -1,5 +1,5 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { SecretSharingAccessType } from "@app/lib/types"; import { TOrgDALFactory } from "../org/org-dal"; @@ -8,7 +8,7 @@ import { TCreatePublicSharedSecretDTO, TCreateSharedSecretDTO, TDeleteSharedSecretDTO, - TSharedSecretPermission + TGetSharedSecretsDTO } from "./secret-sharing-types"; type TSecretSharingServiceFactoryDep = { @@ -34,8 +34,8 @@ export const secretSharingServiceFactory = ({ encryptedValue, iv, tag, + name, accessType, - hashedHex, expiresAt, expiresAfterViews } = createSharedSecretInput; @@ -60,10 +60,10 @@ export const secretSharingServiceFactory = ({ } const newSharedSecret = await secretSharingDAL.create({ + name, encryptedValue, iv, tag, - hashedHex, expiresAt: new Date(expiresAt), expiresAfterViews, userId: actorId, @@ -75,7 +75,7 @@ export const secretSharingServiceFactory = ({ }; const createPublicSharedSecret = async (createSharedSecretInput: TCreatePublicSharedSecretDTO) => { - const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews, accessType } = createSharedSecretInput; + const { encryptedValue, iv, tag, expiresAt, expiresAfterViews, accessType } = createSharedSecretInput; if (new Date(expiresAt) < new Date()) { throw new BadRequestError({ message: "Expiration date cannot be in the past" }); } @@ -97,7 +97,6 @@ export const secretSharingServiceFactory = ({ encryptedValue, iv, tag, - hashedHex, expiresAt: new Date(expiresAt), expiresAfterViews, accessType @@ -105,43 +104,90 @@ export const secretSharingServiceFactory = ({ return { id: newSharedSecret.id }; }; - const getSharedSecrets = async (getSharedSecretsInput: TSharedSecretPermission) => { - const { actor, actorId, orgId, actorAuthMethod, actorOrgId } = getSharedSecretsInput; - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const getSharedSecrets = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + offset, + limit + }: TGetSharedSecretsDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); if (!permission) throw new UnauthorizedError({ name: "User not in org" }); - const userSharedSecrets = await secretSharingDAL.findActiveSharedSecrets({ userId: actorId, orgId }); - return userSharedSecrets; + + const secrets = await secretSharingDAL.find( + { + userId: actorId, + orgId: actorOrgId + }, + { offset, limit, sort: [["createdAt", "desc"]] } + ); + + const count = await secretSharingDAL.countAllUserOrgSharedSecrets({ + orgId: actorOrgId, + userId: actorId + }); + + return { + secrets, + totalCount: count + }; }; - const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string, orgId?: string) => { - const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex }); - if (!sharedSecret) return; + const getActiveSharedSecretById = async (sharedSecretId: string, orgId?: string) => { + const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId }); + if (!sharedSecret) + throw new NotFoundError({ + message: "Shared secret not found" + }); + + const { accessType, expiresAt, expiresAfterViews } = sharedSecret; const orgName = sharedSecret.orgId ? (await orgDAL.findOrgById(sharedSecret.orgId))?.name : ""; - // Support organization level access for secret sharing - if (sharedSecret.accessType === SecretSharingAccessType.Organization && orgId !== sharedSecret.orgId) { - return { - ...sharedSecret, - encryptedValue: "", - iv: "", - tag: "", - orgName - }; + + if (accessType === SecretSharingAccessType.Organization && orgId !== sharedSecret.orgId) + throw new UnauthorizedError(); + + if (expiresAt !== null && expiresAt < new Date()) { + // check lifetime expiry + await secretSharingDAL.softDeleteById(sharedSecretId); + throw new ForbiddenRequestError({ + message: "Access denied: Secret has expired by lifetime" + }); } - if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) { - return; + + if (expiresAfterViews !== null && expiresAfterViews === 0) { + // check view count expiry + await secretSharingDAL.softDeleteById(sharedSecretId); + throw new ForbiddenRequestError({ + message: "Access denied: Secret has expired by view count" + }); } - if (sharedSecret.expiresAfterViews != null && sharedSecret.expiresAfterViews >= 0) { - if (sharedSecret.expiresAfterViews === 0) { - await secretSharingDAL.softDeleteById(sharedSecretId); - return; - } + + if (expiresAfterViews) { + // decrement view count if view count expiry set await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } }); } - if (sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId) { - return { ...sharedSecret, orgName }; - } - return { ...sharedSecret, orgName: undefined }; + + await secretSharingDAL.updateById(sharedSecretId, { + lastViewedAt: new Date() + }); + + return { + ...sharedSecret, + orgName: + sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId + ? orgName + : undefined + }; }; const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => { @@ -157,6 +203,6 @@ export const secretSharingServiceFactory = ({ createPublicSharedSecret, getSharedSecrets, deleteSharedSecretById, - getActiveSharedSecretByIdAndHashedHex + getActiveSharedSecretById }; }; diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts index bb0e19d5b..5ee656e77 100644 --- a/backend/src/services/secret-sharing/secret-sharing-types.ts +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -1,7 +1,12 @@ -import { SecretSharingAccessType } from "@app/lib/types"; +import { SecretSharingAccessType, TGenericPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +export type TGetSharedSecretsDTO = { + offset: number; + limit: number; +} & TGenericPermission; + export type TSharedSecretPermission = { actor: ActorType; actorId: string; @@ -9,13 +14,13 @@ export type TSharedSecretPermission = { actorOrgId: string; orgId: string; accessType?: SecretSharingAccessType; + name?: string; }; export type TCreatePublicSharedSecretDTO = { encryptedValue: string; iv: string; tag: string; - hashedHex: string; expiresAt: string; expiresAfterViews?: number; accessType: SecretSharingAccessType; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 7e19ece33..08e0b59ba 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -29,6 +29,7 @@ export * from "./secretFolders"; export * from "./secretImports"; export * from "./secretRotation"; export * from "./secrets"; +export * from "./secretSharing"; export * from "./secretSnapshots"; export * from "./serverDetails"; export * from "./serviceTokens"; diff --git a/frontend/src/hooks/api/secretSharing/mutations.ts b/frontend/src/hooks/api/secretSharing/mutations.ts index e0c1dcc3c..7dec0bd5e 100644 --- a/frontend/src/hooks/api/secretSharing/mutations.ts +++ b/frontend/src/hooks/api/secretSharing/mutations.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { secretSharingKeys } from "./queries"; import { TCreateSharedSecretRequest, TDeleteSharedSecretRequest, TSharedSecret } from "./types"; export const useCreateSharedSecret = () => { @@ -11,7 +12,7 @@ export const useCreateSharedSecret = () => { const { data } = await apiRequest.post("/api/v1/secret-sharing", inputData); return data; }, - onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"]) + onSuccess: () => queryClient.invalidateQueries(secretSharingKeys.allSharedSecrets()) }); }; @@ -25,7 +26,7 @@ export const useCreatePublicSharedSecret = () => { ); return data; }, - onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"]) + onSuccess: () => queryClient.invalidateQueries(secretSharingKeys.allSharedSecrets()) }); }; @@ -38,8 +39,6 @@ export const useDeleteSharedSecret = () => { ); return data; }, - onSuccess: () => { - queryClient.invalidateQueries(["sharedSecrets"]); - } + onSuccess: () => queryClient.invalidateQueries(secretSharingKeys.allSharedSecrets()) }); }; diff --git a/frontend/src/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts index 44b2c3193..c36ab18c1 100644 --- a/frontend/src/hooks/api/secretSharing/queries.ts +++ b/frontend/src/hooks/api/secretSharing/queries.ts @@ -2,24 +2,46 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { SecretSharingAccessType, TSharedSecret, TViewSharedSecretResponse } from "./types"; +import { TSharedSecret, TViewSharedSecretResponse } from "./types"; -export const useGetSharedSecrets = () => { +export const secretSharingKeys = { + allSharedSecrets: () => ["sharedSecrets"] as const, + specificSharedSecrets: ({ offset, limit }: { offset: number; limit: number }) => + [...secretSharingKeys.allSharedSecrets(), { offset, limit }] as const +}; + +export const useGetSharedSecrets = ({ + offset = 0, + limit = 25 +}: { + offset: number; + limit: number; +}) => { return useQuery({ - queryKey: ["sharedSecrets"], + queryKey: secretSharingKeys.specificSharedSecrets({ offset, limit }), queryFn: async () => { - const { data } = await apiRequest.get("/api/v1/secret-sharing/"); + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit) + }); + + const { data } = await apiRequest.get<{ secrets: TSharedSecret[]; totalCount: number }>( + "/api/v1/secret-sharing/", + { + params + } + ); return data; } }); }; -export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex: string) => { +export const useGetActiveSharedSecretById = (secretId: string) => { return useQuery({ + enabled: Boolean(secretId), queryFn: async () => { - if(!id || !hashedHex) return Promise.resolve({ encryptedValue: "", iv: "", tag: "", accessType: SecretSharingAccessType.Organization, orgName: "" }); const { data } = await apiRequest.get( - `/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}` + `/api/v1/secret-sharing/public/${secretId}` ); return { encryptedValue: data.encryptedValue, diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts index 3a9576e1e..d9d10c9f6 100644 --- a/frontend/src/hooks/api/secretSharing/types.ts +++ b/frontend/src/hooks/api/secretSharing/types.ts @@ -4,16 +4,23 @@ export type TSharedSecret = { orgId: string; createdAt: Date; updatedAt: Date; -} & TCreateSharedSecretRequest; - -export type TCreateSharedSecretRequest = { + name: string | null; + lastViewedAt?: Date; + expiresAt: Date; + expiresAfterViews: number | null; + encryptedValue: string; + iv: string; + tag: string; +}; + +export type TCreateSharedSecretRequest = { + name?: string; encryptedValue: string; iv: string; tag: string; - hashedHex: string; expiresAt: Date; expiresAfterViews?: number; - accessType: SecretSharingAccessType; + accessType?: SecretSharingAccessType; }; export type TViewSharedSecretResponse = { diff --git a/frontend/src/pages/share-secret/index.tsx b/frontend/src/pages/share-secret/index.tsx index 53b034650..8fb2b26c4 100644 --- a/frontend/src/pages/share-secret/index.tsx +++ b/frontend/src/pages/share-secret/index.tsx @@ -13,7 +13,7 @@ const ShareNewPublicSecretPage = () => {
- +
); diff --git a/frontend/src/pages/shared/secret/[id]/index.tsx b/frontend/src/pages/shared/secret/[id]/index.tsx index bda56347b..71c670fad 100644 --- a/frontend/src/pages/shared/secret/[id]/index.tsx +++ b/frontend/src/pages/shared/secret/[id]/index.tsx @@ -1,6 +1,6 @@ import Head from "next/head"; -import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage"; +import { ViewSecretPublicPage } from "@app/views/ViewSecretPublicPage"; const SecretSharedPublicPage = () => { return ( @@ -12,9 +12,7 @@ const SecretSharedPublicPage = () => { -
- -
+ ); }; diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx index 2709590e2..83971a505 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx @@ -13,7 +13,7 @@ import { secretKeys } from "@app/hooks/api/secrets/queries"; import { DecryptedSecret, SecretType } from "@app/hooks/api/secrets/types"; import { secretSnapshotKeys } from "@app/hooks/api/secretSnapshots/queries"; import { UserWsKeyPair, WsTag } from "@app/hooks/api/types"; -import { AddShareSecretModal } from "@app/views/ShareSecretPage/components/AddShareSecretModal"; +import { AddShareSecretModal2 } from "@app/views/ShareSecretPage/components/AddShareSecretModal2"; import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store"; import { Filter, GroupBy, SortDir } from "../../SecretMainPage.types"; @@ -404,12 +404,7 @@ export const SecretListView = ({ isOpen={popUp.createTag.isOpen} onToggle={(isOpen) => handlePopUpToggle("createTag", isOpen)} /> - + ); }; diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx index abed9b289..e7b24d682 100644 --- a/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx @@ -88,7 +88,6 @@ export const AddShareSecretForm = ({ encryptedValue: ciphertext, iv, tag, - hashedHex, expiresAt, expiresAfterViews: expiresAfterViews === "-1" ? undefined : Number(expiresAfterViews), accessType: accessType as SecretSharingAccessType diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx deleted file mode 100644 index 5410bbe4d..000000000 --- a/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; - -import { Modal, ModalContent } from "@app/components/v2"; -import { useTimedReset } from "@app/hooks"; -import { UsePopUpState } from "@app/hooks/usePopUp"; - -import { AddShareSecretForm } from "./AddShareSecretForm"; -import { ViewAndCopySharedSecret } from "./ViewAndCopySharedSecret"; - -const schema = yup.object({ - value: yup.string().max(10000).required().label("Shared Secret Value"), - expiresInValue: yup.string().required().label("Expiration Value"), - expiresAfterViews: yup.string().required().label("Expires After Views") -}); - -export type FormData = yup.InferType; - -type Props = { - popUp: UsePopUpState<["createSharedSecret"]>; - handlePopUpToggle: ( - popUpName: keyof UsePopUpState<["createSharedSecret"]>, - state?: boolean - ) => void; - isPublic: boolean; - inModal: boolean; -}; - -export const AddShareSecretModal = ({ popUp, handlePopUpToggle, isPublic, inModal }: Props) => { - const { - control, - reset, - handleSubmit, - setValue, - formState: { isSubmitting } - } = useForm({ - resolver: yupResolver(schema) - }); - - const [newSharedSecret, setNewSharedSecret] = useState(""); - const hasSharedSecret = Boolean(newSharedSecret); - const [isUrlCopied, , setIsUrlCopied] = useTimedReset({ - initialState: false - }); - - const [isSecretInputDisabled, setIsSecretInputDisabled] = useState(false); - - const copyUrlToClipboard = () => { - navigator.clipboard.writeText(newSharedSecret); - setIsUrlCopied(true); - }; - useEffect(() => { - if (isUrlCopied) { - setTimeout(() => setIsUrlCopied(false), 2000); - } - }, [isUrlCopied]); - - useEffect(() => { - if (popUp.createSharedSecret.data) { - setValue("value", (popUp.createSharedSecret.data as { value: string }).value); - setIsSecretInputDisabled(true); - } - }, [popUp.createSharedSecret.data]); - - // eslint-disable-next-line no-nested-ternary - return inModal ? ( - { - handlePopUpToggle("createSharedSecret", open); - reset(); - setNewSharedSecret(""); - setIsSecretInputDisabled(false); - }} - > - - {!hasSharedSecret ? ( - - ) : ( - - )} - - - ) : !hasSharedSecret ? ( - - ) : ( - - ); -}; diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretModal2.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal2.tsx new file mode 100644 index 000000000..c4cff68aa --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal2.tsx @@ -0,0 +1,32 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { UsePopUpState } from "@app/hooks/usePopUp"; +import { ShareSecretForm } from "@app/views/ShareSecretPublicPage/components"; + +type Props = { + popUp: UsePopUpState<["createSharedSecret"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["createSharedSecret"]>, + state?: boolean + ) => void; +}; + +export const AddShareSecretModal2 = ({ popUp, handlePopUpToggle }: Props) => { + return ( + { + handlePopUpToggle("createSharedSecret", isOpen); + }} + > + + + + + ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx index 841a9667c..f208e7857 100644 --- a/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx @@ -7,7 +7,7 @@ import { Button, DeleteActionModal } from "@app/components/v2"; import { usePopUp } from "@app/hooks"; import { useDeleteSharedSecret } from "@app/hooks/api/secretSharing"; -import { AddShareSecretModal } from "./AddShareSecretModal"; +import { AddShareSecretModal2 } from "./AddShareSecretModal2"; import { ShareSecretsTable } from "./ShareSecretsTable"; type DeleteModalData = { name: string; id: string }; @@ -59,12 +59,7 @@ export const ShareSecretSection = () => { - + void; }) => { + // const [isRowExpanded, setIsRowExpanded] = useToggle(); + const lastViewedAt = row.lastViewedAt + ? format(new Date(row.lastViewedAt), "yyyy-MM-dd - HH:mm a") + : undefined; + + let isExpired = false; + if (row.expiresAfterViews !== null && row.expiresAfterViews <= 0) { + isExpired = true; + } + + if (row.expiresAt !== null && new Date(row.expiresAt) < new Date()) { + isExpired = true; + } + return ( - - {`${row.encryptedValue.substring(0, 5)}...`} - {format(new Date(row.createdAt), "yyyy-MM-dd - HH:mm a")} - {format(new Date(row.expiresAt), "yyyy-MM-dd - HH:mm a")} - {row.expiresAfterViews ? row.expiresAfterViews : "-"} - - - handlePopUpOpen("deleteSharedSecretConfirmation", { - name: "delete", - id: row.id - }) - } - variant="plain" - ariaLabel="delete" - > - - - - + <> + setIsRowExpanded.toggle()} + > + + + + + + {row.name ? `${row.name}` : "-"} + + + {isExpired ? "Expired" : "Active"} + + + {`${format(new Date(row.createdAt), "yyyy-MM-dd - HH:mm a")}`} + {format(new Date(row.expiresAt), "yyyy-MM-dd - HH:mm a")} + {row.expiresAfterViews !== null ? row.expiresAfterViews : "-"} + + { + e.stopPropagation(); + handlePopUpOpen("deleteSharedSecretConfirmation", { + name: "delete", + id: row.id + }); + }} + variant="plain" + ariaLabel="delete" + > + + + + + {/* {isRowExpanded && ( + + +
+
Test 1
+
Test 2
+
Test 3
+
+ + + )} */} + ); }; diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx index ce4ef79d5..939414b7d 100644 --- a/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx @@ -1,12 +1,13 @@ +import { useState } from "react"; import { faKey } from "@fortawesome/free-solid-svg-icons"; import { EmptyState, + Pagination, Table, TableContainer, TableSkeleton, TBody, - Td, Th, THead, Tr @@ -30,34 +31,46 @@ type Props = { }; export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => { - const { isLoading, data = [] } = useGetSharedSecrets(); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + const { isLoading, data } = useGetSharedSecrets({ + offset: (page - 1) * perPage, + limit: perPage + }); return ( - - + + + - {isLoading && } + {isLoading && } {!isLoading && - data?.map((row) => ( + data?.secrets?.map((row) => ( ))} - {!isLoading && data?.length === 0 && ( - - - - )}
Encrypted SecretCreated + NameStatusCreated At Valid Until Views Left
- -
+ {!isLoading && data?.totalCount !== undefined && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isLoading && !data?.secrets?.length && ( + + )}
); }; diff --git a/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx b/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx index 1d0e69172..e21946336 100644 --- a/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx +++ b/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx @@ -1,180 +1,36 @@ -import { useMemo } from "react"; -import Head from "next/head"; -import Image from "next/image"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto"; -import { Button } from "@app/components/v2"; -import { usePopUp, useTimedReset } from "@app/hooks"; -import { useGetActiveSharedSecretByIdAndHashedHex } from "@app/hooks/api/secretSharing"; - -import { AddShareSecretModal } from "../ShareSecretPage/components/AddShareSecretModal"; -import { SecretTable } from "./components"; - -// note: isNewSession: controls if the user is sharing a new secret or viewing a shared secret - -export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean }) => { - const router = useRouter(); - const { id, key: urlEncodedPublicKey } = router.query; - const [hashedHex, key] = urlEncodedPublicKey - ? urlEncodedPublicKey.toString().split("-") - : ["", ""]; - - const publicKey = decodeURIComponent(urlEncodedPublicKey as string); - const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex( - id as string, - hashedHex as string - ); - const accessType = data?.accessType; - const orgName = data?.orgName; - - const decryptedSecret = useMemo(() => { - if (data && data.encryptedValue && publicKey) { - const res = decryptSymmetric({ - ciphertext: data.encryptedValue, - iv: data.iv, - tag: data.tag, - key - }); - return res; - } - return ""; - }, [data, publicKey]); - - const [isUrlCopied, , setIsUrlCopied] = useTimedReset({ - initialState: false - }); - - const copyUrlToClipboard = () => { - navigator.clipboard.writeText(decryptedSecret); - setIsUrlCopied(true); - }; - const { popUp, handlePopUpToggle } = usePopUp(["createSharedSecret"] as const); +import { ShareSecretForm } from "./components"; +export const ShareSecretPublicPage = () => { return ( -
- - Infisical | Secret Sharing - - -
-
-
- - Infisical logo - -
- -
- {id && ( - - )} -
- {isNewSession && ( -
- -
- )} - {!isNewSession && ( -
- )} -
-
-
-
-
-

- Open source{" "} - - secret management - {" "} - for developers -

-
-

- - Infisical - {" "} - is the all-in-one secret management platform to securely manage secrets, configs, - and certificates across your team and infrastructure. -

- - - Try Infisical - - -
-
-
- + Infisical → + +

+
+
+
-
+

- © 2024{" "} + Made with ❤️ by{" "} Infisical - . All rights reserved.
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸

diff --git a/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx deleted file mode 100644 index f2218be65..000000000 --- a/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { faArrowRight, faCheck, faCopy, faEye, faEyeSlash, faKey } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { Button, EmptyState, IconButton, Td, Tr } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; -import { SecretSharingAccessType } from "@app/hooks/api/secretSharing/types"; - -type Props = { - isLoading: boolean; - decryptedSecret: string; - isUrlCopied: boolean; - copyUrlToClipboard: () => void; - accessType?: SecretSharingAccessType; - orgName?: string; -}; - -const replaceContentWithDot = (str: string) => { - let finalStr = ""; - for (let i = 0; i < str.length; i += 1) { - const char = str.at(i); - finalStr += char === "\n" ? "\n" : "*"; - } - return finalStr; -}; - -export const SecretTable = ({ - isLoading, - decryptedSecret, - isUrlCopied, - copyUrlToClipboard, - accessType, - orgName -}: Props) => { - const [isVisible, setIsVisible] = useToggle(false); - const title = orgName - ? (

Someone from {orgName} organization has shared a secret with you

) - : (

You need to be logged in to view this secret

); - - return ( -
- {isLoading &&
Loading...
} - {!isLoading && !decryptedSecret && accessType !== SecretSharingAccessType.Organization && ( - - - - - - )} - {!isLoading && !decryptedSecret && accessType === SecretSharingAccessType.Organization && ( - - - - - - - - )} - {!isLoading && decryptedSecret && ( -
-
-
- {isVisible ? decryptedSecret : replaceContentWithDot(decryptedSecret)} -
-
-
- - Copy - - setIsVisible.toggle()} - className="flex max-h-8 items-center rounded" - size="xs" - > - - -
-
- )} -
- ); -}; diff --git a/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx b/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx new file mode 100644 index 000000000..918ae5cb9 --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx @@ -0,0 +1,252 @@ +import crypto from "crypto"; + +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faCopy, faRedo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto"; +import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; +import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api"; +import { SecretSharingAccessType } from "@app/hooks/api/secretSharing"; + +// values in ms +const expiresInOptions = [ + { label: "5 min", value: 5 * 60 * 1000 }, + { label: "30 min", value: 30 * 60 * 1000 }, + { label: "1 hour", value: 60 * 60 * 1000 }, + { label: "1 day", value: 24 * 60 * 60 * 1000 }, + { label: "7 days", value: 7 * 24 * 60 * 60 * 1000 }, + { label: "14 days", value: 14 * 24 * 60 * 60 * 1000 }, + { label: "30 days", value: 30 * 24 * 60 * 60 * 1000 } +]; + +const viewLimitOptions = [ + { label: "1", value: 1 }, + { label: "Unlimited", value: -1 } +]; + +const schema = z.object({ + name: z.string().optional(), + secret: z.string(), + expiresIn: z.string(), + viewLimit: z.string(), + accessType: z.nativeEnum(SecretSharingAccessType).optional() +}); + +export type FormData = z.infer; + +type Props = { + isPublic: boolean; // whether or not this is a public (non-authenticated) secret sharing form + value?: string; +}; + +export const ShareSecretForm = ({ isPublic, value }: Props) => { + const [secretLink, setSecretLink] = useState(""); + const [, isCopyingSecret, setCopyTextSecret] = useTimedReset({ + initialState: "Copy to clipboard" + }); + + const publicSharedSecretCreator = useCreatePublicSharedSecret(); + const privateSharedSecretCreator = useCreateSharedSecret(); + const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator; + + const { + control, + reset, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + secret: value || "" + } + }); + + const onFormSubmit = async ({ name, secret, expiresIn, viewLimit, accessType }: FormData) => { + try { + const expiresAt = new Date(new Date().getTime() + Number(expiresIn)); + + const key = crypto.randomBytes(16).toString("hex"); + const { ciphertext, iv, tag } = encryptSymmetric({ + plaintext: secret, + key + }); + + const { id } = await createSharedSecret.mutateAsync({ + name, + encryptedValue: ciphertext, + iv, + tag, + expiresAt, + expiresAfterViews: viewLimit === "-1" ? undefined : Number(viewLimit), + accessType + }); + + setSecretLink(`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(key)}`); + + reset(); + + setCopyTextSecret("secret"); + createNotification({ + text: "Successfully created a shared secret", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to create a shared secret", + type: "error" + }); + } + }; + + const hasSecretLink = Boolean(secretLink); + + return !hasSecretLink ? ( +
+ {!isPublic && ( + ( + + + + )} + /> + )} + ( + +