mirror of
https://github.com/Infisical/infisical.git
synced 2025-03-25 14:05:03 +00:00
Compare commits
15 Commits
feat/oidc-
...
daniel/azu
Author | SHA1 | Date | |
---|---|---|---|
7ed8feee6f | |||
9a861499df | |||
d1f3c98f21 | |||
eac621db73 | |||
ab7983973e | |||
ff43773f37 | |||
68574be05b | |||
1d9966af76 | |||
4dddf764bd | |||
8b06215366 | |||
28723e9a4e | |||
079e005f49 | |||
d20ae39f32 | |||
05bf2e4696 | |||
a06dee66f8 |
@ -45,6 +45,7 @@ export enum EventType {
|
||||
CREATE_SECRETS = "create-secrets",
|
||||
UPDATE_SECRET = "update-secret",
|
||||
UPDATE_SECRETS = "update-secrets",
|
||||
MOVE_SECRETS = "move-secrets",
|
||||
DELETE_SECRET = "delete-secret",
|
||||
DELETE_SECRETS = "delete-secrets",
|
||||
GET_WORKSPACE_KEY = "get-workspace-key",
|
||||
@ -240,6 +241,17 @@ interface UpdateSecretBatchEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface MoveSecretsEvent {
|
||||
type: EventType.MOVE_SECRETS;
|
||||
metadata: {
|
||||
sourceEnvironment: string;
|
||||
sourceSecretPath: string;
|
||||
destinationEnvironment: string;
|
||||
destinationSecretPath: string;
|
||||
secretIds: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteSecretEvent {
|
||||
type: EventType.DELETE_SECRET;
|
||||
metadata: {
|
||||
@ -1159,6 +1171,7 @@ export type Event =
|
||||
| CreateSecretBatchEvent
|
||||
| UpdateSecretEvent
|
||||
| UpdateSecretBatchEvent
|
||||
| MoveSecretsEvent
|
||||
| DeleteSecretEvent
|
||||
| DeleteSecretBatchEvent
|
||||
| GetWorkspaceKeyEvent
|
||||
|
@ -712,7 +712,10 @@ export const registerRoutes = async (
|
||||
secretQueueService,
|
||||
secretImportDAL,
|
||||
projectEnvDAL,
|
||||
projectBotService
|
||||
projectBotService,
|
||||
secretApprovalPolicyService,
|
||||
secretApprovalRequestDAL,
|
||||
secretApprovalRequestSecretDAL
|
||||
});
|
||||
|
||||
const secretSharingService = secretSharingServiceFactory({
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
IdentitiesSchema,
|
||||
IdentityProjectMembershipsSchema,
|
||||
ProjectMembershipRole,
|
||||
ProjectsSchema,
|
||||
ProjectUserMembershipRolesSchema
|
||||
} from "@app/db/schemas";
|
||||
import { PROJECT_IDENTITIES } from "@app/lib/api-docs";
|
||||
@ -234,7 +235,8 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
|
||||
temporaryAccessEndTime: z.date().nullable().optional()
|
||||
})
|
||||
),
|
||||
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true })
|
||||
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }),
|
||||
project: ProjectsSchema.pick({ name: true, id: true })
|
||||
})
|
||||
.array()
|
||||
})
|
||||
@ -291,7 +293,8 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
|
||||
temporaryAccessEndTime: z.date().nullable().optional()
|
||||
})
|
||||
),
|
||||
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true })
|
||||
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }),
|
||||
project: ProjectsSchema.pick({ name: true, id: true })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -1325,6 +1325,61 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/move",
|
||||
config: {
|
||||
rateLimit: secretsLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
projectSlug: z.string().trim(),
|
||||
sourceEnvironment: z.string().trim(),
|
||||
sourceSecretPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||
destinationEnvironment: z.string().trim(),
|
||||
destinationSecretPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||
secretIds: z.string().array(),
|
||||
shouldOverwrite: z.boolean().default(false)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
isSourceUpdated: z.boolean(),
|
||||
isDestinationUpdated: z.boolean()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { projectId, isSourceUpdated, isDestinationUpdated } = await server.services.secret.moveSecrets({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.body
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
projectId,
|
||||
...req.auditLogInfo,
|
||||
event: {
|
||||
type: EventType.MOVE_SECRETS,
|
||||
metadata: {
|
||||
sourceEnvironment: req.body.sourceEnvironment,
|
||||
sourceSecretPath: req.body.sourceSecretPath,
|
||||
destinationEnvironment: req.body.destinationEnvironment,
|
||||
destinationSecretPath: req.body.destinationSecretPath,
|
||||
secretIds: req.body.secretIds
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isSourceUpdated,
|
||||
isDestinationUpdated
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/batch",
|
||||
|
@ -17,6 +17,7 @@ export const validateAzureIdentity = async ({
|
||||
const jwksUri = `https://login.microsoftonline.com/${tenantId}/discovery/keys`;
|
||||
|
||||
const decodedJwt = jwt.decode(azureJwt, { complete: true }) as TDecodedAzureAuthJwt;
|
||||
|
||||
const { kid } = decodedJwt.header;
|
||||
|
||||
const { data }: { data: TAzureJwksUriResponse } = await axios.get(jwksUri);
|
||||
@ -27,6 +28,13 @@ export const validateAzureIdentity = async ({
|
||||
|
||||
const publicKey = `-----BEGIN CERTIFICATE-----\n${signingKey.x5c[0]}\n-----END CERTIFICATE-----`;
|
||||
|
||||
// Case: This can happen when the user uses a custom resource (such as https://management.azure.com&client_id=value).
|
||||
// In this case, the audience in the decoded JWT will not have a trailing slash, but the resource will.
|
||||
if (!decodedJwt.payload.aud.endsWith("/") && resource.endsWith("/")) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resource = resource.slice(0, -1);
|
||||
}
|
||||
|
||||
return jwt.verify(azureJwt, publicKey, {
|
||||
audience: resource,
|
||||
issuer: `https://sts.windows.net/${tenantId}/`
|
||||
|
@ -111,6 +111,7 @@ export const identityProjectDALFactory = (db: TDbClient) => {
|
||||
try {
|
||||
const docs = await (tx || db.replicaNode())(TableName.IdentityProjectMembership)
|
||||
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
|
||||
.join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`)
|
||||
.join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`)
|
||||
.where((qb) => {
|
||||
if (filter.identityId) {
|
||||
@ -149,12 +150,13 @@ export const identityProjectDALFactory = (db: TDbClient) => {
|
||||
db.ref("isTemporary").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("temporaryRange").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("temporaryAccessStartTime").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole)
|
||||
db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("name").as("projectName").withSchema(TableName.Project)
|
||||
);
|
||||
|
||||
const members = sqlNestRelationships({
|
||||
data: docs,
|
||||
parentMapper: ({ identityId, identityName, identityAuthMethod, id, createdAt, updatedAt }) => ({
|
||||
parentMapper: ({ identityId, identityName, identityAuthMethod, id, createdAt, updatedAt, projectName }) => ({
|
||||
id,
|
||||
identityId,
|
||||
createdAt,
|
||||
@ -163,6 +165,10 @@ export const identityProjectDALFactory = (db: TDbClient) => {
|
||||
id: identityId,
|
||||
name: identityName,
|
||||
authMethod: identityAuthMethod
|
||||
},
|
||||
project: {
|
||||
id: projectId,
|
||||
name: projectName
|
||||
}
|
||||
}),
|
||||
key: "id",
|
||||
|
@ -11,6 +11,9 @@ import {
|
||||
} from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
||||
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
|
||||
import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal";
|
||||
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import {
|
||||
@ -18,9 +21,10 @@ import {
|
||||
decryptSymmetric128BitHexKeyUTF8,
|
||||
encryptSymmetric128BitHexKeyUTF8
|
||||
} from "@app/lib/crypto";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { groupBy, pick } from "@app/lib/fn";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
@ -44,6 +48,7 @@ import {
|
||||
} from "./secret-fns";
|
||||
import { TSecretQueueFactory } from "./secret-queue";
|
||||
import {
|
||||
SecretOperations,
|
||||
TAttachSecretTagsDTO,
|
||||
TBackFillSecretReferencesDTO,
|
||||
TCreateBulkSecretDTO,
|
||||
@ -59,6 +64,7 @@ import {
|
||||
TGetSecretsDTO,
|
||||
TGetSecretsRawDTO,
|
||||
TGetSecretVersionsDTO,
|
||||
TMoveSecretsDTO,
|
||||
TUpdateBulkSecretDTO,
|
||||
TUpdateManySecretRawDTO,
|
||||
TUpdateSecretDTO,
|
||||
@ -84,6 +90,12 @@ type TSecretServiceFactoryDep = {
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
|
||||
secretVersionTagDAL: Pick<TSecretVersionTagDALFactory, "insertMany">;
|
||||
secretApprovalPolicyService: Pick<TSecretApprovalPolicyServiceFactory, "getSecretApprovalPolicy">;
|
||||
secretApprovalRequestDAL: Pick<TSecretApprovalRequestDALFactory, "create" | "transaction">;
|
||||
secretApprovalRequestSecretDAL: Pick<
|
||||
TSecretApprovalRequestSecretDALFactory,
|
||||
"insertMany" | "insertApprovalSecretTags"
|
||||
>;
|
||||
};
|
||||
|
||||
export type TSecretServiceFactory = ReturnType<typeof secretServiceFactory>;
|
||||
@ -100,7 +112,10 @@ export const secretServiceFactory = ({
|
||||
projectDAL,
|
||||
projectBotService,
|
||||
secretImportDAL,
|
||||
secretVersionTagDAL
|
||||
secretVersionTagDAL,
|
||||
secretApprovalPolicyService,
|
||||
secretApprovalRequestDAL,
|
||||
secretApprovalRequestSecretDAL
|
||||
}: TSecretServiceFactoryDep) => {
|
||||
const getSecretReference = async (projectId: string) => {
|
||||
// if bot key missing means e2e still exist
|
||||
@ -1683,6 +1698,393 @@ export const secretServiceFactory = ({
|
||||
return { message: "Successfully backfilled secret references" };
|
||||
};
|
||||
|
||||
const moveSecrets = async ({
|
||||
sourceEnvironment,
|
||||
sourceSecretPath,
|
||||
destinationEnvironment,
|
||||
destinationSecretPath,
|
||||
secretIds,
|
||||
projectSlug,
|
||||
shouldOverwrite,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TMoveSecretsDTO) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) {
|
||||
throw new NotFoundError({
|
||||
message: "Project not found."
|
||||
});
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
project.id,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
subject(ProjectPermissionSub.Secrets, { environment: sourceEnvironment, secretPath: sourceSecretPath })
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, { environment: destinationEnvironment, secretPath: destinationSecretPath })
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, { environment: destinationEnvironment, secretPath: destinationSecretPath })
|
||||
);
|
||||
|
||||
const botKey = await projectBotService.getBotKey(project.id);
|
||||
if (!botKey) {
|
||||
throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
|
||||
}
|
||||
|
||||
const sourceFolder = await folderDAL.findBySecretPath(project.id, sourceEnvironment, sourceSecretPath);
|
||||
if (!sourceFolder) {
|
||||
throw new NotFoundError({
|
||||
message: "Source path does not exist."
|
||||
});
|
||||
}
|
||||
|
||||
const destinationFolder = await folderDAL.findBySecretPath(
|
||||
project.id,
|
||||
destinationEnvironment,
|
||||
destinationSecretPath
|
||||
);
|
||||
|
||||
if (!destinationFolder) {
|
||||
throw new NotFoundError({
|
||||
message: "Destination path does not exist."
|
||||
});
|
||||
}
|
||||
|
||||
const sourceSecrets = await secretDAL.find({
|
||||
type: SecretType.Shared,
|
||||
$in: {
|
||||
id: secretIds
|
||||
}
|
||||
});
|
||||
|
||||
if (sourceSecrets.length !== secretIds.length) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid secrets"
|
||||
});
|
||||
}
|
||||
|
||||
const decryptedSourceSecrets = sourceSecrets.map((secret) => ({
|
||||
...secret,
|
||||
secretKey: decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretKeyCiphertext,
|
||||
iv: secret.secretKeyIV,
|
||||
tag: secret.secretKeyTag,
|
||||
key: botKey
|
||||
}),
|
||||
secretValue: decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretValueCiphertext,
|
||||
iv: secret.secretValueIV,
|
||||
tag: secret.secretValueTag,
|
||||
key: botKey
|
||||
})
|
||||
}));
|
||||
|
||||
let isSourceUpdated = false;
|
||||
let isDestinationUpdated = false;
|
||||
|
||||
// Moving secrets is a two-step process.
|
||||
await secretDAL.transaction(async (tx) => {
|
||||
// First step is to create/update the secret in the destination:
|
||||
const destinationSecretsFromDB = await secretDAL.find(
|
||||
{
|
||||
folderId: destinationFolder.id
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
|
||||
const decryptedDestinationSecrets = destinationSecretsFromDB.map((secret) => {
|
||||
return {
|
||||
...secret,
|
||||
secretKey: decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretKeyCiphertext,
|
||||
iv: secret.secretKeyIV,
|
||||
tag: secret.secretKeyTag,
|
||||
key: botKey
|
||||
}),
|
||||
secretValue: decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretValueCiphertext,
|
||||
iv: secret.secretValueIV,
|
||||
tag: secret.secretValueTag,
|
||||
key: botKey
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const destinationSecretsGroupedByBlindIndex = groupBy(
|
||||
decryptedDestinationSecrets.filter(({ secretBlindIndex }) => Boolean(secretBlindIndex)),
|
||||
(i) => i.secretBlindIndex as string
|
||||
);
|
||||
|
||||
const locallyCreatedSecrets = decryptedSourceSecrets
|
||||
.filter(({ secretBlindIndex }) => !destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0])
|
||||
.map((el) => ({ ...el, operation: SecretOperations.Create }));
|
||||
|
||||
const locallyUpdatedSecrets = decryptedSourceSecrets
|
||||
.filter(
|
||||
({ secretBlindIndex, secretKey, secretValue }) =>
|
||||
destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0] &&
|
||||
// if key or value changed
|
||||
(destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretKey !== secretKey ||
|
||||
destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretValue !== secretValue)
|
||||
)
|
||||
.map((el) => ({ ...el, operation: SecretOperations.Update }));
|
||||
|
||||
if (locallyUpdatedSecrets.length > 0 && !shouldOverwrite) {
|
||||
const existingKeys = locallyUpdatedSecrets.map((s) => s.secretKey);
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Failed to move secrets. The following secrets already exist in the destination: ${existingKeys.join(
|
||||
","
|
||||
)}`
|
||||
});
|
||||
}
|
||||
|
||||
const isEmpty = locallyCreatedSecrets.length + locallyUpdatedSecrets.length === 0;
|
||||
|
||||
if (isEmpty) {
|
||||
throw new BadRequestError({
|
||||
message: "Selected secrets already exist in the destination."
|
||||
});
|
||||
}
|
||||
const destinationFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy(
|
||||
project.id,
|
||||
destinationFolder.environment.slug,
|
||||
destinationFolder.path
|
||||
);
|
||||
|
||||
if (destinationFolderPolicy && actor === ActorType.USER) {
|
||||
// if secret approval policy exists for destination, we create the secret approval request
|
||||
const localSecretsIds = decryptedDestinationSecrets.map(({ id }) => id);
|
||||
const latestSecretVersions = await secretVersionDAL.findLatestVersionMany(
|
||||
destinationFolder.id,
|
||||
localSecretsIds,
|
||||
tx
|
||||
);
|
||||
|
||||
const approvalRequestDoc = await secretApprovalRequestDAL.create(
|
||||
{
|
||||
folderId: destinationFolder.id,
|
||||
slug: alphaNumericNanoId(),
|
||||
policyId: destinationFolderPolicy.id,
|
||||
status: "open",
|
||||
hasMerged: false,
|
||||
committerUserId: actorId
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
const commits = locallyCreatedSecrets.concat(locallyUpdatedSecrets).map((doc) => {
|
||||
const { operation } = doc;
|
||||
const localSecret = destinationSecretsGroupedByBlindIndex[doc.secretBlindIndex as string]?.[0];
|
||||
|
||||
return {
|
||||
op: operation,
|
||||
keyEncoding: doc.keyEncoding,
|
||||
algorithm: doc.algorithm,
|
||||
requestId: approvalRequestDoc.id,
|
||||
metadata: doc.metadata,
|
||||
secretKeyIV: doc.secretKeyIV,
|
||||
secretKeyTag: doc.secretKeyTag,
|
||||
secretKeyCiphertext: doc.secretKeyCiphertext,
|
||||
secretValueIV: doc.secretValueIV,
|
||||
secretValueTag: doc.secretValueTag,
|
||||
secretValueCiphertext: doc.secretValueCiphertext,
|
||||
secretBlindIndex: doc.secretBlindIndex,
|
||||
secretCommentIV: doc.secretCommentIV,
|
||||
secretCommentTag: doc.secretCommentTag,
|
||||
secretCommentCiphertext: doc.secretCommentCiphertext,
|
||||
skipMultilineEncoding: doc.skipMultilineEncoding,
|
||||
// except create operation other two needs the secret id and version id
|
||||
...(operation !== SecretOperations.Create
|
||||
? { secretId: localSecret.id, secretVersion: latestSecretVersions[localSecret.id].id }
|
||||
: {})
|
||||
};
|
||||
});
|
||||
await secretApprovalRequestSecretDAL.insertMany(commits, tx);
|
||||
} else {
|
||||
// apply changes directly
|
||||
if (locallyCreatedSecrets.length) {
|
||||
await fnSecretBulkInsert({
|
||||
folderId: destinationFolder.id,
|
||||
secretVersionDAL,
|
||||
secretDAL,
|
||||
tx,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
inputSecrets: locallyCreatedSecrets.map((doc) => {
|
||||
return {
|
||||
keyEncoding: doc.keyEncoding,
|
||||
algorithm: doc.algorithm,
|
||||
type: doc.type,
|
||||
metadata: doc.metadata,
|
||||
secretKeyIV: doc.secretKeyIV,
|
||||
secretKeyTag: doc.secretKeyTag,
|
||||
secretKeyCiphertext: doc.secretKeyCiphertext,
|
||||
secretValueIV: doc.secretValueIV,
|
||||
secretValueTag: doc.secretValueTag,
|
||||
secretValueCiphertext: doc.secretValueCiphertext,
|
||||
secretBlindIndex: doc.secretBlindIndex,
|
||||
secretCommentIV: doc.secretCommentIV,
|
||||
secretCommentTag: doc.secretCommentTag,
|
||||
secretCommentCiphertext: doc.secretCommentCiphertext,
|
||||
skipMultilineEncoding: doc.skipMultilineEncoding
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
if (locallyUpdatedSecrets.length) {
|
||||
await fnSecretBulkUpdate({
|
||||
projectId: project.id,
|
||||
folderId: destinationFolder.id,
|
||||
secretVersionDAL,
|
||||
secretDAL,
|
||||
tx,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL,
|
||||
inputSecrets: locallyUpdatedSecrets.map((doc) => {
|
||||
return {
|
||||
filter: {
|
||||
folderId: destinationFolder.id,
|
||||
id: destinationSecretsGroupedByBlindIndex[doc.secretBlindIndex as string][0].id
|
||||
},
|
||||
data: {
|
||||
keyEncoding: doc.keyEncoding,
|
||||
algorithm: doc.algorithm,
|
||||
type: doc.type,
|
||||
metadata: doc.metadata,
|
||||
secretKeyIV: doc.secretKeyIV,
|
||||
secretKeyTag: doc.secretKeyTag,
|
||||
secretKeyCiphertext: doc.secretKeyCiphertext,
|
||||
secretValueIV: doc.secretValueIV,
|
||||
secretValueTag: doc.secretValueTag,
|
||||
secretValueCiphertext: doc.secretValueCiphertext,
|
||||
secretBlindIndex: doc.secretBlindIndex,
|
||||
secretCommentIV: doc.secretCommentIV,
|
||||
secretCommentTag: doc.secretCommentTag,
|
||||
secretCommentCiphertext: doc.secretCommentCiphertext,
|
||||
skipMultilineEncoding: doc.skipMultilineEncoding
|
||||
}
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
isDestinationUpdated = true;
|
||||
}
|
||||
|
||||
// Next step is to delete the secrets from the source folder:
|
||||
const sourceSecretsGroupByBlindIndex = groupBy(sourceSecrets, (i) => i.secretBlindIndex as string);
|
||||
const locallyDeletedSecrets = decryptedSourceSecrets.map((el) => ({ ...el, operation: SecretOperations.Delete }));
|
||||
|
||||
const sourceFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy(
|
||||
project.id,
|
||||
sourceFolder.environment.slug,
|
||||
sourceFolder.path
|
||||
);
|
||||
|
||||
if (sourceFolderPolicy && actor === ActorType.USER) {
|
||||
// if secret approval policy exists for source, we create the secret approval request
|
||||
const localSecretsIds = decryptedSourceSecrets.map(({ id }) => id);
|
||||
const latestSecretVersions = await secretVersionDAL.findLatestVersionMany(sourceFolder.id, localSecretsIds, tx);
|
||||
const approvalRequestDoc = await secretApprovalRequestDAL.create(
|
||||
{
|
||||
folderId: sourceFolder.id,
|
||||
slug: alphaNumericNanoId(),
|
||||
policyId: sourceFolderPolicy.id,
|
||||
status: "open",
|
||||
hasMerged: false,
|
||||
committerUserId: actorId
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
const commits = locallyDeletedSecrets.map((doc) => {
|
||||
const { operation } = doc;
|
||||
const localSecret = sourceSecretsGroupByBlindIndex[doc.secretBlindIndex as string]?.[0];
|
||||
|
||||
return {
|
||||
op: operation,
|
||||
keyEncoding: doc.keyEncoding,
|
||||
algorithm: doc.algorithm,
|
||||
requestId: approvalRequestDoc.id,
|
||||
metadata: doc.metadata,
|
||||
secretKeyIV: doc.secretKeyIV,
|
||||
secretKeyTag: doc.secretKeyTag,
|
||||
secretKeyCiphertext: doc.secretKeyCiphertext,
|
||||
secretValueIV: doc.secretValueIV,
|
||||
secretValueTag: doc.secretValueTag,
|
||||
secretValueCiphertext: doc.secretValueCiphertext,
|
||||
secretBlindIndex: doc.secretBlindIndex,
|
||||
secretCommentIV: doc.secretCommentIV,
|
||||
secretCommentTag: doc.secretCommentTag,
|
||||
secretCommentCiphertext: doc.secretCommentCiphertext,
|
||||
skipMultilineEncoding: doc.skipMultilineEncoding,
|
||||
secretId: localSecret.id,
|
||||
secretVersion: latestSecretVersions[localSecret.id].id
|
||||
};
|
||||
});
|
||||
|
||||
await secretApprovalRequestSecretDAL.insertMany(commits, tx);
|
||||
} else {
|
||||
// if no secret approval policy is present, we delete directly.
|
||||
await secretDAL.delete(
|
||||
{
|
||||
$in: {
|
||||
id: locallyDeletedSecrets.map(({ id }) => id)
|
||||
},
|
||||
folderId: sourceFolder.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
isSourceUpdated = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (isDestinationUpdated) {
|
||||
await snapshotService.performSnapshot(destinationFolder.id);
|
||||
await secretQueueService.syncSecrets({
|
||||
projectId: project.id,
|
||||
secretPath: destinationFolder.path,
|
||||
environmentSlug: destinationFolder.environment.slug,
|
||||
actorId,
|
||||
actor
|
||||
});
|
||||
}
|
||||
|
||||
if (isSourceUpdated) {
|
||||
await snapshotService.performSnapshot(sourceFolder.id);
|
||||
await secretQueueService.syncSecrets({
|
||||
projectId: project.id,
|
||||
secretPath: sourceFolder.path,
|
||||
environmentSlug: sourceFolder.environment.slug,
|
||||
actorId,
|
||||
actor
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
isSourceUpdated,
|
||||
isDestinationUpdated
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
attachTags,
|
||||
detachTags,
|
||||
@ -1703,6 +2105,7 @@ export const secretServiceFactory = ({
|
||||
updateManySecretsRaw,
|
||||
deleteManySecretsRaw,
|
||||
getSecretVersions,
|
||||
backfillSecretReferences
|
||||
backfillSecretReferences,
|
||||
moveSecrets
|
||||
};
|
||||
};
|
||||
|
@ -397,3 +397,13 @@ export type TSyncSecretsDTO<T extends boolean = false> = {
|
||||
// used for import creation to trigger replication
|
||||
pickOnlyImportIds?: string[];
|
||||
});
|
||||
|
||||
export type TMoveSecretsDTO = {
|
||||
projectSlug: string;
|
||||
sourceEnvironment: string;
|
||||
sourceSecretPath: string;
|
||||
destinationEnvironment: string;
|
||||
destinationSecretPath: string;
|
||||
secretIds: string[];
|
||||
shouldOverwrite: boolean;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
@ -2,7 +2,7 @@ const path = require("path");
|
||||
|
||||
const ContentSecurityPolicy = `
|
||||
default-src 'self';
|
||||
script-src 'self' https://app.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval';
|
||||
script-src 'self' https://*.posthog.com https://*.*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval';
|
||||
style-src 'self' https://rsms.me 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com;
|
||||
child-src https://api.stripe.com;
|
||||
frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/ https://hcaptcha.com https://*.hcaptcha.com;
|
||||
|
@ -16,9 +16,9 @@ export {
|
||||
useDeleteIdentityAzureAuth,
|
||||
useDeleteIdentityGcpAuth,
|
||||
useDeleteIdentityKubernetesAuth,
|
||||
useDeleteIdentityOidcAuth,
|
||||
useDeleteIdentityTokenAuth,
|
||||
useDeleteIdentityUniversalAuth,
|
||||
useDeleteIdentityOidcAuth,
|
||||
useRevokeIdentityTokenAuthToken,
|
||||
useRevokeIdentityUniversalAuthClientSecret,
|
||||
useUpdateIdentity,
|
||||
@ -26,21 +26,19 @@ export {
|
||||
useUpdateIdentityAzureAuth,
|
||||
useUpdateIdentityGcpAuth,
|
||||
useUpdateIdentityKubernetesAuth,
|
||||
useUpdateIdentityOidcAuth,
|
||||
useUpdateIdentityTokenAuth,
|
||||
useUpdateIdentityTokenAuthToken,
|
||||
useUpdateIdentityUniversalAuth,
|
||||
useUpdateIdentityOidcAuth
|
||||
} from "./mutations";
|
||||
useUpdateIdentityUniversalAuth} from "./mutations";
|
||||
export {
|
||||
useGetIdentityAwsAuth,
|
||||
useGetIdentityAzureAuth,
|
||||
useGetIdentityById,
|
||||
useGetIdentityGcpAuth,
|
||||
useGetIdentityKubernetesAuth,
|
||||
useGetIdentityOidcAuth,
|
||||
useGetIdentityProjectMemberships,
|
||||
useGetIdentityTokenAuth,
|
||||
useGetIdentityTokensTokenAuth,
|
||||
useGetIdentityUniversalAuth,
|
||||
useGetIdentityUniversalAuthClientSecrets,
|
||||
useGetIdentityOidcAuth
|
||||
} from "./queries";
|
||||
useGetIdentityUniversalAuthClientSecrets} from "./queries";
|
||||
|
@ -23,10 +23,10 @@ import {
|
||||
DeleteIdentityDTO,
|
||||
DeleteIdentityGcpAuthDTO,
|
||||
DeleteIdentityKubernetesAuthDTO,
|
||||
DeleteIdentityOidcAuthDTO,
|
||||
DeleteIdentityTokenAuthDTO,
|
||||
DeleteIdentityUniversalAuthClientSecretDTO,
|
||||
DeleteIdentityUniversalAuthDTO,
|
||||
DeleteIdentityOidcAuthDTO,
|
||||
Identity,
|
||||
IdentityAccessToken,
|
||||
IdentityAwsAuth,
|
||||
@ -44,8 +44,8 @@ import {
|
||||
UpdateIdentityGcpAuthDTO,
|
||||
UpdateIdentityKubernetesAuthDTO,
|
||||
UpdateIdentityOidcAuthDTO,
|
||||
UpdateIdentityUniversalAuthDTO,
|
||||
UpdateIdentityTokenAuthDTO,
|
||||
UpdateIdentityUniversalAuthDTO,
|
||||
UpdateTokenIdentityTokenAuthDTO
|
||||
} from "./types";
|
||||
|
||||
|
@ -9,11 +9,11 @@ import {
|
||||
IdentityAzureAuth,
|
||||
IdentityGcpAuth,
|
||||
IdentityKubernetesAuth,
|
||||
IdentityMembership,
|
||||
IdentityMembershipOrg,
|
||||
IdentityOidcAuth,
|
||||
IdentityTokenAuth,
|
||||
IdentityUniversalAuth,
|
||||
IdentityOidcAuth
|
||||
} from "./types";
|
||||
IdentityUniversalAuth} from "./types";
|
||||
|
||||
export const identitiesKeys = {
|
||||
getIdentityById: (identityId: string) => [{ identityId }, "identity"] as const,
|
||||
@ -56,7 +56,9 @@ export const useGetIdentityProjectMemberships = (identityId: string) => {
|
||||
queryFn: async () => {
|
||||
const {
|
||||
data: { identityMemberships }
|
||||
} = await apiRequest.get(`/api/v1/identities/${identityId}/identity-memberships`);
|
||||
} = await apiRequest.get<{ identityMemberships: IdentityMembership[] }>(
|
||||
`/api/v1/identities/${identityId}/identity-memberships`
|
||||
);
|
||||
return identityMemberships;
|
||||
}
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { TOrgRole } from "../roles/types";
|
||||
import { Workspace } from "../workspace/types";
|
||||
import { IdentityAuthMethod } from "./enums";
|
||||
|
||||
export type IdentityTrustedIp = {
|
||||
@ -45,6 +46,7 @@ export type IdentityMembershipOrg = {
|
||||
export type IdentityMembership = {
|
||||
id: string;
|
||||
identity: Identity;
|
||||
project: Pick<Workspace, "id" | "name">;
|
||||
roles: Array<
|
||||
{
|
||||
id: string;
|
||||
|
@ -4,6 +4,7 @@ export {
|
||||
useCreateSecretV3,
|
||||
useDeleteSecretBatch,
|
||||
useDeleteSecretV3,
|
||||
useMoveSecrets,
|
||||
useUpdateSecretBatch,
|
||||
useUpdateSecretV3
|
||||
} from "./mutations";
|
||||
|
@ -17,6 +17,7 @@ import {
|
||||
TCreateSecretsV3DTO,
|
||||
TDeleteSecretBatchDTO,
|
||||
TDeleteSecretsV3DTO,
|
||||
TMoveSecretsDTO,
|
||||
TUpdateSecretBatchDTO,
|
||||
TUpdateSecretsV3DTO
|
||||
} from "./types";
|
||||
@ -87,11 +88,11 @@ export const useCreateSecretV3 = ({
|
||||
|
||||
const randomBytes = latestFileKey
|
||||
? decryptAssymmetric({
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
: crypto.randomBytes(16).toString("hex");
|
||||
|
||||
const reqBody = {
|
||||
@ -148,11 +149,11 @@ export const useUpdateSecretV3 = ({
|
||||
|
||||
const randomBytes = latestFileKey
|
||||
? decryptAssymmetric({
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
: crypto.randomBytes(16).toString("hex");
|
||||
|
||||
const reqBody = {
|
||||
@ -244,11 +245,11 @@ export const useCreateSecretBatch = ({
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const randomBytes = latestFileKey
|
||||
? decryptAssymmetric({
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
: crypto.randomBytes(16).toString("hex");
|
||||
|
||||
const reqBody = {
|
||||
@ -297,11 +298,11 @@ export const useUpdateSecretBatch = ({
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const randomBytes = latestFileKey
|
||||
? decryptAssymmetric({
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
: crypto.randomBytes(16).toString("hex");
|
||||
|
||||
const reqBody = {
|
||||
@ -375,6 +376,73 @@ export const useDeleteSecretBatch = ({
|
||||
});
|
||||
};
|
||||
|
||||
export const useMoveSecrets = ({
|
||||
options
|
||||
}: {
|
||||
options?: Omit<MutationOptions<{}, {}, TMoveSecretsDTO>, "mutationFn">;
|
||||
} = {}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
{
|
||||
isSourceUpdated: boolean;
|
||||
isDestinationUpdated: boolean;
|
||||
},
|
||||
{},
|
||||
TMoveSecretsDTO
|
||||
>({
|
||||
mutationFn: async ({
|
||||
sourceEnvironment,
|
||||
sourceSecretPath,
|
||||
projectSlug,
|
||||
destinationEnvironment,
|
||||
destinationSecretPath,
|
||||
secretIds,
|
||||
shouldOverwrite
|
||||
}) => {
|
||||
const { data } = await apiRequest.post<{
|
||||
isSourceUpdated: boolean;
|
||||
isDestinationUpdated: boolean;
|
||||
}>("/api/v3/secrets/move", {
|
||||
sourceEnvironment,
|
||||
sourceSecretPath,
|
||||
projectSlug,
|
||||
destinationEnvironment,
|
||||
destinationSecretPath,
|
||||
secretIds,
|
||||
shouldOverwrite
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectId, sourceEnvironment, sourceSecretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({
|
||||
workspaceId: projectId,
|
||||
environment: sourceEnvironment,
|
||||
secretPath: sourceSecretPath
|
||||
})
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list({
|
||||
environment: sourceEnvironment,
|
||||
workspaceId: projectId,
|
||||
directory: sourceSecretPath
|
||||
})
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count({
|
||||
environment: sourceEnvironment,
|
||||
workspaceId: projectId,
|
||||
directory: sourceSecretPath
|
||||
})
|
||||
);
|
||||
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId: projectId }));
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const createSecret = async (dto: CreateSecretDTO) => {
|
||||
const { data } = await apiRequest.post(`/api/v3/secrets/${dto.secretKey}`, dto);
|
||||
return data;
|
||||
|
@ -177,6 +177,17 @@ export type TDeleteSecretBatchDTO = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TMoveSecretsDTO = {
|
||||
projectSlug: string;
|
||||
projectId: string;
|
||||
sourceEnvironment: string;
|
||||
sourceSecretPath: string;
|
||||
destinationEnvironment: string;
|
||||
destinationSecretPath: string;
|
||||
secretIds: string[];
|
||||
shouldOverwrite: boolean;
|
||||
};
|
||||
|
||||
export type CreateSecretDTO = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
|
@ -6,6 +6,7 @@ import { CaStatus } from "../ca/enums";
|
||||
import { TCertificateAuthority } from "../ca/types";
|
||||
import { TCertificate } from "../certificates/types";
|
||||
import { TGroupMembership } from "../groups/types";
|
||||
import { identitiesKeys } from "../identities/queries";
|
||||
import { IdentityMembership } from "../identities/types";
|
||||
import { IntegrationAuth } from "../integrationAuth/types";
|
||||
import { TIntegration } from "../integrations/types";
|
||||
@ -152,7 +153,7 @@ export const useGetWorkspaceById = (workspaceId: string) => {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.getWorkspaceById(workspaceId),
|
||||
queryFn: () => fetchWorkspaceById(workspaceId),
|
||||
enabled: true
|
||||
enabled: Boolean(workspaceId)
|
||||
});
|
||||
};
|
||||
|
||||
@ -441,8 +442,9 @@ export const useAddIdentityToWorkspace = () => {
|
||||
|
||||
return identityMembership;
|
||||
},
|
||||
onSuccess: (_, { workspaceId }) => {
|
||||
onSuccess: (_, { identityId, workspaceId }) => {
|
||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(workspaceId));
|
||||
queryClient.invalidateQueries(identitiesKeys.getIdentityProjectMemberships(identityId));
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -462,8 +464,9 @@ export const useUpdateIdentityWorkspaceRole = () => {
|
||||
|
||||
return identityMembership;
|
||||
},
|
||||
onSuccess: (_, { workspaceId }) => {
|
||||
onSuccess: (_, { identityId, workspaceId }) => {
|
||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(workspaceId));
|
||||
queryClient.invalidateQueries(identitiesKeys.getIdentityProjectMemberships(identityId));
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -485,8 +488,9 @@ export const useDeleteIdentityFromWorkspace = () => {
|
||||
);
|
||||
return identityMembership;
|
||||
},
|
||||
onSuccess: (_, { workspaceId }) => {
|
||||
onSuccess: (_, { identityId, workspaceId }) => {
|
||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(workspaceId));
|
||||
queryClient.invalidateQueries(identitiesKeys.getIdentityProjectMemberships(identityId));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { faCheck, faCopy,faKey, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faCheck, faCopy, faKey, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
|
||||
@ -37,21 +37,23 @@ export const IdentityClientSecrets = ({ identityId, handlePopUpOpen }: Props) =>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Client ID</p>
|
||||
<div className="flex align-top">
|
||||
<div className="group flex align-top">
|
||||
<p className="text-sm text-mineshaft-300">{identityUniversalAuth?.clientId ?? ""}</p>
|
||||
<Tooltip content={copyTextClientId}>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative ml-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(identityUniversalAuth?.clientId ?? "");
|
||||
setCopyTextClientId("Copied");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopyingClientId ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<Tooltip content={copyTextClientId}>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative ml-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(identityUniversalAuth?.clientId ?? "");
|
||||
setCopyTextClientId("Copied");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopyingClientId ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{clientSecrets?.length ? (
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { faCheck,faCopy, faPencil } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
@ -54,21 +54,23 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
|
||||
<div className="pt-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Identity ID</p>
|
||||
<div className="flex align-top">
|
||||
<div className="group flex align-top">
|
||||
<p className="text-sm text-mineshaft-300">{data.identity.id}</p>
|
||||
<Tooltip content={copyTextId}>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative ml-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(data.identity.id);
|
||||
setCopyTextId("Copied");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<Tooltip content={copyTextId}>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative ml-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(data.identity.id);
|
||||
setCopyTextId("Copied");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
|
@ -1,57 +0,0 @@
|
||||
import { faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useGetIdentityProjectMemberships } from "@app/hooks/api";
|
||||
|
||||
type Props = {
|
||||
identityId: string;
|
||||
};
|
||||
|
||||
export const IdentityProjectsSection = ({ identityId }: Props) => {
|
||||
const { data: projectMemberships, isLoading } = useGetIdentityProjectMemberships(identityId);
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Projects</h3>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Role</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={2} innerKey="identity-project-memberships" />}
|
||||
{!isLoading &&
|
||||
projectMemberships?.map((membership: any) => {
|
||||
// TODO: fix any
|
||||
return (
|
||||
<Tr className="h-10" key={`identity-project-membership-${membership.id}`}>
|
||||
<Td>{membership.project.name}</Td>
|
||||
<Td>{membership.roles[0].role}</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && !projectMemberships?.length && (
|
||||
<EmptyState title="This identity has not been assigned to any projects" icon={faKey} />
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,175 @@
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Modal, ModalContent,Select, SelectItem } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddIdentityToWorkspace,
|
||||
useGetIdentityProjectMemberships,
|
||||
useGetProjectRoles,
|
||||
useGetWorkspaceById
|
||||
} from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
projectId: z.string(),
|
||||
role: z.string()
|
||||
})
|
||||
.required();
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
identityId: string;
|
||||
popUp: UsePopUpState<["addIdentityToProject"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["addIdentityToProject"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle }: Props) => {
|
||||
const { workspaces } = useWorkspace();
|
||||
const { mutateAsync: addIdentityToWorkspace } = useAddIdentityToWorkspace();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
watch
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
const projectId = watch("projectId");
|
||||
const { data: projectMemberships } = useGetIdentityProjectMemberships(identityId);
|
||||
const { data: project } = useGetWorkspaceById(projectId);
|
||||
const { data: roles } = useGetProjectRoles(project?.slug ?? "");
|
||||
|
||||
const filteredWorkspaces = useMemo(() => {
|
||||
const wsWorkspaceIds = new Map();
|
||||
|
||||
projectMemberships?.forEach((projectMembership: any) => {
|
||||
wsWorkspaceIds.set(projectMembership.project.id, true);
|
||||
});
|
||||
|
||||
return (workspaces || []).filter(({ id }) => !wsWorkspaceIds.has(id));
|
||||
}, [workspaces, projectMemberships]);
|
||||
|
||||
const onFormSubmit = async ({ projectId: workspaceId, role }: FormData) => {
|
||||
try {
|
||||
await addIdentityToWorkspace({
|
||||
workspaceId,
|
||||
identityId,
|
||||
role: role || undefined
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully added identity to project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
reset();
|
||||
handlePopUpToggle("addIdentityToProject", false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to add identity to project";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.addIdentityToProject?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("addIdentityToProject", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Add Identity to Project">
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectId"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Project"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(filteredWorkspaces || []).map(({ id, name }) => (
|
||||
<SelectItem value={id} key={`project-${id}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`project-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("addIdentityToProject", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
membership: IdentityMembership;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["removeIdentityFromProject"]>,
|
||||
data?: {}
|
||||
) => void;
|
||||
};
|
||||
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Admin) return "Admin";
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
if (role === ProjectMembershipRole.Viewer) return "Viewer";
|
||||
if (role === ProjectMembershipRole.NoAccess) return "No access";
|
||||
return role;
|
||||
};
|
||||
|
||||
export const IdentityProjectRow = ({
|
||||
membership: { id, createdAt, identity, project, roles },
|
||||
handlePopUpOpen
|
||||
}: Props) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Tr
|
||||
className="group h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
|
||||
key={`identity-project-membership-${id}`}
|
||||
onClick={() => router.push(`/project/${project.id}/members`)}
|
||||
>
|
||||
<Td>{project.name}</Td>
|
||||
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
|
||||
roles.length > 1 ? ` (+${roles.length - 1})` : ""
|
||||
}`}</Td>
|
||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
||||
<Td>
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<Tooltip content="Remove">
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("removeIdentityFromProject", {
|
||||
identityId: identity.id,
|
||||
identityName: identity.name,
|
||||
projectId: project.id,
|
||||
projectName: project.name
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
@ -0,0 +1,92 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal, IconButton } from "@app/components/v2";
|
||||
import { useDeleteIdentityFromWorkspace } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityAddToProjectModal } from "./IdentityAddToProjectModal";
|
||||
import { IdentityProjectsTable } from "./IdentityProjectsTable";
|
||||
|
||||
type Props = {
|
||||
identityId: string;
|
||||
};
|
||||
|
||||
export const IdentityProjectsSection = ({ identityId }: Props) => {
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"addIdentityToProject",
|
||||
"removeIdentityFromProject"
|
||||
] as const);
|
||||
|
||||
const onRemoveIdentitySubmit = async (id: string, projectId: string) => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
identityId: id,
|
||||
workspaceId: projectId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed identity from project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("removeIdentityFromProject");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Projects</h3>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addIdentityToProject");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<IdentityProjectsTable identityId={identityId} handlePopUpOpen={handlePopUpOpen} />
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeIdentityFromProject.isOpen}
|
||||
title={`Are you sure want to remove ${
|
||||
(popUp?.removeIdentityFromProject?.data as { identityName: string })?.identityName || ""
|
||||
} from ${
|
||||
(popUp?.removeIdentityFromProject?.data as { projectName: string })?.projectName || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("removeIdentityFromProject", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => {
|
||||
const popupData = popUp?.removeIdentityFromProject?.data as {
|
||||
identityId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
return onRemoveIdentitySubmit(popupData.identityId, popupData.projectId);
|
||||
}}
|
||||
/>
|
||||
<IdentityAddToProjectModal
|
||||
identityId={identityId}
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,58 @@
|
||||
import { faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useGetIdentityProjectMemberships } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityProjectRow } from "./IdentityProjectRow";
|
||||
|
||||
type Props = {
|
||||
identityId: string;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["removeIdentityFromProject"]>,
|
||||
data?: {}
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => {
|
||||
const { data: projectMemberships, isLoading } = useGetIdentityProjectMemberships(identityId);
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Added On</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={2} innerKey="identity-project-memberships" />}
|
||||
{!isLoading &&
|
||||
projectMemberships?.map((membership) => {
|
||||
return (
|
||||
<IdentityProjectRow
|
||||
key={`identity-project-membership-${membership.id}`}
|
||||
membership={membership}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && !projectMemberships?.length && (
|
||||
<EmptyState title="This identity has not been assigned to any projects" icon={faKey} />
|
||||
)}
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { IdentityProjectsSection } from "./IdentityProjectsSection";
|
@ -92,7 +92,6 @@ export const IdentityTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
});
|
||||
|
||||
setToken(newTokenData.accessToken);
|
||||
// note: may be helpful to tell user ttl etc.
|
||||
}
|
||||
|
||||
createNotification({
|
||||
|
@ -1,6 +1,6 @@
|
||||
export { IdentityAuthenticationSection } from "./IdentityAuthenticationSection/IdentityAuthenticationSection";
|
||||
export { IdentityClientSecretModal } from "./IdentityClientSecretModal";
|
||||
export { IdentityDetailsSection } from "./IdentityDetailsSection";
|
||||
export { IdentityProjectsSection } from "./IdentityProjectsSection";
|
||||
export { IdentityProjectsSection } from "./IdentityProjectsSection/IdentityProjectsSection";
|
||||
export { IdentityTokenListModal } from "./IdentityTokenListModal";
|
||||
export { IdentityTokenModal } from "./IdentityTokenModal";
|
||||
|
@ -19,10 +19,9 @@ import {
|
||||
useDeleteIdentityAzureAuth,
|
||||
useDeleteIdentityGcpAuth,
|
||||
useDeleteIdentityKubernetesAuth,
|
||||
useDeleteIdentityOidcAuth,
|
||||
useDeleteIdentityTokenAuth,
|
||||
useDeleteIdentityUniversalAuth,
|
||||
useDeleteIdentityOidcAuth
|
||||
} from "@app/hooks/api";
|
||||
useDeleteIdentityUniversalAuth} from "@app/hooks/api";
|
||||
import { IdentityAuthMethod, identityAuthToNameMap } from "@app/hooks/api/identities";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { TypeOptions } from "react-toastify";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faAngleDown,
|
||||
faAnglesRight,
|
||||
faCheckCircle,
|
||||
faChevronRight,
|
||||
faCodeCommit,
|
||||
@ -46,7 +48,12 @@ import {
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
|
||||
import { interpolateSecrets } from "@app/helpers/secret";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useCreateFolder, useDeleteSecretBatch, useGetUserWsKey } from "@app/hooks/api";
|
||||
import {
|
||||
useCreateFolder,
|
||||
useDeleteSecretBatch,
|
||||
useGetUserWsKey,
|
||||
useMoveSecrets
|
||||
} from "@app/hooks/api";
|
||||
import { DecryptedSecret, SecretType, TImportedSecrets, WsTag } from "@app/hooks/api/types";
|
||||
import { debounce } from "@app/lib/fn/debounce";
|
||||
|
||||
@ -60,6 +67,7 @@ import { Filter, GroupBy } from "../../SecretMainPage.types";
|
||||
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||
import { FolderForm } from "./FolderForm";
|
||||
import { MoveSecretsModal } from "./MoveSecretsModal";
|
||||
|
||||
type Props = {
|
||||
secrets?: DecryptedSecret[];
|
||||
@ -105,6 +113,7 @@ export const ActionBar = ({
|
||||
"addDynamicSecret",
|
||||
"addSecretImport",
|
||||
"bulkDeleteSecrets",
|
||||
"moveSecrets",
|
||||
"misc",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
@ -114,6 +123,7 @@ export const ActionBar = ({
|
||||
|
||||
const { mutateAsync: createFolder } = useCreateFolder();
|
||||
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
||||
const { mutateAsync: moveSecrets } = useMoveSecrets();
|
||||
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
|
||||
|
||||
const selectedSecrets = useSelectedSecrets();
|
||||
@ -228,6 +238,55 @@ export const ActionBar = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecretsMove = async ({
|
||||
destinationEnvironment,
|
||||
destinationSecretPath,
|
||||
shouldOverwrite
|
||||
}: {
|
||||
destinationEnvironment: string;
|
||||
destinationSecretPath: string;
|
||||
shouldOverwrite: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const secretsToMove = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id]));
|
||||
const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({
|
||||
projectSlug,
|
||||
shouldOverwrite,
|
||||
sourceEnvironment: environment,
|
||||
sourceSecretPath: secretPath,
|
||||
destinationEnvironment,
|
||||
destinationSecretPath,
|
||||
projectId: workspaceId,
|
||||
secretIds: secretsToMove.map((sec) => sec.id)
|
||||
});
|
||||
|
||||
let notificationMessage = "";
|
||||
let notificationType: TypeOptions = "info";
|
||||
|
||||
if (isDestinationUpdated && isSourceUpdated) {
|
||||
notificationMessage = "Successfully moved selected secrets";
|
||||
notificationType = "success";
|
||||
} else if (isDestinationUpdated) {
|
||||
notificationMessage =
|
||||
"Successfully created secrets in destination. A secret approval request has been generated for the source.";
|
||||
} else if (isSourceUpdated) {
|
||||
notificationMessage = "A secret approval request has been generated in the destination";
|
||||
} else {
|
||||
notificationMessage =
|
||||
"A secret approval request has been generated in both the source and the destination.";
|
||||
}
|
||||
|
||||
createNotification({
|
||||
type: notificationType,
|
||||
text: notificationMessage
|
||||
});
|
||||
|
||||
resetSelectedSecret();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
@ -455,6 +514,25 @@ export const ActionBar = ({
|
||||
<div className="ml-4 flex-grow px-2 text-sm">
|
||||
{Object.keys(selectedSecrets).length} Selected
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Move"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faAnglesRight} />}
|
||||
className="ml-4"
|
||||
onClick={() => handlePopUpOpen("moveSecrets")}
|
||||
isDisabled={!isAllowed}
|
||||
size="xs"
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
@ -466,7 +544,7 @@ export const ActionBar = ({
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
className="ml-4"
|
||||
className="ml-2"
|
||||
onClick={() => handlePopUpOpen("bulkDeleteSecrets")}
|
||||
isDisabled={!isAllowed}
|
||||
size="xs"
|
||||
@ -509,6 +587,11 @@ export const ActionBar = ({
|
||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
|
||||
onDeleteApproved={handleSecretBulkDelete}
|
||||
/>
|
||||
<MoveSecretsModal
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
onMoveApproved={handleSecretsMove}
|
||||
/>
|
||||
{subscription && (
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
|
@ -0,0 +1,147 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["moveSecrets"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["moveSecrets"]>, state?: boolean) => void;
|
||||
onMoveApproved: (moveParams: {
|
||||
destinationEnvironment: string;
|
||||
destinationSecretPath: string;
|
||||
shouldOverwrite: boolean;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
environment: z.string().trim(),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
),
|
||||
shouldOverwrite: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
export const MoveSecretsModal = ({ popUp, handlePopUpToggle, onMoveApproved }: Props) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TFormSchema>({ resolver: zodResolver(formSchema) });
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
const selectedEnvironment = watch("environment");
|
||||
|
||||
const handleFormSubmit = (data: TFormSchema) => {
|
||||
onMoveApproved({
|
||||
destinationEnvironment: data.environment,
|
||||
destinationSecretPath: data.secretPath,
|
||||
shouldOverwrite: data.shouldOverwrite
|
||||
});
|
||||
|
||||
handlePopUpToggle("moveSecrets", false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp.moveSecrets.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
reset();
|
||||
handlePopUpToggle("moveSecrets", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title="Move Secrets"
|
||||
subTitle="Move secrets from the current path to the selected destination"
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{environments.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||
<SecretPathInput {...field} environment={selectedEnvironment} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="shouldOverwrite"
|
||||
defaultValue={false}
|
||||
render={({ field: { onBlur, value, onChange } }) => (
|
||||
<Checkbox
|
||||
id="overwrite-checkbox"
|
||||
className="ml-2"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
Overwrite existing secrets
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-7 flex items-center">
|
||||
<Button
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
key="move-secrets-submit"
|
||||
className="mr-4"
|
||||
type="submit"
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
<Button
|
||||
key="move-secrets-cancel"
|
||||
onClick={() => handlePopUpToggle("moveSecrets", false)}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -77,7 +77,7 @@ export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean
|
||||
</div>
|
||||
<div className="w-full flex justify-center">
|
||||
<h1 className={`${id ? "max-w-sm mb-4": "max-w-md mt-4 mb-6"} bg-gradient-to-b from-white to-bunker-200 bg-clip-text px-4 text-center text-3xl font-medium text-transparent`}>
|
||||
{id ? "Someone shared a secret on Infisical with you" : "Share a secret with Infisical"}
|
||||
{id ? "Someone shared a secret via Infisical with you" : "Share a secret via Infisical"}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="m-auto mt-4 flex w-full max-w-2xl justify-center px-6">
|
||||
|
Reference in New Issue
Block a user