mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(secret-sharing): secret requests
This commit is contained in:
25
backend/src/db/migrations/20250226021631_secret-requests.ts
Normal file
25
backend/src/db/migrations/20250226021631_secret-requests.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { SecretSharingType } from "@app/services/secret-sharing/secret-sharing-types";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasSharingTypeColumn = await knex.schema.hasColumn(TableName.SecretSharing, "type");
|
||||
|
||||
await knex.schema.alterTable(TableName.SecretSharing, (table) => {
|
||||
if (!hasSharingTypeColumn) {
|
||||
table.string("type", 32).defaultTo(SecretSharingType.Share).notNullable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasSharingTypeColumn = await knex.schema.hasColumn(TableName.SecretSharing, "type");
|
||||
|
||||
await knex.schema.alterTable(TableName.SecretSharing, (table) => {
|
||||
if (hasSharingTypeColumn) {
|
||||
table.dropColumn("type");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { TImmutableDBKeys } from "./models";
|
||||
export const SecretSharingSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
encryptedValue: z.string().nullable().optional(),
|
||||
type: z.string(),
|
||||
iv: z.string().nullable().optional(),
|
||||
tag: z.string().nullable().optional(),
|
||||
hashedHex: z.string().nullable().optional(),
|
||||
|
||||
@@ -250,6 +250,7 @@ export enum EventType {
|
||||
UPDATE_APP_CONNECTION = "update-app-connection",
|
||||
DELETE_APP_CONNECTION = "delete-app-connection",
|
||||
CREATE_SHARED_SECRET = "create-shared-secret",
|
||||
CREATE_SECRET_REQUEST = "create-secret-request",
|
||||
DELETE_SHARED_SECRET = "delete-shared-secret",
|
||||
READ_SHARED_SECRET = "read-shared-secret",
|
||||
GET_SECRET_SYNCS = "get-secret-syncs",
|
||||
@@ -2020,6 +2021,15 @@ interface CreateSharedSecretEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateSecretRequestEvent {
|
||||
type: EventType.CREATE_SECRET_REQUEST;
|
||||
metadata: {
|
||||
id: string;
|
||||
accessType: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteSharedSecretEvent {
|
||||
type: EventType.DELETE_SHARED_SECRET;
|
||||
metadata: {
|
||||
@@ -2470,4 +2480,5 @@ export type Event =
|
||||
| KmipOperationActivateEvent
|
||||
| KmipOperationRevokeEvent
|
||||
| KmipOperationLocateEvent
|
||||
| KmipOperationRegisterEvent;
|
||||
| KmipOperationRegisterEvent
|
||||
| CreateSecretRequestEvent;
|
||||
|
||||
@@ -1096,7 +1096,9 @@ export const registerRoutes = async (
|
||||
permissionService,
|
||||
secretSharingDAL,
|
||||
orgDAL,
|
||||
kmsService
|
||||
kmsService,
|
||||
smtpService,
|
||||
userDAL
|
||||
});
|
||||
|
||||
const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({
|
||||
|
||||
@@ -37,6 +37,7 @@ import { registerProjectMembershipRouter } from "./project-membership-router";
|
||||
import { registerProjectRouter } from "./project-router";
|
||||
import { registerSecretFolderRouter } from "./secret-folder-router";
|
||||
import { registerSecretImportRouter } from "./secret-import-router";
|
||||
import { registerSecretRequestsRouter } from "./secret-requests-router";
|
||||
import { registerSecretSharingRouter } from "./secret-sharing-router";
|
||||
import { registerSecretTagRouter } from "./secret-tag-router";
|
||||
import { registerSlackRouter } from "./slack-router";
|
||||
@@ -110,7 +111,15 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
||||
await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" });
|
||||
await server.register(registerWebhookRouter, { prefix: "/webhooks" });
|
||||
await server.register(registerIdentityRouter, { prefix: "/identities" });
|
||||
await server.register(registerSecretSharingRouter, { prefix: "/secret-sharing" });
|
||||
|
||||
await server.register(
|
||||
async (secretSharingRouter) => {
|
||||
await secretSharingRouter.register(registerSecretSharingRouter, { prefix: "/shared" });
|
||||
await secretSharingRouter.register(registerSecretRequestsRouter, { prefix: "/requests" });
|
||||
},
|
||||
{ prefix: "/secret-sharing" }
|
||||
);
|
||||
|
||||
await server.register(registerUserEngagementRouter, { prefix: "/user-engagement" });
|
||||
await server.register(registerDashboardRouter, { prefix: "/dashboard" });
|
||||
await server.register(registerCmekRouter, { prefix: "/kms" });
|
||||
|
||||
272
backend/src/server/routes/v1/secret-requests-router.ts
Normal file
272
backend/src/server/routes/v1/secret-requests-router.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretSharingSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { SecretSharingAccessType } from "@app/lib/types";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { SecretSharingType } from "@app/services/secret-sharing/secret-sharing-types";
|
||||
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
|
||||
|
||||
export const registerSecretRequestsRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:id",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
secretRequest: SecretSharingSchema.omit({
|
||||
encryptedSecret: true,
|
||||
tag: true,
|
||||
iv: true,
|
||||
encryptedValue: true
|
||||
}).extend({
|
||||
isSecretValueSet: z.boolean(),
|
||||
requester: z.object({
|
||||
organizationName: z.string(),
|
||||
firstName: z.string().nullish(),
|
||||
lastName: z.string().nullish(),
|
||||
username: z.string()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const secretRequest = await req.server.services.secretSharing.getSecretRequestById({
|
||||
id: req.params.id,
|
||||
actorOrgId: req.permission?.orgId,
|
||||
orgId: req.permission?.orgId,
|
||||
actor: req.permission?.type,
|
||||
actorId: req.permission?.id,
|
||||
actorAuthMethod: req.permission?.authMethod
|
||||
});
|
||||
|
||||
return { secretRequest };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:id/set-value",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
body: z.object({
|
||||
secretValue: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
secretRequest: SecretSharingSchema.omit({
|
||||
encryptedSecret: true,
|
||||
tag: true,
|
||||
iv: true,
|
||||
encryptedValue: true
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const secretRequest = await req.server.services.secretSharing.setSecretRequestValue({
|
||||
id: req.params.id,
|
||||
actorOrgId: req.permission?.orgId,
|
||||
orgId: req.permission?.orgId,
|
||||
actor: req.permission?.type,
|
||||
actorId: req.permission?.id,
|
||||
actorAuthMethod: req.permission?.authMethod,
|
||||
secretValue: req.body.secretValue
|
||||
});
|
||||
|
||||
return { secretRequest };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:id/reveal-value",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
secretRequest: SecretSharingSchema.omit({
|
||||
encryptedSecret: true,
|
||||
tag: true,
|
||||
iv: true,
|
||||
encryptedValue: true
|
||||
}).extend({
|
||||
secretValue: z.string()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const secretRequest = await req.server.services.secretSharing.revealSecretRequestValue({
|
||||
id: req.params.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
orgId: req.permission.orgId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod
|
||||
});
|
||||
|
||||
return { secretRequest };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:id",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
secretRequest: SecretSharingSchema.omit({
|
||||
encryptedSecret: true,
|
||||
tag: true,
|
||||
iv: true,
|
||||
encryptedValue: true
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const secretRequest = await req.server.services.secretSharing.deleteSharedSecretById({
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorId: req.permission.id,
|
||||
sharedSecretId: req.params.id,
|
||||
orgId: req.permission.orgId,
|
||||
actor: req.permission.type,
|
||||
type: SecretSharingType.Request
|
||||
});
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretRequestDeleted,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
secretRequestId: req.params.id,
|
||||
organizationId: req.permission.orgId,
|
||||
...req.auditLogInfo
|
||||
}
|
||||
});
|
||||
return { secretRequest };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
config: {
|
||||
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.object({
|
||||
secrets: z.array(SecretSharingSchema),
|
||||
totalCount: z.number()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const { secrets, totalCount } = await req.server.services.secretSharing.getSharedSecrets({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
type: SecretSharingType.Request,
|
||||
...req.query
|
||||
});
|
||||
|
||||
return {
|
||||
secrets,
|
||||
totalCount
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
name: z.string().max(50).optional(),
|
||||
expiresAt: z.string(),
|
||||
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
id: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const shareRequest = await req.server.services.secretSharing.createSecretRequest({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
orgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.body
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
orgId: req.permission.orgId,
|
||||
...req.auditLogInfo,
|
||||
event: {
|
||||
type: EventType.CREATE_SECRET_REQUEST,
|
||||
metadata: {
|
||||
accessType: req.body.accessType,
|
||||
name: req.body.name,
|
||||
id: shareRequest.id
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretRequestCreated,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
secretRequestId: shareRequest.id,
|
||||
organizationId: req.permission.orgId,
|
||||
secretRequestName: req.body.name,
|
||||
...req.auditLogInfo
|
||||
}
|
||||
});
|
||||
|
||||
return { id: shareRequest.id };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { SecretSharingType } from "@app/services/secret-sharing/secret-sharing-types";
|
||||
|
||||
export const registerSecretSharingRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
@@ -38,6 +39,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
type: SecretSharingType.Share,
|
||||
...req.query
|
||||
});
|
||||
|
||||
@@ -211,7 +213,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
orgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
sharedSecretId
|
||||
sharedSecretId,
|
||||
type: SecretSharingType.Share
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
|
||||
@@ -20,7 +20,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = {
|
||||
secretDAL: Pick<TSecretDALFactory, "pruneSecretReminders">;
|
||||
secretFolderVersionDAL: Pick<TSecretFolderVersionDALFactory, "pruneExcessVersions">;
|
||||
snapshotDAL: Pick<TSnapshotDALFactory, "pruneExcessSnapshots">;
|
||||
secretSharingDAL: Pick<TSecretSharingDALFactory, "pruneExpiredSharedSecrets">;
|
||||
secretSharingDAL: Pick<TSecretSharingDALFactory, "pruneExpiredSharedSecrets" | "pruneExpiredSecretRequests">;
|
||||
queueService: TQueueServiceFactory;
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
|
||||
await identityAccessTokenDAL.removeExpiredTokens();
|
||||
await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets();
|
||||
await secretSharingDAL.pruneExpiredSharedSecrets();
|
||||
await secretSharingDAL.pruneExpiredSecretRequests();
|
||||
await snapshotDAL.pruneExcessSnapshots();
|
||||
await secretVersionDAL.pruneExcessVersions();
|
||||
await secretVersionV2DAL.pruneExcessVersions();
|
||||
|
||||
@@ -7,12 +7,58 @@ import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueName } from "@app/queue";
|
||||
|
||||
import { SecretSharingType } from "./secret-sharing-types";
|
||||
|
||||
export type TSecretSharingDALFactory = ReturnType<typeof secretSharingDALFactory>;
|
||||
|
||||
export const secretSharingDALFactory = (db: TDbClient) => {
|
||||
const sharedSecretOrm = ormify(db, TableName.SecretSharing);
|
||||
|
||||
const countAllUserOrgSharedSecrets = async ({ orgId, userId }: { orgId: string; userId: string }) => {
|
||||
const getSecretRequestById = async (id: string) => {
|
||||
const repDb = db.replicaNode();
|
||||
|
||||
const secretRequest = await repDb(TableName.SecretSharing)
|
||||
.leftJoin(TableName.Organization, `${TableName.Organization}.id`, `${TableName.SecretSharing}.orgId`)
|
||||
.leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretSharing}.userId`)
|
||||
.where(`${TableName.SecretSharing}.id`, id)
|
||||
.where(`${TableName.SecretSharing}.type`, SecretSharingType.Request)
|
||||
.select(
|
||||
repDb.ref("name").withSchema(TableName.Organization).as("orgName"),
|
||||
repDb.ref("firstName").withSchema(TableName.Users).as("requesterFirstName"),
|
||||
repDb.ref("lastName").withSchema(TableName.Users).as("requesterLastName"),
|
||||
repDb.ref("username").withSchema(TableName.Users).as("requesterUsername")
|
||||
)
|
||||
.select(selectAllTableCols(TableName.SecretSharing))
|
||||
.first();
|
||||
|
||||
if (!secretRequest) {
|
||||
throw new DatabaseError({
|
||||
error: new Error("Get Secret Request By Id, Not found"),
|
||||
message: "Get Secret Request By Id, Not found",
|
||||
name: "GetSecretRequestById"
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...secretRequest,
|
||||
requester: {
|
||||
organizationName: secretRequest.orgName,
|
||||
firstName: secretRequest.requesterFirstName,
|
||||
lastName: secretRequest.requesterLastName,
|
||||
username: secretRequest.requesterUsername
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const countAllUserOrgSharedSecrets = async ({
|
||||
orgId,
|
||||
userId,
|
||||
type
|
||||
}: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
type: SecretSharingType;
|
||||
}) => {
|
||||
try {
|
||||
interface CountResult {
|
||||
count: string;
|
||||
@@ -22,6 +68,7 @@ export const secretSharingDALFactory = (db: TDbClient) => {
|
||||
.replicaNode()(TableName.SecretSharing)
|
||||
.where(`${TableName.SecretSharing}.orgId`, orgId)
|
||||
.where(`${TableName.SecretSharing}.userId`, userId)
|
||||
.where(`${TableName.SecretSharing}.type`, type)
|
||||
.count("*")
|
||||
.first();
|
||||
|
||||
@@ -38,6 +85,7 @@ export const secretSharingDALFactory = (db: TDbClient) => {
|
||||
const docs = await (tx || db)(TableName.SecretSharing)
|
||||
.where("expiresAt", "<", today)
|
||||
.andWhere("encryptedValue", "<>", "")
|
||||
.andWhere("type", SecretSharingType.Share)
|
||||
.update({
|
||||
encryptedValue: "",
|
||||
tag: "",
|
||||
@@ -50,6 +98,26 @@ export const secretSharingDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pruneExpiredSecretRequests = async (tx?: Knex) => {
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: pruning expired secret requests started`);
|
||||
try {
|
||||
const today = new Date();
|
||||
|
||||
const docs = await (tx || db)(TableName.SecretSharing)
|
||||
.whereNotNull("expiresAt")
|
||||
.andWhere("expiresAt", "<", today)
|
||||
.andWhere("encryptedSecret", null)
|
||||
.andWhere("type", SecretSharingType.Request)
|
||||
.delete();
|
||||
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: pruning expired secret requests completed`);
|
||||
|
||||
return docs;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "pruneExpiredSecretRequests" });
|
||||
}
|
||||
};
|
||||
|
||||
const findActiveSharedSecrets = async (filters: Partial<TSecretSharing>, tx?: Knex) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
@@ -57,6 +125,7 @@ export const secretSharingDALFactory = (db: TDbClient) => {
|
||||
.where(filters)
|
||||
.andWhere("expiresAt", ">", now)
|
||||
.andWhere("encryptedValue", "<>", "")
|
||||
.andWhere("type", SecretSharingType.Share)
|
||||
.select(selectAllTableCols(TableName.SecretSharing))
|
||||
.orderBy("expiresAt", "asc");
|
||||
} catch (error) {
|
||||
@@ -86,7 +155,9 @@ export const secretSharingDALFactory = (db: TDbClient) => {
|
||||
...sharedSecretOrm,
|
||||
countAllUserOrgSharedSecrets,
|
||||
pruneExpiredSharedSecrets,
|
||||
pruneExpiredSecretRequests,
|
||||
softDeleteById,
|
||||
findActiveSharedSecrets
|
||||
findActiveSharedSecrets,
|
||||
getSecretRequestById
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,26 +4,36 @@ import bcrypt from "bcrypt";
|
||||
|
||||
import { TSecretSharing } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { SecretSharingAccessType } from "@app/lib/types";
|
||||
import { isUuidV4 } from "@app/lib/validator";
|
||||
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { TOrgDALFactory } from "../org/org-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { TSecretSharingDALFactory } from "./secret-sharing-dal";
|
||||
import {
|
||||
SecretSharingType,
|
||||
TCreatePublicSharedSecretDTO,
|
||||
TCreateSecretRequestDTO,
|
||||
TCreateSharedSecretDTO,
|
||||
TDeleteSharedSecretDTO,
|
||||
TGetActiveSharedSecretByIdDTO,
|
||||
TGetSharedSecretsDTO
|
||||
TGetSecretRequestByIdDTO,
|
||||
TGetSharedSecretsDTO,
|
||||
TRevealSecretRequestValueDTO,
|
||||
TSetSecretRequestValueDTO
|
||||
} from "./secret-sharing-types";
|
||||
|
||||
type TSecretSharingServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||
secretSharingDAL: TSecretSharingDALFactory;
|
||||
orgDAL: TOrgDALFactory;
|
||||
userDAL: TUserDALFactory;
|
||||
kmsService: TKmsServiceFactory;
|
||||
smtpService: TSmtpService;
|
||||
};
|
||||
|
||||
export type TSecretSharingServiceFactory = ReturnType<typeof secretSharingServiceFactory>;
|
||||
@@ -32,7 +42,9 @@ export const secretSharingServiceFactory = ({
|
||||
permissionService,
|
||||
secretSharingDAL,
|
||||
orgDAL,
|
||||
kmsService
|
||||
kmsService,
|
||||
smtpService,
|
||||
userDAL
|
||||
}: TSecretSharingServiceFactoryDep) => {
|
||||
const $validateSharedSecretExpiry = (expiresAt: string) => {
|
||||
if (new Date(expiresAt) < new Date()) {
|
||||
@@ -75,7 +87,6 @@ export const secretSharingServiceFactory = ({
|
||||
}
|
||||
|
||||
const encryptWithRoot = kmsService.encryptWithRootKey();
|
||||
|
||||
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
|
||||
|
||||
const id = crypto.randomBytes(32).toString("hex");
|
||||
@@ -88,6 +99,7 @@ export const secretSharingServiceFactory = ({
|
||||
encryptedValue: null,
|
||||
encryptedSecret,
|
||||
name,
|
||||
type: SecretSharingType.Share,
|
||||
password: hashedPassword,
|
||||
expiresAt: new Date(expiresAt),
|
||||
expiresAfterViews,
|
||||
@@ -101,6 +113,193 @@ export const secretSharingServiceFactory = ({
|
||||
return { id: idToReturn };
|
||||
};
|
||||
|
||||
const createSecretRequest = async ({
|
||||
actor,
|
||||
accessType,
|
||||
expiresAt,
|
||||
name,
|
||||
actorId,
|
||||
orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TCreateSecretRequestDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
|
||||
|
||||
$validateSharedSecretExpiry(expiresAt);
|
||||
|
||||
const newSecretRequest = await secretSharingDAL.create({
|
||||
type: SecretSharingType.Request,
|
||||
userId: actorId,
|
||||
orgId,
|
||||
name,
|
||||
encryptedSecret: null,
|
||||
accessType,
|
||||
expiresAt: new Date(expiresAt)
|
||||
});
|
||||
|
||||
return { id: newSecretRequest.id };
|
||||
};
|
||||
|
||||
const revealSecretRequestValue = async ({
|
||||
id,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
orgId,
|
||||
actorAuthMethod
|
||||
}: TRevealSecretRequestValueDTO) => {
|
||||
const secretRequest = await secretSharingDAL.getSecretRequestById(id);
|
||||
|
||||
if (!secretRequest) {
|
||||
throw new NotFoundError({ message: `Secret request with ID '${id}' not found` });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
|
||||
|
||||
if (secretRequest.userId !== actorId || secretRequest.orgId !== orgId) {
|
||||
throw new ForbiddenRequestError({ name: "User does not have permission to access this secret request" });
|
||||
}
|
||||
|
||||
if (!secretRequest.encryptedSecret) {
|
||||
throw new BadRequestError({ message: "Secret request has no value set" });
|
||||
}
|
||||
|
||||
const decryptWithRoot = kmsService.decryptWithRootKey();
|
||||
const decryptedSecret = decryptWithRoot(secretRequest.encryptedSecret);
|
||||
|
||||
return { ...secretRequest, secretValue: decryptedSecret.toString() };
|
||||
};
|
||||
|
||||
const getSecretRequestById = async ({
|
||||
id,
|
||||
actor,
|
||||
actorId,
|
||||
orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TGetSecretRequestByIdDTO) => {
|
||||
const secretRequest = await secretSharingDAL.getSecretRequestById(id);
|
||||
|
||||
if (!secretRequest) {
|
||||
throw new NotFoundError({ message: `Secret request with ID '${id}' not found` });
|
||||
}
|
||||
|
||||
if (secretRequest.accessType === SecretSharingAccessType.Organization) {
|
||||
if (orgId === undefined) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
|
||||
|
||||
if (secretRequest.orgId !== orgId) {
|
||||
throw new ForbiddenRequestError({ name: "User does not have permission to access this secret request" });
|
||||
}
|
||||
}
|
||||
|
||||
if (secretRequest.expiresAt && secretRequest.expiresAt < new Date()) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Access denied: Secret request has expired"
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...secretRequest,
|
||||
isSecretValueSet: Boolean(secretRequest.encryptedSecret)
|
||||
};
|
||||
};
|
||||
|
||||
const setSecretRequestValue = async ({
|
||||
id,
|
||||
actor,
|
||||
actorId,
|
||||
orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
secretValue
|
||||
}: TSetSecretRequestValueDTO) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const secretRequest = await secretSharingDAL.getSecretRequestById(id);
|
||||
|
||||
if (!secretRequest) {
|
||||
throw new NotFoundError({ message: `Secret request with ID '${id}' not found` });
|
||||
}
|
||||
|
||||
let respondentUsername: string | undefined;
|
||||
|
||||
if (secretRequest.accessType === SecretSharingAccessType.Organization) {
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
|
||||
|
||||
if (!orgId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
if (secretRequest.orgId !== orgId) {
|
||||
throw new ForbiddenRequestError({ name: "User does not have permission to access this secret request" });
|
||||
}
|
||||
|
||||
const user = await userDAL.findById(actorId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError({ message: `User with ID '${actorId}' not found` });
|
||||
}
|
||||
|
||||
respondentUsername = user.username;
|
||||
}
|
||||
|
||||
if (secretRequest.encryptedSecret) {
|
||||
throw new BadRequestError({ message: "Secret request already has a value set" });
|
||||
}
|
||||
|
||||
if (secretValue.length > 10_000) {
|
||||
throw new BadRequestError({ message: "Shared secret value too long" });
|
||||
}
|
||||
|
||||
if (secretRequest.expiresAt && secretRequest.expiresAt < new Date()) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Access denied: Secret request has expired"
|
||||
});
|
||||
}
|
||||
|
||||
const encryptWithRoot = kmsService.encryptWithRootKey();
|
||||
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
|
||||
|
||||
const request = await secretSharingDAL.transaction(async (tx) => {
|
||||
const updatedRequest = await secretSharingDAL.updateById(id, { encryptedSecret }, tx);
|
||||
|
||||
await smtpService.sendMail({
|
||||
recipients: [secretRequest.requesterUsername],
|
||||
subjectLine: "Secret Request Completed",
|
||||
substitutions: {
|
||||
name: secretRequest.name,
|
||||
respondentUsername,
|
||||
secretRequestUrl: `${appCfg.SITE_URL}/organization/secret-sharing?selectedTab=request-secret`
|
||||
},
|
||||
template: SmtpTemplates.SecretRequestCompleted
|
||||
});
|
||||
|
||||
return updatedRequest;
|
||||
});
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
const createPublicSharedSecret = async ({
|
||||
password,
|
||||
secretValue,
|
||||
@@ -121,6 +320,7 @@ export const secretSharingServiceFactory = ({
|
||||
encryptedValue: null,
|
||||
iv: null,
|
||||
tag: null,
|
||||
type: SecretSharingType.Share,
|
||||
encryptedSecret,
|
||||
password: hashedPassword,
|
||||
expiresAt: new Date(expiresAt),
|
||||
@@ -137,7 +337,8 @@ export const secretSharingServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
offset,
|
||||
limit
|
||||
limit,
|
||||
type
|
||||
}: TGetSharedSecretsDTO) => {
|
||||
if (!actorOrgId) throw new ForbiddenRequestError();
|
||||
|
||||
@@ -153,14 +354,16 @@ export const secretSharingServiceFactory = ({
|
||||
const secrets = await secretSharingDAL.find(
|
||||
{
|
||||
userId: actorId,
|
||||
orgId: actorOrgId
|
||||
orgId: actorOrgId,
|
||||
type
|
||||
},
|
||||
{ offset, limit, sort: [["createdAt", "desc"]] }
|
||||
);
|
||||
|
||||
const count = await secretSharingDAL.countAllUserOrgSharedSecrets({
|
||||
orgId: actorOrgId,
|
||||
userId: actorId
|
||||
userId: actorId,
|
||||
type
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -187,9 +390,11 @@ export const secretSharingServiceFactory = ({
|
||||
const sharedSecret = isUuidV4(sharedSecretId)
|
||||
? await secretSharingDAL.findOne({
|
||||
id: sharedSecretId,
|
||||
type: SecretSharingType.Share,
|
||||
hashedHex
|
||||
})
|
||||
: await secretSharingDAL.findOne({
|
||||
type: SecretSharingType.Share,
|
||||
identifier: Buffer.from(sharedSecretId, "base64url").toString("hex")
|
||||
});
|
||||
|
||||
@@ -254,7 +459,7 @@ export const secretSharingServiceFactory = ({
|
||||
secret: {
|
||||
...sharedSecret,
|
||||
...(decryptedSecretValue && {
|
||||
secretValue: Buffer.from(decryptedSecretValue).toString()
|
||||
secretValue: decryptedSecretValue.toString()
|
||||
}),
|
||||
orgName:
|
||||
sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId
|
||||
@@ -270,8 +475,8 @@ export const secretSharingServiceFactory = ({
|
||||
if (!permission) throw new ForbiddenRequestError({ name: "User does not belong to the specified organization" });
|
||||
|
||||
const sharedSecret = isUuidV4(sharedSecretId)
|
||||
? await secretSharingDAL.findById(sharedSecretId)
|
||||
: await secretSharingDAL.findOne({ identifier: sharedSecretId });
|
||||
? await secretSharingDAL.findOne({ id: sharedSecretId, type: deleteSharedSecretInput.type })
|
||||
: await secretSharingDAL.findOne({ identifier: sharedSecretId, type: deleteSharedSecretInput.type });
|
||||
|
||||
if (sharedSecret.orgId && sharedSecret.orgId !== orgId)
|
||||
throw new ForbiddenRequestError({ message: "User does not have permission to delete shared secret" });
|
||||
@@ -286,6 +491,11 @@ export const secretSharingServiceFactory = ({
|
||||
createPublicSharedSecret,
|
||||
getSharedSecrets,
|
||||
deleteSharedSecretById,
|
||||
getSharedSecretById
|
||||
getSharedSecretById,
|
||||
|
||||
createSecretRequest,
|
||||
getSecretRequestById,
|
||||
setSecretRequestValue,
|
||||
revealSecretRequestValue
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { SecretSharingAccessType, TGenericPermission } from "@app/lib/types";
|
||||
import { SecretSharingAccessType, TGenericPermission, TOrgPermission } from "@app/lib/types";
|
||||
|
||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
|
||||
export enum SecretSharingType {
|
||||
Share = "share",
|
||||
Request = "request"
|
||||
}
|
||||
|
||||
export type TGetSharedSecretsDTO = {
|
||||
type: SecretSharingType;
|
||||
offset: number;
|
||||
limit: number;
|
||||
} & TGenericPermission;
|
||||
@@ -39,6 +45,26 @@ export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & {
|
||||
|
||||
export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO;
|
||||
|
||||
export type TCreateSecretRequestDTO = {
|
||||
name?: string;
|
||||
accessType: SecretSharingAccessType;
|
||||
expiresAt: string;
|
||||
} & TOrgPermission;
|
||||
|
||||
export type TRevealSecretRequestValueDTO = {
|
||||
id: string;
|
||||
} & TOrgPermission;
|
||||
|
||||
export type TGetSecretRequestByIdDTO = {
|
||||
id: string;
|
||||
} & TOrgPermission;
|
||||
|
||||
export type TSetSecretRequestValueDTO = {
|
||||
id: string;
|
||||
secretValue: string;
|
||||
} & TOrgPermission;
|
||||
|
||||
export type TDeleteSharedSecretDTO = {
|
||||
sharedSecretId: string;
|
||||
type: SecretSharingType;
|
||||
} & TSharedSecretPermission;
|
||||
|
||||
@@ -39,7 +39,8 @@ export enum SmtpTemplates {
|
||||
SecretSyncFailed = "secretSyncFailed.handlebars",
|
||||
ExternalImportSuccessful = "externalImportSuccessful.handlebars",
|
||||
ExternalImportFailed = "externalImportFailed.handlebars",
|
||||
ExternalImportStarted = "externalImportStarted.handlebars"
|
||||
ExternalImportStarted = "externalImportStarted.handlebars",
|
||||
SecretRequestCompleted = "secretRequestCompleted.handlebars"
|
||||
}
|
||||
|
||||
export enum SmtpHost {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Secret Request Completed</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Infisical</h2>
|
||||
<h2>A secret has been shared with you</h2>
|
||||
|
||||
{{#if name}}
|
||||
<p>Secret request name: {{name}}</p>
|
||||
{{/if}}
|
||||
{{#if respondentUsername}}
|
||||
<p>Shared by: {{respondentUsername}}</p>
|
||||
{{/if}}
|
||||
|
||||
<br />
|
||||
<br/>
|
||||
|
||||
<p>
|
||||
You can access the secret by clicking the link below.
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{secretRequestUrl}}">Access Secret</a>
|
||||
</p>
|
||||
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -13,7 +13,9 @@ export enum PostHogEventTypes {
|
||||
IntegrationCreated = "Integration Created",
|
||||
MachineIdentityCreated = "Machine Identity Created",
|
||||
UserOrgInvitation = "User Org Invitation",
|
||||
TelemetryInstanceStats = "Self Hosted Instance Stats"
|
||||
TelemetryInstanceStats = "Self Hosted Instance Stats",
|
||||
SecretRequestCreated = "Secret Request Created",
|
||||
SecretRequestDeleted = "Secret Request Deleted"
|
||||
}
|
||||
|
||||
export type TSecretModifiedEvent = {
|
||||
@@ -120,6 +122,23 @@ export type TTelemetryInstanceStatsEvent = {
|
||||
};
|
||||
};
|
||||
|
||||
export type TSecretRequestCreatedEvent = {
|
||||
event: PostHogEventTypes.SecretRequestCreated;
|
||||
properties: {
|
||||
secretRequestId: string;
|
||||
organizationId: string;
|
||||
secretRequestName?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TSecretRequestDeletedEvent = {
|
||||
event: PostHogEventTypes.SecretRequestDeleted;
|
||||
properties: {
|
||||
secretRequestId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TPostHogEvent = { distinctId: string } & (
|
||||
| TSecretModifiedEvent
|
||||
| TAdminInitEvent
|
||||
@@ -130,4 +149,6 @@ export type TPostHogEvent = { distinctId: string } & (
|
||||
| TIntegrationCreatedEvent
|
||||
| TProjectCreateEvent
|
||||
| TTelemetryInstanceStatsEvent
|
||||
| TSecretRequestCreatedEvent
|
||||
| TSecretRequestDeletedEvent
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ export const publicPaths = [
|
||||
"/login/sso",
|
||||
"/admin/signup",
|
||||
"/shared/secret/[id]",
|
||||
"/secret-request/secret/[id]",
|
||||
"/share-secret"
|
||||
];
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ export const ROUTE_PATHS = Object.freeze({
|
||||
"/organization/secret-scanning",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-scanning"
|
||||
),
|
||||
SecretSharing: setRoute(
|
||||
"/organization/secret-sharing",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing"
|
||||
),
|
||||
SettingsPage: setRoute(
|
||||
"/organization/settings",
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/settings"
|
||||
@@ -285,6 +289,10 @@ export const ROUTE_PATHS = Object.freeze({
|
||||
)
|
||||
},
|
||||
Public: {
|
||||
ViewSharedSecretByIDPage: setRoute("/shared/secret/$secretId", "/shared/secret/$secretId")
|
||||
ViewSharedSecretByIDPage: setRoute("/shared/secret/$secretId", "/shared/secret/$secretId"),
|
||||
ViewSecretRequestByIDPage: setRoute(
|
||||
"/secret-request/secret/$secretRequestId",
|
||||
"/secret-request/secret/$secretRequestId"
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,8 +5,13 @@ import { apiRequest } from "@app/config/request";
|
||||
import { secretSharingKeys } from "./queries";
|
||||
import {
|
||||
TCreatedSharedSecret,
|
||||
TCreateSecretRequestRequest,
|
||||
TCreateSharedSecretRequest,
|
||||
TDeleteSharedSecretRequest,
|
||||
TDeleteSecretRequestDTO,
|
||||
TDeleteSharedSecretRequestDTO,
|
||||
TRevealedSecretRequest,
|
||||
TRevealSecretRequestValueRequest,
|
||||
TSetSecretRequestValueRequest,
|
||||
TSharedSecret
|
||||
} from "./types";
|
||||
|
||||
@@ -15,7 +20,7 @@ export const useCreateSharedSecret = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (inputData: TCreateSharedSecretRequest) => {
|
||||
const { data } = await apiRequest.post<TCreatedSharedSecret>(
|
||||
"/api/v1/secret-sharing",
|
||||
"/api/v1/secret-sharing/shared",
|
||||
inputData
|
||||
);
|
||||
return data;
|
||||
@@ -30,7 +35,7 @@ export const useCreatePublicSharedSecret = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (inputData: TCreateSharedSecretRequest) => {
|
||||
const { data } = await apiRequest.post<TCreatedSharedSecret>(
|
||||
"/api/v1/secret-sharing/public",
|
||||
"/api/v1/secret-sharing/shared/public",
|
||||
inputData
|
||||
);
|
||||
return data;
|
||||
@@ -40,12 +45,50 @@ export const useCreatePublicSharedSecret = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateSecretRequest = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (inputData: TCreateSecretRequestRequest) => {
|
||||
const { data } = await apiRequest.post<TCreatedSharedSecret>(
|
||||
"/api/v1/secret-sharing/requests",
|
||||
inputData
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: secretSharingKeys.allSecretRequests() })
|
||||
});
|
||||
};
|
||||
|
||||
export const useSetSecretRequestValue = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (inputData: TSetSecretRequestValueRequest) => {
|
||||
const { data } = await apiRequest.post<TSharedSecret>(
|
||||
`/api/v1/secret-sharing/requests/${inputData.id}/set-value`,
|
||||
inputData
|
||||
);
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useRevealSecretRequestValue = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (inputData: TRevealSecretRequestValueRequest) => {
|
||||
const { data } = await apiRequest.post<TRevealedSecretRequest>(
|
||||
`/api/v1/secret-sharing/requests/${inputData.id}/reveal-value`,
|
||||
inputData
|
||||
);
|
||||
return data.secretRequest;
|
||||
}
|
||||
});
|
||||
};
|
||||
export const useDeleteSharedSecret = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<TSharedSecret, { message: string }, { sharedSecretId: string }>({
|
||||
mutationFn: async ({ sharedSecretId }: TDeleteSharedSecretRequest) => {
|
||||
mutationFn: async ({ sharedSecretId }: TDeleteSharedSecretRequestDTO) => {
|
||||
const { data } = await apiRequest.delete<TSharedSecret>(
|
||||
`/api/v1/secret-sharing/${sharedSecretId}`
|
||||
`/api/v1/secret-sharing/shared/${sharedSecretId}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
@@ -53,3 +96,19 @@ export const useDeleteSharedSecret = () => {
|
||||
queryClient.invalidateQueries({ queryKey: secretSharingKeys.allSharedSecrets() })
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteSecretRequest = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<TSharedSecret, unknown, TDeleteSecretRequestDTO>({
|
||||
mutationFn: async ({ secretRequestId }: TDeleteSecretRequestDTO) => {
|
||||
const { data } = await apiRequest.delete<TSharedSecret>(
|
||||
`/api/v1/secret-sharing/requests/${secretRequestId}`
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: secretSharingKeys.allSecretRequests() });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,16 +2,20 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TSharedSecret, TViewSharedSecretResponse } from "./types";
|
||||
import { TGetSecretRequestByIdResponse, TSharedSecret, TViewSharedSecretResponse } from "./types";
|
||||
|
||||
export const secretSharingKeys = {
|
||||
allSharedSecrets: () => ["sharedSecrets"] as const,
|
||||
specificSharedSecrets: ({ offset, limit }: { offset: number; limit: number }) =>
|
||||
[...secretSharingKeys.allSharedSecrets(), { offset, limit }] as const,
|
||||
allSecretRequests: () => ["secretRequests"] as const,
|
||||
specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) =>
|
||||
[...secretSharingKeys.allSecretRequests(), { offset, limit }] as const,
|
||||
getSecretById: (arg: { id: string; hashedHex: string | null; password?: string }) => [
|
||||
"shared-secret",
|
||||
arg
|
||||
]
|
||||
],
|
||||
getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const
|
||||
};
|
||||
|
||||
export const useGetSharedSecrets = ({
|
||||
@@ -30,7 +34,7 @@ export const useGetSharedSecrets = ({
|
||||
});
|
||||
|
||||
const { data } = await apiRequest.get<{ secrets: TSharedSecret[]; totalCount: number }>(
|
||||
"/api/v1/secret-sharing/",
|
||||
"/api/v1/secret-sharing/shared",
|
||||
{
|
||||
params
|
||||
}
|
||||
@@ -40,6 +44,29 @@ export const useGetSharedSecrets = ({
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetSecretRequests = ({
|
||||
offset = 0,
|
||||
limit = 25
|
||||
}: {
|
||||
offset: number;
|
||||
limit: number;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: secretSharingKeys.specificSecretRequests({ offset, limit }),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{ secrets: TSharedSecret[]; totalCount: number }>(
|
||||
"/api/v1/secret-sharing/requests",
|
||||
{
|
||||
params: {
|
||||
offset: String(offset),
|
||||
limit: String(limit)
|
||||
}
|
||||
}
|
||||
);
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
export const useGetActiveSharedSecretById = ({
|
||||
sharedSecretId,
|
||||
hashedHex,
|
||||
@@ -53,7 +80,7 @@ export const useGetActiveSharedSecretById = ({
|
||||
queryKey: secretSharingKeys.getSecretById({ id: sharedSecretId, hashedHex, password }),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.post<TViewSharedSecretResponse>(
|
||||
`/api/v1/secret-sharing/public/${sharedSecretId}`,
|
||||
`/api/v1/secret-sharing/shared/public/${sharedSecretId}`,
|
||||
{
|
||||
...(hashedHex && { hashedHex }),
|
||||
password
|
||||
@@ -65,3 +92,16 @@ export const useGetActiveSharedSecretById = ({
|
||||
enabled: Boolean(sharedSecretId)
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetSecretRequestById = ({ secretRequestId }: { secretRequestId: string }) => {
|
||||
return useQuery({
|
||||
queryKey: secretSharingKeys.getSecretRequestById({ id: secretRequestId }),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TGetSecretRequestByIdResponse>(
|
||||
`/api/v1/secret-sharing/requests/${secretRequestId}`
|
||||
);
|
||||
|
||||
return data.secretRequest;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -9,10 +9,17 @@ export type TSharedSecret = {
|
||||
expiresAt: Date;
|
||||
expiresAfterViews: number | null;
|
||||
encryptedValue: string;
|
||||
encryptedSecret: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
export type TRevealedSecretRequest = {
|
||||
secretRequest: {
|
||||
secretValue: string;
|
||||
} & TSharedSecret;
|
||||
};
|
||||
|
||||
export type TCreatedSharedSecret = {
|
||||
id: string;
|
||||
};
|
||||
@@ -26,6 +33,21 @@ export type TCreateSharedSecretRequest = {
|
||||
accessType?: SecretSharingAccessType;
|
||||
};
|
||||
|
||||
export type TCreateSecretRequestRequest = {
|
||||
name?: string;
|
||||
accessType?: SecretSharingAccessType;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export type TSetSecretRequestValueRequest = {
|
||||
secretValue: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type TRevealSecretRequestValueRequest = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type TViewSharedSecretResponse = {
|
||||
isPasswordProtected: boolean;
|
||||
secret: {
|
||||
@@ -38,10 +60,27 @@ export type TViewSharedSecretResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type TDeleteSharedSecretRequest = {
|
||||
export type TGetSecretRequestByIdResponse = {
|
||||
secretRequest: {
|
||||
isSecretValueSet: boolean;
|
||||
accessType: SecretSharingAccessType;
|
||||
requester: {
|
||||
organizationName: string;
|
||||
username: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type TDeleteSharedSecretRequestDTO = {
|
||||
sharedSecretId: string;
|
||||
};
|
||||
|
||||
export type TDeleteSecretRequestDTO = {
|
||||
secretRequestId: string;
|
||||
};
|
||||
|
||||
export enum SecretSharingAccessType {
|
||||
Anyone = "anyone",
|
||||
Organization = "organization"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
|
||||
import { ShareSecretSection } from "./components";
|
||||
import { ShareSecretSection } from "./ShareSecretSection";
|
||||
|
||||
export const SecretSharingPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteSharedSecret } from "@app/hooks/api/secretSharing";
|
||||
|
||||
import { RequestSecretTab } from "./components/RequestSecret/RequestSecretTab";
|
||||
import { ShareSecretTab } from "./components/ShareSecret/ShareSecretTab";
|
||||
|
||||
type DeleteModalData = { name: string; id: string };
|
||||
|
||||
enum SecretSharingPageTabs {
|
||||
ShareSecret = "share-secret",
|
||||
RequestSecret = "request-secret"
|
||||
}
|
||||
|
||||
export const ShareSecretSection = () => {
|
||||
const deleteSharedSecret = useDeleteSharedSecret();
|
||||
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
|
||||
"createSharedSecret",
|
||||
"deleteSharedSecretConfirmation",
|
||||
"createSecretRequest",
|
||||
"deleteSecretRequestConfirmation",
|
||||
"revealSecretRequestValue"
|
||||
] as const);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { selectedTab } = useSearch({
|
||||
from: ROUTE_PATHS.Organization.SecretSharing.id
|
||||
});
|
||||
|
||||
const updateSelectedTab = (tab: string) => {
|
||||
navigate({
|
||||
to: ROUTE_PATHS.Organization.SecretSharing.path,
|
||||
search: (prev) => ({ ...prev, selectedTab: tab as SecretSharingPageTabs })
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>Secret Sharing</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
</Helmet>
|
||||
|
||||
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
|
||||
<TabList>
|
||||
<Tab value={SecretSharingPageTabs.ShareSecret}>Share Secrets</Tab>
|
||||
<Tab value={SecretSharingPageTabs.RequestSecret}>Request Secrets</Tab>
|
||||
</TabList>
|
||||
<TabPanel value={SecretSharingPageTabs.ShareSecret}>
|
||||
<ShareSecretTab
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value={SecretSharingPageTabs.RequestSecret}>
|
||||
<RequestSecretTab
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { RequestSecretForm } from "./RequestSecretForm";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["createSecretRequest"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["createSecretRequest"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const AddSecretRequestModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.createSecretRequest?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("createSecretRequest", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title="Request a Secret"
|
||||
subTitle="Securely request one off secrets from your team or people outside your organization."
|
||||
>
|
||||
<RequestSecretForm />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
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 { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import { SecretSharingAccessType, useCreateSecretRequest } from "@app/hooks/api/secretSharing";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().optional(),
|
||||
accessType: z
|
||||
.nativeEnum(SecretSharingAccessType)
|
||||
.default(SecretSharingAccessType.Anyone)
|
||||
.optional(),
|
||||
expiresIn: z.string()
|
||||
});
|
||||
|
||||
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 }
|
||||
];
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
export const RequestSecretForm = () => {
|
||||
const [secretLink, setSecretLink] = useState("");
|
||||
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
||||
initialState: "Copy to clipboard"
|
||||
});
|
||||
|
||||
const { mutateAsync: createSecretRequest } = useCreateSecretRequest();
|
||||
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ name, accessType, expiresIn }: FormData) => {
|
||||
const expiresAt = new Date(new Date().getTime() + Number(expiresIn));
|
||||
|
||||
try {
|
||||
const { id } = await createSecretRequest({
|
||||
name,
|
||||
accessType,
|
||||
expiresAt
|
||||
});
|
||||
|
||||
const link = `${window.location.origin}/secret-request/secret/${id}`;
|
||||
|
||||
setSecretLink(link);
|
||||
reset();
|
||||
|
||||
navigator.clipboard.writeText(link);
|
||||
setCopyTextSecret("secret");
|
||||
|
||||
createNotification({
|
||||
text: "Shared secret link copied to clipboard.",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to create a shared secret.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const hasSecretLink = Boolean(secretLink);
|
||||
|
||||
return !hasSecretLink ? (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Name (Optional)" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} placeholder="API Key" type="text" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="expiresIn"
|
||||
defaultValue="3600000"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Expires In" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{expiresInOptions.map(({ label, value: expiresInValue }) => (
|
||||
<SelectItem value={String(expiresInValue || "")} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="accessType"
|
||||
defaultValue={SecretSharingAccessType.Organization}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Select who is able to input the secret"
|
||||
label="General Access"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value={SecretSharingAccessType.Anyone}>Anyone</SelectItem>
|
||||
<SelectItem value={SecretSharingAccessType.Organization}>
|
||||
People within your organization
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Create Request Link
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="mr-2 flex items-center justify-end rounded-md bg-white/[0.05] p-2 text-base text-gray-400">
|
||||
<p className="mr-4 break-all">{secretLink}</p>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
colorSchema="secondary"
|
||||
className="group relative ml-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(secretLink);
|
||||
setCopyTextSecret("Copied");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopyingSecret ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Button
|
||||
className="mt-4 w-full bg-mineshaft-700 py-3 text-bunker-200"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
onClick={() => setSecretLink("")}
|
||||
rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
|
||||
>
|
||||
Request Another Secret
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { useDeleteSecretRequest } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { AddSecretRequestModal } from "./AddSecretRequestModal";
|
||||
import { RequestedSecretsTable } from "./RequestedSecretsTable";
|
||||
import { RevealSecretValueModal } from "./RevealSecretValueModal";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
["createSecretRequest", "deleteSecretRequestConfirmation", "revealSecretRequestValue"]
|
||||
>,
|
||||
data?: any
|
||||
) => void;
|
||||
popUp: UsePopUpState<
|
||||
["createSecretRequest", "deleteSecretRequestConfirmation", "revealSecretRequestValue"]
|
||||
>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
["createSecretRequest", "deleteSecretRequestConfirmation", "revealSecretRequestValue"]
|
||||
>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
handlePopUpClose: (
|
||||
popUpName: keyof UsePopUpState<["deleteSecretRequestConfirmation", "revealSecretRequestValue"]>
|
||||
) => void;
|
||||
};
|
||||
|
||||
type DeleteModalData = { name: string; id: string };
|
||||
|
||||
export const RequestSecretTab = ({
|
||||
handlePopUpOpen,
|
||||
popUp,
|
||||
handlePopUpToggle,
|
||||
handlePopUpClose
|
||||
}: Props) => {
|
||||
const { mutateAsync: deleteSecretRequest } = useDeleteSecretRequest();
|
||||
|
||||
const onDeleteApproved = async () => {
|
||||
try {
|
||||
await deleteSecretRequest({
|
||||
secretRequestId: popUp.deleteSecretRequestConfirmation.data?.id
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully deleted secret request",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteSecretRequestConfirmation");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to delete shared secret",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Secret Requests</p>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("createSecretRequest");
|
||||
}}
|
||||
>
|
||||
Request Secret
|
||||
</Button>
|
||||
</div>
|
||||
<RequestedSecretsTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<AddSecretRequestModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<RevealSecretValueModal
|
||||
isOpen={popUp.revealSecretRequestValue.isOpen}
|
||||
popUp={popUp}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("revealSecretRequestValue", isOpen)}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteSecretRequestConfirmation.isOpen}
|
||||
title={`Delete ${
|
||||
(popUp?.deleteSecretRequestConfirmation?.data as DeleteModalData)?.name || " "
|
||||
} secret request?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteSecretRequestConfirmation", isOpen)}
|
||||
deleteKey={(popUp?.deleteSecretRequestConfirmation?.data as DeleteModalData)?.name}
|
||||
onClose={() => handlePopUpClose("deleteSecretRequestConfirmation")}
|
||||
onDeleteApproved={onDeleteApproved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
/* eslint-disable no-extra-boolean-cast */
|
||||
import { faCopy, faEye, faSpinner, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Badge, IconButton, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { TSharedSecret, useRevealSecretRequestValue } from "@app/hooks/api/secretSharing";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
export const RequestedSecretsRow = ({
|
||||
row,
|
||||
handlePopUpOpen
|
||||
}: {
|
||||
row: TSharedSecret;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteSecretRequestConfirmation", "revealSecretRequestValue"]>,
|
||||
data: unknown
|
||||
) => void;
|
||||
}) => {
|
||||
const { mutateAsync: revealSecretValue, isPending } = useRevealSecretRequestValue();
|
||||
|
||||
let isExpired = false;
|
||||
if (row.expiresAt !== null && new Date(row.expiresAt) < new Date()) {
|
||||
isExpired = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr key={row.id}>
|
||||
<Td>{row.name ? `${row.name}` : "-"}</Td>
|
||||
<Td>
|
||||
{isExpired && !row.encryptedSecret ? (
|
||||
<Badge variant="danger">Expired</Badge>
|
||||
) : (
|
||||
<Badge variant={row.encryptedSecret ? "success" : "primary"}>
|
||||
{row.encryptedSecret ? "Secret Provided" : "Pending Secret"}
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td>{`${format(new Date(row.createdAt), "yyyy-MM-dd - HH:mm a")}`}</Td>
|
||||
<Td>{row.expiresAt ? format(new Date(row.expiresAt), "yyyy-MM-dd - HH:mm a") : "-"}</Td>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip
|
||||
content={
|
||||
row.encryptedSecret
|
||||
? "Reveal shared secret"
|
||||
: "Secret value must be provided before it can be viewed."
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
isDisabled={!row.encryptedSecret}
|
||||
className={row.encryptedSecret ? "" : "opacity-50"}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const secretRequest = await revealSecretValue({
|
||||
id: row.id
|
||||
});
|
||||
|
||||
console.log("revealSecretRequestValue", {
|
||||
secretValue: secretRequest.secretValue,
|
||||
secretRequestName: secretRequest.name
|
||||
});
|
||||
|
||||
handlePopUpOpen("revealSecretRequestValue", {
|
||||
secretValue: secretRequest.secretValue,
|
||||
secretRequestName: secretRequest.name
|
||||
});
|
||||
}}
|
||||
variant="plain"
|
||||
ariaLabel="reveal"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className={isPending ? "animate-spin" : ""}
|
||||
icon={!isPending ? faEye : faSpinner}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<IconButton
|
||||
isDisabled={Boolean(row.encryptedSecret) || isExpired}
|
||||
className={Boolean(row.encryptedSecret) || isExpired ? "opacity-50" : ""}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/secret-request/secret/${row.id}`
|
||||
);
|
||||
|
||||
createNotification({
|
||||
text: "Shared secret link copied to clipboard.",
|
||||
type: "success"
|
||||
});
|
||||
}}
|
||||
variant="plain"
|
||||
ariaLabel="copy link"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</IconButton>
|
||||
|
||||
<Tooltip content="Delete Secret Request">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteSecretRequestConfirmation", {
|
||||
name: "delete",
|
||||
id: row.id
|
||||
});
|
||||
}}
|
||||
variant="plain"
|
||||
ariaLabel="delete"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from "react";
|
||||
import { faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useGetSecretRequests } from "@app/hooks/api/secretSharing";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { RequestedSecretsRow } from "./RequestedSecretsRow";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteSecretRequestConfirmation", "revealSecretRequestValue"]>,
|
||||
data: unknown
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const RequestedSecretsTable = ({ handlePopUpOpen }: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const { isPending, data } = useGetSecretRequests({
|
||||
offset: (page - 1) * perPage,
|
||||
limit: perPage
|
||||
});
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Created At</Th>
|
||||
<Th>Valid Until</Th>
|
||||
<Th aria-label="button" className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={7} innerKey="shared-secrets" />}
|
||||
{!isPending &&
|
||||
data?.secrets?.map((row) => (
|
||||
<RequestedSecretsRow key={row.id} row={row} handlePopUpOpen={handlePopUpOpen} />
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending &&
|
||||
data?.secrets &&
|
||||
data?.totalCount >= perPage &&
|
||||
data?.totalCount !== undefined && (
|
||||
<Pagination
|
||||
count={data.totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !data?.secrets?.length && (
|
||||
<EmptyState title="No secrets shared yet" icon={faKey} />
|
||||
)}
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button, IconButton, Modal, ModalClose, ModalContent, Tooltip } from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
popUp: UsePopUpState<["revealSecretRequestValue"]>;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
secretValue: string;
|
||||
secretRequestName?: string;
|
||||
};
|
||||
|
||||
const Content = ({ secretValue, secretRequestName }: ContentProps) => {
|
||||
const [isSecretValueCopied, setIsSecretValueCopied] = useToggle(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{secretRequestName && (
|
||||
<p className="mb-8 text-sm text-mineshaft-200">
|
||||
Shared secret value for <strong>{secretRequestName}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
||||
<p className="mr-4 break-all">{secretValue}</p>
|
||||
<Tooltip content="Click to copy">
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
colorSchema="secondary"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(secretValue);
|
||||
setIsSecretValueCopied.on();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isSecretValueCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex w-full items-center justify-between gap-2">
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="primary">Close</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const RevealSecretValueModal = ({ isOpen, onOpenChange, popUp }: Props) => {
|
||||
const data = popUp.revealSecretRequestValue.data as {
|
||||
secretValue: string;
|
||||
secretRequestName?: string;
|
||||
};
|
||||
|
||||
const title = data?.secretRequestName
|
||||
? `Shared secret value for secret request ${data.secretRequestName}`
|
||||
: "Shared secret value";
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent title={title}>
|
||||
<Content secretRequestName={data?.secretRequestName} secretValue={data?.secretValue} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,40 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteSharedSecret } from "@app/hooks/api/secretSharing";
|
||||
import { useDeleteSharedSecret } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { AddShareSecretModal } from "./AddShareSecretModal";
|
||||
import { ShareSecretsTable } from "./ShareSecretsTable";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["createSharedSecret", "deleteSharedSecretConfirmation"]>,
|
||||
data?: any
|
||||
) => void;
|
||||
popUp: UsePopUpState<["createSharedSecret", "deleteSharedSecretConfirmation"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["createSharedSecret", "deleteSharedSecretConfirmation"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
handlePopUpClose: (popUpName: keyof UsePopUpState<["deleteSharedSecretConfirmation"]>) => void;
|
||||
};
|
||||
|
||||
type DeleteModalData = { name: string; id: string };
|
||||
|
||||
export const ShareSecretSection = () => {
|
||||
const deleteSharedSecret = useDeleteSharedSecret();
|
||||
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
|
||||
"createSharedSecret",
|
||||
"deleteSharedSecretConfirmation"
|
||||
] as const);
|
||||
export const ShareSecretTab = ({
|
||||
handlePopUpOpen,
|
||||
popUp,
|
||||
handlePopUpToggle,
|
||||
handlePopUpClose
|
||||
}: Props) => {
|
||||
const deleteSecretShare = useDeleteSharedSecret();
|
||||
|
||||
const onDeleteApproved = async () => {
|
||||
try {
|
||||
deleteSharedSecret.mutateAsync({
|
||||
deleteSecretShare.mutateAsync({
|
||||
sharedSecretId: (popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.id
|
||||
});
|
||||
createNotification({
|
||||
@@ -41,11 +54,6 @@ export const ShareSecretSection = () => {
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<Helmet>
|
||||
<title>Secret Sharing</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
</Helmet>
|
||||
<div className="mb-4 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Shared Secrets</p>
|
||||
<Button
|
||||
@@ -1,13 +1,24 @@
|
||||
import { faHome } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretSharingPage } from "./SecretSharingPage";
|
||||
|
||||
const SecretSharingQueryParams = z.object({
|
||||
selectedTab: z.string().catch("")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing"
|
||||
)({
|
||||
component: SecretSharingPage,
|
||||
|
||||
validateSearch: zodValidator(SecretSharingQueryParams),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ selectedTab: "" })]
|
||||
},
|
||||
context: () => ({
|
||||
breadcrumbs: [
|
||||
{
|
||||
@@ -16,7 +27,7 @@ export const Route = createFileRoute(
|
||||
link: linkOptions({ to: "/" })
|
||||
},
|
||||
{
|
||||
label: "secret sharing"
|
||||
label: "Secret Sharing"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { AxiosError } from "axios";
|
||||
import { addSeconds, formatISO } from "date-fns";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { SessionStorageKeys } from "@app/const";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useGetSecretRequestById } from "@app/hooks/api/secretSharing";
|
||||
|
||||
import { SecretRequestErrorContainer } from "./components/SecretErrorContainer";
|
||||
import { SecretRequestContainer } from "./components/SecretRequestContainer";
|
||||
import { SecretRequestSuccessContainer } from "./components/SecretRequestSuccessContainer";
|
||||
import { SecretValueAlreadySharedContainer } from "./components/SecretValueAlreadySharedContainer";
|
||||
|
||||
export const ViewSecretRequestByIDPage = () => {
|
||||
const id = useParams({
|
||||
from: ROUTE_PATHS.Public.ViewSecretRequestByIDPage.id,
|
||||
select: (el) => el.secretRequestId
|
||||
});
|
||||
|
||||
const [step, setStep] = useState<"set-value" | "success">("set-value");
|
||||
|
||||
const {
|
||||
data: secretRequest,
|
||||
error,
|
||||
isPending
|
||||
} = useGetSecretRequestById({
|
||||
secretRequestId: id
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const statusCode = ((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode;
|
||||
const message = ((error as AxiosError)?.response?.data as { message: string })?.message;
|
||||
|
||||
const isUnauthorized = statusCode === 401;
|
||||
const isForbidden = statusCode === 403;
|
||||
const isInvalidCredential = message === "Invalid credentials";
|
||||
|
||||
useEffect(() => {
|
||||
if (isUnauthorized && !isInvalidCredential) {
|
||||
// persist current URL in session storage so that we can come back to this after successful login
|
||||
sessionStorage.setItem(
|
||||
SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL,
|
||||
JSON.stringify({
|
||||
expiry: formatISO(addSeconds(new Date(), 60)),
|
||||
data: window.location.href
|
||||
})
|
||||
);
|
||||
|
||||
createNotification({
|
||||
type: "info",
|
||||
text: "Login is required in order to access the shared secret."
|
||||
});
|
||||
|
||||
navigate({
|
||||
to: "/login"
|
||||
});
|
||||
}
|
||||
|
||||
if (isForbidden) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "You do not have access to this shared secret."
|
||||
});
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Securely Share Secrets | Infisical</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
<meta property="og:title" content="" />
|
||||
<meta name="og:description" content="" />
|
||||
</Helmet>
|
||||
<div className="flex h-screen flex-col justify-between overflow-auto bg-gradient-to-tr from-mineshaft-700 to-bunker-800 text-gray-200 dark:[color-scheme:dark]">
|
||||
<div />
|
||||
<div className="mx-auto w-full max-w-xl px-4 py-4 md:px-0">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mb-4 flex justify-center pt-8">
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://infisical.com">
|
||||
<img
|
||||
src="/images/gradientLogo.svg"
|
||||
height={90}
|
||||
width={120}
|
||||
alt="Infisical logo"
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-4xl font-medium text-transparent">
|
||||
{step === "set-value" ? "Secret Request" : "Secret request shared"}
|
||||
</h1>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
Secret requested by {secretRequest?.requester.username} from the{" "}
|
||||
{secretRequest?.requester.organizationName} organization
|
||||
</p>
|
||||
<p className="text-md mt-2">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href="https://github.com/infisical/infisical"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bold bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent"
|
||||
>
|
||||
Infisical →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{!isPending && (
|
||||
<>
|
||||
{!error &&
|
||||
secretRequest &&
|
||||
step === "set-value" &&
|
||||
!secretRequest.isSecretValueSet && (
|
||||
<SecretRequestContainer
|
||||
onSuccess={() => setStep("success")}
|
||||
secretRequestId={id}
|
||||
/>
|
||||
)}
|
||||
{secretRequest?.isSecretValueSet && <SecretValueAlreadySharedContainer />}
|
||||
{error && !isInvalidCredential && !isUnauthorized && <SecretRequestErrorContainer />}
|
||||
{step === "success" && (
|
||||
<SecretRequestSuccessContainer
|
||||
requesterUsername={secretRequest!.requester.username}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="m-auto my-8 flex w-full">
|
||||
<div className="w-full border-t border-mineshaft-600" />
|
||||
</div>
|
||||
<div className="m-auto flex w-full flex-col rounded-md border border-primary-500/30 bg-primary/5 p-6 pt-5">
|
||||
<p className="w-full pb-2 text-lg font-semibold text-mineshaft-100 md:pb-3 md:text-xl">
|
||||
Open source{" "}
|
||||
<span className="bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent">
|
||||
secret management
|
||||
</span>{" "}
|
||||
for developers
|
||||
</p>
|
||||
<div className="flex flex-col items-start sm:flex-row sm:items-center">
|
||||
<p className="md:text-md text-md mr-4">
|
||||
<a
|
||||
href="https://github.com/infisical/infisical"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bold bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent"
|
||||
>
|
||||
Infisical
|
||||
</a>{" "}
|
||||
is the all-in-one secret management platform to securely manage secrets, configs,
|
||||
and certificates across your team and infrastructure.
|
||||
</p>
|
||||
<div className="mt-4 cursor-pointer sm:mt-0">
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://infisical.com">
|
||||
<div className="flex items-center justify-between rounded-md border border-mineshaft-400/40 bg-mineshaft-600 px-3 py-2 duration-200 hover:border-primary/60 hover:bg-primary/20 hover:text-white">
|
||||
<p className="mr-4 whitespace-nowrap">Try Infisical</p>
|
||||
<FontAwesomeIcon icon={faArrowRight} />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-mineshaft-600 p-2">
|
||||
<p className="text-center text-sm text-mineshaft-300">
|
||||
Made with ❤️ by{" "}
|
||||
<a className="text-primary" href="https://infisical.com">
|
||||
Infisical
|
||||
</a>
|
||||
<br />
|
||||
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
export const SecretRequestErrorContainer = () => {
|
||||
return (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-8">
|
||||
<div className="text-center">
|
||||
<FontAwesomeIcon icon={faKey} size="2x" />
|
||||
<p className="mt-4">The secret request you are looking is missing or has expired.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { faArrowRight } 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 { Button, FormControl, TextArea } from "@app/components/v2";
|
||||
import { useSetSecretRequestValue } from "@app/hooks/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
secretValue: z.string().min(1)
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
onSuccess: () => void;
|
||||
secretRequestId: string;
|
||||
};
|
||||
|
||||
export const SecretRequestContainer = ({ onSuccess, secretRequestId }: Props) => {
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const { mutateAsync: setSecretValue, isPending } = useSetSecretRequestValue();
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
await setSecretValue({
|
||||
id: secretRequestId,
|
||||
secretValue: data.secretValue
|
||||
});
|
||||
|
||||
createNotification({
|
||||
title: "Secret request value shared",
|
||||
text: "The secret request value has been shared",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4">
|
||||
<div className="flex items-center justify-between rounded-md bg-white/[0.05] p-2 text-base text-gray-400">
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="secretValue"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
label="Secret Value"
|
||||
>
|
||||
<TextArea {...field} rows={10} reSize="none" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
isDisabled={isPending}
|
||||
colorSchema="secondary"
|
||||
className="w-full"
|
||||
type="submit"
|
||||
>
|
||||
Share Secret
|
||||
<FontAwesomeIcon className="ml-2" icon={faArrowRight} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { faCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
type Props = {
|
||||
requesterUsername: string;
|
||||
};
|
||||
|
||||
export const SecretRequestSuccessContainer = ({ requesterUsername }: Props) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-8">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-min rounded-md border border-mineshaft-800 bg-mineshaft-600 p-3">
|
||||
<FontAwesomeIcon icon={faCheck} size="2x" className="text-primary-500" />
|
||||
</div>
|
||||
<p className="text-md mt-2 font-semibold">Secret Shared</p>
|
||||
<p className="mt-2 text-sm text-mineshaft-300">
|
||||
<strong>{requesterUsername}</strong> has now been notified of your shared secret, and will
|
||||
be able to access it shortly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
export const SecretValueAlreadySharedContainer = () => {
|
||||
return (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-8">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-min rounded-md border border-mineshaft-800 bg-mineshaft-600 p-3">
|
||||
<FontAwesomeIcon icon={faKey} size="2x" className="text-primary-500" />
|
||||
</div>
|
||||
<p className="text-md mt-2 font-semibold">Secret Already Shared</p>
|
||||
<p className="mt-2 text-sm text-mineshaft-300">
|
||||
A secret value has already been shared for this secret request.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
|
||||
|
||||
import { ViewSecretRequestByIDPage } from "./ViewSecretRequestByIDPage";
|
||||
|
||||
export const Route = createFileRoute("/secret-request/secret/$secretRequestId")({
|
||||
component: ViewSecretRequestByIDPage,
|
||||
beforeLoad: async ({ context }) => {
|
||||
await context.queryClient
|
||||
.ensureQueryData({
|
||||
queryKey: authKeys.getAuthToken,
|
||||
queryFn: fetchAuthToken
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import { Route as authPasswordSetupPageRouteImport } from './pages/auth/Password
|
||||
import { Route as userLayoutImport } from './pages/user/layout'
|
||||
import { Route as organizationLayoutImport } from './pages/organization/layout'
|
||||
import { Route as publicViewSharedSecretByIDPageRouteImport } from './pages/public/ViewSharedSecretByIDPage/route'
|
||||
import { Route as publicViewSecretRequestByIDPageRouteImport } from './pages/public/ViewSecretRequestByIDPage/route'
|
||||
import { Route as authSignUpSsoPageRouteImport } from './pages/auth/SignUpSsoPage/route'
|
||||
import { Route as authLoginSsoPageRouteImport } from './pages/auth/LoginSsoPage/route'
|
||||
import { Route as authSelectOrgPageRouteImport } from './pages/auth/SelectOrgPage/route'
|
||||
@@ -354,6 +355,13 @@ const publicViewSharedSecretByIDPageRouteRoute =
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const publicViewSecretRequestByIDPageRouteRoute =
|
||||
publicViewSecretRequestByIDPageRouteImport.update({
|
||||
id: '/secret-request/secret/$secretRequestId',
|
||||
path: '/secret-request/secret/$secretRequestId',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const authSignUpSsoPageRouteRoute = authSignUpSsoPageRouteImport.update({
|
||||
id: '/sso',
|
||||
path: '/sso',
|
||||
@@ -1760,6 +1768,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof authSignUpSsoPageRouteImport
|
||||
parentRoute: typeof RestrictLoginSignupSignupImport
|
||||
}
|
||||
'/secret-request/secret/$secretRequestId': {
|
||||
id: '/secret-request/secret/$secretRequestId'
|
||||
path: '/secret-request/secret/$secretRequestId'
|
||||
fullPath: '/secret-request/secret/$secretRequestId'
|
||||
preLoaderRoute: typeof publicViewSecretRequestByIDPageRouteImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/shared/secret/$secretId': {
|
||||
id: '/shared/secret/$secretId'
|
||||
path: '/shared/secret/$secretId'
|
||||
@@ -3643,6 +3658,7 @@ export interface FileRoutesByFullPath {
|
||||
'/login/select-organization': typeof authSelectOrgPageRouteRoute
|
||||
'/login/sso': typeof authLoginSsoPageRouteRoute
|
||||
'/signup/sso': typeof authSignUpSsoPageRouteRoute
|
||||
'/secret-request/secret/$secretRequestId': typeof publicViewSecretRequestByIDPageRouteRoute
|
||||
'/shared/secret/$secretId': typeof publicViewSharedSecretByIDPageRouteRoute
|
||||
'/admin': typeof adminLayoutRouteWithChildren
|
||||
'/personal-settings/': typeof userPersonalSettingsPageRouteRoute
|
||||
@@ -3817,6 +3833,7 @@ export interface FileRoutesByTo {
|
||||
'/login/select-organization': typeof authSelectOrgPageRouteRoute
|
||||
'/login/sso': typeof authLoginSsoPageRouteRoute
|
||||
'/signup/sso': typeof authSignUpSsoPageRouteRoute
|
||||
'/secret-request/secret/$secretRequestId': typeof publicViewSecretRequestByIDPageRouteRoute
|
||||
'/shared/secret/$secretId': typeof publicViewSharedSecretByIDPageRouteRoute
|
||||
'/admin': typeof adminOverviewPageRouteRoute
|
||||
'/login/provider/error': typeof authProviderErrorPageRouteRoute
|
||||
@@ -3991,6 +4008,7 @@ export interface FileRoutesById {
|
||||
'/_restrict-login-signup/login/select-organization': typeof authSelectOrgPageRouteRoute
|
||||
'/_restrict-login-signup/login/sso': typeof authLoginSsoPageRouteRoute
|
||||
'/_restrict-login-signup/signup/sso': typeof authSignUpSsoPageRouteRoute
|
||||
'/secret-request/secret/$secretRequestId': typeof publicViewSecretRequestByIDPageRouteRoute
|
||||
'/shared/secret/$secretId': typeof publicViewSharedSecretByIDPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout': typeof organizationLayoutRouteWithChildren
|
||||
'/_authenticate/_inject-org-details/admin': typeof AuthenticateInjectOrgDetailsAdminRouteWithChildren
|
||||
@@ -4176,6 +4194,7 @@ export interface FileRouteTypes {
|
||||
| '/login/select-organization'
|
||||
| '/login/sso'
|
||||
| '/signup/sso'
|
||||
| '/secret-request/secret/$secretRequestId'
|
||||
| '/shared/secret/$secretId'
|
||||
| '/admin'
|
||||
| '/personal-settings/'
|
||||
@@ -4349,6 +4368,7 @@ export interface FileRouteTypes {
|
||||
| '/login/select-organization'
|
||||
| '/login/sso'
|
||||
| '/signup/sso'
|
||||
| '/secret-request/secret/$secretRequestId'
|
||||
| '/shared/secret/$secretId'
|
||||
| '/admin'
|
||||
| '/login/provider/error'
|
||||
@@ -4521,6 +4541,7 @@ export interface FileRouteTypes {
|
||||
| '/_restrict-login-signup/login/select-organization'
|
||||
| '/_restrict-login-signup/login/sso'
|
||||
| '/_restrict-login-signup/signup/sso'
|
||||
| '/secret-request/secret/$secretRequestId'
|
||||
| '/shared/secret/$secretId'
|
||||
| '/_authenticate/_inject-org-details/_org-layout'
|
||||
| '/_authenticate/_inject-org-details/admin'
|
||||
@@ -4689,6 +4710,7 @@ export interface RootRouteChildren {
|
||||
publicShareSecretPageRouteRoute: typeof publicShareSecretPageRouteRoute
|
||||
middlewaresAuthenticateRoute: typeof middlewaresAuthenticateRouteWithChildren
|
||||
middlewaresRestrictLoginSignupRoute: typeof middlewaresRestrictLoginSignupRouteWithChildren
|
||||
publicViewSecretRequestByIDPageRouteRoute: typeof publicViewSecretRequestByIDPageRouteRoute
|
||||
publicViewSharedSecretByIDPageRouteRoute: typeof publicViewSharedSecretByIDPageRouteRoute
|
||||
}
|
||||
|
||||
@@ -4699,6 +4721,8 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
middlewaresAuthenticateRoute: middlewaresAuthenticateRouteWithChildren,
|
||||
middlewaresRestrictLoginSignupRoute:
|
||||
middlewaresRestrictLoginSignupRouteWithChildren,
|
||||
publicViewSecretRequestByIDPageRouteRoute:
|
||||
publicViewSecretRequestByIDPageRouteRoute,
|
||||
publicViewSharedSecretByIDPageRouteRoute:
|
||||
publicViewSharedSecretByIDPageRouteRoute,
|
||||
}
|
||||
@@ -4718,6 +4742,7 @@ export const routeTree = rootRoute
|
||||
"/share-secret",
|
||||
"/_authenticate",
|
||||
"/_restrict-login-signup",
|
||||
"/secret-request/secret/$secretRequestId",
|
||||
"/shared/secret/$secretId"
|
||||
]
|
||||
},
|
||||
@@ -4843,6 +4868,9 @@ export const routeTree = rootRoute
|
||||
"filePath": "auth/SignUpSsoPage/route.tsx",
|
||||
"parent": "/_restrict-login-signup/signup"
|
||||
},
|
||||
"/secret-request/secret/$secretRequestId": {
|
||||
"filePath": "public/ViewSecretRequestByIDPage/route.tsx"
|
||||
},
|
||||
"/shared/secret/$secretId": {
|
||||
"filePath": "public/ViewSharedSecretByIDPage/route.tsx"
|
||||
},
|
||||
|
||||
@@ -316,6 +316,7 @@ const sshRoutes = route("/ssh/$projectId", [
|
||||
export const routes = rootRoute("root.tsx", [
|
||||
index("index.tsx"),
|
||||
route("/shared/secret/$secretId", "public/ViewSharedSecretByIDPage/route.tsx"),
|
||||
route("/secret-request/secret/$secretRequestId", "public/ViewSecretRequestByIDPage/route.tsx"),
|
||||
route("/share-secret", "public/ShareSecretPage/route.tsx"),
|
||||
route("/cli-redirect", "auth/CliRedirectPage/route.tsx"),
|
||||
middleware("restrict-login-signup.tsx", [
|
||||
|
||||
Reference in New Issue
Block a user