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 #2200 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 #2198 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 #2190 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 #2196 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 #2192 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 #2182 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 #2186 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 #2188 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 #2187 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 #2185 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 #2183 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 #2181 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 #2180 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 #2178 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 #2173 from felixtrav/patch-1
Update envars.mdx - Added PORT
2024-07-25 10:21:06 -04:00
32627c20c4 Merge pull request #2176 from Infisical/org-role-cleanup
Cleanup frontend unused org role logic (moved)
2024-07-25 07:17:56 -07:00
c50f8fd78c Merge pull request #2175 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

View File

@ -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");
});
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -101,7 +101,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
message: "Slug must be a valid"
}),
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: {
200: z.object({
@ -120,7 +120,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
roleId: req.params.roleId,
data: {
...req.body,
permissions: JSON.stringify(packRules(req.body.permissions))
permissions: req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined
}
});
return { role };

View File

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

View File

@ -425,6 +425,21 @@ export const PROJECTS = {
},
LIST_INTEGRATION_AUTHORIZATION: {
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;

View File

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

View File

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

View File

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

View File

@ -162,12 +162,19 @@ export const projectRoleServiceFactory = ({
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Role);
if (data?.slug) {
const existingRole = await projectRoleDAL.findOne({ slug: data.slug, projectId });
if (existingRole && existingRole.id !== roleId)
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" });
return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) };
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,7 +24,6 @@ import (
"github.com/Infisical/infisical-merge/packages/models"
"github.com/Infisical/infisical-merge/packages/srp"
"github.com/Infisical/infisical-merge/packages/util"
"github.com/chzyer/readline"
"github.com/fatih/color"
"github.com/go-resty/resty/v2"
"github.com/manifoldco/promptui"
@ -205,6 +204,7 @@ var loginCmd = &cobra.Command{
if !overrideDomain {
domainQuery = false
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
}
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)
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.")
@ -807,26 +807,22 @@ func browserCliLogin() (models.UserCredentials, error) {
log.Debug().Msgf("Callback server listening on port %d", callbackPort)
stdin := readline.NewCancelableStdin(os.Stdin)
go http.Serve(listener, corsHandler)
go askToPasteJwtToken(stdin, success, failure)
go askToPasteJwtToken(success, failure)
for {
select {
case loginResponse := <-success:
_ = closeListener(&listener)
_ = stdin.Close()
fmt.Println("Browser login successful")
return loginResponse, nil
case err := <-failure:
serverErr := closeListener(&listener)
stdErr := stdin.Close()
return models.UserCredentials{}, errors.Join(err, serverErr, stdErr)
return models.UserCredentials{}, errors.Join(err, serverErr)
case <-timeout:
_ = closeListener(&listener)
_ = stdin.Close()
return models.UserCredentials{}, errors.New("server timeout")
}
}

View File

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

View File

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

View File

@ -4,6 +4,30 @@ title: "Changelog"
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
- 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.

View File

@ -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.

View File

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

View File

@ -118,6 +118,10 @@ namespace Example
Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`)
</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">
The authentication object to use for the client. This is required unless you're using environment variables.
</ParamField>

View File

@ -122,6 +122,10 @@ public class App {
Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`)
</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">
The authentication object to use for the client. This is required unless you're using environment variables.
</ParamField>

View File

@ -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.
</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">
The authentication object to use for the client. This is required unless you're using environment variables.
</ParamField>

View File

@ -97,16 +97,14 @@ client = InfisicalClient(ClientSettings(
If manually set to 0, caching will be disabled, this is not recommended.
</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`)
</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">
The authentication object to use for the client. This is required unless you're using environment variables.
</ParamField>

View File

@ -25,6 +25,10 @@ Used to configure platform-specific security and operational settings
https://app.infisical.com).
</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>
Telemetry helps us improve Infisical but if you want to dsiable it you may set this to `false`.
</ParamField>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;

View File

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

View File

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

View File

@ -168,16 +168,27 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => {
const { token: newJwtToken } = await selectOrganization({ organizationId });
const instance = axios.create();
await instance.post(cliUrl, {
const payload = {
...isCliLoginSuccessful.loginResponse,
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))
})
);
});
await navigateUserToOrg(router, organizationId);
router.push("/cli-redirect");
return;
}
// 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
else {
const userOrgs = await fetchOrganizations();
// 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 {
await navigateUserToOrg(router);
}
}
}
} else {
const isLoginSuccessful = await attemptLoginMfa({

View File

@ -4,6 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import axios from "axios";
import { addSeconds, formatISO } from "date-fns";
import jwt_decode from "jwt-decode";
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 SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Input, Spinner } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { fetchMyPrivateKey } from "@app/hooks/api/users/queries";
@ -79,11 +81,24 @@ export const PasswordStep = ({
if (callbackPort) {
console.log("organization id was present. new JWT token to be used in CLI:", newJwtToken);
const instance = axios.create();
await instance.post(cliUrl, {
const payload = {
privateKey,
email,
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);
@ -165,26 +180,35 @@ export const PasswordStep = ({
);
const instance = axios.create();
await instance.post(cliUrl, {
const payload = {
...isCliLoginSuccessful.loginResponse,
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))
})
);
});
await navigateUserToOrg(router, organizationId);
router.push("/cli-redirect");
return;
}
// 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
else {
const userOrgs = await fetchOrganizations();
const userOrgs = await fetchOrganizations();
// case: user has orgs, so we navigate the user to select an org
if (userOrgs.length > 0) {
navigateToSelectOrganization(callbackPort);
}
// case: no orgs found, so we navigate the user to create an org
else {
await navigateUserToOrg(router);
}
// case: user has orgs, so we navigate the user to select an org
if (userOrgs.length > 0) {
navigateToSelectOrganization(callbackPort);
}
// case: no orgs found, so we navigate the user to create an org
else {
await navigateUserToOrg(router);
}
}
} else {

View File

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

View File

@ -25,7 +25,7 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
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">Details</h3>
<h3 className="text-lg font-semibold text-mineshaft-100">Identity Details</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
{(isAllowed) => {
return (

View File

@ -10,6 +10,7 @@ import { useWorkspace } from "@app/context";
import { IdentityMembership } from "@app/hooks/api/identities/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { TabSections } from "@app/views/Org/Types";
type Props = {
membership: IdentityMembership;
@ -47,11 +48,11 @@ export const IdentityProjectRow = ({
return (
<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}`}
onClick={() => {
if (isAccessible) {
router.push(`/project/${project.id}/members`);
router.push(`/project/${project.id}/members?selectedTab=${TabSections.Identities}`);
return;
}

View File

@ -1,23 +1,39 @@
/* 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 { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { isTabSection, TabSections } from "@app/views/Org/Types";
import { OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components";
enum TabSections {
Member = "members",
Roles = "roles",
Identities = "identities"
}
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 (
<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">
<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>
<Tab value={TabSections.Member}>Users</Tab>
<Tab value={TabSections.Identities}>
@ -25,7 +41,7 @@ export const MembersPage = withPermission(
<p>Machine Identities</p>
</div>
</Tab>
<Tab value={TabSections.Roles}>Organization Roles</Tab>
<Tab value={TabSections.Roles}>Organization Roles</Tab>
</TabList>
<TabPanel value={TabSections.Member}>
<OrgMembersTab />

View File

@ -86,7 +86,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
data?.map(({ identity: { id, name }, role, customRole }) => {
return (
<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}`}
onClick={() => router.push(`/org/${orgId}/identities/${id}`)}
>

View File

@ -184,7 +184,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
return (
<Tr
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}`)}
>
<Td className={isActive ? "" : "text-mineshaft-400"}>{name}</Td>

View File

@ -93,7 +93,7 @@ export const OrgRoleTable = () => {
return (
<Tr
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}`)}
>
<Td>{name}</Td>

View File

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

View File

@ -26,7 +26,7 @@ export const RoleDetailsSection = ({ roleId, handlePopUpOpen }: Props) => {
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">Details</h3>
<h3 className="text-lg font-semibold text-mineshaft-100">Org Role Details</h3>
{isCustomRole && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Role}>
{(isAllowed) => {

View File

@ -150,7 +150,7 @@ export const RolePermissionRow = ({ isEditable, title, formName, control, setVal
return (
<>
<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()}
>
<Td>

View File

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

View File

@ -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);
}

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import { useWorkspace } from "@app/context";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { TabSections } from "@app/views/Org/Types";
type Props = {
membership: TWorkspaceUser;
@ -43,11 +44,11 @@ export const UserProjectRow = ({
return (
<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}`}
onClick={() => {
if (isAccessible) {
router.push(`/project/${project.id}/members`);
router.push(`/project/${project.id}/members?selectedTab=${TabSections.Member}`);
return;
}

View File

@ -1,25 +1,40 @@
/* 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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { isTabSection,TabSections } from "../Types";
import { IdentityTab, MembersTab,ProjectRoleListTab, ServiceTokenTab } from "./components";
enum TabSections {
Member = "members",
Roles = "roles",
Groups = "groups",
Identities = "identities",
ServiceTokens = "service-tokens"
}
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 (
<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">
<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>
<Tab value={TabSections.Member}>Users</Tab>
<Tab value={TabSections.Identities}>

View File

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

View File

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

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

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

View File

@ -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 }
);

View File

@ -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 />
);
};

View File

@ -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>
);
};

View File

@ -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>
)}
</>
);
};

View File

@ -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>
)}
</>
);
};

View File

@ -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>
);
};

View File

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

View File

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

View File

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

View File

@ -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);
}

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { useState, useCallback } from "react";
import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
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 { 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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks";
@ -59,6 +60,11 @@ export const SecretEditRow = ({
}
});
const [isDeleting, setIsDeleting] = useToggle();
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const toggleModal = useCallback(() => {
setIsModalOpen((prev) => !prev)
}, [])
const handleFormReset = () => {
reset();
@ -94,18 +100,29 @@ export const SecretEditRow = ({
reset({ value });
};
const handleDeleteSecret = async () => {
const handleDeleteSecret = useCallback(async () => {
setIsDeleting.on();
setIsModalOpen(false);
try {
await onSecretDelete(environment, secretName, secretId);
reset({ value: null });
} finally {
setIsDeleting.off();
}
};
}, [onSecretDelete, environment, secretName, secretId, reset, setIsDeleting]);
return (
<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">
<Controller
disabled={isImportedSecret && !defaultValue}
@ -193,7 +210,7 @@ export const SecretEditRow = ({
variant="plain"
ariaLabel="delete-value"
className="h-full"
onClick={handleDeleteSecret}
onClick={toggleModal}
isDisabled={isDeleting || !isAllowed}
>
<FontAwesomeIcon icon={faTrash} />

View File

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

View File

@ -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>
);
};

View File

@ -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 { useTimedReset } from "@app/hooks";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { AddShareSecretForm } from "./AddShareSecretForm";
import { ViewAndCopySharedSecret } from "./ViewAndCopySharedSecret";
const schema = yup.object({
value: yup.string().max(10000).required().label("Shared Secret Value"),
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>;
import { ShareSecretForm } from "@app/views/ShareSecretPublicPage/components";
type Props = {
popUp: UsePopUpState<["createSharedSecret"]>;
@ -25,97 +8,25 @@ type Props = {
popUpName: keyof UsePopUpState<["createSharedSecret"]>,
state?: boolean
) => void;
isPublic: boolean;
inModal: boolean;
};
export const AddShareSecretModal = ({ popUp, handlePopUpToggle, isPublic, inModal }: Props) => {
const {
control,
reset,
handleSubmit,
setValue,
formState: { isSubmitting }
} = useForm<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 ? (
export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
return (
<Modal
isOpen={popUp?.createSharedSecret?.isOpen}
onOpenChange={(open) => {
handlePopUpToggle("createSharedSecret", open);
reset();
setNewSharedSecret("");
setIsSecretInputDisabled(false);
onOpenChange={(isOpen) => {
handlePopUpToggle("createSharedSecret", isOpen);
}}
>
<ModalContent
title="Share a Secret"
subTitle="Once you share a secret, the share link is only accessible once."
>
{!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}
/>
)}
<ShareSecretForm
isPublic={false}
value={(popUp.createSharedSecret.data as { value?: string })?.value}
/>
</ModalContent>
</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}
/>
);
};

View File

@ -46,9 +46,8 @@ export const ShareSecretSection = () => {
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</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>
<Button
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
@ -60,12 +59,7 @@ export const ShareSecretSection = () => {
</Button>
</div>
<ShareSecretsTable handlePopUpOpen={handlePopUpOpen} />
<AddShareSecretModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
isPublic={false}
inModal
/>
<AddShareSecretModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteSharedSecretConfirmation.isOpen}
title={`Delete ${

View File

@ -1,77 +1,16 @@
import { useEffect, useState } from "react";
import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { faEnvelope, faEnvelopeOpen, faTrash } from "@fortawesome/free-solid-svg-icons";
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 { 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 = ({
row,
handlePopUpOpen,
onSecretExpiration
handlePopUpOpen
}: {
row: TSharedSecret;
handlePopUpOpen: (
@ -84,58 +23,72 @@ export const ShareSecretsRow = ({
id: string;
}
) => 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(() => {
const intervalId = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
let isExpired = false;
if (row.expiresAfterViews !== null && row.expiresAfterViews <= 0) {
isExpired = true;
}
return () => clearInterval(intervalId);
}, []);
useEffect(() => {
if (isExpired(row.expiresAt || row.expiresAfterViews)) {
onSecretExpiration(row.id);
}
}, [isExpired(row.expiresAt || row.expiresAfterViews)]);
if (row.expiresAt !== null && new Date(row.expiresAt) < new Date()) {
isExpired = true;
}
return (
<Tr key={row.id}>
<Td>{`${row.encryptedValue.substring(0, 5)}...`}</Td>
<Td>
<p className="text-sm text-yellow-400">{timeAgo(row.createdAt, currentTime)}</p>
<p className="text-xs text-gray-500">{formatDate(row.createdAt)}</p>
</Td>
<Td>
<>
<p className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}>
{getValidityStatusText(row.expiresAt!) + timeAgo(row.expiresAt!, currentTime)}
</p>
<p className="text-xs text-gray-500">{formatDate(row.expiresAt!)}</p>
</>
</Td>
<Td>
<p className={`text-sm ${row.expiresAfterViews <= 0 ? "text-red-500" : "text-green-500"}`}>
{row.expiresAfterViews}
</p>
</Td>
<Td>
<IconButton
onClick={() =>
handlePopUpOpen("deleteSharedSecretConfirmation", {
name: "delete",
id: row.id
})
}
colorSchema="danger"
ariaLabel="delete"
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
</Td>
</Tr>
<>
<Tr
key={row.id}
// className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
// onClick={() => setIsRowExpanded.toggle()}
>
<Td>
<Tooltip content={lastViewedAt ? `Last opened at ${lastViewedAt}` : "Not yet opened"}>
<FontAwesomeIcon icon={lastViewedAt ? faEnvelopeOpen : faEnvelope} />
</Tooltip>
</Td>
<Td>{row.name ? `${row.name}` : "-"}</Td>
<Td>
<Badge variant={isExpired ? "danger" : "success"}>
{isExpired ? "Expired" : "Active"}
</Badge>
</Td>
<Td>{`${format(new Date(row.createdAt), "yyyy-MM-dd - HH:mm a")}`}</Td>
<Td>{format(new Date(row.expiresAt), "yyyy-MM-dd - HH:mm a")}</Td>
<Td>{row.expiresAfterViews !== null ? row.expiresAfterViews : "-"}</Td>
<Td>
<IconButton
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteSharedSecretConfirmation", {
name: "delete",
id: row.id
});
}}
variant="plain"
ariaLabel="delete"
>
<FontAwesomeIcon icon={faTrash} />
</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>
)} */}
</>
);
};

View File

@ -1,12 +1,13 @@
import { useState } from "react";
import { faKey } from "@fortawesome/free-solid-svg-icons";
import {
EmptyState,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
@ -30,47 +31,49 @@ type Props = {
};
export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
const { isLoading, data = [] } = useGetSharedSecrets();
let tableData = data.filter(
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
);
const handleSecretExpiration = () => {
tableData = data.filter(
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
);
};
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const { isLoading, data } = useGetSharedSecrets({
offset: (page - 1) * perPage,
limit: perPage
});
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Encrypted Secret</Th> <Th>Created</Th> <Th>Valid Until</Th> <Th>Views Left</Th>
<Th aria-label="button" />
<Th className="w-5" />
<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>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="shared-secrets" />}
{isLoading && <TableSkeleton columns={7} innerKey="shared-secrets" />}
{!isLoading &&
tableData &&
tableData.map((row) => (
<ShareSecretsRow
key={row.id}
row={row}
handlePopUpOpen={handlePopUpOpen}
onSecretExpiration={handleSecretExpiration}
/>
data?.secrets?.map((row) => (
<ShareSecretsRow key={row.id} row={row} handlePopUpOpen={handlePopUpOpen} />
))}
{!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>
</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>
);
};

View File

@ -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>
);
};

View File

@ -1,65 +1,16 @@
import { useMemo } from "react";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto";
import { Button } from "@app/components/v2";
import { usePopUp, useTimedReset } from "@app/hooks";
import { useGetActiveSharedSecretByIdAndHashedHex } from "@app/hooks/api/secretSharing";
import { AddShareSecretModal } from "../ShareSecretPage/components/AddShareSecretModal";
import { SecretTable } from "./components";
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);
import { ShareSecretForm } from "./components";
export const ShareSecretPublicPage = () => {
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]">
<Head>
<title>Secret Shared | Infisical</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<div className="flex w-full flex-grow items-center justify-center dark:[color-scheme:dark]">
<div className="relative">
<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
@ -71,109 +22,66 @@ export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean
/>
</Link>
</div>
<div className="flex w-full justify-center">
<h1
className={`${
id ? "mb-4 max-w-sm" : "mt-4 mb-6 max-w-md"
} bg-gradient-to-b from-white to-bunker-200 bg-clip-text px-4 text-center text-3xl font-medium text-transparent`}
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-4xl font-medium text-transparent">
Share a 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"
>
{id
? "Someone shared a secret via Infisical with you"
: "Share a secret via Infisical"}
</h1>
</div>
<div className="m-auto mt-4 flex w-full max-w-2xl justify-center px-6">
{id && (
<SecretTable
isLoading={isLoading}
decryptedSecret={decryptedSecret}
isUrlCopied={isUrlCopied}
copyUrlToClipboard={copyUrlToClipboard}
accessType={accessType}
orgName={orgName}
/>
)}
</div>
{isNewSession && (
<div className="px-0 sm:px-6">
<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">
Infisical &rarr;
</a>
</p>
</div>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4">
<ShareSecretForm isPublic />
</div>
<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://share.infisical.com/"
href="https://github.com/infisical/infisical"
target="_blank"
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
className="w-full bg-mineshaft-700 py-3 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="sm"
onClick={() => {}}
rightIcon={<FontAwesomeIcon icon={faArrowRight} className="pl-2" />}
>
Share your own Secret
</Button>
</a>
</div>
)}
<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>
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>
<AddShareSecretModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
isPublic
inModal
/>
</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">
© 2024{" "}
Made with by{" "}
<a className="text-primary" href="https://infisical.com">
Infisical
</a>
. All rights reserved.
<br />
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
</p>

View File

@ -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>
);
};

View File

@ -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>
</>
);
};

View File

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

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

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

View File

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