mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-04 02:26:19 +00:00
Merge
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing");
|
||||||
|
if (!hasSecretSharingColumn) {
|
||||||
|
await knex.schema.table(TableName.Project, (table) => {
|
||||||
|
table.boolean("secretSharing").notNullable().defaultTo(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing");
|
||||||
|
if (hasSecretSharingColumn) {
|
||||||
|
await knex.schema.table(TableName.Project, (table) => {
|
||||||
|
table.dropColumn("secretSharing");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (await knex.schema.hasTable(TableName.SecretSharing)) {
|
||||||
|
const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt");
|
||||||
|
const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails");
|
||||||
|
|
||||||
|
if (!hasEncryptedSalt || !hasAuthorizedEmails) {
|
||||||
|
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
|
||||||
|
// These two columns are only needed when secrets are shared with a specific list of emails
|
||||||
|
|
||||||
|
if (!hasEncryptedSalt) {
|
||||||
|
t.binary("encryptedSalt").nullable();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAuthorizedEmails) {
|
||||||
|
t.json("authorizedEmails").nullable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
if (await knex.schema.hasTable(TableName.SecretSharing)) {
|
||||||
|
const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt");
|
||||||
|
const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails");
|
||||||
|
|
||||||
|
if (hasEncryptedSalt || hasAuthorizedEmails) {
|
||||||
|
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
|
||||||
|
if (hasEncryptedSalt) {
|
||||||
|
t.dropColumn("encryptedSalt");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAuthorizedEmails) {
|
||||||
|
t.dropColumn("authorizedEmails");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,8 @@ export const ProjectsSchema = z.object({
|
|||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
enforceCapitalization: z.boolean().default(false),
|
enforceCapitalization: z.boolean().default(false),
|
||||||
hasDeleteProtection: z.boolean().default(false).nullable().optional()
|
hasDeleteProtection: z.boolean().default(false).nullable().optional(),
|
||||||
|
secretSharing: z.boolean().default(true)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TProjects = z.infer<typeof ProjectsSchema>;
|
export type TProjects = z.infer<typeof ProjectsSchema>;
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export const SecretSharingSchema = z.object({
|
|||||||
password: z.string().nullable().optional(),
|
password: z.string().nullable().optional(),
|
||||||
encryptedSecret: zodBuffer.nullable().optional(),
|
encryptedSecret: zodBuffer.nullable().optional(),
|
||||||
identifier: z.string().nullable().optional(),
|
identifier: z.string().nullable().optional(),
|
||||||
type: z.string().default("share")
|
type: z.string().default("share"),
|
||||||
|
encryptedSalt: zodBuffer.nullable().optional(),
|
||||||
|
authorizedEmails: z.unknown().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSecretSharing = z.infer<typeof SecretSharingSchema>;
|
export type TSecretSharing = z.infer<typeof SecretSharingSchema>;
|
||||||
|
|||||||
@@ -608,7 +608,8 @@ export const PROJECTS = {
|
|||||||
projectDescription: "An optional description label for the project.",
|
projectDescription: "An optional description label for the project.",
|
||||||
autoCapitalization: "Disable or enable auto-capitalization for the project.",
|
autoCapitalization: "Disable or enable auto-capitalization for the project.",
|
||||||
slug: "An optional slug for the project. (must be unique within the organization)",
|
slug: "An optional slug for the project. (must be unique within the organization)",
|
||||||
hasDeleteProtection: "Enable or disable delete protection for the project."
|
hasDeleteProtection: "Enable or disable delete protection for the project.",
|
||||||
|
secretSharing: "Enable or disable secret sharing for the project."
|
||||||
},
|
},
|
||||||
GET_KEY: {
|
GET_KEY: {
|
||||||
workspaceId: "The ID of the project to get the key from."
|
workspaceId: "The ID of the project to get the key from."
|
||||||
|
|||||||
@@ -261,7 +261,8 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({
|
|||||||
pitVersionLimit: true,
|
pitVersionLimit: true,
|
||||||
kmsCertificateKeyId: true,
|
kmsCertificateKeyId: true,
|
||||||
auditLogsRetentionDays: true,
|
auditLogsRetentionDays: true,
|
||||||
hasDeleteProtection: true
|
hasDeleteProtection: true,
|
||||||
|
secretSharing: true
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SanitizedTagSchema = SecretTagsSchema.pick({
|
export const SanitizedTagSchema = SecretTagsSchema.pick({
|
||||||
|
|||||||
@@ -131,8 +131,8 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
|||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate),
|
certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate),
|
||||||
certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain),
|
certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain),
|
||||||
privateKey: z.string().trim().describe(CERTIFICATES.GET_CERT.privateKey),
|
privateKey: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.privateKey),
|
||||||
serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes)
|
serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -518,7 +518,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
|||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate),
|
certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate),
|
||||||
certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain),
|
certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain),
|
||||||
serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes)
|
serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,7 +346,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
"Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore."
|
"Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore."
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.describe(PROJECTS.UPDATE.slug)
|
.describe(PROJECTS.UPDATE.slug),
|
||||||
|
secretSharing: z.boolean().optional().describe(PROJECTS.UPDATE.secretSharing)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
@@ -366,7 +367,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
description: req.body.description,
|
description: req.body.description,
|
||||||
autoCapitalization: req.body.autoCapitalization,
|
autoCapitalization: req.body.autoCapitalization,
|
||||||
hasDeleteProtection: req.body.hasDeleteProtection,
|
hasDeleteProtection: req.body.hasDeleteProtection,
|
||||||
slug: req.body.slug
|
slug: req.body.slug,
|
||||||
|
secretSharing: req.body.secretSharing
|
||||||
},
|
},
|
||||||
actorAuthMethod: req.permission.authMethod,
|
actorAuthMethod: req.permission.authMethod,
|
||||||
actorId: req.permission.id,
|
actorId: req.permission.id,
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
|||||||
}),
|
}),
|
||||||
body: z.object({
|
body: z.object({
|
||||||
hashedHex: z.string().min(1).optional(),
|
hashedHex: z.string().min(1).optional(),
|
||||||
password: z.string().optional()
|
password: z.string().optional(),
|
||||||
|
email: z.string().optional(),
|
||||||
|
hash: z.string().optional()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
@@ -88,7 +90,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
|||||||
sharedSecretId: req.params.id,
|
sharedSecretId: req.params.id,
|
||||||
hashedHex: req.body.hashedHex,
|
hashedHex: req.body.hashedHex,
|
||||||
password: req.body.password,
|
password: req.body.password,
|
||||||
orgId: req.permission?.orgId
|
orgId: req.permission?.orgId,
|
||||||
|
email: req.body.email,
|
||||||
|
hash: req.body.hash
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sharedSecret.secret?.orgId) {
|
if (sharedSecret.secret?.orgId) {
|
||||||
@@ -151,7 +155,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
|||||||
secretValue: z.string(),
|
secretValue: z.string(),
|
||||||
expiresAt: z.string(),
|
expiresAt: z.string(),
|
||||||
expiresAfterViews: z.number().min(1).optional(),
|
expiresAfterViews: z.number().min(1).optional(),
|
||||||
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization)
|
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization),
|
||||||
|
emails: z.string().email().array().max(100).optional()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const buildCertificateChain = async ({
|
|||||||
kmsService,
|
kmsService,
|
||||||
kmsId
|
kmsId
|
||||||
}: TBuildCertificateChainDTO) => {
|
}: TBuildCertificateChainDTO) => {
|
||||||
if (!encryptedCertificateChain && (!caCert || !caCertChain)) {
|
if (!encryptedCertificateChain && !caCert) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
TGetCertPrivateKeyDTO,
|
TGetCertPrivateKeyDTO,
|
||||||
TRevokeCertDTO
|
TRevokeCertDTO
|
||||||
} from "./certificate-types";
|
} from "./certificate-types";
|
||||||
|
import { NotFoundError } from "@app/lib/errors";
|
||||||
|
|
||||||
type TCertificateServiceFactoryDep = {
|
type TCertificateServiceFactoryDep = {
|
||||||
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">;
|
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">;
|
||||||
@@ -337,18 +338,27 @@ export const certificateServiceFactory = ({
|
|||||||
encryptedCertificateChain: certBody.encryptedCertificateChain || undefined
|
encryptedCertificateChain: certBody.encryptedCertificateChain || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
const { certPrivateKey } = await getCertificateCredentials({
|
let privateKey: string | null = null;
|
||||||
certId: cert.id,
|
try {
|
||||||
projectId: ca.projectId,
|
const { certPrivateKey } = await getCertificateCredentials({
|
||||||
certificateSecretDAL,
|
certId: cert.id,
|
||||||
projectDAL,
|
projectId: ca.projectId,
|
||||||
kmsService
|
certificateSecretDAL,
|
||||||
});
|
projectDAL,
|
||||||
|
kmsService
|
||||||
|
});
|
||||||
|
privateKey = certPrivateKey;
|
||||||
|
} catch (e) {
|
||||||
|
// Skip NotFound errors but throw all others
|
||||||
|
if (!(e instanceof NotFoundError)) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
certificate,
|
certificate,
|
||||||
certificateChain,
|
certificateChain,
|
||||||
privateKey: certPrivateKey,
|
privateKey,
|
||||||
serialNumber,
|
serialNumber,
|
||||||
cert,
|
cert,
|
||||||
ca
|
ca
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ export const identityKubernetesAuthServiceFactory = ({
|
|||||||
|
|
||||||
const callbackResult = await withGatewayProxy(
|
const callbackResult = await withGatewayProxy(
|
||||||
async (port) => {
|
async (port) => {
|
||||||
const res = await gatewayCallback("localhost", port);
|
// Needs to be https protocol or the kubernetes API server will fail with "Client sent an HTTP request to an HTTPS server"
|
||||||
|
const res = await gatewayCallback("https://localhost", port);
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -138,11 +139,7 @@ export const identityKubernetesAuthServiceFactory = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => {
|
const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => {
|
||||||
let baseUrl = `https://${host}`;
|
const baseUrl = port ? `${host}:${port}` : host;
|
||||||
|
|
||||||
if (port) {
|
|
||||||
baseUrl += `:${port}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await axios
|
const res = await axios
|
||||||
.post<TCreateTokenReviewResponse>(
|
.post<TCreateTokenReviewResponse>(
|
||||||
|
|||||||
@@ -658,7 +658,8 @@ export const projectServiceFactory = ({
|
|||||||
autoCapitalization: update.autoCapitalization,
|
autoCapitalization: update.autoCapitalization,
|
||||||
enforceCapitalization: update.autoCapitalization,
|
enforceCapitalization: update.autoCapitalization,
|
||||||
hasDeleteProtection: update.hasDeleteProtection,
|
hasDeleteProtection: update.hasDeleteProtection,
|
||||||
slug: update.slug
|
slug: update.slug,
|
||||||
|
secretSharing: update.secretSharing
|
||||||
});
|
});
|
||||||
|
|
||||||
return updatedProject;
|
return updatedProject;
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export type TUpdateProjectDTO = {
|
|||||||
autoCapitalization?: boolean;
|
autoCapitalization?: boolean;
|
||||||
hasDeleteProtection?: boolean;
|
hasDeleteProtection?: boolean;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
|
secretSharing?: boolean;
|
||||||
};
|
};
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TSecretSharing } from "@app/db/schemas";
|
|||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||||
|
import { logger } from "@app/lib/logger";
|
||||||
import { SecretSharingAccessType } from "@app/lib/types";
|
import { SecretSharingAccessType } from "@app/lib/types";
|
||||||
import { isUuidV4 } from "@app/lib/validator";
|
import { isUuidV4 } from "@app/lib/validator";
|
||||||
|
|
||||||
@@ -78,8 +79,11 @@ export const secretSharingServiceFactory = ({
|
|||||||
password,
|
password,
|
||||||
accessType,
|
accessType,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
expiresAfterViews
|
expiresAfterViews,
|
||||||
|
emails
|
||||||
}: TCreateSharedSecretDTO) => {
|
}: TCreateSharedSecretDTO) => {
|
||||||
|
const appCfg = getConfig();
|
||||||
|
|
||||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
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 (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
|
||||||
$validateSharedSecretExpiry(expiresAt);
|
$validateSharedSecretExpiry(expiresAt);
|
||||||
@@ -110,6 +114,31 @@ export const secretSharingServiceFactory = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const encryptWithRoot = kmsService.encryptWithRootKey();
|
const encryptWithRoot = kmsService.encryptWithRootKey();
|
||||||
|
|
||||||
|
let salt: string | undefined;
|
||||||
|
let encryptedSalt: Buffer | undefined;
|
||||||
|
const orgEmails = [];
|
||||||
|
|
||||||
|
if (emails && emails.length > 0) {
|
||||||
|
const allOrgMembers = await orgDAL.findAllOrgMembers(orgId);
|
||||||
|
|
||||||
|
// Check to see that all emails are a part of the organization (if enforced) while also collecting a list of emails which are in the org
|
||||||
|
for (const email of emails) {
|
||||||
|
if (allOrgMembers.some((v) => v.user.email === email)) {
|
||||||
|
orgEmails.push(email);
|
||||||
|
// If the email is not part of the org, but access type / org settings require it
|
||||||
|
} else if (!org.allowSecretSharingOutsideOrganization || accessType === SecretSharingAccessType.Organization) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Organization does not allow sharing secrets to members outside of this organization"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate salt for signing email hashes (if emails are provided)
|
||||||
|
salt = crypto.randomBytes(32).toString("hex");
|
||||||
|
encryptedSalt = encryptWithRoot(Buffer.from(salt));
|
||||||
|
}
|
||||||
|
|
||||||
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
|
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
|
||||||
|
|
||||||
const id = crypto.randomBytes(32).toString("hex");
|
const id = crypto.randomBytes(32).toString("hex");
|
||||||
@@ -128,11 +157,45 @@ export const secretSharingServiceFactory = ({
|
|||||||
expiresAfterViews,
|
expiresAfterViews,
|
||||||
userId: actorId,
|
userId: actorId,
|
||||||
orgId,
|
orgId,
|
||||||
accessType
|
accessType,
|
||||||
|
authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined,
|
||||||
|
encryptedSalt
|
||||||
});
|
});
|
||||||
|
|
||||||
const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`;
|
const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`;
|
||||||
|
|
||||||
|
// Loop through recipients and send out emails with unique access links
|
||||||
|
if (emails && salt) {
|
||||||
|
const user = await userDAL.findById(actorId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError({ message: `User with ID '${actorId}' not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const email of emails) {
|
||||||
|
try {
|
||||||
|
const hmac = crypto.createHmac("sha256", salt).update(email);
|
||||||
|
const hash = hmac.digest("hex");
|
||||||
|
|
||||||
|
// Only show the username to emails which are part of the organization
|
||||||
|
const respondentUsername = orgEmails.includes(email) ? user.username : undefined;
|
||||||
|
|
||||||
|
await smtpService.sendMail({
|
||||||
|
recipients: [email],
|
||||||
|
subjectLine: "A secret has been shared with you",
|
||||||
|
substitutions: {
|
||||||
|
name,
|
||||||
|
respondentUsername,
|
||||||
|
secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}?email=${encodeURIComponent(email)}&hash=${hash}`
|
||||||
|
},
|
||||||
|
template: SmtpTemplates.SecretRequestCompleted
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e, "Failed to send shared secret URL to a recipient's email.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { id: idToReturn };
|
return { id: idToReturn };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -406,8 +469,15 @@ export const secretSharingServiceFactory = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get's password-less secret. validates all secret's requested (must be fresh). */
|
/** Gets password-less secret. validates all secret's requested (must be fresh). */
|
||||||
const getSharedSecretById = async ({ sharedSecretId, hashedHex, orgId, password }: TGetActiveSharedSecretByIdDTO) => {
|
const getSharedSecretById = async ({
|
||||||
|
sharedSecretId,
|
||||||
|
hashedHex,
|
||||||
|
orgId,
|
||||||
|
password,
|
||||||
|
email,
|
||||||
|
hash
|
||||||
|
}: TGetActiveSharedSecretByIdDTO) => {
|
||||||
const sharedSecret = isUuidV4(sharedSecretId)
|
const sharedSecret = isUuidV4(sharedSecretId)
|
||||||
? await secretSharingDAL.findOne({
|
? await secretSharingDAL.findOne({
|
||||||
id: sharedSecretId,
|
id: sharedSecretId,
|
||||||
@@ -454,6 +524,32 @@ export const secretSharingServiceFactory = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decryptWithRoot = kmsService.decryptWithRootKey();
|
||||||
|
|
||||||
|
if (sharedSecret.authorizedEmails && sharedSecret.encryptedSalt) {
|
||||||
|
// Verify both params were passed
|
||||||
|
if (!email || !hash) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "This secret is email protected. Parameters must include email and hash."
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that email is authorized to view shared secret
|
||||||
|
} else if (!(sharedSecret.authorizedEmails as string[]).includes(email)) {
|
||||||
|
throw new UnauthorizedError({ message: "Email not authorized to view secret" });
|
||||||
|
|
||||||
|
// Verify that hash matches
|
||||||
|
} else {
|
||||||
|
const salt = decryptWithRoot(sharedSecret.encryptedSalt).toString();
|
||||||
|
const hmac = crypto.createHmac("sha256", salt).update(email);
|
||||||
|
const rebuiltHash = hmac.digest("hex");
|
||||||
|
|
||||||
|
if (rebuiltHash !== hash) {
|
||||||
|
throw new UnauthorizedError({ message: "Email not authorized to view secret" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password checks
|
||||||
const isPasswordProtected = Boolean(sharedSecret.password);
|
const isPasswordProtected = Boolean(sharedSecret.password);
|
||||||
const hasProvidedPassword = Boolean(password);
|
const hasProvidedPassword = Boolean(password);
|
||||||
if (isPasswordProtected) {
|
if (isPasswordProtected) {
|
||||||
@@ -468,7 +564,6 @@ export const secretSharingServiceFactory = ({
|
|||||||
// If encryptedSecret is set, we know that this secret has been encrypted using KMS, and we can therefore do server-side decryption.
|
// If encryptedSecret is set, we know that this secret has been encrypted using KMS, and we can therefore do server-side decryption.
|
||||||
let decryptedSecretValue: Buffer | undefined;
|
let decryptedSecretValue: Buffer | undefined;
|
||||||
if (sharedSecret.encryptedSecret) {
|
if (sharedSecret.encryptedSecret) {
|
||||||
const decryptWithRoot = kmsService.decryptWithRootKey();
|
|
||||||
decryptedSecretValue = decryptWithRoot(sharedSecret.encryptedSecret);
|
decryptedSecretValue = decryptWithRoot(sharedSecret.encryptedSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type TSharedSecretPermission = {
|
|||||||
accessType?: SecretSharingAccessType;
|
accessType?: SecretSharingAccessType;
|
||||||
name?: string;
|
name?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
emails?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TCreatePublicSharedSecretDTO = {
|
export type TCreatePublicSharedSecretDTO = {
|
||||||
@@ -37,6 +38,10 @@ export type TGetActiveSharedSecretByIdDTO = {
|
|||||||
hashedHex?: string;
|
hashedHex?: string;
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
|
// For secrets shared with specific emails
|
||||||
|
email?: string;
|
||||||
|
hash?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & {
|
export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & {
|
||||||
|
|||||||
+1
@@ -144,6 +144,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
|
|||||||
<a
|
<a
|
||||||
href="https://infisical.com/docs/integrations/secret-syncs/overview#key-schemas"
|
href="https://infisical.com/docs/integrations/secret-syncs/overview#key-schemas"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Key Schema
|
Key Schema
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const useGetCertBundle = (serialNumber: string) => {
|
|||||||
certificate: string;
|
certificate: string;
|
||||||
certificateChain: string;
|
certificateChain: string;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
privateKey: string;
|
privateKey: string | null;
|
||||||
}>(`/api/v1/pki/certificates/${serialNumber}/bundle`);
|
}>(`/api/v1/pki/certificates/${serialNumber}/bundle`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ export const secretSharingKeys = {
|
|||||||
allSecretRequests: () => ["secretRequests"] as const,
|
allSecretRequests: () => ["secretRequests"] as const,
|
||||||
specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) =>
|
specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) =>
|
||||||
[...secretSharingKeys.allSecretRequests(), { offset, limit }] as const,
|
[...secretSharingKeys.allSecretRequests(), { offset, limit }] as const,
|
||||||
getSecretById: (arg: { id: string; hashedHex: string | null; password?: string }) => [
|
getSecretById: (arg: {
|
||||||
"shared-secret",
|
id: string;
|
||||||
arg
|
hashedHex: string | null;
|
||||||
],
|
password?: string;
|
||||||
|
email?: string;
|
||||||
|
hash?: string;
|
||||||
|
}) => ["shared-secret", arg],
|
||||||
getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const
|
getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,20 +73,34 @@ export const useGetSecretRequests = ({
|
|||||||
export const useGetActiveSharedSecretById = ({
|
export const useGetActiveSharedSecretById = ({
|
||||||
sharedSecretId,
|
sharedSecretId,
|
||||||
hashedHex,
|
hashedHex,
|
||||||
password
|
password,
|
||||||
|
email,
|
||||||
|
hash
|
||||||
}: {
|
}: {
|
||||||
sharedSecretId: string;
|
sharedSecretId: string;
|
||||||
hashedHex: string | null;
|
hashedHex: string | null;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
|
// For secrets shared to specific emails (optional)
|
||||||
|
email?: string;
|
||||||
|
hash?: string;
|
||||||
}) => {
|
}) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: secretSharingKeys.getSecretById({ id: sharedSecretId, hashedHex, password }),
|
queryKey: secretSharingKeys.getSecretById({
|
||||||
|
id: sharedSecretId,
|
||||||
|
hashedHex,
|
||||||
|
password,
|
||||||
|
email,
|
||||||
|
hash
|
||||||
|
}),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiRequest.post<TViewSharedSecretResponse>(
|
const { data } = await apiRequest.post<TViewSharedSecretResponse>(
|
||||||
`/api/v1/secret-sharing/shared/public/${sharedSecretId}`,
|
`/api/v1/secret-sharing/shared/public/${sharedSecretId}`,
|
||||||
{
|
{
|
||||||
...(hashedHex && { hashedHex }),
|
...(hashedHex && { hashedHex }),
|
||||||
password
|
password,
|
||||||
|
email,
|
||||||
|
hash
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type TCreateSharedSecretRequest = {
|
|||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
expiresAfterViews?: number;
|
expiresAfterViews?: number;
|
||||||
accessType?: SecretSharingAccessType;
|
accessType?: SecretSharingAccessType;
|
||||||
|
emails?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TCreateSecretRequestRequestDTO = {
|
export type TCreateSecretRequestRequestDTO = {
|
||||||
|
|||||||
@@ -277,13 +277,20 @@ export const useUpdateProject = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation<Workspace, object, UpdateProjectDTO>({
|
return useMutation<Workspace, object, UpdateProjectDTO>({
|
||||||
mutationFn: async ({ projectID, newProjectName, newProjectDescription, newSlug }) => {
|
mutationFn: async ({
|
||||||
|
projectID,
|
||||||
|
newProjectName,
|
||||||
|
newProjectDescription,
|
||||||
|
newSlug,
|
||||||
|
secretSharing
|
||||||
|
}) => {
|
||||||
const { data } = await apiRequest.patch<{ workspace: Workspace }>(
|
const { data } = await apiRequest.patch<{ workspace: Workspace }>(
|
||||||
`/api/v1/workspace/${projectID}`,
|
`/api/v1/workspace/${projectID}`,
|
||||||
{
|
{
|
||||||
name: newProjectName,
|
name: newProjectName,
|
||||||
description: newProjectDescription,
|
description: newProjectDescription,
|
||||||
slug: newSlug
|
slug: newSlug,
|
||||||
|
secretSharing
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return data.workspace;
|
return data.workspace;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type Workspace = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
roles?: TProjectRole[];
|
roles?: TProjectRole[];
|
||||||
hasDeleteProtection: boolean;
|
hasDeleteProtection: boolean;
|
||||||
|
secretSharing: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceEnv = {
|
export type WorkspaceEnv = {
|
||||||
@@ -73,9 +74,10 @@ export type CreateWorkspaceDTO = {
|
|||||||
|
|
||||||
export type UpdateProjectDTO = {
|
export type UpdateProjectDTO = {
|
||||||
projectID: string;
|
projectID: string;
|
||||||
newProjectName: string;
|
newProjectName?: string;
|
||||||
newProjectDescription?: string;
|
newProjectDescription?: string;
|
||||||
newSlug?: string;
|
newSlug?: string;
|
||||||
|
secretSharing?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number };
|
export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number };
|
||||||
|
|||||||
+2
-2
@@ -35,7 +35,7 @@ export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
certificate: string;
|
certificate: string;
|
||||||
certificateChain: string;
|
certificateChain: string;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
privateKey?: string;
|
privateKey?: string | null;
|
||||||
}
|
}
|
||||||
| undefined = canReadPrivateKey ? bundleData : bodyData;
|
| undefined = canReadPrivateKey ? bundleData : bodyData;
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
serialNumber={data.serialNumber}
|
serialNumber={data.serialNumber}
|
||||||
certificate={data.certificate}
|
certificate={data.certificate}
|
||||||
certificateChain={data.certificateChain}
|
certificateChain={data.certificateChain}
|
||||||
privateKey={data.privateKey}
|
privateKey={data.privateKey || undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div />
|
<div />
|
||||||
|
|||||||
+3
-3
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { Switch } from "@app/components/v2";
|
import { Switch } from "@app/components/v2";
|
||||||
import { useOrganization } from "@app/context";
|
import { useOrganization } from "@app/context";
|
||||||
import { useUpdateOrg } from "@app/hooks/api";
|
import { useUpdateOrg } from "@app/hooks/api";
|
||||||
import axios from "axios";
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
|
||||||
|
|
||||||
export const OrgProductSelectSection = () => {
|
export const OrgProductSelectSection = () => {
|
||||||
const [toggledProducts, setToggledProducts] = useState<{
|
const [toggledProducts, setToggledProducts] = useState<{
|
||||||
@@ -79,7 +79,7 @@ export const OrgProductSelectSection = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-6 py-5">
|
||||||
<h2 className="text-xl font-semibold text-mineshaft-100">Organization Products</h2>
|
<h2 className="text-xl font-semibold text-mineshaft-100">Organization Products</h2>
|
||||||
<p className="mb-4 text-gray-400">
|
<p className="mb-4 text-gray-400">
|
||||||
Select which products are available for your organization.
|
Select which products are available for your organization.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||||
import { Button, ContentLoader } from "@app/components/v2";
|
import { Button, ContentLoader, EmptyState } from "@app/components/v2";
|
||||||
import {
|
import {
|
||||||
OrgPermissionActions,
|
OrgPermissionActions,
|
||||||
OrgPermissionSubjects,
|
OrgPermissionSubjects,
|
||||||
@@ -60,102 +60,108 @@ export const OrgSsoTab = withPermission(
|
|||||||
const shouldShowCreateIdentityProviderView =
|
const shouldShowCreateIdentityProviderView =
|
||||||
!isOidcConfigured && !isSamlConfigured && !isLdapConfigured;
|
!isOidcConfigured && !isSamlConfigured && !isLdapConfigured;
|
||||||
|
|
||||||
const createIdentityProviderView = (shouldDisplaySection(LoginMethod.SAML) ||
|
const createIdentityProviderView =
|
||||||
|
shouldDisplaySection(LoginMethod.SAML) ||
|
||||||
shouldDisplaySection(LoginMethod.OIDC) ||
|
shouldDisplaySection(LoginMethod.OIDC) ||
|
||||||
shouldDisplaySection(LoginMethod.LDAP)) && (
|
shouldDisplaySection(LoginMethod.LDAP) ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||||
<p className="text-xl font-semibold text-gray-200">Connect an Identity Provider</p>
|
<p className="text-xl font-semibold text-gray-200">Connect an Identity Provider</p>
|
||||||
<p className="mb-2 mt-1 text-gray-400">
|
<p className="mb-2 mt-1 text-gray-400">
|
||||||
Connect your identity provider to simplify user management
|
Connect your identity provider to simplify user management
|
||||||
</p>
|
</p>
|
||||||
{shouldDisplaySection(LoginMethod.SAML) && (
|
{shouldDisplaySection(LoginMethod.SAML) && (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"mt-4 flex items-center justify-between",
|
"mt-4 flex items-center justify-between",
|
||||||
(shouldDisplaySection(LoginMethod.OIDC) ||
|
(shouldDisplaySection(LoginMethod.OIDC) ||
|
||||||
shouldDisplaySection(LoginMethod.LDAP)) &&
|
shouldDisplaySection(LoginMethod.LDAP)) &&
|
||||||
"border-b border-mineshaft-500 pb-4"
|
"border-b border-mineshaft-500 pb-4"
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<p className="text-lg text-gray-200">SAML</p>
|
|
||||||
<Button
|
|
||||||
colorSchema="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
if (!subscription?.samlSSO) {
|
|
||||||
handlePopUpOpen("upgradePlan", { feature: "SAML SSO", plan: "Pro" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePopUpOpen("addSSO");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Connect
|
<p className="text-lg text-gray-200">SAML</p>
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
colorSchema="secondary"
|
||||||
)}
|
onClick={() => {
|
||||||
{shouldDisplaySection(LoginMethod.OIDC) && (
|
if (!subscription?.samlSSO) {
|
||||||
<div
|
handlePopUpOpen("upgradePlan", { feature: "SAML SSO", plan: "Pro" });
|
||||||
className={twMerge(
|
return;
|
||||||
"mt-4 flex items-center justify-between",
|
}
|
||||||
shouldDisplaySection(LoginMethod.LDAP) && "border-b border-mineshaft-500 pb-4"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-lg text-gray-200">OIDC</p>
|
|
||||||
<Button
|
|
||||||
colorSchema="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
if (!subscription?.oidcSSO) {
|
|
||||||
handlePopUpOpen("upgradePlan", { feature: "OIDC SSO", plan: "Pro" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePopUpOpen("addOIDC");
|
handlePopUpOpen("addSSO");
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shouldDisplaySection(LoginMethod.OIDC) && (
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
"mt-4 flex items-center justify-between",
|
||||||
|
shouldDisplaySection(LoginMethod.LDAP) && "border-b border-mineshaft-500 pb-4"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Connect
|
<p className="text-lg text-gray-200">OIDC</p>
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
colorSchema="secondary"
|
||||||
)}
|
onClick={() => {
|
||||||
{shouldDisplaySection(LoginMethod.LDAP) && (
|
if (!subscription?.oidcSSO) {
|
||||||
<div className="mt-4 flex items-center justify-between">
|
handlePopUpOpen("upgradePlan", { feature: "OIDC SSO", plan: "Pro" });
|
||||||
<p className="text-lg text-gray-200">LDAP</p>
|
return;
|
||||||
<Button
|
}
|
||||||
colorSchema="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
if (!subscription?.ldap) {
|
|
||||||
handlePopUpOpen("upgradePlan", { feature: "LDAP", plan: "Enterprise" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePopUpOpen("addLDAP");
|
handlePopUpOpen("addOIDC");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Connect
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{shouldDisplaySection(LoginMethod.LDAP) && (
|
||||||
<SSOModal
|
<div className="mt-4 flex items-center justify-between">
|
||||||
hideDelete
|
<p className="text-lg text-gray-200">LDAP</p>
|
||||||
popUp={popUp}
|
<Button
|
||||||
handlePopUpClose={handlePopUpClose}
|
colorSchema="secondary"
|
||||||
handlePopUpToggle={handlePopUpToggle}
|
onClick={() => {
|
||||||
/>
|
if (!subscription?.ldap) {
|
||||||
<OIDCModal
|
handlePopUpOpen("upgradePlan", { feature: "LDAP", plan: "Enterprise" });
|
||||||
hideDelete
|
return;
|
||||||
popUp={popUp}
|
}
|
||||||
handlePopUpClose={handlePopUpClose}
|
|
||||||
handlePopUpToggle={handlePopUpToggle}
|
handlePopUpOpen("addLDAP");
|
||||||
/>
|
}}
|
||||||
<LDAPModal
|
>
|
||||||
hideDelete
|
Connect
|
||||||
popUp={popUp}
|
</Button>
|
||||||
handlePopUpClose={handlePopUpClose}
|
</div>
|
||||||
handlePopUpToggle={handlePopUpToggle}
|
)}
|
||||||
/>
|
</div>
|
||||||
</>
|
<SSOModal
|
||||||
);
|
hideDelete
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpClose={handlePopUpClose}
|
||||||
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
|
/>
|
||||||
|
<OIDCModal
|
||||||
|
hideDelete
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpClose={handlePopUpClose}
|
||||||
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
|
/>
|
||||||
|
<LDAPModal
|
||||||
|
hideDelete
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpClose={handlePopUpClose}
|
||||||
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="" iconSize="2x" className="!pb-10 pt-14">
|
||||||
|
<p className="text-center text-lg">Single Sign-On (SSO) has been disabled</p>
|
||||||
|
<p className="text-center">Contact your server administrator</p>
|
||||||
|
</EmptyState>
|
||||||
|
);
|
||||||
|
|
||||||
if (areConfigsLoading) {
|
if (areConfigsLoading) {
|
||||||
return <ContentLoader />;
|
return <ContentLoader />;
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const ShareSecretPage = () => {
|
|||||||
Infisical
|
Infisical
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,19 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Switch
|
||||||
|
} from "@app/components/v2";
|
||||||
import { useTimedReset } from "@app/hooks";
|
import { useTimedReset } from "@app/hooks";
|
||||||
import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api";
|
import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api";
|
||||||
import { SecretSharingAccessType } from "@app/hooks/api/secretSharing";
|
import { SecretSharingAccessType } from "@app/hooks/api/secretSharing";
|
||||||
@@ -33,7 +45,24 @@ const schema = z.object({
|
|||||||
secret: z.string().min(1),
|
secret: z.string().min(1),
|
||||||
expiresIn: z.string(),
|
expiresIn: z.string(),
|
||||||
viewLimit: z.string(),
|
viewLimit: z.string(),
|
||||||
accessType: z.nativeEnum(SecretSharingAccessType).optional()
|
accessType: z.nativeEnum(SecretSharingAccessType).optional(),
|
||||||
|
emails: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(val) => {
|
||||||
|
if (!val) return true;
|
||||||
|
const emails = val
|
||||||
|
.split(",")
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.filter((email) => email !== "");
|
||||||
|
if (emails.length > 100) return false;
|
||||||
|
return emails.every((email) => z.string().email().safeParse(email).success);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Must be a comma-separated list of valid emails (max 100) or empty."
|
||||||
|
}
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FormData = z.infer<typeof schema>;
|
export type FormData = z.infer<typeof schema>;
|
||||||
@@ -53,7 +82,7 @@ export const ShareSecretForm = ({
|
|||||||
maxSharedSecretLifetime,
|
maxSharedSecretLifetime,
|
||||||
maxSharedSecretViewLimit
|
maxSharedSecretViewLimit
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [secretLink, setSecretLink] = useState("");
|
const [secretLink, setSecretLink] = useState<string | null>(null);
|
||||||
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
||||||
initialState: "Copy to clipboard"
|
initialState: "Copy to clipboard"
|
||||||
});
|
});
|
||||||
@@ -79,7 +108,10 @@ export const ShareSecretForm = ({
|
|||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
secret: value || ""
|
secret: value || "",
|
||||||
|
viewLimit: filteredViewLimitOptions[filteredViewLimitOptions.length - 1].value.toString(),
|
||||||
|
expiresIn:
|
||||||
|
filteredExpiresInOptions[Math.min(filteredExpiresInOptions.length - 1, 2)].value.toString()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,32 +121,45 @@ export const ShareSecretForm = ({
|
|||||||
secret,
|
secret,
|
||||||
expiresIn,
|
expiresIn,
|
||||||
viewLimit,
|
viewLimit,
|
||||||
accessType
|
accessType,
|
||||||
|
emails
|
||||||
}: FormData) => {
|
}: FormData) => {
|
||||||
try {
|
try {
|
||||||
const expiresAt = new Date(new Date().getTime() + Number(expiresIn));
|
const expiresAt = new Date(new Date().getTime() + Number(expiresIn));
|
||||||
|
|
||||||
|
const processedEmails = emails ? emails.split(",").map((e) => e.trim()) : undefined;
|
||||||
|
|
||||||
const { id } = await createSharedSecret.mutateAsync({
|
const { id } = await createSharedSecret.mutateAsync({
|
||||||
name,
|
name,
|
||||||
password,
|
password,
|
||||||
secretValue: secret,
|
secretValue: secret,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
expiresAfterViews: viewLimit === "-1" ? undefined : Number(viewLimit),
|
expiresAfterViews: viewLimit === "-1" ? undefined : Number(viewLimit),
|
||||||
accessType
|
accessType,
|
||||||
|
emails: processedEmails
|
||||||
});
|
});
|
||||||
|
|
||||||
const link = `${window.location.origin}/shared/secret/${id}`;
|
if (processedEmails && processedEmails.length > 0) {
|
||||||
|
setSecretLink("");
|
||||||
|
createNotification({
|
||||||
|
text: `Shared secret link emailed to ${processedEmails.length} user(s).`,
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const link = `${window.location.origin}/shared/secret/${id}`;
|
||||||
|
|
||||||
|
setSecretLink(link);
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(link);
|
||||||
|
setCopyTextSecret("secret");
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Shared secret link copied to clipboard.",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setSecretLink(link);
|
|
||||||
reset();
|
reset();
|
||||||
|
|
||||||
navigator.clipboard.writeText(link);
|
|
||||||
setCopyTextSecret("secret");
|
|
||||||
|
|
||||||
createNotification({
|
|
||||||
text: "Shared secret link copied to clipboard.",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
createNotification({
|
createNotification({
|
||||||
@@ -124,156 +169,230 @@ export const ShareSecretForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasSecretLink = Boolean(secretLink);
|
if (secretLink === null)
|
||||||
|
return (
|
||||||
return !hasSecretLink ? (
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
{!isPublic && (
|
||||||
{!isPublic && (
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Name"
|
||||||
|
isOptional
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="API Key"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck="false"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="name"
|
name="secret"
|
||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormControl
|
<FormControl
|
||||||
label="Name (Optional)"
|
label="Your Secret"
|
||||||
isError={Boolean(error)}
|
isError={Boolean(error)}
|
||||||
errorText={error?.message}
|
errorText={error?.message}
|
||||||
|
className="mb-2"
|
||||||
|
isRequired
|
||||||
>
|
>
|
||||||
<Input
|
<textarea
|
||||||
|
placeholder="Enter sensitive data to share via an encrypted link..."
|
||||||
{...field}
|
{...field}
|
||||||
placeholder="API Key"
|
className="h-40 min-h-[70px] w-full rounded-md border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5 text-bunker-300 outline-none transition-all placeholder:text-mineshaft-400 hover:border-primary-400/30 focus:border-primary-400/50 group-hover:mr-2"
|
||||||
type="text"
|
disabled={value !== undefined}
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
spellCheck="false"
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="secret"
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
|
||||||
label="Your Secret"
|
|
||||||
isError={Boolean(error)}
|
|
||||||
errorText={error?.message}
|
|
||||||
className="mb-2"
|
|
||||||
isRequired
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
placeholder="Enter sensitive data to share via an encrypted link..."
|
|
||||||
{...field}
|
|
||||||
className="h-40 min-h-[70px] w-full rounded-md border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5 text-bunker-300 outline-none transition-all placeholder:text-mineshaft-400 hover:border-primary-400/30 focus:border-primary-400/50 group-hover:mr-2"
|
|
||||||
disabled={value !== undefined}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="password"
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
|
||||||
label="Password"
|
|
||||||
isError={Boolean(error)}
|
|
||||||
errorText={error?.message}
|
|
||||||
isOptional
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
placeholder="Password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
autoCorrect="off"
|
|
||||||
spellCheck="false"
|
|
||||||
aria-autocomplete="none"
|
|
||||||
data-form-type="other"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="expiresIn"
|
|
||||||
defaultValue={filteredExpiresInOptions[
|
|
||||||
Math.min(filteredExpiresInOptions.length - 1, 2)
|
|
||||||
].value.toString()}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{filteredExpiresInOptions.map(({ label, value: expiresInValue }) => (
|
|
||||||
<SelectItem value={String(expiresInValue || "")} key={label}>
|
|
||||||
{label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="viewLimit"
|
|
||||||
defaultValue={filteredViewLimitOptions[
|
|
||||||
filteredViewLimitOptions.length - 1
|
|
||||||
].value.toString()}
|
|
||||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
|
||||||
<FormControl label="Max Views" errorText={error?.message} isError={Boolean(error)}>
|
|
||||||
<Select
|
|
||||||
defaultValue={field.value}
|
|
||||||
{...field}
|
|
||||||
onValueChange={(e) => onChange(e)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{filteredViewLimitOptions.map(({ label, value: viewLimitValue }) => (
|
|
||||||
<SelectItem value={String(viewLimitValue || "")} key={label}>
|
|
||||||
{label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{!isPublic && (
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="accessType"
|
name="password"
|
||||||
defaultValue={SecretSharingAccessType.Organization}
|
render={({ field, fieldState: { error } }) => (
|
||||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
<FormControl
|
||||||
<FormControl label="General Access" errorText={error?.message} isError={Boolean(error)}>
|
label="Password"
|
||||||
<Select
|
isError={Boolean(error)}
|
||||||
defaultValue={field.value}
|
errorText={error?.message}
|
||||||
|
isOptional
|
||||||
|
>
|
||||||
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
onValueChange={(e) => onChange(e)}
|
placeholder="Password"
|
||||||
className="w-full"
|
type="password"
|
||||||
>
|
autoComplete="new-password"
|
||||||
{allowSecretSharingOutsideOrganization && (
|
autoCorrect="off"
|
||||||
<SelectItem value={SecretSharingAccessType.Anyone}>Anyone</SelectItem>
|
spellCheck="false"
|
||||||
)}
|
aria-autocomplete="none"
|
||||||
<SelectItem value={SecretSharingAccessType.Organization}>
|
data-form-type="other"
|
||||||
People within your organization
|
/>
|
||||||
</SelectItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<Button
|
{!isPublic && (
|
||||||
className="mt-4"
|
<Controller
|
||||||
size="sm"
|
control={control}
|
||||||
type="submit"
|
name="accessType"
|
||||||
isLoading={isSubmitting}
|
defaultValue={SecretSharingAccessType.Organization}
|
||||||
isDisabled={isSubmitting}
|
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||||
>
|
<FormControl
|
||||||
Create Secret Link
|
helperText={
|
||||||
</Button>
|
allowSecretSharingOutsideOrganization ? undefined : (
|
||||||
</form>
|
<span className="text-red-500">Feature enforced by organization</span>
|
||||||
) : (
|
)
|
||||||
|
}
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error)}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
className={`ml-0 mr-2 bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-primary ${!allowSecretSharingOutsideOrganization ? "opacity-50" : ""}`}
|
||||||
|
thumbClassName="bg-mineshaft-800"
|
||||||
|
containerClassName="flex-row-reverse w-fit"
|
||||||
|
isChecked={
|
||||||
|
field.value === SecretSharingAccessType.Organization ||
|
||||||
|
!allowSecretSharingOutsideOrganization
|
||||||
|
}
|
||||||
|
isDisabled={!allowSecretSharingOutsideOrganization}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
onChange(
|
||||||
|
v ? SecretSharingAccessType.Organization : SecretSharingAccessType.Anyone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
id="org-access-only"
|
||||||
|
>
|
||||||
|
Limit access to people within organization
|
||||||
|
</Switch>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
<AccordionItem value="advance-settings" className="data-[state=open]:border-none">
|
||||||
|
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
|
||||||
|
<div className="order-1 ml-3">Advanced Settings</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent childrenClassName="p-0">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="expiresIn"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{filteredExpiresInOptions.map(({ label, value: expiresInValue }) => (
|
||||||
|
<SelectItem value={String(expiresInValue || "")} key={label}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="viewLimit"
|
||||||
|
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Max Views"
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error)}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
defaultValue={field.value}
|
||||||
|
{...field}
|
||||||
|
onValueChange={(e) => onChange(e)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{filteredViewLimitOptions.map(({ label, value: viewLimitValue }) => (
|
||||||
|
<SelectItem value={String(viewLimitValue || "")} key={label}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isPublic && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="emails"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Authorized Emails"
|
||||||
|
isOptional
|
||||||
|
tooltipText="Unique secret links will be emailed to each individual. The secret will only be accessible to those links."
|
||||||
|
tooltipClassName="max-w-sm"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="[email protected], [email protected]"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<div className="flex w-full justify-end">
|
||||||
|
<Button
|
||||||
|
className="mt-4"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Create Secret Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secretLink === "")
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-1 flex w-full items-center justify-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="text-green-500" />
|
||||||
|
<span>Shared secret link has been emailed to select users.</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="mt-6 w-full bg-mineshaft-700 py-3 text-bunker-200"
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSecretLink(null)}
|
||||||
|
rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
|
||||||
|
>
|
||||||
|
Share Another Secret
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mr-2 flex items-center justify-end rounded-md bg-white/[0.05] p-2 text-base text-gray-400">
|
<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>
|
<p className="mr-4 break-all">{secretLink}</p>
|
||||||
@@ -282,7 +401,7 @@ export const ShareSecretForm = ({
|
|||||||
colorSchema="secondary"
|
colorSchema="secondary"
|
||||||
className="group relative ml-2"
|
className="group relative ml-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(secretLink);
|
navigator.clipboard.writeText(secretLink || "");
|
||||||
setCopyTextSecret("Copied");
|
setCopyTextSecret("Copied");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -294,7 +413,7 @@ export const ShareSecretForm = ({
|
|||||||
colorSchema="primary"
|
colorSchema="primary"
|
||||||
variant="outline_bg"
|
variant="outline_bg"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setSecretLink("")}
|
onClick={() => setSecretLink(null)}
|
||||||
rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
|
rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
|
||||||
>
|
>
|
||||||
Share Another Secret
|
Share Another Secret
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export const ViewSecretRequestByIDPage = () => {
|
|||||||
Infisical
|
Infisical
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ export const ViewSharedSecretByIDPage = () => {
|
|||||||
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
|
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
|
||||||
select: (el) => el.key
|
select: (el) => el.key
|
||||||
});
|
});
|
||||||
|
const email = useSearch({
|
||||||
|
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
|
||||||
|
select: (el) => el.email
|
||||||
|
});
|
||||||
|
const hash = useSearch({
|
||||||
|
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
|
||||||
|
select: (el) => el.hash
|
||||||
|
});
|
||||||
const [password, setPassword] = useState<string>();
|
const [password, setPassword] = useState<string>();
|
||||||
const { hashedHex, key } = extractDetailsFromUrl(urlEncodedKey);
|
const { hashedHex, key } = extractDetailsFromUrl(urlEncodedKey);
|
||||||
|
|
||||||
@@ -49,7 +57,9 @@ export const ViewSharedSecretByIDPage = () => {
|
|||||||
} = useGetActiveSharedSecretById({
|
} = useGetActiveSharedSecretById({
|
||||||
sharedSecretId: id,
|
sharedSecretId: id,
|
||||||
hashedHex,
|
hashedHex,
|
||||||
password
|
password,
|
||||||
|
email,
|
||||||
|
hash
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -57,15 +67,16 @@ export const ViewSharedSecretByIDPage = () => {
|
|||||||
const isUnauthorized =
|
const isUnauthorized =
|
||||||
((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 401;
|
((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 401;
|
||||||
|
|
||||||
const isForbidden =
|
|
||||||
((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 403;
|
|
||||||
|
|
||||||
const isInvalidCredential =
|
const isInvalidCredential =
|
||||||
((error as AxiosError)?.response?.data as { message: string })?.message ===
|
((error as AxiosError)?.response?.data as { message: string })?.message ===
|
||||||
"Invalid credentials";
|
"Invalid credentials";
|
||||||
|
|
||||||
|
const isEmailUnauthorized =
|
||||||
|
((error as AxiosError)?.response?.data as { message: string })?.message ===
|
||||||
|
"Email not authorized to view secret";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isUnauthorized && !isInvalidCredential) {
|
if (isUnauthorized && !isInvalidCredential && !isEmailUnauthorized) {
|
||||||
// persist current URL in session storage so that we can come back to this after successful login
|
// persist current URL in session storage so that we can come back to this after successful login
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL,
|
SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL,
|
||||||
@@ -85,10 +96,10 @@ export const ViewSharedSecretByIDPage = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isForbidden) {
|
if (error) {
|
||||||
createNotification({
|
createNotification({
|
||||||
type: "error",
|
type: "error",
|
||||||
text: "You do not have access to this shared secret."
|
text: ((error as AxiosError)?.response?.data as { message: string })?.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [error]);
|
}, [error]);
|
||||||
@@ -195,7 +206,7 @@ export const ViewSharedSecretByIDPage = () => {
|
|||||||
Infisical
|
Infisical
|
||||||
</a>
|
</a>
|
||||||
<br />
|
<br />
|
||||||
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
|
|||||||
import { ViewSharedSecretByIDPage } from "./ViewSharedSecretByIDPage";
|
import { ViewSharedSecretByIDPage } from "./ViewSharedSecretByIDPage";
|
||||||
|
|
||||||
const SharedSecretByIDPageQuerySchema = z.object({
|
const SharedSecretByIDPageQuerySchema = z.object({
|
||||||
key: z.string().catch("")
|
key: z.string().catch(""),
|
||||||
|
email: z.string().optional(),
|
||||||
|
hash: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Route = createFileRoute("/shared/secret/$secretId")({
|
export const Route = createFileRoute("/shared/secret/$secretId")({
|
||||||
|
|||||||
+11
-3
@@ -398,11 +398,19 @@ export const SecretDetailSidebar = ({
|
|||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content="You don't have permission to view the secret value."
|
content={
|
||||||
isDisabled={!secret?.secretValueHidden}
|
!currentWorkspace.secretSharing
|
||||||
|
? "This project does not allow secret sharing."
|
||||||
|
: "You don't have permission to view the secret value."
|
||||||
|
}
|
||||||
|
isDisabled={
|
||||||
|
!secret?.secretValueHidden && currentWorkspace.secretSharing
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
isDisabled={secret?.secretValueHidden}
|
isDisabled={
|
||||||
|
secret?.secretValueHidden || !currentWorkspace.secretSharing
|
||||||
|
}
|
||||||
className="px-2 py-[0.43rem] font-normal"
|
className="px-2 py-[0.43rem] font-normal"
|
||||||
variant="outline_bg"
|
variant="outline_bg"
|
||||||
leftIcon={<FontAwesomeIcon icon={faShare} />}
|
leftIcon={<FontAwesomeIcon icon={faShare} />}
|
||||||
|
|||||||
+1
-1
@@ -589,7 +589,7 @@ export const SecretItem = memo(
|
|||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
<IconButton
|
<IconButton
|
||||||
isDisabled={secret.secretValueHidden}
|
isDisabled={secret.secretValueHidden || !currentWorkspace.secretSharing}
|
||||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
size="md"
|
size="md"
|
||||||
|
|||||||
+2
@@ -10,6 +10,7 @@ import { DeleteProjectSection } from "../DeleteProjectSection";
|
|||||||
import { EnvironmentSection } from "../EnvironmentSection";
|
import { EnvironmentSection } from "../EnvironmentSection";
|
||||||
import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection";
|
import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection";
|
||||||
import { RebuildSecretIndicesSection } from "../RebuildSecretIndicesSection/RebuildSecretIndicesSection";
|
import { RebuildSecretIndicesSection } from "../RebuildSecretIndicesSection/RebuildSecretIndicesSection";
|
||||||
|
import { SecretSharingSection } from "../SecretSharingSection";
|
||||||
import { SecretTagsSection } from "../SecretTagsSection";
|
import { SecretTagsSection } from "../SecretTagsSection";
|
||||||
|
|
||||||
export const ProjectGeneralTab = () => {
|
export const ProjectGeneralTab = () => {
|
||||||
@@ -22,6 +23,7 @@ export const ProjectGeneralTab = () => {
|
|||||||
{isSecretManager && <EnvironmentSection />}
|
{isSecretManager && <EnvironmentSection />}
|
||||||
{isSecretManager && <SecretTagsSection />}
|
{isSecretManager && <SecretTagsSection />}
|
||||||
{isSecretManager && <AutoCapitalizationSection />}
|
{isSecretManager && <AutoCapitalizationSection />}
|
||||||
|
{isSecretManager && <SecretSharingSection />}
|
||||||
{isSecretManager && <PointInTimeVersionLimitSection />}
|
{isSecretManager && <PointInTimeVersionLimitSection />}
|
||||||
<AuditLogsRetentionSection />
|
<AuditLogsRetentionSection />
|
||||||
{isSecretManager && <BackfillSecretReferenceSecretion />}
|
{isSecretManager && <BackfillSecretReferenceSecretion />}
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import { Checkbox } from "@app/components/v2";
|
||||||
|
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||||
|
import { useUpdateProject } from "@app/hooks/api/workspace/queries";
|
||||||
|
|
||||||
|
export const SecretSharingSection = () => {
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { mutateAsync: updateProject } = useUpdateProject();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleToggle = async (state: boolean) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!currentWorkspace?.id) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateProject({
|
||||||
|
projectID: currentWorkspace.id,
|
||||||
|
secretSharing: state
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: `Successfully ${state ? "enabled" : "disabled"} secret sharing for this project`,
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to update secret sharing for this project",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
|
<p className="mb-3 text-xl font-semibold">Allow Secret Sharing</p>
|
||||||
|
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<div className="w-max">
|
||||||
|
<Checkbox
|
||||||
|
className="data-[state=checked]:bg-primary"
|
||||||
|
id="secretSharing"
|
||||||
|
isDisabled={!isAllowed || isLoading}
|
||||||
|
isChecked={currentWorkspace?.secretSharing ?? true}
|
||||||
|
onCheckedChange={(state) => handleToggle(state as boolean)}
|
||||||
|
>
|
||||||
|
This feature enables your project members to securely share secrets.
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { SecretSharingSection } from "./SecretSharingSection";
|
||||||
Reference in New Issue
Block a user