1
0
mirror of https://github.com/Infisical/infisical.git synced 2025-03-21 10:54:38 +00:00

Compare commits

..

71 Commits

Author SHA1 Message Date
6e1dc7375c Update csharp.mdx 2024-07-30 22:24:43 +02:00
164627139e TLS docs 2024-07-30 22:24:23 +02:00
021dbf3558 Merge pull request from Infisical/secret-sharing-fix
Minor UI Improvements
2024-07-30 11:17:53 -07:00
7e9389cb26 Made with love 2024-07-30 10:32:58 -07:00
eda57881ec Minor UI adjustments 2024-07-30 10:31:30 -07:00
553d51e5b3 Merge pull request from Infisical/maidul-dwdqwdfwef
Lint fixes to unblock prod pipeline
2024-07-30 11:06:01 -04:00
16e0a441ae unblock prod pipeline 2024-07-30 11:00:27 -04:00
d6c0941fa9 Merge pull request from Infisical/secret-sharing-update
Secret Sharing Update
2024-07-30 07:27:56 -07:00
7cbd254f06 Add back hashed hex for secret sharing 2024-07-30 07:16:03 -07:00
4b83b92725 Merge pull request from Infisical/handbook-update
add envkey migration page
2024-07-30 08:54:40 -04:00
fe72f034c1 Update migrating-from-envkey.mdx 2024-07-30 08:54:22 -04:00
6803553b21 add envkey migration page 2024-07-29 23:23:05 -07:00
1c8299054a Merge pull request from GLEF1X/perf/optimize-group-delete
perf(group-fns): optimize sequential delete to be concurrent
2024-07-29 22:13:00 -04:00
98b6373d6a perf(group-fns): optimize sequential delete to be concurrent 2024-07-29 21:40:48 -04:00
1d97921c7c Merge pull request from LemmyMwaura/delete-secret-modal
feat: add confirm step (modal) before deleting a secret
2024-07-29 19:52:51 -04:00
0d4164ea81 Merge remote-tracking branch 'origin' into secret-sharing-update 2024-07-29 15:22:13 -07:00
79bd8613d3 Fix padding 2024-07-29 15:16:11 -07:00
8deea21a83 Bring back logo, promo text in secret sharing 2024-07-29 15:05:38 -07:00
3b3c2be933 Merge pull request from LemmyMwaura/persist-tab-state
feat: persist tab state on route change.
2024-07-29 17:35:07 -04:00
c041e44399 Continue secret sharing 2024-07-29 14:32:11 -07:00
c1aeb04174 Merge pull request from Infisical/vmatsiiako-changelog-patch-1
Update changelog
2024-07-29 17:26:28 -04:00
3f3c0aab0f refactor: revert the org level enum to only types that existed before 2024-07-29 20:04:58 +03:00
b740e8c900 Rename types to Types with correct case 2024-07-29 20:02:42 +03:00
4416b11094 refactor: change folder name to uppercase for consistency 2024-07-29 19:48:49 +03:00
d8169a866d refactor: update types import path 2024-07-29 19:41:02 +03:00
7239158e7f refactor: localize tabs at both the org and project level 2024-07-29 19:37:19 +03:00
fefe2d1de1 Update changelog 2024-07-28 10:53:44 -07:00
3f3e41282d fix: remove unnecessary selectedTab div 2024-07-28 20:33:17 +03:00
c14f94177a Merge pull request from Infisical/vmatsiiako-changelog-update-july2024
Update changelog
2024-07-28 10:14:59 -07:00
ceb741955d Update changelog 2024-07-28 10:08:58 -07:00
f5bc4e1b5f refactor: return value as Tabsection from isTabSection fn (avoids assertion at setState level) 2024-07-28 07:50:27 +03:00
06900b9c99 refactor: create helper fn to check if string is in TabSections 2024-07-28 07:14:57 +03:00
d71cb96adf fix(lint): resolve type error 2024-07-27 23:33:09 +03:00
61ebec25b3 refactor: update envs to environments 2024-07-27 23:24:10 +03:00
57320c51fb fix: add selectedtab when moving back from roles page 2024-07-27 23:10:12 +03:00
4aa9cd0f72 feat: also persist the state on delete 2024-07-27 22:58:36 +03:00
ea39ef9269 feat: persist state at the org level when tab switching 2024-07-27 22:45:53 +03:00
15749a1f52 feat: update url onvalue change 2024-07-27 22:18:56 +03:00
9e9aff129e feat: use shared enum for consistent values 2024-07-27 22:12:19 +03:00
4ac487c974 feat: selectTab state from url 2024-07-27 22:04:43 +03:00
2e50072caa feat: move shared enum to separate file 2024-07-27 22:04:11 +03:00
2bd170df7d feat: add queryparam when switching tabs 2024-07-27 22:03:44 +03:00
938a7b7e72 Merge pull request from Infisical/secret-sharing
Secret Sharing UI/UX Adjustment
2024-07-27 10:09:03 -07:00
af864b456b Adjust secret sharing screen form padding 2024-07-27 07:32:56 -07:00
a30e3874cd Adjustments to secret sharing styling 2024-07-27 07:31:30 -07:00
de886f8dd0 feat: make title dynamic when deleting folders and secrets 2024-07-27 12:27:06 +03:00
b3db29ac37 refactor: update modal message to match other delete modals in the dashboard 2024-07-27 11:42:30 +03:00
ce1db38afd refactor: re-use existing modal for deletion 2024-07-26 22:05:44 +03:00
0fa6b7a08a Merge pull request from Infisical/project-role-concept
Project Role Page
2024-07-26 11:27:25 -07:00
29c5bf5491 Remove top margin from RolePermissionSecretsRow 2024-07-26 11:22:15 -07:00
4d711ae149 Finish project role page 2024-07-26 11:00:47 -07:00
9dd675ff98 refactor: move delete statement into body tag 2024-07-26 19:56:31 +03:00
8fd3e50d04 feat: implement delete secret via modal logic 2024-07-26 19:48:30 +03:00
391ed0723e feat: add delete secret modal 2024-07-26 19:47:35 +03:00
84af8e708e Merge remote-tracking branch 'origin' into project-role-concept 2024-07-26 07:28:17 -07:00
b39b5bd1a1 Merge pull request from Infisical/patch-org-role-update
Fix updating org role details should not send empty array of permissions
2024-07-26 07:27:51 -07:00
b3d9d91b52 Fix updating org role details should not send empty array of permissions 2024-07-26 06:52:21 -07:00
5ad4061881 Continue project role page 2024-07-26 06:43:09 -07:00
f29862eaf2 Merge pull request from Infisical/list-ca-endpoint-descriptions
Add descriptions for parameters for LIST (GET) CAs / certificates endpoints
2024-07-25 17:59:57 -04:00
7cb174b644 Add descriptions for list cas/certs endpoints 2024-07-25 14:53:41 -07:00
bf00d16c80 Continue progress on project role page 2024-07-25 14:45:02 -07:00
e30a0fe8be Merge pull request from Infisical/cert-search-filtering
Add List CAs / Certificates to Documentation + Filter Options
2024-07-25 09:40:44 -07:00
6e6f0252ae Adjust default offsets for cas/certs query 2024-07-25 08:09:21 -07:00
2348df7a4d Add list cert, ca + logical filters to docs 2024-07-25 08:06:18 -07:00
962cf67dfb Merge pull request from felixtrav/patch-1
Update envars.mdx - Added PORT
2024-07-25 10:21:06 -04:00
32627c20c4 Merge pull request from Infisical/org-role-cleanup
Cleanup frontend unused org role logic (moved)
2024-07-25 07:17:56 -07:00
c50f8fd78c Merge pull request from akhilmhdh/feat/cli-login-fallback-missing
Missing paste token option in CLI brower login flow
2024-07-25 10:08:57 -04:00
1cb4dc9e84 Start project role concept 2024-07-25 06:47:18 -07:00
=
08d7dead8c fix(cli): resolved not printing the url on api override 2024-07-25 15:28:54 +05:30
=
a30e06e392 feat: added back missing token paste option in cli login from browser 2024-07-25 15:28:29 +05:30
5cd0f665fa Update envars.mdx - Added PORT
Added the PORT configuration option to the documentation which controls the port the application listens on.
2024-07-24 19:17:33 -04:00
95 changed files with 2566 additions and 2235 deletions
backend/src
cli/packages/cmd
docs
api-reference/endpoints
certificate-authorities
certificates
changelog
documentation/guides
mint.json
sdks/languages
self-hosting/configuration
frontend/src
hooks/api
pages
project/[id]/roles/[roleSlug]
share-secret
shared/secret/[id]
views
Login/components
MFAStep
PasswordStep
Org
IdentityPage
MembersPage
MembersPage.tsx
components
OrgIdentityTab/components/IdentitySection
OrgMembersTab/components/OrgMembersSection
OrgRoleTabSection
RolePage
Types
UserPage
Project
SecretMainPage/components/SecretListView
SecretOverviewPage/components
SecretOverviewTableRow
SelectionPanel
ShareSecretPage/components
ShareSecretPublicPage
ViewSecretPublicPage

@ -0,0 +1,39 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.SecretSharing)) {
const doesNameExist = await knex.schema.hasColumn(TableName.SecretSharing, "name");
if (!doesNameExist) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
t.string("name").nullable();
});
}
const doesLastViewedAtExist = await knex.schema.hasColumn(TableName.SecretSharing, "lastViewedAt");
if (!doesLastViewedAtExist) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
t.timestamp("lastViewedAt").nullable();
});
}
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.SecretSharing)) {
const doesNameExist = await knex.schema.hasColumn(TableName.SecretSharing, "name");
if (doesNameExist) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
t.dropColumn("name");
});
}
const doesLastViewedAtExist = await knex.schema.hasColumn(TableName.SecretSharing, "lastViewedAt");
if (doesLastViewedAtExist) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
t.dropColumn("lastViewedAt");
});
}
}
}

@ -5,8 +5,6 @@
import { z } from "zod"; import { z } from "zod";
import { EnforcementLevel } from "@app/lib/types";
import { TImmutableDBKeys } from "./models"; import { TImmutableDBKeys } from "./models";
export const AccessApprovalPoliciesSchema = z.object({ export const AccessApprovalPoliciesSchema = z.object({
@ -17,7 +15,7 @@ export const AccessApprovalPoliciesSchema = z.object({
envId: z.string().uuid(), envId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard) enforcementLevel: z.string().default("hard")
}); });
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>; export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;

@ -13,9 +13,9 @@ export const KmsKeysSchema = z.object({
isDisabled: z.boolean().default(false).nullable().optional(), isDisabled: z.boolean().default(false).nullable().optional(),
isReserved: z.boolean().default(true).nullable().optional(), isReserved: z.boolean().default(true).nullable().optional(),
orgId: z.string().uuid(), orgId: z.string().uuid(),
slug: z.string(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date()
slug: z.string()
}); });
export type TKmsKeys = z.infer<typeof KmsKeysSchema>; export type TKmsKeys = z.infer<typeof KmsKeysSchema>;

@ -18,7 +18,7 @@ export const OrgMembershipsSchema = z.object({
orgId: z.string().uuid(), orgId: z.string().uuid(),
roleId: z.string().uuid().nullable().optional(), roleId: z.string().uuid().nullable().optional(),
projectFavorites: z.string().array().nullable().optional(), projectFavorites: z.string().array().nullable().optional(),
isActive: z.boolean() isActive: z.boolean().default(true)
}); });
export type TOrgMemberships = z.infer<typeof OrgMembershipsSchema>; export type TOrgMemberships = z.infer<typeof OrgMembershipsSchema>;

@ -15,12 +15,12 @@ export const SecretApprovalRequestsSchema = z.object({
conflicts: z.unknown().nullable().optional(), conflicts: z.unknown().nullable().optional(),
slug: z.string(), slug: z.string(),
folderId: z.string().uuid(), folderId: z.string().uuid(),
bypassReason: z.string().nullable().optional(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
isReplicated: z.boolean().nullable().optional(), isReplicated: z.boolean().nullable().optional(),
committerUserId: z.string().uuid(), committerUserId: z.string().uuid(),
statusChangedByUserId: z.string().uuid().nullable().optional() statusChangedByUserId: z.string().uuid().nullable().optional(),
bypassReason: z.string().nullable().optional()
}); });
export type TSecretApprovalRequests = z.infer<typeof SecretApprovalRequestsSchema>; export type TSecretApprovalRequests = z.infer<typeof SecretApprovalRequestsSchema>;

@ -5,8 +5,6 @@
import { z } from "zod"; import { z } from "zod";
import { SecretSharingAccessType } from "@app/lib/types";
import { TImmutableDBKeys } from "./models"; import { TImmutableDBKeys } from "./models";
export const SecretSharingSchema = z.object({ export const SecretSharingSchema = z.object({
@ -18,10 +16,12 @@ export const SecretSharingSchema = z.object({
expiresAt: z.date(), expiresAt: z.date(),
userId: z.string().uuid().nullable().optional(), userId: z.string().uuid().nullable().optional(),
orgId: z.string().uuid().nullable().optional(), orgId: z.string().uuid().nullable().optional(),
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
expiresAfterViews: z.number().nullable().optional() expiresAfterViews: z.number().nullable().optional(),
accessType: z.string().default("anyone"),
name: z.string().nullable().optional(),
lastViewedAt: z.date().nullable().optional()
}); });
export type TSecretSharing = z.infer<typeof SecretSharingSchema>; export type TSecretSharing = z.infer<typeof SecretSharingSchema>;

@ -107,7 +107,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
}), }),
name: z.string().trim().optional(), name: z.string().trim().optional(),
description: z.string().trim().optional(), description: z.string().trim().optional(),
permissions: z.any().array() permissions: z.any().array().optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({

@ -101,7 +101,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
message: "Slug must be a valid" message: "Slug must be a valid"
}), }),
name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name),
permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions) permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@ -120,7 +120,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
roleId: req.params.roleId, roleId: req.params.roleId,
data: { data: {
...req.body, ...req.body,
permissions: JSON.stringify(packRules(req.body.permissions)) permissions: req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined
} }
}); });
return { role }; return { role };

@ -336,31 +336,36 @@ export const removeUsersFromGroupByUserIds = async ({
) )
); );
// TODO: this part can be optimized const promises: Array<Promise<void>> = [];
for await (const userId of userIds) { for (const userId of userIds) {
const t = await userGroupMembershipDAL.filterProjectsByUserMembership(userId, group.id, projectIds, tx); promises.push(
const projectsToDeleteKeyFor = projectIds.filter((p) => !t.has(p)); (async () => {
const t = await userGroupMembershipDAL.filterProjectsByUserMembership(userId, group.id, projectIds, tx);
const projectsToDeleteKeyFor = projectIds.filter((p) => !t.has(p));
if (projectsToDeleteKeyFor.length) { if (projectsToDeleteKeyFor.length) {
await projectKeyDAL.delete( await projectKeyDAL.delete(
{ {
receiverId: userId, receiverId: userId,
$in: { $in: {
projectId: projectsToDeleteKeyFor projectId: projectsToDeleteKeyFor
} }
}, },
tx tx
); );
} }
await userGroupMembershipDAL.delete( await userGroupMembershipDAL.delete(
{ {
groupId: group.id, groupId: group.id,
userId userId
}, },
tx tx
);
})()
); );
} }
await Promise.all(promises);
} }
if (membersToRemoveFromGroupPending.length) { if (membersToRemoveFromGroupPending.length) {

@ -425,6 +425,21 @@ export const PROJECTS = {
}, },
LIST_INTEGRATION_AUTHORIZATION: { LIST_INTEGRATION_AUTHORIZATION: {
workspaceId: "The ID of the project to list integration auths for." workspaceId: "The ID of the project to list integration auths for."
},
LIST_CAS: {
slug: "The slug of the project to list CAs for.",
status: "The status of the CA to filter by.",
friendlyName: "The friendly name of the CA to filter by.",
commonName: "The common name of the CA to filter by.",
offset: "The offset to start from. If you enter 10, it will start from the 10th CA.",
limit: "The number of CAs to return."
},
LIST_CERTIFICATES: {
slug: "The slug of the project to list certificates for.",
friendlyName: "The friendly name of the certificate to filter by.",
commonName: "The common name of the certificate to filter by.",
offset: "The offset to start from. If you enter 10, it will start from the 10th certificate.",
limit: "The number of certificates to return."
} }
} as const; } as const;

@ -19,21 +19,31 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
rateLimit: readLimit rateLimit: readLimit
}, },
schema: { schema: {
querystring: z.object({
offset: z.coerce.number().min(0).max(100).default(0),
limit: z.coerce.number().min(1).max(100).default(25)
}),
response: { response: {
200: z.array(SecretSharingSchema) 200: z.object({
secrets: z.array(SecretSharingSchema),
totalCount: z.number()
})
} }
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const sharedSecrets = await req.server.services.secretSharing.getSharedSecrets({ const { secrets, totalCount } = await req.server.services.secretSharing.getSharedSecrets({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
orgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId actorOrgId: req.permission.orgId,
...req.query
}); });
return sharedSecrets; return {
secrets,
totalCount
};
} }
}); });
@ -48,7 +58,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
id: z.string().uuid() id: z.string().uuid()
}), }),
querystring: z.object({ querystring: z.object({
hashedHex: z.string() hashedHex: z.string().min(1)
}), }),
response: { response: {
200: SecretSharingSchema.pick({ 200: SecretSharingSchema.pick({
@ -64,11 +74,11 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
} }
}, },
handler: async (req) => { handler: async (req) => {
const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretByIdAndHashedHex( const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretById({
req.params.id, sharedSecretId: req.params.id,
req.query.hashedHex, hashedHex: req.query.hashedHex,
req.permission?.orgId orgId: req.permission?.orgId
); });
if (!sharedSecret) return undefined; if (!sharedSecret) return undefined;
return { return {
encryptedValue: sharedSecret.encryptedValue, encryptedValue: sharedSecret.encryptedValue,
@ -91,11 +101,11 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
schema: { schema: {
body: z.object({ body: z.object({
encryptedValue: z.string(), encryptedValue: z.string(),
hashedHex: z.string(),
iv: z.string(), iv: z.string(),
tag: z.string(), tag: z.string(),
hashedHex: z.string(),
expiresAt: z.string(), expiresAt: z.string(),
expiresAfterViews: z.number() expiresAfterViews: z.number().min(1).optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@ -104,14 +114,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
} }
}, },
handler: async (req) => { handler: async (req) => {
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body;
const sharedSecret = await req.server.services.secretSharing.createPublicSharedSecret({ const sharedSecret = await req.server.services.secretSharing.createPublicSharedSecret({
encryptedValue, ...req.body,
iv,
tag,
hashedHex,
expiresAt: new Date(expiresAt),
expiresAfterViews,
accessType: SecretSharingAccessType.Anyone accessType: SecretSharingAccessType.Anyone
}); });
return { id: sharedSecret.id }; return { id: sharedSecret.id };
@ -126,12 +130,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
}, },
schema: { schema: {
body: z.object({ body: z.object({
name: z.string().max(50).optional(),
encryptedValue: z.string(), encryptedValue: z.string(),
hashedHex: z.string(),
iv: z.string(), iv: z.string(),
tag: z.string(), tag: z.string(),
hashedHex: z.string(),
expiresAt: z.string(), expiresAt: z.string(),
expiresAfterViews: z.number(), expiresAfterViews: z.number().min(1).optional(),
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization) accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization)
}), }),
response: { response: {
@ -142,20 +147,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body;
const sharedSecret = await req.server.services.secretSharing.createSharedSecret({ const sharedSecret = await req.server.services.secretSharing.createSharedSecret({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
orgId: req.permission.orgId, orgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
encryptedValue, ...req.body
iv,
tag,
hashedHex,
expiresAt: new Date(expiresAt),
expiresAfterViews,
accessType: req.body.accessType
}); });
return { id: sharedSecret.id }; return { id: sharedSecret.id };
} }

@ -317,10 +317,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}, },
schema: { schema: {
params: z.object({ params: z.object({
slug: slugSchema.describe("The slug of the project to list CAs.") slug: slugSchema.describe(PROJECTS.LIST_CAS.slug)
}), }),
querystring: z.object({ querystring: z.object({
status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional() status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional().describe(PROJECTS.LIST_CAS.status),
friendlyName: z.string().optional().describe(PROJECTS.LIST_CAS.friendlyName),
commonName: z.string().optional().describe(PROJECTS.LIST_CAS.commonName),
offset: z.coerce.number().min(0).max(100).default(0).describe(PROJECTS.LIST_CAS.offset),
limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CAS.limit)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@ -336,11 +340,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
orgId: req.permission.orgId, orgId: req.permission.orgId,
type: ProjectFilterType.SLUG type: ProjectFilterType.SLUG
}, },
status: req.query.status,
actorId: req.permission.id, actorId: req.permission.id,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actor: req.permission.type actor: req.permission.type,
...req.query
}); });
return { cas }; return { cas };
} }
@ -354,11 +358,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}, },
schema: { schema: {
params: z.object({ params: z.object({
slug: slugSchema.describe("The slug of the project to list certificates.") slug: slugSchema.describe(PROJECTS.LIST_CERTIFICATES.slug)
}), }),
querystring: z.object({ querystring: z.object({
offset: z.coerce.number().min(0).max(100).default(0), friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName),
limit: z.coerce.number().min(1).max(100).default(25) commonName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.commonName),
offset: z.coerce.number().min(0).max(100).default(0).describe(PROJECTS.LIST_CERTIFICATES.offset),
limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CERTIFICATES.limit)
}), }),
response: { response: {
200: z.object({ 200: z.object({

@ -8,19 +8,35 @@ export type TCertificateDALFactory = ReturnType<typeof certificateDALFactory>;
export const certificateDALFactory = (db: TDbClient) => { export const certificateDALFactory = (db: TDbClient) => {
const certificateOrm = ormify(db, TableName.Certificate); const certificateOrm = ormify(db, TableName.Certificate);
const countCertificatesInProject = async (projectId: string) => { const countCertificatesInProject = async ({
projectId,
friendlyName,
commonName
}: {
projectId: string;
friendlyName?: string;
commonName?: string;
}) => {
try { try {
interface CountResult { interface CountResult {
count: string; count: string;
} }
const count = await db let query = db
.replicaNode()(TableName.Certificate) .replicaNode()(TableName.Certificate)
.join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`) .join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`)
.join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`) .join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`)
.where(`${TableName.Project}.id`, projectId) .where(`${TableName.Project}.id`, projectId);
.count("*")
.first(); if (friendlyName) {
query = query.andWhere(`${TableName.Certificate}.friendlyName`, friendlyName);
}
if (commonName) {
query = query.andWhere(`${TableName.Certificate}.commonName`, commonName);
}
const count = await query.count("*").first();
return parseInt((count as unknown as CountResult).count || "0", 10); return parseInt((count as unknown as CountResult).count || "0", 10);
} catch (error) { } catch (error) {

@ -162,12 +162,19 @@ export const projectRoleServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Role); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Role);
if (data?.slug) { if (data?.slug) {
const existingRole = await projectRoleDAL.findOne({ slug: data.slug, projectId }); const existingRole = await projectRoleDAL.findOne({ slug: data.slug, projectId });
if (existingRole && existingRole.id !== roleId) if (existingRole && existingRole.id !== roleId)
throw new BadRequestError({ name: "Update Role", message: "Duplicate role" }); throw new BadRequestError({ name: "Update Role", message: "Duplicate role" });
} }
const [updatedRole] = await projectRoleDAL.update({ id: roleId, projectId }, data); const [updatedRole] = await projectRoleDAL.update(
{ id: roleId, projectId },
{
...data,
permissions: data.permissions ? data.permissions : undefined
}
);
if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) }; return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) };
}; };

@ -575,6 +575,10 @@ export const projectServiceFactory = ({
*/ */
const listProjectCas = async ({ const listProjectCas = async ({
status, status,
friendlyName,
commonName,
limit = 25,
offset = 0,
actorId, actorId,
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
@ -596,10 +600,15 @@ export const projectServiceFactory = ({
ProjectPermissionSub.CertificateAuthorities ProjectPermissionSub.CertificateAuthorities
); );
const cas = await certificateAuthorityDAL.find({ const cas = await certificateAuthorityDAL.find(
projectId: project.id, {
...(status && { status }) projectId: project.id,
}); ...(status && { status }),
...(friendlyName && { friendlyName }),
...(commonName && { commonName })
},
{ offset, limit, sort: [["updatedAt", "desc"]] }
);
return cas; return cas;
}; };
@ -608,8 +617,10 @@ export const projectServiceFactory = ({
* Return list of certificates for project * Return list of certificates for project
*/ */
const listProjectCertificates = async ({ const listProjectCertificates = async ({
offset, limit = 25,
limit, offset = 0,
friendlyName,
commonName,
actorId, actorId,
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
@ -634,12 +645,18 @@ export const projectServiceFactory = ({
{ {
$in: { $in: {
caId: cas.map((ca) => ca.id) caId: cas.map((ca) => ca.id)
} },
...(friendlyName && { friendlyName }),
...(commonName && { commonName })
}, },
{ offset, limit, sort: [["updatedAt", "desc"]] } { offset, limit, sort: [["updatedAt", "desc"]] }
); );
const count = await certificateDAL.countCertificatesInProject(project.id); const count = await certificateDAL.countCertificatesInProject({
projectId: project.id,
friendlyName,
commonName
});
return { return {
certificates, certificates,

@ -89,6 +89,10 @@ export type AddUserToWsDTO = {
export type TListProjectCasDTO = { export type TListProjectCasDTO = {
status?: CaStatus; status?: CaStatus;
friendlyName?: string;
offset?: number;
limit?: number;
commonName?: string;
filter: Filter; filter: Filter;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
@ -96,4 +100,6 @@ export type TListProjectCertsDTO = {
filter: Filter; filter: Filter;
offset: number; offset: number;
limit: number; limit: number;
friendlyName?: string;
commonName?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;

@ -10,6 +10,25 @@ export type TSecretSharingDALFactory = ReturnType<typeof secretSharingDALFactory
export const secretSharingDALFactory = (db: TDbClient) => { export const secretSharingDALFactory = (db: TDbClient) => {
const sharedSecretOrm = ormify(db, TableName.SecretSharing); const sharedSecretOrm = ormify(db, TableName.SecretSharing);
const countAllUserOrgSharedSecrets = async ({ orgId, userId }: { orgId: string; userId: string }) => {
try {
interface CountResult {
count: string;
}
const count = await db
.replicaNode()(TableName.SecretSharing)
.where(`${TableName.SecretSharing}.orgId`, orgId)
.where(`${TableName.SecretSharing}.userId`, userId)
.count("*")
.first();
return parseInt((count as unknown as CountResult).count || "0", 10);
} catch (error) {
throw new DatabaseError({ error, name: "Count all user-org shared secrets" });
}
};
const pruneExpiredSharedSecrets = async (tx?: Knex) => { const pruneExpiredSharedSecrets = async (tx?: Knex) => {
try { try {
const today = new Date(); const today = new Date();
@ -19,8 +38,7 @@ export const secretSharingDALFactory = (db: TDbClient) => {
.update({ .update({
encryptedValue: "", encryptedValue: "",
tag: "", tag: "",
iv: "", iv: ""
hashedHex: ""
}); });
return docs; return docs;
} catch (error) { } catch (error) {
@ -50,8 +68,7 @@ export const secretSharingDALFactory = (db: TDbClient) => {
await sharedSecretOrm.updateById(id, { await sharedSecretOrm.updateById(id, {
encryptedValue: "", encryptedValue: "",
iv: "", iv: "",
tag: "", tag: ""
hashedHex: ""
}); });
} catch (error) { } catch (error) {
throw new DatabaseError({ throw new DatabaseError({
@ -63,6 +80,7 @@ export const secretSharingDALFactory = (db: TDbClient) => {
return { return {
...sharedSecretOrm, ...sharedSecretOrm,
countAllUserOrgSharedSecrets,
pruneExpiredSharedSecrets, pruneExpiredSharedSecrets,
softDeleteById, softDeleteById,
findActiveSharedSecrets findActiveSharedSecrets

@ -1,5 +1,5 @@
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { SecretSharingAccessType } from "@app/lib/types"; import { SecretSharingAccessType } from "@app/lib/types";
import { TOrgDALFactory } from "../org/org-dal"; import { TOrgDALFactory } from "../org/org-dal";
@ -8,7 +8,8 @@ import {
TCreatePublicSharedSecretDTO, TCreatePublicSharedSecretDTO,
TCreateSharedSecretDTO, TCreateSharedSecretDTO,
TDeleteSharedSecretDTO, TDeleteSharedSecretDTO,
TSharedSecretPermission TGetActiveSharedSecretByIdDTO,
TGetSharedSecretsDTO
} from "./secret-sharing-types"; } from "./secret-sharing-types";
type TSecretSharingServiceFactoryDep = { type TSecretSharingServiceFactoryDep = {
@ -24,21 +25,21 @@ export const secretSharingServiceFactory = ({
secretSharingDAL, secretSharingDAL,
orgDAL orgDAL
}: TSecretSharingServiceFactoryDep) => { }: TSecretSharingServiceFactoryDep) => {
const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => { const createSharedSecret = async ({
const { actor,
actor, actorId,
actorId, orgId,
orgId, actorAuthMethod,
actorAuthMethod, actorOrgId,
actorOrgId, encryptedValue,
encryptedValue, hashedHex,
iv, iv,
tag, tag,
accessType, name,
hashedHex, accessType,
expiresAt, expiresAt,
expiresAfterViews expiresAfterViews
} = createSharedSecretInput; }: TCreateSharedSecretDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new UnauthorizedError({ name: "User not in org" }); if (!permission) throw new UnauthorizedError({ name: "User not in org" });
@ -60,21 +61,30 @@ export const secretSharingServiceFactory = ({
} }
const newSharedSecret = await secretSharingDAL.create({ const newSharedSecret = await secretSharingDAL.create({
name,
encryptedValue, encryptedValue,
hashedHex,
iv, iv,
tag, tag,
hashedHex, expiresAt: new Date(expiresAt),
expiresAt,
expiresAfterViews, expiresAfterViews,
userId: actorId, userId: actorId,
orgId, orgId,
accessType accessType
}); });
return { id: newSharedSecret.id }; return { id: newSharedSecret.id };
}; };
const createPublicSharedSecret = async (createSharedSecretInput: TCreatePublicSharedSecretDTO) => { const createPublicSharedSecret = async ({
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews, accessType } = createSharedSecretInput; encryptedValue,
hashedHex,
iv,
tag,
expiresAt,
expiresAfterViews,
accessType
}: TCreatePublicSharedSecretDTO) => {
if (new Date(expiresAt) < new Date()) { if (new Date(expiresAt) < new Date()) {
throw new BadRequestError({ message: "Expiration date cannot be in the past" }); throw new BadRequestError({ message: "Expiration date cannot be in the past" });
} }
@ -94,53 +104,103 @@ export const secretSharingServiceFactory = ({
const newSharedSecret = await secretSharingDAL.create({ const newSharedSecret = await secretSharingDAL.create({
encryptedValue, encryptedValue,
hashedHex,
iv, iv,
tag, tag,
hashedHex, expiresAt: new Date(expiresAt),
expiresAt,
expiresAfterViews, expiresAfterViews,
accessType accessType
}); });
return { id: newSharedSecret.id }; return { id: newSharedSecret.id };
}; };
const getSharedSecrets = async (getSharedSecretsInput: TSharedSecretPermission) => { const getSharedSecrets = async ({
const { actor, actorId, orgId, actorAuthMethod, actorOrgId } = getSharedSecretsInput; actor,
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); actorId,
actorAuthMethod,
actorOrgId,
offset,
limit
}: TGetSharedSecretsDTO) => {
if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" });
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
if (!permission) throw new UnauthorizedError({ name: "User not in org" }); if (!permission) throw new UnauthorizedError({ name: "User not in org" });
const userSharedSecrets = await secretSharingDAL.findActiveSharedSecrets({ userId: actorId, orgId });
return userSharedSecrets; const secrets = await secretSharingDAL.find(
{
userId: actorId,
orgId: actorOrgId
},
{ offset, limit, sort: [["createdAt", "desc"]] }
);
const count = await secretSharingDAL.countAllUserOrgSharedSecrets({
orgId: actorOrgId,
userId: actorId
});
return {
secrets,
totalCount: count
};
}; };
const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string, orgId?: string) => { const getActiveSharedSecretById = async ({ sharedSecretId, hashedHex, orgId }: TGetActiveSharedSecretByIdDTO) => {
const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex }); const sharedSecret = await secretSharingDAL.findOne({
if (!sharedSecret) return; id: sharedSecretId,
hashedHex
});
if (!sharedSecret)
throw new NotFoundError({
message: "Shared secret not found"
});
const { accessType, expiresAt, expiresAfterViews } = sharedSecret;
const orgName = sharedSecret.orgId ? (await orgDAL.findOrgById(sharedSecret.orgId))?.name : ""; const orgName = sharedSecret.orgId ? (await orgDAL.findOrgById(sharedSecret.orgId))?.name : "";
// Support organization level access for secret sharing
if (sharedSecret.accessType === SecretSharingAccessType.Organization && orgId !== sharedSecret.orgId) { if (accessType === SecretSharingAccessType.Organization && orgId !== sharedSecret.orgId)
return { throw new UnauthorizedError();
...sharedSecret,
encryptedValue: "", if (expiresAt !== null && expiresAt < new Date()) {
iv: "", // check lifetime expiry
tag: "", await secretSharingDAL.softDeleteById(sharedSecretId);
orgName throw new ForbiddenRequestError({
}; message: "Access denied: Secret has expired by lifetime"
});
} }
if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) {
return; if (expiresAfterViews !== null && expiresAfterViews === 0) {
// check view count expiry
await secretSharingDAL.softDeleteById(sharedSecretId);
throw new ForbiddenRequestError({
message: "Access denied: Secret has expired by view count"
});
} }
if (sharedSecret.expiresAfterViews != null && sharedSecret.expiresAfterViews >= 0) {
if (sharedSecret.expiresAfterViews === 0) { if (expiresAfterViews) {
await secretSharingDAL.softDeleteById(sharedSecretId); // decrement view count if view count expiry set
return;
}
await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } }); await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } });
} }
if (sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId) {
return { ...sharedSecret, orgName }; await secretSharingDAL.updateById(sharedSecretId, {
} lastViewedAt: new Date()
return { ...sharedSecret, orgName: undefined }; });
return {
...sharedSecret,
orgName:
sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId
? orgName
: undefined
};
}; };
const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => { const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => {
@ -156,6 +216,6 @@ export const secretSharingServiceFactory = ({
createPublicSharedSecret, createPublicSharedSecret,
getSharedSecrets, getSharedSecrets,
deleteSharedSecretById, deleteSharedSecretById,
getActiveSharedSecretByIdAndHashedHex getActiveSharedSecretById
}; };
}; };

@ -1,7 +1,12 @@
import { SecretSharingAccessType } from "@app/lib/types"; import { SecretSharingAccessType, TGenericPermission } from "@app/lib/types";
import { ActorAuthMethod, ActorType } from "../auth/auth-type"; import { ActorAuthMethod, ActorType } from "../auth/auth-type";
export type TGetSharedSecretsDTO = {
offset: number;
limit: number;
} & TGenericPermission;
export type TSharedSecretPermission = { export type TSharedSecretPermission = {
actor: ActorType; actor: ActorType;
actorId: string; actorId: string;
@ -9,18 +14,25 @@ export type TSharedSecretPermission = {
actorOrgId: string; actorOrgId: string;
orgId: string; orgId: string;
accessType?: SecretSharingAccessType; accessType?: SecretSharingAccessType;
name?: string;
}; };
export type TCreatePublicSharedSecretDTO = { export type TCreatePublicSharedSecretDTO = {
encryptedValue: string; encryptedValue: string;
hashedHex: string;
iv: string; iv: string;
tag: string; tag: string;
hashedHex: string; expiresAt: string;
expiresAt: Date; expiresAfterViews?: number;
expiresAfterViews: number;
accessType: SecretSharingAccessType; accessType: SecretSharingAccessType;
}; };
export type TGetActiveSharedSecretByIdDTO = {
sharedSecretId: string;
hashedHex: string;
orgId?: string;
};
export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO; export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO;
export type TDeleteSharedSecretDTO = { export type TDeleteSharedSecretDTO = {

@ -24,7 +24,6 @@ import (
"github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/models"
"github.com/Infisical/infisical-merge/packages/srp" "github.com/Infisical/infisical-merge/packages/srp"
"github.com/Infisical/infisical-merge/packages/util" "github.com/Infisical/infisical-merge/packages/util"
"github.com/chzyer/readline"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
"github.com/manifoldco/promptui" "github.com/manifoldco/promptui"
@ -205,6 +204,7 @@ var loginCmd = &cobra.Command{
if !overrideDomain { if !overrideDomain {
domainQuery = false domainQuery = false
config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL_MANUAL_OVERRIDE) config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL_MANUAL_OVERRIDE)
config.INFISICAL_LOGIN_URL = fmt.Sprintf("%s/login", strings.TrimSuffix(config.INFISICAL_URL, "/api"))
} }
} }
@ -713,7 +713,7 @@ func askForMFACode() string {
return mfaVerifyCode return mfaVerifyCode
} }
func askToPasteJwtToken(stdin *readline.CancelableStdin, success chan models.UserCredentials, failure chan error) { func askToPasteJwtToken(success chan models.UserCredentials, failure chan error) {
time.Sleep(time.Second * 5) time.Sleep(time.Second * 5)
fmt.Println("\n\nOnce login is completed via browser, the CLI should be authenticated automatically.") fmt.Println("\n\nOnce login is completed via browser, the CLI should be authenticated automatically.")
fmt.Println("However, if browser fails to communicate with the CLI, please paste the token from the browser below.") fmt.Println("However, if browser fails to communicate with the CLI, please paste the token from the browser below.")
@ -807,26 +807,22 @@ func browserCliLogin() (models.UserCredentials, error) {
log.Debug().Msgf("Callback server listening on port %d", callbackPort) log.Debug().Msgf("Callback server listening on port %d", callbackPort)
stdin := readline.NewCancelableStdin(os.Stdin)
go http.Serve(listener, corsHandler) go http.Serve(listener, corsHandler)
go askToPasteJwtToken(stdin, success, failure) go askToPasteJwtToken(success, failure)
for { for {
select { select {
case loginResponse := <-success: case loginResponse := <-success:
_ = closeListener(&listener) _ = closeListener(&listener)
_ = stdin.Close()
fmt.Println("Browser login successful") fmt.Println("Browser login successful")
return loginResponse, nil return loginResponse, nil
case err := <-failure: case err := <-failure:
serverErr := closeListener(&listener) serverErr := closeListener(&listener)
stdErr := stdin.Close() return models.UserCredentials{}, errors.Join(err, serverErr)
return models.UserCredentials{}, errors.Join(err, serverErr, stdErr)
case <-timeout: case <-timeout:
_ = closeListener(&listener) _ = closeListener(&listener)
_ = stdin.Close()
return models.UserCredentials{}, errors.New("server timeout") return models.UserCredentials{}, errors.New("server timeout")
} }
} }

@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v2/workspace/{slug}/cas"
---

@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v2/workspace/{slug}/certificates"
---

@ -4,6 +4,30 @@ title: "Changelog"
The changelog below reflects new product developments and updates on a monthly basis. The changelog below reflects new product developments and updates on a monthly basis.
## July 2024
- Released the official [Ruby SDK](https://infisical.com/docs/sdks/languages/ruby).
- Increased the speed and efficiency of secret operations.
- Released AWS KMS wrapping (bring your own key).
- Users can now log in to CLI via SSO in non-browser environments.
- Released [Slack Webhooks](https://infisical.com/docs/documentation/platform/webhooks).
- Added [Dynamic Secrets with MS SQL](https://infisical.com/docs/documentation/platform/dynamic-secrets/mssql).
- Redesigned and simplified the Machine Identities page.
- Added the ability to move secrets/folders to another location.
- Added [OIDC](https://infisical.com/docs/documentation/platform/identities/oidc-auth/general) support to CLI, Go SDK, and more.
- Released [Linux installer for Infisical](https://infisical.com/docs/self-hosting/deployment-options/native/standalone-binary).
## June 2024
- Released [Infisical PKI](https://infisical.com/docs/documentation/platform/pki/overview).
- Released the official [Go SDK](https://infisical.com/docs/sdks/languages/go).
- Released [OIDC Authentication method](https://infisical.com/docs/documentation/platform/identities/oidc-auth/general).
- Allowed users to configure log retention periods on self-hosted instances.
- Added [tags](https://registry.terraform.io/providers/Infisical/infisical/latest/docs/resources/secret_tag) to terraform provider.
- Released [public secret sharing](https://share.infisical.com).
- Built a [native integration with Rundeck](https://infisical.com/docs/integrations/cicd/rundeck).
- Added list view for projects in the dashboard.
- Fixed offline coding mode in CLI.
- Users are now able to leave a particular project themselves.
## May 2024 ## May 2024
- Released [AWS](https://infisical.com/docs/documentation/platform/identities/aws-auth), [GCP](https://infisical.com/docs/documentation/platform/identities/gcp-auth), [Azure](https://infisical.com/docs/documentation/platform/identities/azure-auth), and [Kubernetes](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth) Native Auth Methods. - Released [AWS](https://infisical.com/docs/documentation/platform/identities/aws-auth), [GCP](https://infisical.com/docs/documentation/platform/identities/gcp-auth), [Azure](https://infisical.com/docs/documentation/platform/identities/azure-auth), and [Kubernetes](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth) Native Auth Methods.
- Added [Secret Sharing](https://infisical.com/docs/documentation/platform/secret-sharing) functionality for sharing sensitive data through encrypted links within and outside of an organization. - Added [Secret Sharing](https://infisical.com/docs/documentation/platform/secret-sharing) functionality for sharing sensitive data through encrypted links within and outside of an organization.

@ -0,0 +1,23 @@
---
title: "Migrating from EnvKey to Infisical"
sidebarTitle: "Migration"
description: "Learn how to migrate from EnvKey to Infisical in the easiest way possible."
---
## What is Infisical?
[Infisical](https://infisical.com) is an open-source all-in-one secret management platform that helps developers manage secrets (e.g., API-keys, DB access tokens, [certificates](https://infisical.com/docs/documentation/platform/pki/overview)) across their infrastructure. In addition, Infisical provides [secret sharing](https://infisical.com/docs/documentation/platform/secret-sharing) functionality, ability to [prevent secret leaks](https://infisical.com/docs/cli/scanning-overview), and more.
Infisical is used by 10,000+ organizations across all indsutries including First American Financial Corporation, Deivery Hero, and [Hugging Face](https://infisical.com/customers/hugging-face).
## Migrating from EnvKey
To facilitate customer transition from EnvKey to Infisical, we have been working closely with the EnvKey team to provide a simple migration path for all EnvKey customers.
## Automated migration
Our team is currently working on creating an automated migration process that would include secrets, policies, and other important resources. If you are interested in that, please [reach out to our team](mailto:support@infisical.com) with any questions.
## Talk to our team
To make the migration process even more seamless, you can [schedule a meeting with our team](https://infisical.cal.com/vlad/migration-from-envkey-to-infisical) to learn more about how Infisical compares to EnvKey and discuss unique needs of your organization. You are also welcome to email us at [support@infisical.com](mailto:support@infisical.com) to ask any questions or get any technical help.

@ -654,6 +654,7 @@
{ {
"group": "Certificate Authorities", "group": "Certificate Authorities",
"pages": [ "pages": [
"api-reference/endpoints/certificate-authorities/list",
"api-reference/endpoints/certificate-authorities/create", "api-reference/endpoints/certificate-authorities/create",
"api-reference/endpoints/certificate-authorities/read", "api-reference/endpoints/certificate-authorities/read",
"api-reference/endpoints/certificate-authorities/update", "api-reference/endpoints/certificate-authorities/update",
@ -669,6 +670,7 @@
{ {
"group": "Certificates", "group": "Certificates",
"pages": [ "pages": [
"api-reference/endpoints/certificates/list",
"api-reference/endpoints/certificates/read", "api-reference/endpoints/certificates/read",
"api-reference/endpoints/certificates/revoke", "api-reference/endpoints/certificates/revoke",
"api-reference/endpoints/certificates/delete", "api-reference/endpoints/certificates/delete",

@ -118,6 +118,10 @@ namespace Example
Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`)
</ParamField> </ParamField>
<ParamField query="SslCertificatePath" optional>
Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate.
</ParamField>
<ParamField query="Auth" type="AuthenticationOptions"> <ParamField query="Auth" type="AuthenticationOptions">
The authentication object to use for the client. This is required unless you're using environment variables. The authentication object to use for the client. This is required unless you're using environment variables.
</ParamField> </ParamField>

@ -122,6 +122,10 @@ public class App {
Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`)
</ParamField> </ParamField>
<ParamField query="setSSLCertificatePath()">
Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate.
</ParamField>
<ParamField query="setAuth()" type="AuthenticationOptions"> <ParamField query="setAuth()" type="AuthenticationOptions">
The authentication object to use for the client. This is required unless you're using environment variables. The authentication object to use for the client. This is required unless you're using environment variables.
</ParamField> </ParamField>

@ -137,6 +137,10 @@ Import the SDK and create a client instance with your [Machine Identity](/docume
The level of logs you wish to log The logs are derived from Rust, as we have written our base SDK in Rust. The level of logs you wish to log The logs are derived from Rust, as we have written our base SDK in Rust.
</ParamField> </ParamField>
<ParamField query="sslCertificatePath" optional>
Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate.
</ParamField>
<ParamField query="auth" type="AuthenticationOptions"> <ParamField query="auth" type="AuthenticationOptions">
The authentication object to use for the client. This is required unless you're using environment variables. The authentication object to use for the client. This is required unless you're using environment variables.
</ParamField> </ParamField>

@ -97,16 +97,14 @@ client = InfisicalClient(ClientSettings(
If manually set to 0, caching will be disabled, this is not recommended. If manually set to 0, caching will be disabled, this is not recommended.
</ParamField> </ParamField>
<ParamField query="site_url" type="string" default="https://app.infisical.com" optional>
<ParamField
query="site_url"
type="string"
default="https://app.infisical.com"
optional
>
Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`)
</ParamField> </ParamField>
<ParamField query="ssl_certificate_path" optional>
Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate.
</ParamField>
<ParamField query="auth" type="AuthenticationOptions"> <ParamField query="auth" type="AuthenticationOptions">
The authentication object to use for the client. This is required unless you're using environment variables. The authentication object to use for the client. This is required unless you're using environment variables.
</ParamField> </ParamField>

@ -25,6 +25,10 @@ Used to configure platform-specific security and operational settings
https://app.infisical.com). https://app.infisical.com).
</ParamField> </ParamField>
<ParamField query="PORT" type="int" default="8080" optional>
Specifies the internal port on which the application listens.
</ParamField>
<ParamField query="TELEMETRY_ENABLED" type="string" default="true" optional> <ParamField query="TELEMETRY_ENABLED" type="string" default="true" optional>
Telemetry helps us improve Infisical but if you want to dsiable it you may set this to `false`. Telemetry helps us improve Infisical but if you want to dsiable it you may set this to `false`.
</ParamField> </ParamField>

@ -29,6 +29,7 @@ export * from "./secretFolders";
export * from "./secretImports"; export * from "./secretImports";
export * from "./secretRotation"; export * from "./secretRotation";
export * from "./secrets"; export * from "./secrets";
export * from "./secretSharing";
export * from "./secretSnapshots"; export * from "./secretSnapshots";
export * from "./serverDetails"; export * from "./serverDetails";
export * from "./serviceTokens"; export * from "./serviceTokens";

@ -10,6 +10,7 @@ import {
TDeleteOrgRoleDTO, TDeleteOrgRoleDTO,
TDeleteProjectRoleDTO, TDeleteProjectRoleDTO,
TOrgRole, TOrgRole,
TProjectRole,
TUpdateOrgRoleDTO, TUpdateOrgRoleDTO,
TUpdateProjectRoleDTO TUpdateProjectRoleDTO
} from "./types"; } from "./types";
@ -17,9 +18,13 @@ import {
export const useCreateProjectRole = () => { export const useCreateProjectRole = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation<TProjectRole, {}, TCreateProjectRoleDTO>({
mutationFn: ({ projectSlug, ...dto }: TCreateProjectRoleDTO) => mutationFn: async ({ projectSlug, ...dto }: TCreateProjectRoleDTO) => {
apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto), const {
data: { role }
} = await apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto);
return role;
},
onSuccess: (_, { projectSlug }) => { onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug));
} }
@ -29,9 +34,13 @@ export const useCreateProjectRole = () => {
export const useUpdateProjectRole = () => { export const useUpdateProjectRole = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation<TProjectRole, {}, TUpdateProjectRoleDTO>({
mutationFn: ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) => mutationFn: async ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) => {
apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto), const {
data: { role }
} = await apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto);
return role;
},
onSuccess: (_, { projectSlug }) => { onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug));
} }
@ -40,10 +49,13 @@ export const useUpdateProjectRole = () => {
export const useDeleteProjectRole = () => { export const useDeleteProjectRole = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<TProjectRole, {}, TDeleteProjectRoleDTO>({
return useMutation({ mutationFn: async ({ projectSlug, id }: TDeleteProjectRoleDTO) => {
mutationFn: ({ projectSlug, id }: TDeleteProjectRoleDTO) => const {
apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`), data: { role }
} = await apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`);
return role;
},
onSuccess: (_, { projectSlug }) => { onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug));
} }
@ -79,7 +91,7 @@ export const useUpdateOrgRole = () => {
data: { role } data: { role }
} = await apiRequest.patch(`/api/v1/organization/${orgId}/roles/${id}`, { } = await apiRequest.patch(`/api/v1/organization/${orgId}/roles/${id}`, {
...dto, ...dto,
permissions: permissions?.length ? packRules(permissions) : [] permissions: permissions?.length ? packRules(permissions) : undefined
}); });
return role; return role;

@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { secretSharingKeys } from "./queries";
import { TCreateSharedSecretRequest, TDeleteSharedSecretRequest, TSharedSecret } from "./types"; import { TCreateSharedSecretRequest, TDeleteSharedSecretRequest, TSharedSecret } from "./types";
export const useCreateSharedSecret = () => { export const useCreateSharedSecret = () => {
@ -11,7 +12,7 @@ export const useCreateSharedSecret = () => {
const { data } = await apiRequest.post<TSharedSecret>("/api/v1/secret-sharing", inputData); const { data } = await apiRequest.post<TSharedSecret>("/api/v1/secret-sharing", inputData);
return data; return data;
}, },
onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"]) onSuccess: () => queryClient.invalidateQueries(secretSharingKeys.allSharedSecrets())
}); });
}; };
@ -25,7 +26,7 @@ export const useCreatePublicSharedSecret = () => {
); );
return data; return data;
}, },
onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"]) onSuccess: () => queryClient.invalidateQueries(secretSharingKeys.allSharedSecrets())
}); });
}; };
@ -38,8 +39,6 @@ export const useDeleteSharedSecret = () => {
); );
return data; return data;
}, },
onSuccess: () => { onSuccess: () => queryClient.invalidateQueries(secretSharingKeys.allSharedSecrets())
queryClient.invalidateQueries(["sharedSecrets"]);
}
}); });
}; };

@ -2,24 +2,59 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { SecretSharingAccessType, TSharedSecret, TViewSharedSecretResponse } from "./types"; import { TSharedSecret, TViewSharedSecretResponse } from "./types";
export const useGetSharedSecrets = () => { export const secretSharingKeys = {
allSharedSecrets: () => ["sharedSecrets"] as const,
specificSharedSecrets: ({ offset, limit }: { offset: number; limit: number }) =>
[...secretSharingKeys.allSharedSecrets(), { offset, limit }] as const
};
export const useGetSharedSecrets = ({
offset = 0,
limit = 25
}: {
offset: number;
limit: number;
}) => {
return useQuery({ return useQuery({
queryKey: ["sharedSecrets"], queryKey: secretSharingKeys.specificSharedSecrets({ offset, limit }),
queryFn: async () => { queryFn: async () => {
const { data } = await apiRequest.get<TSharedSecret[]>("/api/v1/secret-sharing/"); const params = new URLSearchParams({
offset: String(offset),
limit: String(limit)
});
const { data } = await apiRequest.get<{ secrets: TSharedSecret[]; totalCount: number }>(
"/api/v1/secret-sharing/",
{
params
}
);
return data; return data;
} }
}); });
}; };
export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex: string) => { export const useGetActiveSharedSecretById = ({
sharedSecretId,
hashedHex
}: {
sharedSecretId: string;
hashedHex: string;
}) => {
return useQuery<TViewSharedSecretResponse, [string]>({ return useQuery<TViewSharedSecretResponse, [string]>({
enabled: Boolean(sharedSecretId) && Boolean(hashedHex),
queryFn: async () => { queryFn: async () => {
if(!id || !hashedHex) return Promise.resolve({ encryptedValue: "", iv: "", tag: "", accessType: SecretSharingAccessType.Organization, orgName: "" }); const params = new URLSearchParams({
hashedHex
});
const { data } = await apiRequest.get<TViewSharedSecretResponse>( const { data } = await apiRequest.get<TViewSharedSecretResponse>(
`/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}` `/api/v1/secret-sharing/public/${sharedSecretId}`,
{
params
}
); );
return { return {
encryptedValue: data.encryptedValue, encryptedValue: data.encryptedValue,

@ -4,16 +4,24 @@ export type TSharedSecret = {
orgId: string; orgId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} & TCreateSharedSecretRequest; name: string | null;
lastViewedAt?: Date;
export type TCreateSharedSecretRequest = { expiresAt: Date;
expiresAfterViews: number | null;
encryptedValue: string; encryptedValue: string;
iv: string; iv: string;
tag: string; tag: string;
};
export type TCreateSharedSecretRequest = {
name?: string;
encryptedValue: string;
hashedHex: string; hashedHex: string;
iv: string;
tag: string;
expiresAt: Date; expiresAt: Date;
expiresAfterViews: number; expiresAfterViews?: number;
accessType: SecretSharingAccessType; accessType?: SecretSharingAccessType;
}; };
export type TViewSharedSecretResponse = { export type TViewSharedSecretResponse = {
@ -31,4 +39,4 @@ export type TDeleteSharedSecretRequest = {
export enum SecretSharingAccessType { export enum SecretSharingAccessType {
Anyone = "anyone", Anyone = "anyone",
Organization = "organization" Organization = "organization"
} }

@ -0,0 +1,20 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { RolePage } from "@app/views/Project/RolePage";
export default function Role() {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: "Project Settings" })}</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<RolePage />
</>
);
}
Role.requireAuth = true;

@ -13,7 +13,7 @@ const ShareNewPublicSecretPage = () => {
<meta name="og:description" content="" /> <meta name="og:description" content="" />
</Head> </Head>
<div className="dark h-full"> <div className="dark h-full">
<ShareSecretPublicPage isNewSession /> <ShareSecretPublicPage />
</div> </div>
</> </>
); );

@ -1,6 +1,6 @@
import Head from "next/head"; import Head from "next/head";
import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage"; import { ViewSecretPublicPage } from "@app/views/ViewSecretPublicPage";
const SecretSharedPublicPage = () => { const SecretSharedPublicPage = () => {
return ( return (
@ -12,9 +12,7 @@ const SecretSharedPublicPage = () => {
<meta property="og:title" content="" /> <meta property="og:title" content="" />
<meta name="og:description" content="" /> <meta name="og:description" content="" />
</Head> </Head>
<div className="dark h-full"> <ViewSecretPublicPage />
<ShareSecretPublicPage isNewSession={false} />
</div>
</> </>
); );
}; };

@ -168,16 +168,27 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => {
const { token: newJwtToken } = await selectOrganization({ organizationId }); const { token: newJwtToken } = await selectOrganization({ organizationId });
const instance = axios.create(); const instance = axios.create();
await instance.post(cliUrl, { const payload = {
...isCliLoginSuccessful.loginResponse, ...isCliLoginSuccessful.loginResponse,
JTWToken: newJwtToken JTWToken: newJwtToken
};
await instance.post(cliUrl, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 30)),
data: window.btoa(JSON.stringify(payload))
})
);
}); });
router.push("/cli-redirect");
await navigateUserToOrg(router, organizationId); return;
} }
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs // case: no organization ID is present -- navigate to the select org page IF the user has any orgs
// if the user has no orgs, navigate to the create org page // if the user has no orgs, navigate to the create org page
else {
const userOrgs = await fetchOrganizations(); const userOrgs = await fetchOrganizations();
// case: user has orgs, so we navigate the user to select an org // case: user has orgs, so we navigate the user to select an org
@ -189,7 +200,7 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => {
else { else {
await navigateUserToOrg(router); await navigateUserToOrg(router);
} }
}
} }
} else { } else {
const isLoginSuccessful = await attemptLoginMfa({ const isLoginSuccessful = await attemptLoginMfa({

@ -4,6 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import HCaptcha from "@hcaptcha/react-hcaptcha"; import HCaptcha from "@hcaptcha/react-hcaptcha";
import axios from "axios"; import axios from "axios";
import { addSeconds, formatISO } from "date-fns";
import jwt_decode from "jwt-decode"; import jwt_decode from "jwt-decode";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@ -12,6 +13,7 @@ import attemptLogin from "@app/components/utilities/attemptLogin";
import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config"; import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config";
import SecurityClient from "@app/components/utilities/SecurityClient"; import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Input, Spinner } from "@app/components/v2"; import { Button, Input, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api"; import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { fetchMyPrivateKey } from "@app/hooks/api/users/queries"; import { fetchMyPrivateKey } from "@app/hooks/api/users/queries";
@ -79,11 +81,24 @@ export const PasswordStep = ({
if (callbackPort) { if (callbackPort) {
console.log("organization id was present. new JWT token to be used in CLI:", newJwtToken); console.log("organization id was present. new JWT token to be used in CLI:", newJwtToken);
const instance = axios.create(); const instance = axios.create();
await instance.post(cliUrl, { const payload = {
privateKey, privateKey,
email, email,
JTWToken: newJwtToken JTWToken: newJwtToken
};
await instance.post(cliUrl, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 30)),
data: window.btoa(JSON.stringify(payload))
})
);
}); });
router.push("/cli-redirect");
return;
} }
await navigateUserToOrg(router, organizationId); await navigateUserToOrg(router, organizationId);
@ -165,26 +180,35 @@ export const PasswordStep = ({
); );
const instance = axios.create(); const instance = axios.create();
await instance.post(cliUrl, { const payload = {
...isCliLoginSuccessful.loginResponse, ...isCliLoginSuccessful.loginResponse,
JTWToken: newJwtToken JTWToken: newJwtToken
};
await instance.post(cliUrl, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 30)),
data: window.btoa(JSON.stringify(payload))
})
);
}); });
router.push("/cli-redirect");
await navigateUserToOrg(router, organizationId); return;
} }
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs // case: no organization ID is present -- navigate to the select org page IF the user has any orgs
// if the user has no orgs, navigate to the create org page // if the user has no orgs, navigate to the create org page
else { const userOrgs = await fetchOrganizations();
const userOrgs = await fetchOrganizations();
// case: user has orgs, so we navigate the user to select an org // case: user has orgs, so we navigate the user to select an org
if (userOrgs.length > 0) { if (userOrgs.length > 0) {
navigateToSelectOrganization(callbackPort); navigateToSelectOrganization(callbackPort);
} }
// case: no orgs found, so we navigate the user to create an org // case: no orgs found, so we navigate the user to create an org
else { else {
await navigateUserToOrg(router); await navigateUserToOrg(router);
}
} }
} }
} else { } else {

@ -24,6 +24,7 @@ import {
useRevokeIdentityTokenAuthToken, useRevokeIdentityTokenAuthToken,
useRevokeIdentityUniversalAuthClientSecret} from "@app/hooks/api"; useRevokeIdentityUniversalAuthClientSecret} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import { TabSections } from"@app/views/Org/Types";
import { IdentityAuthMethodModal } from "../MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal"; import { IdentityAuthMethodModal } from "../MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal";
import { IdentityModal } from "../MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal"; import { IdentityModal } from "../MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal";
@ -75,7 +76,7 @@ export const IdentityPage = withPermission(
}); });
handlePopUpClose("deleteIdentity"); handlePopUpClose("deleteIdentity");
router.push(`/org/${orgId}/members`); router.push(`/org/${orgId}/members?selectedTab=${TabSections.Identities}`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
const error = err as any; const error = err as any;
@ -154,7 +155,7 @@ export const IdentityPage = withPermission(
type="submit" type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />} leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => { onClick={() => {
router.push(`/org/${orgId}/members`); router.push(`/org/${orgId}/members?selectedTab=${TabSections.Identities}`);
}} }}
className="mb-4" className="mb-4"
> >

@ -25,7 +25,7 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
return data ? ( return data ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="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"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Details</h3> <h3 className="text-lg font-semibold text-mineshaft-100">Identity Details</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}> <OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
{(isAllowed) => { {(isAllowed) => {
return ( return (

@ -10,6 +10,7 @@ import { useWorkspace } from "@app/context";
import { IdentityMembership } from "@app/hooks/api/identities/types"; import { IdentityMembership } from "@app/hooks/api/identities/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { TabSections } from "@app/views/Org/Types";
type Props = { type Props = {
membership: IdentityMembership; membership: IdentityMembership;
@ -47,11 +48,11 @@ export const IdentityProjectRow = ({
return ( return (
<Tr <Tr
className="group h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700" className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`identity-project-membership-${id}`} key={`identity-project-membership-${id}`}
onClick={() => { onClick={() => {
if (isAccessible) { if (isAccessible) {
router.push(`/project/${project.id}/members`); router.push(`/project/${project.id}/members?selectedTab=${TabSections.Identities}`);
return; return;
} }

@ -1,23 +1,39 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc"; import { withPermission } from "@app/hoc";
import { isTabSection, TabSections } from "@app/views/Org/Types";
import { OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components"; import { OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components";
enum TabSections {
Member = "members",
Roles = "roles",
Identities = "identities"
}
export const MembersPage = withPermission( export const MembersPage = withPermission(
() => { () => {
const router = useRouter();
const { query } = router;
const selectedTab = query.selectedTab as string;
const [activeTab, setActiveTab] = useState<TabSections>(TabSections.Member);
useEffect(() => {
if (selectedTab && isTabSection(selectedTab)) {
setActiveTab(selectedTab);
}
}, [isTabSection, selectedTab]);
const updateSelectedTab = (tab: string) => {
router.push({
pathname: router.pathname,
query: { ...router.query, selectedTab: tab },
});
}
return ( return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white"> <div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6"> <div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<p className="mr-4 mb-4 text-3xl font-semibold text-white">Organization Access Control</p> <p className="mr-4 mb-4 text-3xl font-semibold text-white">Organization Access Control</p>
<Tabs defaultValue={TabSections.Member}> <Tabs value={activeTab} onValueChange={updateSelectedTab}>
<TabList> <TabList>
<Tab value={TabSections.Member}>Users</Tab> <Tab value={TabSections.Member}>Users</Tab>
<Tab value={TabSections.Identities}> <Tab value={TabSections.Identities}>
@ -25,7 +41,7 @@ export const MembersPage = withPermission(
<p>Machine Identities</p> <p>Machine Identities</p>
</div> </div>
</Tab> </Tab>
<Tab value={TabSections.Roles}>Organization Roles</Tab> <Tab value={TabSections.Roles}>Organization Roles</Tab>
</TabList> </TabList>
<TabPanel value={TabSections.Member}> <TabPanel value={TabSections.Member}>
<OrgMembersTab /> <OrgMembersTab />

@ -86,7 +86,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
data?.map(({ identity: { id, name }, role, customRole }) => { data?.map(({ identity: { id, name }, role, customRole }) => {
return ( return (
<Tr <Tr
className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700" className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`identity-${id}`} key={`identity-${id}`}
onClick={() => router.push(`/org/${orgId}/identities/${id}`)} onClick={() => router.push(`/org/${orgId}/identities/${id}`)}
> >

@ -184,7 +184,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
return ( return (
<Tr <Tr
key={`org-membership-${orgMembershipId}`} key={`org-membership-${orgMembershipId}`}
className="h-10 w-full cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700" className="h-10 w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => router.push(`/org/${orgId}/memberships/${orgMembershipId}`)} onClick={() => router.push(`/org/${orgId}/memberships/${orgMembershipId}`)}
> >
<Td className={isActive ? "" : "text-mineshaft-400"}>{name}</Td> <Td className={isActive ? "" : "text-mineshaft-400"}>{name}</Td>

@ -93,7 +93,7 @@ export const OrgRoleTable = () => {
return ( return (
<Tr <Tr
key={`role-list-${id}`} key={`role-list-${id}`}
className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700" className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => router.push(`/org/${orgId}/roles/${id}`)} onClick={() => router.push(`/org/${orgId}/roles/${id}`)}
> >
<Td>{name}</Td> <Td>{name}</Td>

@ -19,6 +19,7 @@ import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@a
import { withPermission } from "@app/hoc"; import { withPermission } from "@app/hoc";
import { useDeleteOrgRole, useGetOrgRole } from "@app/hooks/api"; import { useDeleteOrgRole, useGetOrgRole } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import { TabSections } from "@app/views/Org/Types";
import { RoleDetailsSection, RoleModal, RolePermissionsSection } from "./components"; import { RoleDetailsSection, RoleModal, RolePermissionsSection } from "./components";
@ -51,7 +52,7 @@ export const RolePage = withPermission(
}); });
handlePopUpClose("deleteOrgRole"); handlePopUpClose("deleteOrgRole");
router.push(`/org/${orgId}/members`); router.push(`/org/${orgId}/members?selectedTab=${TabSections.Roles}`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
const error = err as any; const error = err as any;
@ -75,7 +76,7 @@ export const RolePage = withPermission(
type="submit" type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />} leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => { onClick={() => {
router.push(`/org/${orgId}/members`); router.push(`/org/${orgId}/members?selectedTab=${TabSections.Roles}`);
}} }}
className="mb-4" className="mb-4"
> >

@ -26,7 +26,7 @@ export const RoleDetailsSection = ({ roleId, handlePopUpOpen }: Props) => {
return data ? ( return data ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="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"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Details</h3> <h3 className="text-lg font-semibold text-mineshaft-100">Org Role Details</h3>
{isCustomRole && ( {isCustomRole && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Role}> <OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Role}>
{(isAllowed) => { {(isAllowed) => {

@ -150,7 +150,7 @@ export const RolePermissionRow = ({ isEditable, title, formName, control, setVal
return ( return (
<> <>
<Tr <Tr
className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700" className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => setIsRowExpanded.toggle()} onClick={() => setIsRowExpanded.toggle()}
> >
<Td> <Td>

@ -16,23 +16,23 @@ import { RolePermissionRow } from "./RolePermissionRow";
const SIMPLE_PERMISSION_OPTIONS = [ const SIMPLE_PERMISSION_OPTIONS = [
{ {
title: "User management", title: "User Management",
formName: "member" formName: "member"
}, },
{ {
title: "Group management", title: "Group Management",
formName: "groups" formName: "groups"
}, },
{ {
title: "Machine identity management", title: "Machine Identity Management",
formName: "identity" formName: "identity"
}, },
{ {
title: "Billing & usage", title: "Usage & Billing",
formName: "billing" formName: "billing"
}, },
{ {
title: "Role management", title: "Role Management",
formName: "role" formName: "role"
}, },
{ {
@ -40,7 +40,7 @@ const SIMPLE_PERMISSION_OPTIONS = [
formName: "incident-contact" formName: "incident-contact"
}, },
{ {
title: "Organization profile", title: "Organization Profile",
formName: "settings" formName: "settings"
}, },
{ {

@ -0,0 +1,9 @@
export enum TabSections {
Member = "members",
Roles = "roles",
Identities = "identities"
}
export const isTabSection = (value: string): value is TabSections => {
return (Object.values(TabSections) as string[]).includes(value);
}

@ -0,0 +1,3 @@
import { TabSections, isTabSection } from "./TabSections";
export { TabSections, isTabSection };

@ -29,6 +29,7 @@ import {
useUpdateOrgMembership useUpdateOrgMembership
} from "@app/hooks/api"; } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import { TabSections } from "@app/views/Org/Types";
import { UserDetailsSection, UserOrgMembershipModal, UserProjectsSection } from "./components"; import { UserDetailsSection, UserOrgMembershipModal, UserProjectsSection } from "./components";
@ -90,7 +91,7 @@ export const UserPage = withPermission(
}); });
handlePopUpClose("removeMember"); handlePopUpClose("removeMember");
router.push(`/org/${orgId}/members`); router.push(`/org/${orgId}/members?selectedTab=${TabSections.Member}`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ createNotification({
@ -111,7 +112,7 @@ export const UserPage = withPermission(
type="submit" type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />} leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => { onClick={() => {
router.push(`/org/${orgId}/members`); router.push(`/org/${orgId}/members?selectedTab=${TabSections.Member}`);
}} }}
className="mb-4" className="mb-4"
> >

@ -3,7 +3,8 @@ import {
faCheckCircle, faCheckCircle,
faCircleXmark, faCircleXmark,
faCopy, faCopy,
faPencil} from "@fortawesome/free-solid-svg-icons"; faPencil
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@ -82,7 +83,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
return membership ? ( return membership ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="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"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Details</h3> <h3 className="text-lg font-semibold text-mineshaft-100">User Details</h3>
{userId !== membership.user.id && ( {userId !== membership.user.id && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}> <OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
{(isAllowed) => { {(isAllowed) => {

@ -9,6 +9,7 @@ import { useWorkspace } from "@app/context";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types"; import { TWorkspaceUser } from "@app/hooks/api/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { TabSections } from "@app/views/Org/Types";
type Props = { type Props = {
membership: TWorkspaceUser; membership: TWorkspaceUser;
@ -43,11 +44,11 @@ export const UserProjectRow = ({
return ( return (
<Tr <Tr
className="group h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700" className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`user-project-membership-${id}`} key={`user-project-membership-${id}`}
onClick={() => { onClick={() => {
if (isAccessible) { if (isAccessible) {
router.push(`/project/${project.id}/members`); router.push(`/project/${project.id}/members?selectedTab=${TabSections.Member}`);
return; return;
} }

@ -1,25 +1,40 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc"; import { withProjectPermission } from "@app/hoc";
import { isTabSection,TabSections } from "../Types";
import { IdentityTab, MembersTab,ProjectRoleListTab, ServiceTokenTab } from "./components"; import { IdentityTab, MembersTab,ProjectRoleListTab, ServiceTokenTab } from "./components";
enum TabSections {
Member = "members",
Roles = "roles",
Groups = "groups",
Identities = "identities",
ServiceTokens = "service-tokens"
}
export const MembersPage = withProjectPermission( export const MembersPage = withProjectPermission(
() => { () => {
const router = useRouter();
const { query } = router;
const selectedTab = query.selectedTab as string;
const [activeTab, setActiveTab] = useState<TabSections>(TabSections.Member);
useEffect(() => {
if (selectedTab && isTabSection(selectedTab)) {
setActiveTab(selectedTab);
}
}, [isTabSection, selectedTab]);
const updateSelectedTab = (tab: string) => {
router.push({
pathname: router.pathname,
query: { ...router.query, selectedTab: tab },
});
}
return ( return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white"> <div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6"> <div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<p className="mr-4 mb-4 text-3xl font-semibold text-white">Project Access Control</p> <p className="mr-4 mb-4 text-3xl font-semibold text-white">Project Access Control</p>
<Tabs defaultValue={TabSections.Member}> <Tabs value={activeTab} onValueChange={updateSelectedTab}>
<TabList> <TabList>
<Tab value={TabSections.Member}>Users</Tab> <Tab value={TabSections.Member}>Users</Tab>
<Tab value={TabSections.Identities}> <Tab value={TabSections.Identities}>

@ -2,29 +2,12 @@ import { motion } from "framer-motion";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc"; import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { ProjectRoleList } from "./components/ProjectRoleList"; import { ProjectRoleList } from "./components/ProjectRoleList";
import { ProjectRoleModifySection } from "./components/ProjectRoleModifySection";
export const ProjectRoleListTab = withProjectPermission( export const ProjectRoleListTab = withProjectPermission(
() => { () => {
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const); return (
return popUp.editRole.isOpen ? (
<motion.div
key="role-modify"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<ProjectRoleModifySection
roleSlug={popUp.editRole.data as string}
onGoBack={() => handlePopUpClose("editRole")}
/>
</motion.div>
) : (
<motion.div <motion.div
key="role-list" key="role-list"
transition={{ duration: 0.1 }} transition={{ duration: 0.1 }}
@ -32,7 +15,7 @@ export const ProjectRoleListTab = withProjectPermission(
animate={{ opacity: 1, translateX: 0 }} animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }} exit={{ opacity: 0, translateX: -30 }}
> >
<ProjectRoleList onSelectRole={(slug) => handlePopUpOpen("editRole", slug)} /> <ProjectRoleList />
</motion.div> </motion.div>
); );
}, },

@ -1,14 +1,17 @@
import { useState } from "react"; import { useRouter } from "next/router";
import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
DeleteActionModal, DeleteActionModal,
IconButton, DropdownMenu,
Input, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@ -22,17 +25,17 @@ import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@a
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api"; import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api";
import { TProjectRole } from "@app/hooks/api/roles/types"; import { TProjectRole } from "@app/hooks/api/roles/types";
import { RoleModal } from "@app/views/Project/RolePage/components";
type Props = { export const ProjectRoleList = () => {
onSelectRole: (slug?: string) => void; const router = useRouter();
}; const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"role",
export const ProjectRoleList = ({ onSelectRole }: Props) => { "deleteRole"
const [searchRoles, setSearchRoles] = useState(""); ] as const);
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const projectSlug = currentWorkspace?.slug || ""; const projectSlug = currentWorkspace?.slug || "";
const projectId = currentWorkspace?.id || "";
const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug);
@ -54,21 +57,16 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => {
}; };
return ( return (
<div className="w-full"> <div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex"> <div className="mb-4 flex justify-between">
<div className="mr-4 flex-1"> <p className="text-xl font-semibold text-mineshaft-100">Project Roles</p>
<Input
value={searchRoles}
onChange={(e) => setSearchRoles(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search roles..."
/>
</div>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}> <ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}>
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => onSelectRole()} onClick={() => handlePopUpOpen("role")}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Add Role Add Role
@ -76,77 +74,94 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => {
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
<div> <TableContainer>
<TableContainer> <Table>
<Table> <THead>
<THead> <Tr>
<Tr> <Th>Name</Th>
<Th>Name</Th> <Th>Slug</Th>
<Th>Slug</Th> <Th aria-label="actions" className="w-5" />
<Th aria-label="actions" /> </Tr>
</Tr> </THead>
</THead> <TBody>
<TBody> {isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />} {roles?.map((role) => {
{roles?.map((role) => { const { id, name, slug } = role;
const { id, name, slug } = role; const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug);
const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug);
return ( return (
<Tr key={`role-list-${id}`}> <Tr
<Td>{name}</Td> key={`role-list-${id}`}
<Td>{slug}</Td> className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
<Td> onClick={() => router.push(`/project/${projectId}/roles/${slug}`)}
<div className="flex justify-end space-x-2"> >
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Role} a={ProjectPermissionSub.Role}
renderTooltip
allowedLabel="Edit"
> >
{(isAllowed) => ( {(isAllowed) => (
<IconButton <DropdownMenuItem
isDisabled={!isAllowed} className={twMerge(
ariaLabel="edit" !isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
onClick={() => onSelectRole(role.slug)} )}
variant="plain" onClick={(e) => {
e.stopPropagation();
router.push(`/project/${projectId}/roles/${slug}`);
}}
disabled={!isAllowed}
> >
<FontAwesomeIcon icon={faEdit} /> {`${isNonMutatable ? "View" : "Edit"} Role`}
</IconButton> </DropdownMenuItem>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan {!isNonMutatable && (
I={ProjectPermissionActions.Delete} <ProjectPermissionCan
a={ProjectPermissionSub.Role} I={ProjectPermissionActions.Delete}
renderTooltip a={ProjectPermissionSub.Role}
allowedLabel={ >
isNonMutatable ? "Reserved roles are non-removable" : "Delete" {(isAllowed) => (
} <DropdownMenuItem
> className={twMerge(
{(isAllowed) => ( isAllowed
<IconButton ? "hover:!bg-red-500 hover:!text-white"
ariaLabel="delete" : "pointer-events-none cursor-not-allowed opacity-50"
onClick={() => handlePopUpOpen("deleteRole", role)} )}
variant="plain" onClick={(e) => {
isDisabled={isNonMutatable || !isAllowed} e.stopPropagation();
> handlePopUpOpen("deleteRole", role);
<FontAwesomeIcon icon={faTrash} /> }}
</IconButton> disabled={!isAllowed}
)} >
</ProjectPermissionCan> Delete Role
</div> </DropdownMenuItem>
</Td> )}
</Tr> </ProjectPermissionCan>
); )}
})} </DropdownMenuContent>
</TBody> </DropdownMenu>
</Table> </Td>
</TableContainer> </Tr>
</div> );
})}
</TBody>
</Table>
</TableContainer>
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteRole.isOpen} isOpen={popUp.deleteRole.isOpen}
title={`Are you sure want to delete ${(popUp?.deleteRole?.data as TProjectRole)?.name || " " title={`Are you sure want to delete ${
} role?`} (popUp?.deleteRole?.data as TProjectRole)?.name || " "
} role?`}
deleteKey={(popUp?.deleteRole?.data as TProjectRole)?.slug || ""} deleteKey={(popUp?.deleteRole?.data as TProjectRole)?.slug || ""}
onClose={() => handlePopUpClose("deleteRole")} onClose={() => handlePopUpClose("deleteRole")}
onDeleteApproved={handleRoleDelete} onDeleteApproved={handleRoleDelete}

@ -1,258 +0,0 @@
import { useMemo } from "react";
import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import GlobPatternExamples from "@app/components/basic/popups/GlobPatternExamples";
import {
Checkbox,
FormControl,
Input,
Select,
SelectItem,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
formName: "secrets";
isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>;
getValue: UseFormGetValues<TFormSchema>;
control: Control<TFormSchema>;
title: string;
subtitle: string;
icon: IconProp;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
export const MultiEnvProjectPermission = ({
isNonEditable,
setValue,
getValue,
control,
formName,
title,
subtitle,
icon
}: Props) => {
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const customRule = useWatch({
control,
name: `permissions.${formName}.custom`
});
const isCustom = Boolean(customRule);
const allRule = useWatch({ control, name: `permissions.${formName}.all` });
const selectedPermissionCategory = useMemo(() => {
const { read, delete: del, edit, create } = allRule || {};
if (read && del && edit && create) return Permission.FullAccess;
if (read) return Permission.ReadOnly;
return Permission.NoAccess;
}, [allRule]);
const handlePermissionChange = (val: Permission) => {
if(!val) return
switch (val) {
case Permission.NoAccess: {
const permissions = getValue("permissions");
if (permissions) delete permissions[formName];
setValue("permissions", permissions, { shouldDirty: true });
break;
}
case Permission.FullAccess:
setValue(
`permissions.${formName}`,
{ all: { read: true, edit: true, create: true, delete: true } },
{ shouldDirty: true }
);
break;
case Permission.ReadOnly:
setValue(
`permissions.${formName}`,
{ all: { read: true, edit: false, create: false, delete: false } },
{ shouldDirty: true }
);
break;
default:
setValue(
`permissions.${formName}`,
{ custom: { read: false, edit: false, create: false, delete: false } },
{ shouldDirty: true }
);
break;
}
};
return (
<div
className={twMerge(
"rounded-md bg-mineshaft-800 px-10 py-6",
(selectedPermissionCategory !== Permission.NoAccess || isCustom) &&
"border-l-2 border-primary-600"
)}
>
<div className="flex items-center space-x-4">
<div>
<FontAwesomeIcon icon={icon} className="text-4xl" />
</div>
<div className="flex flex-grow flex-col">
<div className="mb-1 text-lg font-medium">{title}</div>
<div className="text-xs font-light">{subtitle}</div>
</div>
<div>
<Select
defaultValue={Permission.NoAccess}
isDisabled={isNonEditable}
value={isCustom ? Permission.Custom : selectedPermissionCategory}
onValueChange={handlePermissionChange}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</div>
</div>
<motion.div
initial={false}
animate={{ height: isCustom ? "auto" : 0 }}
className="overflow-hidden"
>
<TableContainer className="mt-6 border-mineshaft-500">
<Table>
<THead>
<Tr>
<Th />
<Th className="min-w-[8rem]">
<div className="flex items-center gap-2">
Secret Path
<span className="text-xs normal-case">
<GlobPatternExamples />
</span>
</div>
</Th>
<Th className="text-center">View</Th>
<Th className="text-center">Create</Th>
<Th className="text-center">Modify</Th>
<Th className="text-center">Delete</Th>
</Tr>
</THead>
<TBody>
{isCustom &&
environments.map(({ name, slug }) => (
<Tr key={`custom-role-project-secret-${slug}`}>
<Td>{name}</Td>
<Td>
<Controller
name={`permissions.${formName}.${slug}.secretPath`}
control={control}
render={({ field }) => (
/* eslint-disable-next-line no-template-curly-in-string */
<FormControl helperText="Supports glob path pattern string">
<Input
{...field}
className="w-full overflow-ellipsis"
placeholder="Glob patterns are supported"
/>
</FormControl>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.${formName}.${slug}.read`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.${formName}.${slug}.read`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.${formName}.${slug}.create`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
id={`permissions.${formName}.${slug}.modify`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.${formName}.${slug}.edit`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
id={`permissions.${formName}.${slug}.modify`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
defaultValue={false}
name={`permissions.${formName}.${slug}.delete`}
control={control}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.${formName}.${slug}.delete`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</motion.div>
</div>
);
};

@ -1,318 +0,0 @@
import { useForm } from "react-hook-form";
import { faElementor } from "@fortawesome/free-brands-svg-icons";
import {
faAnchorLock,
faArrowLeft,
faBook,
faCertificate,
faCog,
faKey,
faLock,
faNetworkWired,
faPuzzlePiece,
faServer,
faShield,
faTags,
faUser,
faUsers} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Spinner } from "@app/components/v2";
import { ProjectPermissionSub, useWorkspace } from "@app/context";
import {
useCreateProjectRole,
useGetProjectRoleBySlug,
useUpdateProjectRole
} from "@app/hooks/api";
import { TProjectRole } from "@app/hooks/api/roles/types";
import { MultiEnvProjectPermission } from "./MultiEnvProjectPermission";
import {
formRolePermission2API,
formSchema,
rolePermission2Form,
TFormSchema
} from "./ProjectRoleModifySection.utils";
import { SecretRollbackPermission } from "./SecretRollbackPermission";
import { SingleProjectPermission } from "./SingleProjectPermission";
import { WsProjectPermission } from "./WsProjectPermission";
const SINGLE_PERMISSION_LIST = [
{
title: "Integrations",
subtitle: "Integration management control",
icon: faPuzzlePiece,
formName: "integrations"
},
{
title: "Secret Protect policy",
subtitle: "Manage policies for secret protection for unauthorized secret changes",
icon: faShield,
formName: ProjectPermissionSub.SecretApproval
},
{
title: "Roles",
subtitle: "Role management control",
icon: faUsers,
formName: "role"
},
{
title: "User management",
subtitle: "Add, view and remove users from the project",
icon: faUser,
formName: "member"
},
{
title: "Group management",
subtitle: "Add, view and remove user groups from the project",
icon: faUsers,
formName: "groups"
},
{
title: "Machine identity management",
subtitle: "Add, view, update and remove (machine) identities from the project",
icon: faServer,
formName: "identity"
},
{
title: "Webhooks",
subtitle: "Webhook management control",
icon: faAnchorLock,
formName: "webhooks"
},
{
title: "Service Tokens",
subtitle: "Token management control",
icon: faKey,
formName: "service-tokens"
},
{
title: "Settings",
subtitle: "Settings control",
icon: faCog,
formName: "settings"
},
{
title: "Environments",
subtitle: "Environment management control",
icon: faElementor,
formName: "environments"
},
{
title: "Tags",
subtitle: "Tag management control",
icon: faTags,
formName: "tags"
},
{
title: "Audit Logs",
subtitle: "Audit log management control",
icon: faBook,
formName: "audit-logs"
},
{
title: "IP Allowlist",
subtitle: "IP allowlist management control",
icon: faNetworkWired,
formName: "ip-allowlist"
},
{
title: "Certificate Authorities",
subtitle: "CA management control",
icon: faCertificate,
formName: "certificate-authorities"
},
{
title: "Certificates",
subtitle: "Certificate management control",
icon: faCertificate,
formName: "certificates"
}
] as const;
type Props = {
roleSlug?: string;
onGoBack: VoidFunction;
};
export const ProjectRoleModifySection = ({ roleSlug, onGoBack }: Props) => {
const isNonEditable = ["admin", "member", "viewer", "no-access"].includes(roleSlug || "");
const isNewRole = !roleSlug;
const { currentWorkspace } = useWorkspace();
const projectSlug = currentWorkspace?.slug || "";
const { data: roleDetails, isLoading: isRoleDetailsLoading } = useGetProjectRoleBySlug(
currentWorkspace?.slug || "",
roleSlug as string
);
const {
handleSubmit,
register,
formState: { isSubmitting, isDirty, errors },
setValue,
getValues,
control
} = useForm<TFormSchema>({
values: roleDetails
? { ...roleDetails, permissions: rolePermission2Form(roleDetails.permissions) }
: ({} as TProjectRole),
resolver: zodResolver(formSchema)
});
const { mutateAsync: createRole } = useCreateProjectRole();
const { mutateAsync: updateRole } = useUpdateProjectRole();
const handleRoleUpdate = async (el: TFormSchema) => {
if (!roleDetails?.id) return;
try {
await updateRole({
id: roleDetails?.id as string,
projectSlug,
...el,
permissions: formRolePermission2API(el.permissions)
});
createNotification({ type: "success", text: "Successfully updated role" });
onGoBack();
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to update role" });
}
};
const handleFormSubmit = async (el: TFormSchema) => {
if (!isNewRole) {
await handleRoleUpdate(el);
return;
}
try {
await createRole({
projectSlug,
...el,
permissions: formRolePermission2API(el.permissions)
});
createNotification({ type: "success", text: "Created new role" });
onGoBack();
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to create role" });
}
};
if (!isNewRole && isRoleDetailsLoading) {
return (
<div className="flex w-full items-center justify-center p-8">
<Spinner />
</div>
);
}
return (
<div>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="mb-2 flex items-center justify-between">
<h1 className="text-xl font-semibold text-mineshaft-100">
{isNewRole ? "New" : "Edit"} Role
</h1>
<Button
onClick={onGoBack}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
>
Go back
</Button>
</div>
<p className="mb-8 text-gray-400">
Project-level roles allow you to define permissions for resources within projects at a
granular level
</p>
<div className="flex flex-col space-y-6">
<FormControl
label="Name"
isRequired
className="mb-0"
isError={Boolean(errors?.name)}
errorText={errors?.name?.message}
>
<Input {...register("name")} placeholder="Billing Team" isReadOnly={isNonEditable} />
</FormControl>
<FormControl
label="Slug"
isRequired
isError={Boolean(errors?.slug)}
errorText={errors?.slug?.message}
>
<Input {...register("slug")} placeholder="biller" isReadOnly={isNonEditable} />
</FormControl>
<FormControl
label="Description"
helperText="A short description about this role"
isError={Boolean(errors?.description)}
errorText={errors?.description?.message}
>
<Input {...register("description")} isReadOnly={isNonEditable} />
</FormControl>
<div className="flex items-center justify-between border-t border-t-mineshaft-800 pt-6">
<div>
<h2 className="text-xl font-medium">Add Permission</h2>
</div>
</div>
<div>
<MultiEnvProjectPermission
getValue={getValues}
isNonEditable={isNonEditable}
control={control}
setValue={setValue}
icon={faLock}
title="Secrets"
subtitle="Create, modify and remove secrets, folders and secret imports"
formName="secrets"
/>
</div>
<div key="permission-ws">
<WsProjectPermission
control={control}
setValue={setValue}
isNonEditable={isNonEditable}
/>
</div>
{SINGLE_PERMISSION_LIST.map(({ title, subtitle, icon, formName }) => (
<div key={`permission-${title}`}>
<SingleProjectPermission
isNonEditable={isNonEditable}
control={control}
setValue={setValue}
icon={icon}
title={title}
subtitle={subtitle}
formName={formName}
/>
</div>
))}
<div key="permission-secret-rollback">
<SecretRollbackPermission
control={control}
setValue={setValue}
isNonEditable={isNonEditable}
/>
</div>
</div>
<div className="mt-12 flex items-center space-x-4">
<Button
type="submit"
isDisabled={isSubmitting || isNonEditable || !isDirty}
isLoading={isSubmitting}
>
{isNewRole ? "Create Role" : "Save Role"}
</Button>
<Button onClick={onGoBack} variant="outline_bg">
Cancel
</Button>
</div>
</form>
</div>
);
};

@ -1,147 +0,0 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faPuzzlePiece } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { Checkbox, Select, SelectItem } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
const PERMISSIONS = [
{ action: "create", label: "Perform Rollback" },
{ action: "read", label: "View" }
] as const;
export const SecretRollbackPermission = ({ isNonEditable, setValue, control }: Props) => {
const rule = useWatch({
control,
name: "permissions.secret-rollback"
});
const [isCustom, setIsCustom] = useToggle();
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const totalActions = PERMISSIONS.length;
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
if (isCustom) return Permission.Custom;
if (score === 0) return Permission.NoAccess;
if (score === totalActions) return Permission.FullAccess;
return Permission.Custom;
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
const handlePermissionChange = (val: Permission) => {
if(!val) return;
if (val === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
switch (val) {
case Permission.NoAccess:
setValue(
"permissions.secret-rollback",
{ read: false, create: false },
{ shouldDirty: true }
);
break;
case Permission.FullAccess:
setValue(
"permissions.secret-rollback",
{ read: true, create: true },
{ shouldDirty: true }
);
break;
case Permission.ReadOnly:
setValue(
"permissions.secret-rollback",
{ read: true, create: false },
{ shouldDirty: true }
);
break;
default:
setValue(
"permissions.secret-rollback",
{ read: false, create: false },
{ shouldDirty: true }
);
break;
}
};
return (
<div
className={twMerge(
"rounded-md bg-mineshaft-800 px-10 py-6",
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
)}
>
<div className="flex items-center space-x-4">
<div>
<FontAwesomeIcon icon={faPuzzlePiece} className="text-4xl" />
</div>
<div className="flex flex-grow flex-col">
<div className="mb-1 text-lg font-medium">Secret Rollback</div>
<div className="text-xs font-light">Secret rollback control actions</div>
</div>
<div>
<Select
defaultValue={Permission.NoAccess}
isDisabled={isNonEditable}
value={selectedPermissionCategory}
onValueChange={handlePermissionChange}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</div>
</div>
<motion.div
initial={false}
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
className="grid auto-cols-min grid-flow-col gap-8 overflow-hidden"
>
{isCustom &&
PERMISSIONS.map(({ action, label }) => (
<Controller
name={`permissions.secret-rollback.${action}`}
key={`permissions.secret-rollback.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.secret-rollback.${action}`}
isDisabled={isNonEditable}
>
{label}
</Checkbox>
)}
/>
))}
</motion.div>
</div>
);
};

@ -1,194 +0,0 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { Checkbox, Select, SelectItem } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
formName:
| "role"
| "member"
| "groups"
| "integrations"
| "webhooks"
| "service-tokens"
| "settings"
| "environments"
| "tags"
| "audit-logs"
| "ip-allowlist"
| "identity"
| "certificate-authorities"
| "certificates"
| ProjectPermissionSub.SecretApproval;
isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
title: string;
subtitle: string;
icon: IconProp;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
const PERMISSIONS = [
{ action: "read", label: "View" },
{ action: "create", label: "Create" },
{ action: "edit", label: "Modify" },
{ action: "delete", label: "Remove" }
] as const;
const MEMBERS_PERMISSIONS = [
{ action: "read", label: "View all members" },
{ action: "create", label: "Invite members" },
{ action: "edit", label: "Edit members" },
{ action: "delete", label: "Remove members" }
] as const;
const getPermissionList = (option: Props["formName"]) => {
switch (option) {
case "member":
return MEMBERS_PERMISSIONS;
default:
return PERMISSIONS;
}
};
export const SingleProjectPermission = ({
isNonEditable,
setValue,
control,
formName,
subtitle,
title,
icon
}: Props) => {
const rule = useWatch({
control,
name: `permissions.${formName}`
});
const [isCustom, setIsCustom] = useToggle();
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const totalActions = PERMISSIONS.length;
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
if (isCustom) return Permission.Custom;
if (score === 0) return Permission.NoAccess;
if (score === totalActions) return Permission.FullAccess;
if (score === 1 && rule?.read) return Permission.ReadOnly;
return Permission.Custom;
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
const handlePermissionChange = (val: Permission) => {
if(!val) return;
if (val === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
switch (val) {
case Permission.NoAccess:
setValue(
`permissions.${formName}`,
{ read: false, edit: false, create: false, delete: false },
{ shouldDirty: true }
);
break;
case Permission.FullAccess:
setValue(
`permissions.${formName}`,
{ read: true, edit: true, create: true, delete: true },
{ shouldDirty: true }
);
break;
case Permission.ReadOnly:
setValue(
`permissions.${formName}`,
{ read: true, edit: false, create: false, delete: false },
{ shouldDirty: true }
);
break;
default:
setValue(
`permissions.${formName}`,
{ read: false, edit: false, create: false, delete: false },
{ shouldDirty: true }
);
break;
}
};
return (
<div
className={twMerge(
"rounded-md bg-mineshaft-800 px-10 py-6",
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
)}
>
<div className="flex items-center space-x-4">
<div>
<FontAwesomeIcon icon={icon} className="text-4xl" />
</div>
<div className="flex flex-grow flex-col">
<div className="mb-1 text-lg font-medium">{title}</div>
<div className="text-xs font-light">{subtitle}</div>
</div>
<div>
<Select
defaultValue={Permission.NoAccess}
isDisabled={isNonEditable}
value={selectedPermissionCategory}
onValueChange={handlePermissionChange}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</div>
</div>
<motion.div
initial={false}
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
className="grid auto-cols-min grid-flow-col gap-8 overflow-hidden"
>
{isCustom &&
getPermissionList(formName).map(({ action, label }) => (
<Controller
name={`permissions.${formName}.${action}`}
key={`permissions.${formName}.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.${formName}.${action}`}
isDisabled={isNonEditable}
>
{label}
</Checkbox>
)}
/>
))}
</motion.div>
</div>
);
};

@ -1,126 +0,0 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faPuzzlePiece } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { Checkbox, Select, SelectItem } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
FullAccess = "full-acess",
Custom = "custom"
}
const PERMISSIONS = [
{ action: "edit", label: "Update project details" },
{ action: "delete", label: "Delete projects" }
] as const;
export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props) => {
const rule = useWatch({
control,
name: "permissions.workspace"
});
const [isCustom, setIsCustom] = useToggle();
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const totalActions = PERMISSIONS.length;
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
if (isCustom) return Permission.Custom;
if (score === 0) return Permission.NoAccess;
if (score === totalActions) return Permission.FullAccess;
return Permission.Custom;
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
const handlePermissionChange = (val: Permission) => {
if(!val) return;
if (val === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
switch (val) {
case Permission.NoAccess:
setValue("permissions.workspace", { edit: false, delete: false }, { shouldDirty: true });
break;
case Permission.FullAccess:
setValue("permissions.workspace", { edit: true, delete: true }, { shouldDirty: true });
break;
default:
setValue("permissions.workspace", { edit: false, delete: false }, { shouldDirty: true });
break;
}
};
return (
<div
className={twMerge(
"rounded-md bg-mineshaft-800 px-10 py-6",
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
)}
>
<div className="flex items-center space-x-4">
<div>
<FontAwesomeIcon icon={faPuzzlePiece} className="text-4xl" />
</div>
<div className="flex flex-grow flex-col">
<div className="mb-1 text-lg font-medium">Project</div>
<div className="text-xs font-light">Project control actions</div>
</div>
<div>
<Select
defaultValue={Permission.NoAccess}
isDisabled={isNonEditable}
value={selectedPermissionCategory}
onValueChange={handlePermissionChange}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</div>
</div>
<motion.div
initial={false}
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
className="grid auto-cols-min grid-flow-col gap-8 overflow-hidden"
>
{isCustom &&
PERMISSIONS.map(({ action, label }) => (
<Controller
name={`permissions.workspace.${action}`}
key={`permissions.workspace.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.workspace.${action}`}
isDisabled={isNonEditable}
>
{label}
</Checkbox>
)}
/>
))}
</motion.div>
</div>
);
};

@ -1 +0,0 @@
export { ProjectRoleModifySection } from "./ProjectRoleModifySection";

@ -0,0 +1,158 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useRouter } from "next/router";
import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useDeleteProjectRole,useGetProjectRoleBySlug } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { TabSections } from "../Types";
import { RoleDetailsSection, RoleModal, RolePermissionsSection } from "./components";
export const RolePage = withProjectPermission(
() => {
const router = useRouter();
const roleSlug = router.query.roleSlug as string;
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string);
const { mutateAsync: deleteProjectRole } = useDeleteProjectRole();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"role",
"deleteRole"
] as const);
const onDeleteRoleSubmit = async () => {
try {
if (!currentWorkspace?.slug || !data?.id) return;
await deleteProjectRole({
projectSlug: currentWorkspace.slug,
id: data.id
});
createNotification({
text: "Successfully deleted project role",
type: "success"
});
handlePopUpClose("deleteRole");
router.push(`/project/${projectId}/members?selectedTab=${TabSections.Roles}`);
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to delete project role";
createNotification({
text,
type: "error"
});
}
};
const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(data?.slug ?? "");
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => router.push(`/project/${projectId}/members?selectedTab=${TabSections.Roles}`)}
className="mb-4"
>
Roles
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">{data.name}</p>
{isCustomRole && (
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen("role", {
roleSlug
})
}
disabled={!isAllowed}
>
Edit Role
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() => handlePopUpOpen("deleteRole")}
disabled={!isAllowed}
>
Delete Role
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex">
<div className="mr-4 w-96">
<RoleDetailsSection roleSlug={roleSlug} handlePopUpOpen={handlePopUpOpen} />
</div>
<RolePermissionsSection roleSlug={roleSlug} />
</div>
</div>
)}
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteRole.isOpen}
title={`Are you sure want to delete the project role ${data?.name ?? ""}?`}
onChange={(isOpen) => handlePopUpToggle("deleteRole", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => onDeleteRoleSubmit()}
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role }
);

@ -0,0 +1,95 @@
import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { useGetProjectRoleBySlug } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
roleSlug: string;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["role"]>, data?: {}) => void;
};
export const RoleDetailsSection = ({ roleSlug, handlePopUpOpen }: Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const { currentWorkspace } = useWorkspace();
const { data } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string);
const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(data?.slug ?? "");
return data ? (
<div className="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">Project Role Details</h3>
{isCustomRole && (
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Role}>
{(isAllowed) => {
return (
<Tooltip content="Edit Role">
<IconButton
isDisabled={!isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("role", {
roleSlug
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</ProjectPermissionCan>
)}
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Role ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{data.id}</p>
<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.id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{data.name}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Slug</p>
<p className="text-sm text-mineshaft-300">{data.slug}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Description</p>
<p className="text-sm text-mineshaft-300">
{data.description?.length ? data.description : "-"}
</p>
</div>
</div>
</div>
) : (
<div />
);
};

@ -0,0 +1,196 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
useCreateProjectRole,
useGetProjectRoleBySlug,
useUpdateProjectRole} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
name: z.string(),
description: z.string(),
slug: z.string()
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["role"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["role"]>, state?: boolean) => void;
};
export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => {
const router = useRouter();
const popupData = popUp?.role?.data as {
roleSlug: string;
};
const { currentWorkspace } = useWorkspace();
const projectSlug = currentWorkspace?.slug || "";
const { data: role } = useGetProjectRoleBySlug(projectSlug, popupData?.roleSlug ?? "");
const { mutateAsync: createProjectRole } = useCreateProjectRole();
const { mutateAsync: updateProjectRole } = useUpdateProjectRole();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
description: ""
}
});
useEffect(() => {
if (role) {
reset({
name: role.name,
description: role.description,
slug: role.slug
});
} else {
reset({
name: "",
description: "",
slug: ""
});
}
}, [role]);
const onFormSubmit = async ({ name, description, slug }: FormData) => {
try {
if (!projectSlug) return;
if (role) {
// update
await updateProjectRole({
id: role.id,
projectSlug,
name,
description,
slug
});
handlePopUpToggle("role", false);
} else {
// create
const newRole = await createProjectRole({
projectSlug,
name,
description,
slug,
permissions: []
});
router.push(`/project/${currentWorkspace?.id}/roles/${newRole.slug}`);
handlePopUpToggle("role", false);
}
createNotification({
text: `Successfully ${popUp?.role?.data ? "updated" : "created"} role`,
type: "success"
});
reset();
} catch (err) {
console.error(err);
const error = err as any;
const text =
error?.response?.data?.message ??
`Failed to ${popUp?.role?.data ? "update" : "create"} role`;
createNotification({
text,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.role?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("role", isOpen);
reset();
}}
>
<ModalContent title={`${popUp?.role?.data ? "Update" : "Create"} Role`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="Billing Team" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Slug"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="billing" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl label="Description" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="To manage billing" />
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.role?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("role", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

@ -0,0 +1,219 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils";
const GENERAL_PERMISSIONS = [
{ action: "read", label: "View" },
{ action: "create", label: "Create" },
{ action: "edit", label: "Modify" },
{ action: "delete", label: "Remove" }
] as const;
const WORKSPACE_PERMISSIONS = [
{ action: "edit", label: "Update project details" },
{ action: "delete", label: "Delete projects" }
] as const;
const MEMBERS_PERMISSIONS = [
{ action: "read", label: "View all members" },
{ action: "create", label: "Invite members" },
{ action: "edit", label: "Edit members" },
{ action: "delete", label: "Remove members" }
] as const;
const SECRET_ROLLBACK_PERMISSIONS = [
{ action: "create", label: "Perform Rollback" },
{ action: "read", label: "View" }
] as const;
const getPermissionList = (option: Props["formName"]) => {
switch (option) {
case "workspace":
return WORKSPACE_PERMISSIONS;
case "member":
return MEMBERS_PERMISSIONS;
case "secret-rollback":
return SECRET_ROLLBACK_PERMISSIONS;
default:
return GENERAL_PERMISSIONS;
}
};
type PermissionName =
| `permissions.workspace.${"edit" | "delete"}`
| `permissions.secret-rollback.${"create" | "read"}`
| `permissions.${Exclude<
keyof NonNullable<TFormSchema["permissions"]>,
"workspace" | "secret-rollback" | "secrets"
>}.${"read" | "create" | "edit" | "delete"}`;
type Props = {
isEditable: boolean;
title: string;
formName: keyof Omit<Exclude<TFormSchema["permissions"], undefined>, "secrets">;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
export const RolePermissionRow = ({ isEditable, title, formName, control, setValue }: Props) => {
const [isRowExpanded, setIsRowExpanded] = useToggle();
const [isCustom, setIsCustom] = useToggle();
const rule = useWatch({
control,
name: `permissions.${formName}`
});
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
switch (formName) {
default: {
const totalActions = GENERAL_PERMISSIONS.length;
const score = actions
.map((key) => (rule?.[key] ? 1 : 0))
.reduce((a, b) => a + b, 0 as number);
if (isCustom) return Permission.Custom;
if (score === 0) return Permission.NoAccess;
if (score === totalActions) return Permission.FullAccess;
if (rule && "read" in rule) {
if (score === 1 && rule?.read) return Permission.ReadOnly;
}
return Permission.Custom;
}
}
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
useEffect(() => {
const isRowCustom = selectedPermissionCategory === Permission.Custom;
if (isRowCustom) {
setIsRowExpanded.on();
}
}, []);
const handlePermissionChange = (val: Permission) => {
if (val === Permission.Custom) {
setIsRowExpanded.on();
setIsCustom.on();
return;
}
setIsCustom.off();
switch (val) {
case Permission.NoAccess:
setValue(
`permissions.${formName}`,
{ read: false, edit: false, create: false, delete: false },
{ shouldDirty: true }
);
break;
case Permission.FullAccess:
setValue(
`permissions.${formName}`,
{ read: true, edit: true, create: true, delete: true },
{ shouldDirty: true }
);
break;
case Permission.ReadOnly:
setValue(
`permissions.${formName}`,
{ read: true, edit: false, create: false, delete: false },
{ shouldDirty: true }
);
break;
default:
setValue(
`permissions.${formName}`,
{ read: false, edit: false, create: false, delete: false },
{ shouldDirty: true }
);
break;
}
};
return (
<>
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => setIsRowExpanded.toggle()}
>
<Td>
<FontAwesomeIcon icon={isRowExpanded ? faChevronDown : faChevronRight} />
</Td>
<Td>{title}</Td>
<Td>
<Select
value={selectedPermissionCategory}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={handlePermissionChange}
isDisabled={!isEditable}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</Td>
</Tr>
{isRowExpanded && (
<Tr>
<Td
colSpan={3}
className={`bg-bunker-600 px-0 py-0 ${isRowExpanded && " border-mineshaft-500 p-8"}`}
>
<div className="grid grid-cols-3 gap-4">
{getPermissionList(formName).map(({ action, label }) => {
const permissionName = `permissions.${formName}.${action}` as PermissionName;
return (
<Controller
name={permissionName}
key={permissionName}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={(e) => {
if (!isEditable) {
createNotification({
type: "error",
text: "Failed to update default role"
});
return;
}
field.onChange(e);
}}
id={permissionName}
>
{label}
</Checkbox>
)}
/>
);
})}
</div>
</Td>
</Tr>
)}
</>
);
};

@ -0,0 +1,247 @@
import { useMemo } from "react";
import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import GlobPatternExamples from "@app/components/basic/popups/GlobPatternExamples";
import {
Checkbox,
FormControl,
Input,
Select,
SelectItem,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { TFormSchema } from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils";
type Props = {
title: string;
formName: "secrets";
isEditable: boolean;
setValue: UseFormSetValue<TFormSchema>;
getValue: UseFormGetValues<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
export const RowPermissionSecretsRow = ({
title,
formName,
isEditable,
setValue,
getValue,
control
}: Props) => {
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const customRule = useWatch({
control,
name: `permissions.${formName}.custom`
});
const isCustom = Boolean(customRule);
const allRule = useWatch({ control, name: `permissions.${formName}.all` });
const selectedPermissionCategory = useMemo(() => {
const { read, delete: del, edit, create } = allRule || {};
if (read && del && edit && create) return Permission.FullAccess;
if (read) return Permission.ReadOnly;
return Permission.NoAccess;
}, [allRule]);
const handlePermissionChange = (val: Permission) => {
if (!val) return;
switch (val) {
case Permission.NoAccess: {
const permissions = getValue("permissions");
if (permissions) delete permissions[formName];
setValue("permissions", permissions, { shouldDirty: true });
break;
}
case Permission.FullAccess:
setValue(
`permissions.${formName}`,
{ all: { read: true, edit: true, create: true, delete: true } },
{ shouldDirty: true }
);
break;
case Permission.ReadOnly:
setValue(
`permissions.${formName}`,
{ all: { read: true, edit: false, create: false, delete: false } },
{ shouldDirty: true }
);
break;
default:
setValue(
`permissions.${formName}`,
{ custom: { read: false, edit: false, create: false, delete: false } },
{ shouldDirty: true }
);
break;
}
};
return (
<>
<Tr>
<Td>{isCustom && <FontAwesomeIcon icon={faChevronDown} />}</Td>
<Td>{title}</Td>
<Td>
<Select
value={isCustom ? Permission.Custom : selectedPermissionCategory}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={handlePermissionChange}
isDisabled={!isEditable}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</Td>
</Tr>
{isCustom && (
<Tr>
<Td
colSpan={3}
className={`bg-bunker-600 px-0 py-0 ${isCustom && " border-mineshaft-500 p-8"}`}
>
<div>
<TableContainer className="border-mineshaft-500">
<Table>
<THead>
<Tr>
<Th />
<Th className="min-w-[8rem]">
<div className="flex items-center gap-2">
Secret Path
<span className="text-xs normal-case">
<GlobPatternExamples />
</span>
</div>
</Th>
<Th className="text-center">View</Th>
<Th className="text-center">Create</Th>
<Th className="text-center">Modify</Th>
<Th className="text-center">Delete</Th>
</Tr>
</THead>
<TBody>
{isCustom &&
environments.map(({ name, slug }) => (
<Tr key={`custom-role-project-secret-${slug}`}>
<Td>{name}</Td>
<Td>
<Controller
name={`permissions.${formName}.${slug}.secretPath`}
control={control}
render={({ field }) => (
/* eslint-disable-next-line no-template-curly-in-string */
<FormControl helperText="Supports glob path pattern string">
<Input
{...field}
className="w-full overflow-ellipsis"
placeholder="Glob patterns are supported"
/>
</FormControl>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.${formName}.${slug}.read`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.${formName}.${slug}.read`}
isDisabled={!isEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.${formName}.${slug}.create`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
id={`permissions.${formName}.${slug}.modify`}
isDisabled={!isEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.${formName}.${slug}.edit`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
id={`permissions.${formName}.${slug}.modify`}
isDisabled={!isEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
defaultValue={false}
name={`permissions.${formName}.${slug}.delete`}
control={control}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.${formName}.${slug}.delete`}
isDisabled={!isEditable}
/>
</div>
)}
/>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</div>
</Td>
</Tr>
)}
</>
);
};

@ -0,0 +1,198 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications";
import { Button, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2";
import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
import {
formRolePermission2API,
formSchema,
rolePermission2Form,
TFormSchema
} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils";
import { RolePermissionRow } from "./RolePermissionRow";
import { RowPermissionSecretsRow } from "./RolePermissionSecretsRow";
const SINGLE_PERMISSION_LIST = [
{
title: "Project",
formName: "workspace"
},
{
title: "Integrations",
formName: "integrations"
},
{
title: "Secret Protect policy",
formName: ProjectPermissionSub.SecretApproval
},
{
title: "Roles",
formName: "role"
},
{
title: "User Management",
formName: "member"
},
{
title: "Group Management",
formName: "groups"
},
{
title: "Machine Identity Management",
formName: "identity"
},
{
title: "Webhooks",
formName: "webhooks"
},
{
title: "Service Tokens",
formName: "service-tokens"
},
{
title: "Settings",
formName: "settings"
},
{
title: "Environments",
formName: "environments"
},
{
title: "Tags",
formName: "tags"
},
{
title: "Audit Logs",
formName: "audit-logs"
},
{
title: "IP Allowlist",
formName: "ip-allowlist"
},
{
title: "Certificate Authorities",
formName: "certificate-authorities"
},
{
title: "Certificates",
formName: "certificates"
},
{
title: "Secret Rollback",
formName: "secret-rollback"
}
] as const;
type Props = {
roleSlug: string;
};
export const RolePermissionsSection = ({ roleSlug }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectSlug = currentWorkspace?.slug || "";
const { data: role } = useGetProjectRoleBySlug(currentWorkspace?.slug ?? "", roleSlug as string);
const {
setValue,
getValues,
control,
handleSubmit,
formState: { isDirty, isSubmitting },
reset
} = useForm<TFormSchema>({
defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {},
resolver: zodResolver(formSchema)
});
const { mutateAsync: updateRole } = useUpdateProjectRole();
const onSubmit = async (el: TFormSchema) => {
try {
if (!projectSlug || !role?.id) return;
await updateRole({
id: role?.id as string,
projectSlug,
...el,
permissions: formRolePermission2API(el.permissions)
});
createNotification({ type: "success", text: "Successfully updated role" });
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to update role" });
}
};
const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(role?.slug ?? "");
return (
<form
onSubmit={handleSubmit(onSubmit)}
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">Permissions</h3>
{isCustomRole && (
<div className="flex items-center">
<Button
colorSchema="primary"
type="submit"
isDisabled={isSubmitting || !isDirty}
isLoading={isSubmitting}
>
Save
</Button>
<Button
className="ml-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting || !isDirty}
isLoading={isSubmitting}
onClick={() => reset()}
>
Cancel
</Button>
</div>
)}
</div>
<div className="py-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-5" />
<Th>Resource</Th>
<Th>Permission</Th>
</Tr>
</THead>
<TBody>
<RowPermissionSecretsRow
title="Secrets"
formName={ProjectPermissionSub.Secrets}
isEditable={isCustomRole}
setValue={setValue}
getValue={getValues}
control={control}
/>
{SINGLE_PERMISSION_LIST.map((permission) => {
return (
<RolePermissionRow
title={permission.title}
formName={permission.formName}
control={control}
setValue={setValue}
key={`project-role-${roleSlug}-permission-${permission.formName}`}
isEditable={isCustomRole}
/>
);
})}
</TBody>
</Table>
</TableContainer>
</div>
</form>
);
};

@ -0,0 +1 @@
export { RolePermissionsSection } from "./RolePermissionsSection";

@ -0,0 +1,3 @@
export { RoleDetailsSection } from "./RoleDetailsSection";
export { RoleModal } from "./RoleModal";
export { RolePermissionsSection } from "./RolePermissionsSection";

@ -0,0 +1 @@
export { RolePage } from "./RolePage";

@ -0,0 +1,11 @@
export enum TabSections {
Member = "members",
Roles = "roles",
Groups = "groups",
Identities = "identities",
ServiceTokens = "service-tokens"
}
export const isTabSection = (value: string): value is TabSections => {
return (Object.values(TabSections) as string[]).includes(value);
}

@ -0,0 +1,3 @@
import { TabSections, isTabSection } from "./TabSections";
export { TabSections, isTabSection };

@ -404,12 +404,7 @@ export const SecretListView = ({
isOpen={popUp.createTag.isOpen} isOpen={popUp.createTag.isOpen}
onToggle={(isOpen) => handlePopUpToggle("createTag", isOpen)} onToggle={(isOpen) => handlePopUpToggle("createTag", isOpen)}
/> />
<AddShareSecretModal <AddShareSecretModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
isPublic={false}
inModal
/>
</> </>
); );
}; };

@ -1,3 +1,4 @@
import { useState, useCallback } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
@ -6,7 +7,7 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { IconButton, Tooltip } from "@app/components/v2"; import { IconButton, Tooltip, DeleteActionModal } from "@app/components/v2";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
@ -59,6 +60,11 @@ export const SecretEditRow = ({
} }
}); });
const [isDeleting, setIsDeleting] = useToggle(); const [isDeleting, setIsDeleting] = useToggle();
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const toggleModal = useCallback(() => {
setIsModalOpen((prev) => !prev)
}, [])
const handleFormReset = () => { const handleFormReset = () => {
reset(); reset();
@ -94,18 +100,29 @@ export const SecretEditRow = ({
reset({ value }); reset({ value });
}; };
const handleDeleteSecret = async () => { const handleDeleteSecret = useCallback(async () => {
setIsDeleting.on(); setIsDeleting.on();
setIsModalOpen(false);
try { try {
await onSecretDelete(environment, secretName, secretId); await onSecretDelete(environment, secretName, secretId);
reset({ value: null }); reset({ value: null });
} finally { } finally {
setIsDeleting.off(); setIsDeleting.off();
} }
}; }, [onSecretDelete, environment, secretName, secretId, reset, setIsDeleting]);
return ( return (
<div className="group flex w-full cursor-text items-center space-x-2"> <div className="group flex w-full cursor-text items-center space-x-2">
<DeleteActionModal
isOpen={isModalOpen}
onClose={toggleModal}
title="Do you want to delete the selected secret?"
deleteKey="delete"
onDeleteApproved={handleDeleteSecret}
/>
<div className="flex-grow border-r border-r-mineshaft-600 pr-2 pl-1"> <div className="flex-grow border-r border-r-mineshaft-600 pr-2 pl-1">
<Controller <Controller
disabled={isImportedSecret && !defaultValue} disabled={isImportedSecret && !defaultValue}
@ -193,7 +210,7 @@ export const SecretEditRow = ({
variant="plain" variant="plain"
ariaLabel="delete-value" ariaLabel="delete-value"
className="h-full" className="h-full"
onClick={handleDeleteSecret} onClick={toggleModal}
isDisabled={isDeleting || !isAllowed} isDisabled={isDeleting || !isAllowed}
> >
<FontAwesomeIcon icon={faTrash} /> <FontAwesomeIcon icon={faTrash} />

@ -49,8 +49,9 @@ export const SelectionPanel = ({
"bulkDeleteEntries" "bulkDeleteEntries"
] as const); ] as const);
const selectedCount = const selectedFolderCount = Object.keys(selectedEntries.folder).length
Object.keys(selectedEntries.folder).length + Object.keys(selectedEntries.secret).length; const selectedKeysCount = Object.keys(selectedEntries.secret).length
const selectedCount = selectedFolderCount + selectedKeysCount
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
@ -68,6 +69,16 @@ export const SelectionPanel = ({
) )
); );
const getDeleteModalTitle = () => {
if (selectedFolderCount > 0 && selectedKeysCount > 0) {
return "Do you want to delete the selected secrets and folders across environments?";
}
if (selectedKeysCount > 0) {
return "Do you want to delete the selected secrets across environments?";
}
return "Do you want to delete the selected folders across environments?";
}
const handleBulkDelete = async () => { const handleBulkDelete = async () => {
let processedEntries = 0; let processedEntries = 0;
@ -180,7 +191,7 @@ export const SelectionPanel = ({
<DeleteActionModal <DeleteActionModal
isOpen={popUp.bulkDeleteEntries.isOpen} isOpen={popUp.bulkDeleteEntries.isOpen}
deleteKey="delete" deleteKey="delete"
title="Do you want to delete the selected secrets and folders across envs?" title={getDeleteModalTitle()}
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)} onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
onDeleteApproved={handleBulkDelete} onDeleteApproved={handleBulkDelete}
/> />

@ -1,277 +0,0 @@
import crypto from "crypto";
import { useEffect, useRef } from "react";
import { Controller } from "react-hook-form";
import { AxiosError } from "axios";
import * as yup from "yup";
import { createNotification } from "@app/components/notifications";
import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto";
import { Button, Checkbox, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2";
import { SecretSharingAccessType, useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api/secretSharing";
const schema = yup.object({
value: yup.string().max(10000).required().label("Shared Secret Value"),
expiresAfterSingleView: yup.boolean().required().label("Expires After Views"),
expiresInValue: yup.number().min(1).required().label("Expiration Value"),
expiresInUnit: yup.string().required().label("Expiration Unit"),
accessType: yup.string().required().label("General Access")
});
export type FormData = yup.InferType<typeof schema>;
export const AddShareSecretForm = ({
isPublic,
inModal,
handleSubmit,
control,
isSubmitting,
setNewSharedSecret,
isInputDisabled
}: {
isPublic: boolean;
inModal: boolean;
handleSubmit: any;
control: any;
isSubmitting: boolean;
setNewSharedSecret: (value: string) => void;
isInputDisabled?: boolean;
}) => {
const isMounted = useRef(true);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
const publicSharedSecretCreator = useCreatePublicSharedSecret();
const privateSharedSecretCreator = useCreateSharedSecret();
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator;
const expirationUnitsAndActions = [
{
unit: "Minutes",
action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setMinutes(expiresAt.getMinutes() + expiresInValue)
},
{
unit: "Hours",
action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setHours(expiresAt.getHours() + expiresInValue)
},
{
unit: "Days",
action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setDate(expiresAt.getDate() + expiresInValue)
},
{
unit: "Weeks",
action: (expiresAt: Date, expiresInValue: number) =>
expiresAt.setDate(expiresAt.getDate() + expiresInValue * 7)
}
];
const onFormSubmit = async ({
value,
expiresInValue,
expiresInUnit,
expiresAfterSingleView,
accessType
}: FormData) => {
try {
const key = crypto.randomBytes(16).toString("hex");
const hashedHex = crypto.createHash("sha256").update(key).digest("hex");
const { ciphertext, iv, tag } = encryptSymmetric({
plaintext: value,
key
});
const expiresAt = new Date();
const updateExpiresAt = expirationUnitsAndActions.find(
(item) => item.unit === expiresInUnit
)?.action;
if (updateExpiresAt && expiresInValue) {
updateExpiresAt(expiresAt, expiresInValue);
}
const { id } = await createSharedSecret.mutateAsync({
encryptedValue: ciphertext,
iv,
tag,
hashedHex,
expiresAt,
expiresAfterViews: expiresAfterSingleView ? 1 : 1000,
accessType: accessType as SecretSharingAccessType
});
if (isMounted.current) {
setNewSharedSecret(
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(
hashedHex
)}-${encodeURIComponent(key)}`
);
createNotification({
text: "Successfully created a shared secret",
type: "success"
});
}
} catch (err) {
console.error(err);
const axiosError = err as AxiosError;
if (axiosError?.response?.status === 401) {
createNotification({
text: "You do not have access to create shared secrets",
type: "error"
});
} else {
createNotification({
text: "Failed to create a shared secret",
type: "error"
});
}
}
};
return (
<form className="flex w-full flex-col items-center px-4 sm:px-0" onSubmit={handleSubmit(onFormSubmit)}>
<div
className={`w-full ${!inModal && "rounded-md border border-mineshaft-600 bg-mineshaft-800 p-6"}`}
>
<div>
<Controller
control={control}
name="value"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Shared Secret"
isError={Boolean(error)}
errorText={error?.message}
className="mb-2"
>
<textarea
disabled={isInputDisabled}
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 py-1.5 px-2 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"
/>
</FormControl>
)}
/>
</div>
<div className="flex w-full flex-col md:flex-row justify-stretch">
<div className="flex justify-start">
<div className="flex justify-start">
<div className="flex w-full justify-center pr-2">
<Controller
control={control}
name="expiresInValue"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Expires after Time"
isError={Boolean(error)}
errorText="Please enter a valid time duration"
className="w-32"
>
<Input {...field} type="number" min={0} />
</FormControl>
)}
/>
</div>
<div className="flex justify-center">
<Controller
control={control}
name="expiresInUnit"
defaultValue={expirationUnitsAndActions[1].unit}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Unit" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mineshaft-600"
>
{expirationUnitsAndActions.map(({ unit }) => (
<SelectItem value={unit} key={unit}>
{unit}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
</div>
</div>
<div className="sm:w-1/7 mx-auto items-center justify-center hidden md:flex">
<p className="mt-2 text-sm text-gray-400">AND</p>
</div>
<div className="items-center pb-4 md:pb-0 md:pt-2 flex">
<Controller
control={control}
name="expiresAfterViews"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-4 w-full hidden"
label="Expires after Views"
isError={Boolean(error)}
errorText="Please enter a valid number of views"
>
<Input {...field} type="number" min={1} />
</FormControl>
)}
/>
<div className="bg-mineshaft-900 py-2 h-max rounded-md border border-mineshaft-600 px-4">
<Controller
control={control}
name="expiresAfterSingleView"
defaultValue={false}
render={({ field: { onBlur, value, onChange } }) => (
<Checkbox
id="is-single-view"
isChecked={value}
onCheckedChange={onChange}
isDisabled={false}
onBlur={onBlur}
>
Can be viewed only 1 time
</Checkbox>
)}
/>
</div>
</div>
</div>
{!isPublic && (
<Controller
control={control}
name="accessType"
defaultValue="organization"
render={({ field: { onChange, ...field } }) => (
<FormControl label="General Access">
<Select
{...field}
onValueChange={(e) => onChange(e)}
>
<SelectItem value="organization">People within your organization</SelectItem>
<SelectItem value="anyone">Anyone</SelectItem>
</Select>
</FormControl>
)}
/>
)}
<div className={`flex items-center space-x-4 pt-2 ${!inModal && ""}`}>
<Button className="mr-0" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
{inModal ? "Create" : "Share Secret"}
</Button>
{inModal && (
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
)}
</div>
</div>
</form>
);
};

@ -1,23 +1,6 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { Modal, ModalContent } from "@app/components/v2"; import { Modal, ModalContent } from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { ShareSecretForm } from "@app/views/ShareSecretPublicPage/components";
import { AddShareSecretForm } from "./AddShareSecretForm";
import { ViewAndCopySharedSecret } from "./ViewAndCopySharedSecret";
const schema = yup.object({
value: yup.string().max(10000).required().label("Shared Secret Value"),
expiresAfterViews: yup.number().min(1).required().label("Expires After Views"),
expiresInValue: yup.number().min(1).required().label("Expiration Value"),
expiresInUnit: yup.string().required().label("Expiration Unit")
});
export type FormData = yup.InferType<typeof schema>;
type Props = { type Props = {
popUp: UsePopUpState<["createSharedSecret"]>; popUp: UsePopUpState<["createSharedSecret"]>;
@ -25,97 +8,25 @@ type Props = {
popUpName: keyof UsePopUpState<["createSharedSecret"]>, popUpName: keyof UsePopUpState<["createSharedSecret"]>,
state?: boolean state?: boolean
) => void; ) => void;
isPublic: boolean;
inModal: boolean;
}; };
export const AddShareSecretModal = ({ popUp, handlePopUpToggle, isPublic, inModal }: Props) => { export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
const { return (
control,
reset,
handleSubmit,
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: yupResolver(schema)
});
const [newSharedSecret, setNewSharedSecret] = useState("");
const hasSharedSecret = Boolean(newSharedSecret);
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false
});
const [isSecretInputDisabled, setIsSecretInputDisabled] = useState(false);
const copyUrlToClipboard = () => {
navigator.clipboard.writeText(newSharedSecret);
setIsUrlCopied(true);
};
useEffect(() => {
if (isUrlCopied) {
setTimeout(() => setIsUrlCopied(false), 2000);
}
}, [isUrlCopied]);
useEffect(() => {
if (popUp.createSharedSecret.data) {
setValue("value", (popUp.createSharedSecret.data as { value: string }).value);
setIsSecretInputDisabled(true);
}
}, [popUp.createSharedSecret.data]);
// eslint-disable-next-line no-nested-ternary
return inModal ? (
<Modal <Modal
isOpen={popUp?.createSharedSecret?.isOpen} isOpen={popUp?.createSharedSecret?.isOpen}
onOpenChange={(open) => { onOpenChange={(isOpen) => {
handlePopUpToggle("createSharedSecret", open); handlePopUpToggle("createSharedSecret", isOpen);
reset();
setNewSharedSecret("");
setIsSecretInputDisabled(false);
}} }}
> >
<ModalContent <ModalContent
title="Share a Secret" title="Share a Secret"
subTitle="Once you share a secret, the share link is only accessible once." subTitle="Once you share a secret, the share link is only accessible once."
> >
{!hasSharedSecret ? ( <ShareSecretForm
<AddShareSecretForm isPublic={false}
isPublic={isPublic} value={(popUp.createSharedSecret.data as { value?: string })?.value}
inModal={inModal} />
control={control}
handleSubmit={handleSubmit}
isSubmitting={isSubmitting}
setNewSharedSecret={setNewSharedSecret}
isInputDisabled={isSecretInputDisabled}
/>
) : (
<ViewAndCopySharedSecret
inModal={inModal}
newSharedSecret={newSharedSecret}
isUrlCopied={isUrlCopied}
copyUrlToClipboard={copyUrlToClipboard}
/>
)}
</ModalContent> </ModalContent>
</Modal> </Modal>
) : !hasSharedSecret ? (
<AddShareSecretForm
isPublic={isPublic}
inModal={inModal}
control={control}
handleSubmit={handleSubmit}
isSubmitting={isSubmitting}
setNewSharedSecret={setNewSharedSecret}
isInputDisabled={isSecretInputDisabled}
/>
) : (
<ViewAndCopySharedSecret
inModal={inModal}
newSharedSecret={newSharedSecret}
isUrlCopied={isUrlCopied}
copyUrlToClipboard={copyUrlToClipboard}
/>
); );
}; };

@ -46,9 +46,8 @@ export const ShareSecretSection = () => {
<link rel="icon" href="/infisical.ico" /> <link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" /> <meta property="og:image" content="/images/message.png" />
</Head> </Head>
<div className="mb-2 flex justify-between"> <div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Shared Secrets</p> <p className="text-xl font-semibold text-mineshaft-100">Shared Secrets</p>
<Button <Button
colorSchema="primary" colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
@ -60,12 +59,7 @@ export const ShareSecretSection = () => {
</Button> </Button>
</div> </div>
<ShareSecretsTable handlePopUpOpen={handlePopUpOpen} /> <ShareSecretsTable handlePopUpOpen={handlePopUpOpen} />
<AddShareSecretModal <AddShareSecretModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
isPublic={false}
inModal
/>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteSharedSecretConfirmation.isOpen} isOpen={popUp.deleteSharedSecretConfirmation.isOpen}
title={`Delete ${ title={`Delete ${

@ -1,77 +1,16 @@
import { useEffect, useState } from "react"; import { faEnvelope, faEnvelopeOpen, faTrash } from "@fortawesome/free-solid-svg-icons";
import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { IconButton, Td, Tr } from "@app/components/v2"; import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
// import { useToggle } from "@app/hooks";
import { Badge } from "@app/components/v2/Badge";
import { TSharedSecret } from "@app/hooks/api/secretSharing"; import { TSharedSecret } from "@app/hooks/api/secretSharing";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
const formatDate = (date: Date): string => (date ? new Date(date).toUTCString() : "");
const isExpired = (expiresAt: Date | number | undefined): boolean => {
if (typeof expiresAt === "number") {
return expiresAt <= 0;
}
if (expiresAt instanceof Date) {
return new Date(expiresAt) < new Date();
}
return false;
};
const getValidityStatusText = (expiresAt: Date): string =>
isExpired(expiresAt) ? "Expired " : "Valid for ";
const timeAgo = (inputDate: Date, currentDate: Date): string => {
const now = new Date(currentDate).getTime();
const date = new Date(inputDate).getTime();
const elapsedMilliseconds = now - date;
const elapsedSeconds = Math.abs(Math.floor(elapsedMilliseconds / 1000));
const elapsedMinutes = Math.abs(Math.floor(elapsedSeconds / 60));
const elapsedHours = Math.abs(Math.floor(elapsedMinutes / 60));
const elapsedDays = Math.abs(Math.floor(elapsedHours / 24));
const elapsedWeeks = Math.abs(Math.floor(elapsedDays / 7));
const elapsedMonths = Math.abs(Math.floor(elapsedDays / 30));
const elapsedYears = Math.abs(Math.floor(elapsedDays / 365));
if (elapsedYears > 0) {
return `${elapsedYears} year${elapsedYears === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedMonths > 0) {
return `${elapsedMonths} month${elapsedMonths === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedWeeks > 0) {
return `${elapsedWeeks} week${elapsedWeeks === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedDays > 0) {
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedHours > 0) {
return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedMinutes > 0) {
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
};
export const ShareSecretsRow = ({ export const ShareSecretsRow = ({
row, row,
handlePopUpOpen, handlePopUpOpen
onSecretExpiration
}: { }: {
row: TSharedSecret; row: TSharedSecret;
handlePopUpOpen: ( handlePopUpOpen: (
@ -84,58 +23,72 @@ export const ShareSecretsRow = ({
id: string; id: string;
} }
) => void; ) => void;
onSecretExpiration: (expiredSecretId: string) => void;
}) => { }) => {
const [currentTime, setCurrentTime] = useState(new Date()); // const [isRowExpanded, setIsRowExpanded] = useToggle();
const lastViewedAt = row.lastViewedAt
? format(new Date(row.lastViewedAt), "yyyy-MM-dd - HH:mm a")
: undefined;
useEffect(() => { let isExpired = false;
const intervalId = setInterval(() => { if (row.expiresAfterViews !== null && row.expiresAfterViews <= 0) {
setCurrentTime(new Date()); isExpired = true;
}, 1000); }
return () => clearInterval(intervalId); if (row.expiresAt !== null && new Date(row.expiresAt) < new Date()) {
}, []); isExpired = true;
}
useEffect(() => {
if (isExpired(row.expiresAt || row.expiresAfterViews)) {
onSecretExpiration(row.id);
}
}, [isExpired(row.expiresAt || row.expiresAfterViews)]);
return ( return (
<Tr key={row.id}> <>
<Td>{`${row.encryptedValue.substring(0, 5)}...`}</Td> <Tr
<Td> key={row.id}
<p className="text-sm text-yellow-400">{timeAgo(row.createdAt, currentTime)}</p> // className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
<p className="text-xs text-gray-500">{formatDate(row.createdAt)}</p> // onClick={() => setIsRowExpanded.toggle()}
</Td> >
<Td> <Td>
<> <Tooltip content={lastViewedAt ? `Last opened at ${lastViewedAt}` : "Not yet opened"}>
<p className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}> <FontAwesomeIcon icon={lastViewedAt ? faEnvelopeOpen : faEnvelope} />
{getValidityStatusText(row.expiresAt!) + timeAgo(row.expiresAt!, currentTime)} </Tooltip>
</p> </Td>
<p className="text-xs text-gray-500">{formatDate(row.expiresAt!)}</p> <Td>{row.name ? `${row.name}` : "-"}</Td>
</> <Td>
</Td> <Badge variant={isExpired ? "danger" : "success"}>
<Td> {isExpired ? "Expired" : "Active"}
<p className={`text-sm ${row.expiresAfterViews <= 0 ? "text-red-500" : "text-green-500"}`}> </Badge>
{row.expiresAfterViews} </Td>
</p> <Td>{`${format(new Date(row.createdAt), "yyyy-MM-dd - HH:mm a")}`}</Td>
</Td> <Td>{format(new Date(row.expiresAt), "yyyy-MM-dd - HH:mm a")}</Td>
<Td> <Td>{row.expiresAfterViews !== null ? row.expiresAfterViews : "-"}</Td>
<IconButton <Td>
onClick={() => <IconButton
handlePopUpOpen("deleteSharedSecretConfirmation", { onClick={(e) => {
name: "delete", e.stopPropagation();
id: row.id handlePopUpOpen("deleteSharedSecretConfirmation", {
}) name: "delete",
} id: row.id
colorSchema="danger" });
ariaLabel="delete" }}
> variant="plain"
<FontAwesomeIcon icon={faTrashCan} /> ariaLabel="delete"
</IconButton> >
</Td> <FontAwesomeIcon icon={faTrash} />
</Tr> </IconButton>
</Td>
</Tr>
{/* {isRowExpanded && (
<Tr>
<Td
colSpan={6}
className={`bg-bunker-600 px-0 py-0 ${isRowExpanded && " border-mineshaft-500 p-8"}`}
>
<div className="grid grid-cols-3 gap-4">
<div>Test 1</div>
<div>Test 2</div>
<div>Test 3</div>
</div>
</Td>
</Tr>
)} */}
</>
); );
}; };

@ -1,12 +1,13 @@
import { useState } from "react";
import { faKey } from "@fortawesome/free-solid-svg-icons"; import { faKey } from "@fortawesome/free-solid-svg-icons";
import { import {
EmptyState, EmptyState,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
TBody, TBody,
Td,
Th, Th,
THead, THead,
Tr Tr
@ -30,47 +31,49 @@ type Props = {
}; };
export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => { export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
const { isLoading, data = [] } = useGetSharedSecrets(); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
let tableData = data.filter( const { isLoading, data } = useGetSharedSecrets({
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0 offset: (page - 1) * perPage,
); limit: perPage
const handleSecretExpiration = () => { });
tableData = data.filter(
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
);
};
return ( return (
<TableContainer> <TableContainer>
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Encrypted Secret</Th> <Th>Created</Th> <Th>Valid Until</Th> <Th>Views Left</Th> <Th className="w-5" />
<Th aria-label="button" /> <Th>Name</Th>
<Th>Status</Th>
<Th>Created At</Th>
<Th>Valid Until</Th>
<Th>Views Left</Th>
<Th aria-label="button" className="w-5" />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={4} innerKey="shared-secrets" />} {isLoading && <TableSkeleton columns={7} innerKey="shared-secrets" />}
{!isLoading && {!isLoading &&
tableData && data?.secrets?.map((row) => (
tableData.map((row) => ( <ShareSecretsRow key={row.id} row={row} handlePopUpOpen={handlePopUpOpen} />
<ShareSecretsRow
key={row.id}
row={row}
handlePopUpOpen={handlePopUpOpen}
onSecretExpiration={handleSecretExpiration}
/>
))} ))}
{!isLoading && tableData && tableData?.length === 0 && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="No secrets shared yet" icon={faKey} />
</Td>
</Tr>
)}
</TBody> </TBody>
</Table> </Table>
{!isLoading &&
data?.secrets &&
data.secrets.length >= perPage &&
data?.totalCount !== undefined && (
<Pagination
count={data.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && !data?.secrets?.length && (
<EmptyState title="No secrets shared yet" icon={faKey} />
)}
</TableContainer> </TableContainer>
); );
}; };

@ -1,37 +0,0 @@
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconButton } from "@app/components/v2";
export const ViewAndCopySharedSecret = ({
inModal,
newSharedSecret,
isUrlCopied,
copyUrlToClipboard
}: {
inModal: boolean;
newSharedSecret: string;
isUrlCopied: boolean;
copyUrlToClipboard: () => void;
}) => {
return (
<div className={`flex w-full justify-center px-6 ${!inModal ? "mx-auto max-w-2xl" : ""}`}>
<div className={`${!inModal ? "border border-mineshaft-600 bg-mineshaft-800 rounded-md p-4" : ""}`}>
<div className="my-2 flex items-center justify-end rounded-md border border-mineshaft-500 bg-mineshaft-700 p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newSharedSecret}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyUrlToClipboard}
>
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to Copy
</span>
</IconButton>
</div>
</div>
</div>
);
};

@ -1,65 +1,16 @@
import { useMemo } from "react";
import Head from "next/head";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto"; import { ShareSecretForm } from "./components";
import { Button } from "@app/components/v2";
import { usePopUp, useTimedReset } from "@app/hooks";
import { useGetActiveSharedSecretByIdAndHashedHex } from "@app/hooks/api/secretSharing";
import { AddShareSecretModal } from "../ShareSecretPage/components/AddShareSecretModal";
import { SecretTable } from "./components";
export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean }) => {
const router = useRouter();
const { id, key: urlEncodedPublicKey } = router.query;
const [hashedHex, key] = urlEncodedPublicKey
? urlEncodedPublicKey.toString().split("-")
: ["", ""];
const publicKey = decodeURIComponent(urlEncodedPublicKey as string);
const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex(
id as string,
hashedHex as string
);
const accessType = data?.accessType;
const orgName = data?.orgName;
const decryptedSecret = useMemo(() => {
if (data && data.encryptedValue && publicKey) {
const res = decryptSymmetric({
ciphertext: data.encryptedValue,
iv: data.iv,
tag: data.tag,
key
});
return res;
}
return "";
}, [data, publicKey]);
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false
});
const copyUrlToClipboard = () => {
navigator.clipboard.writeText(decryptedSecret);
setIsUrlCopied(true);
};
const { popUp, handlePopUpToggle } = usePopUp(["createSharedSecret"] as const);
export const ShareSecretPublicPage = () => {
return ( return (
<div className="flex h-screen flex-col overflow-y-auto bg-gradient-to-tr from-mineshaft-700 to-bunker-800 text-gray-200 dark:[color-scheme:dark]"> <div className="flex h-screen flex-col justify-between overflow-auto bg-gradient-to-tr from-mineshaft-700 to-bunker-800 text-gray-200 dark:[color-scheme:dark]">
<Head> <div />
<title>Secret Shared | Infisical</title> <div className="mx-auto w-full max-w-xl p-4">
<link rel="icon" href="/infisical.ico" /> <div className="mb-8 text-center">
</Head>
<div className="flex w-full flex-grow items-center justify-center dark:[color-scheme:dark]">
<div className="relative">
<div className="mb-4 flex justify-center pt-8"> <div className="mb-4 flex justify-center pt-8">
<Link href="https://infisical.com"> <Link href="https://infisical.com">
<Image <Image
@ -71,109 +22,66 @@ export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean
/> />
</Link> </Link>
</div> </div>
<div className="flex w-full justify-center"> <h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-4xl font-medium text-transparent">
<h1 Share a secret
className={`${ </h1>
id ? "mb-4 max-w-sm" : "mt-4 mb-6 max-w-md" <p className="text-md">
} bg-gradient-to-b from-white to-bunker-200 bg-clip-text px-4 text-center text-3xl font-medium text-transparent`} Powered by{" "}
<a
href="https://github.com/infisical/infisical"
target="_blank"
rel="noopener noreferrer"
className="text-bold bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent"
> >
{id Infisical &rarr;
? "Someone shared a secret via Infisical with you" </a>
: "Share a secret via Infisical"} </p>
</h1> </div>
</div> <div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4">
<div className="m-auto mt-4 flex w-full max-w-2xl justify-center px-6"> <ShareSecretForm isPublic />
{id && ( </div>
<SecretTable <div className="m-auto my-8 flex w-full">
isLoading={isLoading} <div className="w-full border-t border-mineshaft-600" />
decryptedSecret={decryptedSecret} </div>
isUrlCopied={isUrlCopied} <div className="m-auto flex w-full flex-col rounded-md border border-primary-500/30 bg-primary/5 p-6 pt-5">
copyUrlToClipboard={copyUrlToClipboard} <p className="w-full pb-2 text-lg font-semibold text-mineshaft-100 md:pb-3 md:text-xl">
accessType={accessType} Open source{" "}
orgName={orgName} <span className="bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent">
/> secret management
)} </span>{" "}
</div> for developers
</p>
{isNewSession && ( <div className="flex flex-col items-start sm:flex-row sm:items-center">
<div className="px-0 sm:px-6"> <p className="md:text-md text-md mr-4">
<AddShareSecretModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
inModal={false}
isPublic
/>
</div>
)}
{!isNewSession && (
<div className="flex flex-1 flex-col items-center justify-center px-6 pt-4">
<a <a
href="https://share.infisical.com/" href="https://github.com/infisical/infisical"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-3 w-full text-sm font-normal leading-[1.2rem] text-mineshaft-300 hover:text-mineshaft-100" className="text-bold bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent"
> >
<Button Infisical
className="w-full bg-mineshaft-700 py-3 text-bunker-200" </a>{" "}
colorSchema="primary" is the all-in-one secret management platform to securely manage secrets, configs, and
variant="outline_bg" certificates across your team and infrastructure.
size="sm" </p>
onClick={() => {}} <div className="mt-4 cursor-pointer sm:mt-0">
rightIcon={<FontAwesomeIcon icon={faArrowRight} className="pl-2" />} <Link href="https://infisical.com">
> <div className="flex items-center justify-between rounded-md border border-mineshaft-400/40 bg-mineshaft-600 py-2 px-3 duration-200 hover:border-primary/60 hover:bg-primary/20 hover:text-white">
Share your own Secret <p className="mr-4 whitespace-nowrap">Try Infisical</p>
</Button> <FontAwesomeIcon icon={faArrowRight} />
</a> </div>
</div> </Link>
)}
<div className="m-auto my-6 flex w-full max-w-xl justify-center px-4 sm:my-8">
<div className="w-full border-t border-mineshaft-600" />
</div>
<div className="m-auto flex max-w-2xl flex-col items-center justify-center px-4 sm:px-6">
<div className="m-auto mb-12 flex w-full max-w-2xl flex-col justify-center rounded-md border border-primary-500/30 bg-primary/5 p-6 pt-5">
<p className="w-full pb-2 text-lg font-semibold text-mineshaft-100 md:pb-3 md:text-xl">
Open source{" "}
<span className="bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent">
secret management
</span>{" "}
for developers
</p>
<div className="flex flex-col gap-x-4 sm:flex-row">
<p className="md:text-md text-md">
<a
href="https://github.com/infisical/infisical"
target="_blank"
rel="noopener noreferrer"
className="text-bold bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent"
>
Infisical
</a>{" "}
is the all-in-one secret management platform to securely manage secrets, configs,
and certificates across your team and infrastructure.
</p>
<Link href="https://infisical.com">
<span className="mt-4 h-min w-[17.5rem] cursor-pointer rounded-md border border-mineshaft-400/40 bg-mineshaft-600 py-2 px-3 duration-200 hover:border-primary/60 hover:bg-primary/20 hover:text-white">
Try Infisical <FontAwesomeIcon icon={faArrowRight} className="pl-1" />
</span>
</Link>
</div>
</div> </div>
</div> </div>
<AddShareSecretModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
isPublic
inModal
/>
</div> </div>
</div> </div>
<div className="mt-auto flex w-full items-center justify-center bg-mineshaft-600 p-2">
<div className="w-full bg-mineshaft-600 p-2">
<p className="text-center text-sm text-mineshaft-300"> <p className="text-center text-sm text-mineshaft-300">
© 2024{" "} Made with by{" "}
<a className="text-primary" href="https://infisical.com"> <a className="text-primary" href="https://infisical.com">
Infisical Infisical
</a> </a>
. All rights reserved.
<br /> <br />
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸 156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
</p> </p>

@ -1,110 +0,0 @@
import { faArrowRight, faCheck, faCopy, faEye, faEyeSlash, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, EmptyState, IconButton, Td, Tr } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { SecretSharingAccessType } from "@app/hooks/api/secretSharing/types";
type Props = {
isLoading: boolean;
decryptedSecret: string;
isUrlCopied: boolean;
copyUrlToClipboard: () => void;
accessType?: SecretSharingAccessType;
orgName?: string;
};
const replaceContentWithDot = (str: string) => {
let finalStr = "";
for (let i = 0; i < str.length; i += 1) {
const char = str.at(i);
finalStr += char === "\n" ? "\n" : "*";
}
return finalStr;
};
export const SecretTable = ({
isLoading,
decryptedSecret,
isUrlCopied,
copyUrlToClipboard,
accessType,
orgName
}: Props) => {
const [isVisible, setIsVisible] = useToggle(false);
const title = orgName
? (<p>Someone from <strong>{orgName}</strong> organization has shared a secret with you</p>)
: (<p>You need to be logged in to view this secret</p>);
return (
<div className="flex w-full items-center justify-center rounded-md border border-solid border-mineshaft-700 bg-mineshaft-800 p-2">
{isLoading && <div className="bg-mineshaft-800 text-center text-bunker-400">Loading...</div>}
{!isLoading && !decryptedSecret && accessType !== SecretSharingAccessType.Organization && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="Secret has either expired or does not exist!" icon={faKey} />
</Td>
</Tr>
)}
{!isLoading && !decryptedSecret && accessType === SecretSharingAccessType.Organization && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-4000">
<EmptyState title={title} icon={faKey}>
<div className="flex flex-1 flex-col items-center justify-center pt-6">
<a
href="/login"
target="_blank"
rel="noopener noreferrer"
>
<Button
colorSchema="primary"
size="sm"
onClick={() => {}}
rightIcon={<FontAwesomeIcon icon={faArrowRight} className="ml-2" />}
>
Login into <strong>{orgName}</strong> to view this secret
</Button>
</a>
</div>
</EmptyState>
</Td>
</Tr>
)}
{!isLoading && decryptedSecret && (
<div className="dark relative flex h-full w-full items-center overflow-y-auto rounded-md border border-mineshaft-700 bg-mineshaft-900 p-2 pr-2 md:p-3">
<div
className={`thin-scrollbar flex h-full max-h-44 w-full flex-1 overflow-y-scroll ${
isVisible ? "break-words" : "break-all"
} pr-4 dark:[color-scheme:dark]`}
>
<div className="align-center flex w-full min-w-full whitespace-pre-line">
{isVisible ? decryptedSecret : replaceContentWithDot(decryptedSecret)}
</div>
</div>
<div className="absolute top-1 right-0 mx-1 flex max-h-8 sm:top-2 sm:right-5">
<IconButton
variant="outline_bg"
colorSchema="primary"
ariaLabel="copy to clipboard"
onClick={copyUrlToClipboard}
className="mr-1 flex max-h-8 items-center rounded"
size="xs"
>
<FontAwesomeIcon className="pr-2" icon={isUrlCopied ? faCheck : faCopy} /> Copy
</IconButton>
<IconButton
variant="outline_bg"
colorSchema="primary"
ariaLabel="toggle visibility"
onClick={() => setIsVisible.toggle()}
className="flex max-h-8 items-center rounded"
size="xs"
>
<FontAwesomeIcon icon={isVisible ? faEyeSlash : faEye} />
</IconButton>
</div>
</div>
)}
</div>
);
};

@ -0,0 +1,254 @@
import crypto from "crypto";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy, faRedo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto";
import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api";
import { SecretSharingAccessType } from "@app/hooks/api/secretSharing";
// values in ms
const expiresInOptions = [
{ label: "5 min", value: 5 * 60 * 1000 },
{ label: "30 min", value: 30 * 60 * 1000 },
{ label: "1 hour", value: 60 * 60 * 1000 },
{ label: "1 day", value: 24 * 60 * 60 * 1000 },
{ label: "7 days", value: 7 * 24 * 60 * 60 * 1000 },
{ label: "14 days", value: 14 * 24 * 60 * 60 * 1000 },
{ label: "30 days", value: 30 * 24 * 60 * 60 * 1000 }
];
const viewLimitOptions = [
{ label: "1", value: 1 },
{ label: "Unlimited", value: -1 }
];
const schema = z.object({
name: z.string().optional(),
secret: z.string(),
expiresIn: z.string(),
viewLimit: z.string(),
accessType: z.nativeEnum(SecretSharingAccessType).optional()
});
export type FormData = z.infer<typeof schema>;
type Props = {
isPublic: boolean; // whether or not this is a public (non-authenticated) secret sharing form
value?: string;
};
export const ShareSecretForm = ({ isPublic, value }: Props) => {
const [secretLink, setSecretLink] = useState("");
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
initialState: "Copy to clipboard"
});
const publicSharedSecretCreator = useCreatePublicSharedSecret();
const privateSharedSecretCreator = useCreateSharedSecret();
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator;
const {
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
secret: value || ""
}
});
const onFormSubmit = async ({ name, secret, expiresIn, viewLimit, accessType }: FormData) => {
try {
const expiresAt = new Date(new Date().getTime() + Number(expiresIn));
const key = crypto.randomBytes(16).toString("hex");
const hashedHex = crypto.createHash("sha256").update(key).digest("hex");
const { ciphertext, iv, tag } = encryptSymmetric({
plaintext: secret,
key
});
const { id } = await createSharedSecret.mutateAsync({
name,
encryptedValue: ciphertext,
hashedHex,
iv,
tag,
expiresAt,
expiresAfterViews: viewLimit === "-1" ? undefined : Number(viewLimit),
accessType
});
setSecretLink(
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(
hashedHex
)}-${encodeURIComponent(key)}`
);
reset();
setCopyTextSecret("secret");
createNotification({
text: "Successfully created a shared secret",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to create a shared secret",
type: "error"
});
}
};
const hasSecretLink = Boolean(secretLink);
return !hasSecretLink ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
{!isPublic && (
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name (Optional)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="API Key" type="text" />
</FormControl>
)}
/>
)}
<Controller
control={control}
name="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 py-1.5 px-2 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="expiresIn"
defaultValue="3600000"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Expires In" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expiresInOptions.map(({ label, value: expiresInValue }) => (
<SelectItem value={String(expiresInValue || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="viewLimit"
defaultValue="-1"
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"
>
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
<SelectItem value={String(viewLimitValue || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{!isPublic && (
<Controller
control={control}
name="accessType"
defaultValue={SecretSharingAccessType.Organization}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="General Access" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
<SelectItem value={SecretSharingAccessType.Anyone}>Anyone</SelectItem>
<SelectItem value={SecretSharingAccessType.Organization}>
People within your organization
</SelectItem>
</Select>
</FormControl>
)}
/>
)}
<Button
className="mt-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create secret link
</Button>
</form>
) : (
<>
<div className="mr-2 flex items-center justify-end rounded-md bg-white/[0.05] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{secretLink}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(secretLink);
setCopyTextSecret("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingSecret ? faCheck : faCopy} />
</IconButton>
</div>
<Button
className="mt-4 w-full bg-mineshaft-700 py-3 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="sm"
onClick={() => setSecretLink("")}
rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
>
Share another secret
</Button>
</>
);
};

@ -1 +1 @@
export { SecretTable } from "./SecretTable"; export { ShareSecretForm } from "./ShareSecretForm";

@ -0,0 +1,104 @@
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useGetActiveSharedSecretById } from "@app/hooks/api/secretSharing";
import { SecretContainer, SecretErrorContainer } from "./components";
export const ViewSecretPublicPage = () => {
const router = useRouter();
const { id, key: urlEncodedPublicKey } = router.query;
const [hashedHex, key] = urlEncodedPublicKey
? urlEncodedPublicKey.toString().split("-")
: ["", ""];
const { data: secret, error } = useGetActiveSharedSecretById({
sharedSecretId: id as string,
hashedHex
});
return (
<div className="flex h-screen flex-col justify-between overflow-auto bg-gradient-to-tr from-mineshaft-700 to-bunker-800 text-gray-200 dark:[color-scheme:dark]">
<div />
<div className="mx-auto w-full max-w-xl p-4 ">
<div className="mb-8 text-center">
<div className="mb-4 flex justify-center pt-8">
<Link href="https://infisical.com">
<Image
src="/images/gradientLogo.svg"
height={90}
width={120}
alt="Infisical logo"
className="cursor-pointer"
/>
</Link>
</div>
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-4xl font-medium text-transparent">
View shared secret
</h1>
<p className="text-md">
Powered by{" "}
<a
href="https://github.com/infisical/infisical"
target="_blank"
rel="noopener noreferrer"
className="text-bold bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent"
>
Infisical &rarr;
</a>
</p>
</div>
{secret && key && <SecretContainer secret={secret} secretKey={key} />}
{error && <SecretErrorContainer />}
<div className="m-auto my-8 flex w-full">
<div className="w-full border-t border-mineshaft-600" />
</div>
<div className="m-auto flex w-full flex-col rounded-md border border-primary-500/30 bg-primary/5 p-6 pt-5">
<p className="w-full pb-2 text-lg font-semibold text-mineshaft-100 md:pb-3 md:text-xl">
Open source{" "}
<span className="bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent">
secret management
</span>{" "}
for developers
</p>
<div className="flex flex-col items-start sm:flex-row sm:items-center">
<p className="md:text-md text-md mr-4">
<a
href="https://github.com/infisical/infisical"
target="_blank"
rel="noopener noreferrer"
className="text-bold bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent"
>
Infisical
</a>{" "}
is the all-in-one secret management platform to securely manage secrets, configs, and
certificates across your team and infrastructure.
</p>
<div className="mt-4 cursor-pointer sm:mt-0">
<Link href="https://infisical.com">
<div className="flex items-center justify-between rounded-md border border-mineshaft-400/40 bg-mineshaft-600 py-2 px-3 duration-200 hover:border-primary/60 hover:bg-primary/20 hover:text-white">
<p className="mr-4 whitespace-nowrap">Try Infisical</p>
<FontAwesomeIcon icon={faArrowRight} />
</div>
</Link>
</div>
</div>
</div>
</div>
<div className="w-full bg-mineshaft-600 p-2">
<p className="text-center text-sm text-mineshaft-300">
Made with by{" "}
<a className="text-primary" href="https://infisical.com">
Infisical
</a>
<br />
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
</p>
</div>
</div>
);
};

@ -0,0 +1,82 @@
import { useMemo } from "react";
import {
faArrowRight,
faCheck,
faCopy,
faEye,
faEyeSlash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto";
import { Button, IconButton } from "@app/components/v2";
import { useTimedReset, useToggle } from "@app/hooks";
import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing";
type Props = {
secret: TViewSharedSecretResponse;
secretKey: string;
};
export const SecretContainer = ({ secret, secretKey: key }: Props) => {
const [isVisible, setIsVisible] = useToggle(false);
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
initialState: "Copy to clipboard"
});
const decryptedSecret = useMemo(() => {
if (secret && secret.encryptedValue && key) {
const res = decryptSymmetric({
ciphertext: secret.encryptedValue,
iv: secret.iv,
tag: secret.tag,
key
});
return res;
}
return "";
}, [secret, key]);
const hiddenSecret = decryptedSecret ? "*".repeat(decryptedSecret.length) : "";
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4">
<div className="flex items-center justify-between rounded-md bg-white/[0.05] p-2 text-base text-gray-400">
<p className="whitespace-pre-wrap break-all">
{isVisible ? decryptedSecret : hiddenSecret}
</p>
<div className="flex">
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(decryptedSecret);
setCopyTextSecret("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingSecret ? faCheck : faCopy} />
</IconButton>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => setIsVisible.toggle()}
>
<FontAwesomeIcon icon={isVisible ? faEyeSlash : faEye} />
</IconButton>
</div>
</div>
<Button
className="mt-4 w-full bg-mineshaft-700 py-3 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="sm"
onClick={() => window.open("https://app.infisical.com/share-secret", "_blank")}
rightIcon={<FontAwesomeIcon icon={faArrowRight} className="pl-2" />}
>
Share your own secret
</Button>
</div>
);
};

@ -0,0 +1,13 @@
import { faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export const SecretErrorContainer = () => {
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-8">
<div className="text-center">
<FontAwesomeIcon icon={faKey} size="2x" />
<p className="mt-4">The secret you are looking is missing or has expired</p>
</div>
</div>
);
};

@ -0,0 +1,2 @@
export { SecretContainer } from "./SecretContainer";
export { SecretErrorContainer } from "./SecretErrorContainer";

@ -0,0 +1 @@
export { ViewSecretPublicPage } from "./ViewSecretPublicPage";