mirror of
https://github.com/Infisical/infisical.git
synced 2025-03-21 10:54:38 +00:00
Compare commits
71 Commits
org-role-c
...
daniel/tls
Author | SHA1 | Date | |
---|---|---|---|
6e1dc7375c | |||
164627139e | |||
021dbf3558 | |||
7e9389cb26 | |||
eda57881ec | |||
553d51e5b3 | |||
16e0a441ae | |||
d6c0941fa9 | |||
7cbd254f06 | |||
4b83b92725 | |||
fe72f034c1 | |||
6803553b21 | |||
1c8299054a | |||
98b6373d6a | |||
1d97921c7c | |||
0d4164ea81 | |||
79bd8613d3 | |||
8deea21a83 | |||
3b3c2be933 | |||
c041e44399 | |||
c1aeb04174 | |||
3f3c0aab0f | |||
b740e8c900 | |||
4416b11094 | |||
d8169a866d | |||
7239158e7f | |||
fefe2d1de1 | |||
3f3e41282d | |||
c14f94177a | |||
ceb741955d | |||
f5bc4e1b5f | |||
06900b9c99 | |||
d71cb96adf | |||
61ebec25b3 | |||
57320c51fb | |||
4aa9cd0f72 | |||
ea39ef9269 | |||
15749a1f52 | |||
9e9aff129e | |||
4ac487c974 | |||
2e50072caa | |||
2bd170df7d | |||
938a7b7e72 | |||
af864b456b | |||
a30e3874cd | |||
de886f8dd0 | |||
b3db29ac37 | |||
ce1db38afd | |||
0fa6b7a08a | |||
29c5bf5491 | |||
4d711ae149 | |||
9dd675ff98 | |||
8fd3e50d04 | |||
391ed0723e | |||
84af8e708e | |||
b39b5bd1a1 | |||
b3d9d91b52 | |||
5ad4061881 | |||
f29862eaf2 | |||
7cb174b644 | |||
bf00d16c80 | |||
e30a0fe8be | |||
6e6f0252ae | |||
2348df7a4d | |||
962cf67dfb | |||
32627c20c4 | |||
c50f8fd78c | |||
1cb4dc9e84 | |||
08d7dead8c | |||
a30e06e392 | |||
5cd0f665fa |
backend/src
db
migrations
schemas
ee
lib/api-docs
server/routes
services
certificate
project-role
project
secret-sharing
cli/packages/cmd
docs
api-reference/endpoints
changelog
documentation/guides
mint.jsonsdks/languages
self-hosting/configuration
frontend/src
hooks/api
pages
views
Login/components
Org
IdentityPage
MembersPage
MembersPage.tsx
components
OrgIdentityTab/components/IdentitySection
OrgMembersTab/components/OrgMembersSection
OrgRoleTabSection
RolePage
RolePage.tsx
components
Types
UserPage
Project
MembersPage
RolePage
RolePage.tsx
components
RoleDetailsSection.tsxRoleModal.tsx
index.tsxRolePermissionsSection
ProjectRoleModifySection.utils.tsRolePermissionRow.tsxRolePermissionSecretsRow.tsxRolePermissionsSection.tsxindex.tsx
index.tsxTypes
SecretMainPage/components/SecretListView
SecretOverviewPage/components
ShareSecretPage/components
AddShareSecretForm.tsxAddShareSecretModal.tsxShareSecretSection.tsxShareSecretsRow.tsxShareSecretsTable.tsxViewAndCopySharedSecret.tsx
ShareSecretPublicPage
ViewSecretPublicPage
@ -0,0 +1,39 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (await knex.schema.hasTable(TableName.SecretSharing)) {
|
||||||
|
const doesNameExist = await knex.schema.hasColumn(TableName.SecretSharing, "name");
|
||||||
|
if (!doesNameExist) {
|
||||||
|
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
|
||||||
|
t.string("name").nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const doesLastViewedAtExist = await knex.schema.hasColumn(TableName.SecretSharing, "lastViewedAt");
|
||||||
|
if (!doesLastViewedAtExist) {
|
||||||
|
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
|
||||||
|
t.timestamp("lastViewedAt").nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
if (await knex.schema.hasTable(TableName.SecretSharing)) {
|
||||||
|
const doesNameExist = await knex.schema.hasColumn(TableName.SecretSharing, "name");
|
||||||
|
if (doesNameExist) {
|
||||||
|
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
|
||||||
|
t.dropColumn("name");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const doesLastViewedAtExist = await knex.schema.hasColumn(TableName.SecretSharing, "lastViewedAt");
|
||||||
|
if (doesLastViewedAtExist) {
|
||||||
|
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
|
||||||
|
t.dropColumn("lastViewedAt");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -5,8 +5,6 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { EnforcementLevel } from "@app/lib/types";
|
|
||||||
|
|
||||||
import { TImmutableDBKeys } from "./models";
|
import { TImmutableDBKeys } from "./models";
|
||||||
|
|
||||||
export const AccessApprovalPoliciesSchema = z.object({
|
export const AccessApprovalPoliciesSchema = z.object({
|
||||||
@ -17,7 +15,7 @@ export const AccessApprovalPoliciesSchema = z.object({
|
|||||||
envId: z.string().uuid(),
|
envId: z.string().uuid(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard)
|
enforcementLevel: z.string().default("hard")
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;
|
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;
|
||||||
|
@ -13,9 +13,9 @@ export const KmsKeysSchema = z.object({
|
|||||||
isDisabled: z.boolean().default(false).nullable().optional(),
|
isDisabled: z.boolean().default(false).nullable().optional(),
|
||||||
isReserved: z.boolean().default(true).nullable().optional(),
|
isReserved: z.boolean().default(true).nullable().optional(),
|
||||||
orgId: z.string().uuid(),
|
orgId: z.string().uuid(),
|
||||||
|
slug: z.string(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date()
|
||||||
slug: z.string()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TKmsKeys = z.infer<typeof KmsKeysSchema>;
|
export type TKmsKeys = z.infer<typeof KmsKeysSchema>;
|
||||||
|
@ -18,7 +18,7 @@ export const OrgMembershipsSchema = z.object({
|
|||||||
orgId: z.string().uuid(),
|
orgId: z.string().uuid(),
|
||||||
roleId: z.string().uuid().nullable().optional(),
|
roleId: z.string().uuid().nullable().optional(),
|
||||||
projectFavorites: z.string().array().nullable().optional(),
|
projectFavorites: z.string().array().nullable().optional(),
|
||||||
isActive: z.boolean()
|
isActive: z.boolean().default(true)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TOrgMemberships = z.infer<typeof OrgMembershipsSchema>;
|
export type TOrgMemberships = z.infer<typeof OrgMembershipsSchema>;
|
||||||
|
@ -15,12 +15,12 @@ export const SecretApprovalRequestsSchema = z.object({
|
|||||||
conflicts: z.unknown().nullable().optional(),
|
conflicts: z.unknown().nullable().optional(),
|
||||||
slug: z.string(),
|
slug: z.string(),
|
||||||
folderId: z.string().uuid(),
|
folderId: z.string().uuid(),
|
||||||
bypassReason: z.string().nullable().optional(),
|
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
isReplicated: z.boolean().nullable().optional(),
|
isReplicated: z.boolean().nullable().optional(),
|
||||||
committerUserId: z.string().uuid(),
|
committerUserId: z.string().uuid(),
|
||||||
statusChangedByUserId: z.string().uuid().nullable().optional()
|
statusChangedByUserId: z.string().uuid().nullable().optional(),
|
||||||
|
bypassReason: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSecretApprovalRequests = z.infer<typeof SecretApprovalRequestsSchema>;
|
export type TSecretApprovalRequests = z.infer<typeof SecretApprovalRequestsSchema>;
|
||||||
|
@ -5,8 +5,6 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { SecretSharingAccessType } from "@app/lib/types";
|
|
||||||
|
|
||||||
import { TImmutableDBKeys } from "./models";
|
import { TImmutableDBKeys } from "./models";
|
||||||
|
|
||||||
export const SecretSharingSchema = z.object({
|
export const SecretSharingSchema = z.object({
|
||||||
@ -18,10 +16,12 @@ export const SecretSharingSchema = z.object({
|
|||||||
expiresAt: z.date(),
|
expiresAt: z.date(),
|
||||||
userId: z.string().uuid().nullable().optional(),
|
userId: z.string().uuid().nullable().optional(),
|
||||||
orgId: z.string().uuid().nullable().optional(),
|
orgId: z.string().uuid().nullable().optional(),
|
||||||
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization),
|
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
expiresAfterViews: z.number().nullable().optional()
|
expiresAfterViews: z.number().nullable().optional(),
|
||||||
|
accessType: z.string().default("anyone"),
|
||||||
|
name: z.string().nullable().optional(),
|
||||||
|
lastViewedAt: z.date().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSecretSharing = z.infer<typeof SecretSharingSchema>;
|
export type TSecretSharing = z.infer<typeof SecretSharingSchema>;
|
||||||
|
@ -107,7 +107,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
|
|||||||
}),
|
}),
|
||||||
name: z.string().trim().optional(),
|
name: z.string().trim().optional(),
|
||||||
description: z.string().trim().optional(),
|
description: z.string().trim().optional(),
|
||||||
permissions: z.any().array()
|
permissions: z.any().array().optional()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
@ -101,7 +101,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
|
|||||||
message: "Slug must be a valid"
|
message: "Slug must be a valid"
|
||||||
}),
|
}),
|
||||||
name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name),
|
name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name),
|
||||||
permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions)
|
permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
@ -120,7 +120,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
|
|||||||
roleId: req.params.roleId,
|
roleId: req.params.roleId,
|
||||||
data: {
|
data: {
|
||||||
...req.body,
|
...req.body,
|
||||||
permissions: JSON.stringify(packRules(req.body.permissions))
|
permissions: req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { role };
|
return { role };
|
||||||
|
@ -336,31 +336,36 @@ export const removeUsersFromGroupByUserIds = async ({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: this part can be optimized
|
const promises: Array<Promise<void>> = [];
|
||||||
for await (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
const t = await userGroupMembershipDAL.filterProjectsByUserMembership(userId, group.id, projectIds, tx);
|
promises.push(
|
||||||
const projectsToDeleteKeyFor = projectIds.filter((p) => !t.has(p));
|
(async () => {
|
||||||
|
const t = await userGroupMembershipDAL.filterProjectsByUserMembership(userId, group.id, projectIds, tx);
|
||||||
|
const projectsToDeleteKeyFor = projectIds.filter((p) => !t.has(p));
|
||||||
|
|
||||||
if (projectsToDeleteKeyFor.length) {
|
if (projectsToDeleteKeyFor.length) {
|
||||||
await projectKeyDAL.delete(
|
await projectKeyDAL.delete(
|
||||||
{
|
{
|
||||||
receiverId: userId,
|
receiverId: userId,
|
||||||
$in: {
|
$in: {
|
||||||
projectId: projectsToDeleteKeyFor
|
projectId: projectsToDeleteKeyFor
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await userGroupMembershipDAL.delete(
|
await userGroupMembershipDAL.delete(
|
||||||
{
|
{
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
userId
|
userId
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
|
);
|
||||||
|
})()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (membersToRemoveFromGroupPending.length) {
|
if (membersToRemoveFromGroupPending.length) {
|
||||||
|
@ -425,6 +425,21 @@ export const PROJECTS = {
|
|||||||
},
|
},
|
||||||
LIST_INTEGRATION_AUTHORIZATION: {
|
LIST_INTEGRATION_AUTHORIZATION: {
|
||||||
workspaceId: "The ID of the project to list integration auths for."
|
workspaceId: "The ID of the project to list integration auths for."
|
||||||
|
},
|
||||||
|
LIST_CAS: {
|
||||||
|
slug: "The slug of the project to list CAs for.",
|
||||||
|
status: "The status of the CA to filter by.",
|
||||||
|
friendlyName: "The friendly name of the CA to filter by.",
|
||||||
|
commonName: "The common name of the CA to filter by.",
|
||||||
|
offset: "The offset to start from. If you enter 10, it will start from the 10th CA.",
|
||||||
|
limit: "The number of CAs to return."
|
||||||
|
},
|
||||||
|
LIST_CERTIFICATES: {
|
||||||
|
slug: "The slug of the project to list certificates for.",
|
||||||
|
friendlyName: "The friendly name of the certificate to filter by.",
|
||||||
|
commonName: "The common name of the certificate to filter by.",
|
||||||
|
offset: "The offset to start from. If you enter 10, it will start from the 10th certificate.",
|
||||||
|
limit: "The number of certificates to return."
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
@ -19,21 +19,31 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
|||||||
rateLimit: readLimit
|
rateLimit: readLimit
|
||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
|
querystring: z.object({
|
||||||
|
offset: z.coerce.number().min(0).max(100).default(0),
|
||||||
|
limit: z.coerce.number().min(1).max(100).default(25)
|
||||||
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.array(SecretSharingSchema)
|
200: z.object({
|
||||||
|
secrets: z.array(SecretSharingSchema),
|
||||||
|
totalCount: z.number()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const sharedSecrets = await req.server.services.secretSharing.getSharedSecrets({
|
const { secrets, totalCount } = await req.server.services.secretSharing.getSharedSecrets({
|
||||||
actor: req.permission.type,
|
actor: req.permission.type,
|
||||||
actorId: req.permission.id,
|
actorId: req.permission.id,
|
||||||
orgId: req.permission.orgId,
|
|
||||||
actorAuthMethod: req.permission.authMethod,
|
actorAuthMethod: req.permission.authMethod,
|
||||||
actorOrgId: req.permission.orgId
|
actorOrgId: req.permission.orgId,
|
||||||
|
...req.query
|
||||||
});
|
});
|
||||||
|
|
||||||
return sharedSecrets;
|
return {
|
||||||
|
secrets,
|
||||||
|
totalCount
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -48,7 +58,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
|||||||
id: z.string().uuid()
|
id: z.string().uuid()
|
||||||
}),
|
}),
|
||||||
querystring: z.object({
|
querystring: z.object({
|
||||||
hashedHex: z.string()
|
hashedHex: z.string().min(1)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: SecretSharingSchema.pick({
|
200: SecretSharingSchema.pick({
|
||||||
@ -64,11 +74,11 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretByIdAndHashedHex(
|
const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretById({
|
||||||
req.params.id,
|
sharedSecretId: req.params.id,
|
||||||
req.query.hashedHex,
|
hashedHex: req.query.hashedHex,
|
||||||
req.permission?.orgId
|
orgId: req.permission?.orgId
|
||||||
);
|
});
|
||||||
if (!sharedSecret) return undefined;
|
if (!sharedSecret) return undefined;
|
||||||
return {
|
return {
|
||||||
encryptedValue: sharedSecret.encryptedValue,
|
encryptedValue: sharedSecret.encryptedValue,
|
||||||
@ -91,11 +101,11 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
|||||||
schema: {
|
schema: {
|
||||||
body: z.object({
|
body: z.object({
|
||||||
encryptedValue: z.string(),
|
encryptedValue: z.string(),
|
||||||
|
hashedHex: z.string(),
|
||||||
iv: z.string(),
|
iv: z.string(),
|
||||||
tag: z.string(),
|
tag: z.string(),
|
||||||
hashedHex: z.string(),
|
|
||||||
expiresAt: z.string(),
|
expiresAt: z.string(),
|
||||||
expiresAfterViews: z.number()
|
expiresAfterViews: z.number().min(1).optional()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
@ -104,14 +114,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body;
|
|
||||||
const sharedSecret = await req.server.services.secretSharing.createPublicSharedSecret({
|
const sharedSecret = await req.server.services.secretSharing.createPublicSharedSecret({
|
||||||
encryptedValue,
|
...req.body,
|
||||||
iv,
|
|
||||||
tag,
|
|
||||||
hashedHex,
|
|
||||||
expiresAt: new Date(expiresAt),
|
|
||||||
expiresAfterViews,
|
|
||||||
accessType: SecretSharingAccessType.Anyone
|
accessType: SecretSharingAccessType.Anyone
|
||||||
});
|
});
|
||||||
return { id: sharedSecret.id };
|
return { id: sharedSecret.id };
|
||||||
@ -126,12 +130,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
|||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
body: z.object({
|
body: z.object({
|
||||||
|
name: z.string().max(50).optional(),
|
||||||
encryptedValue: z.string(),
|
encryptedValue: z.string(),
|
||||||
|
hashedHex: z.string(),
|
||||||
iv: z.string(),
|
iv: z.string(),
|
||||||
tag: z.string(),
|
tag: z.string(),
|
||||||
hashedHex: z.string(),
|
|
||||||
expiresAt: z.string(),
|
expiresAt: z.string(),
|
||||||
expiresAfterViews: z.number(),
|
expiresAfterViews: z.number().min(1).optional(),
|
||||||
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization)
|
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
@ -142,20 +147,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
|||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body;
|
|
||||||
const sharedSecret = await req.server.services.secretSharing.createSharedSecret({
|
const sharedSecret = await req.server.services.secretSharing.createSharedSecret({
|
||||||
actor: req.permission.type,
|
actor: req.permission.type,
|
||||||
actorId: req.permission.id,
|
actorId: req.permission.id,
|
||||||
orgId: req.permission.orgId,
|
orgId: req.permission.orgId,
|
||||||
actorAuthMethod: req.permission.authMethod,
|
actorAuthMethod: req.permission.authMethod,
|
||||||
actorOrgId: req.permission.orgId,
|
actorOrgId: req.permission.orgId,
|
||||||
encryptedValue,
|
...req.body
|
||||||
iv,
|
|
||||||
tag,
|
|
||||||
hashedHex,
|
|
||||||
expiresAt: new Date(expiresAt),
|
|
||||||
expiresAfterViews,
|
|
||||||
accessType: req.body.accessType
|
|
||||||
});
|
});
|
||||||
return { id: sharedSecret.id };
|
return { id: sharedSecret.id };
|
||||||
}
|
}
|
||||||
|
@ -317,10 +317,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
slug: slugSchema.describe("The slug of the project to list CAs.")
|
slug: slugSchema.describe(PROJECTS.LIST_CAS.slug)
|
||||||
}),
|
}),
|
||||||
querystring: z.object({
|
querystring: z.object({
|
||||||
status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional()
|
status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional().describe(PROJECTS.LIST_CAS.status),
|
||||||
|
friendlyName: z.string().optional().describe(PROJECTS.LIST_CAS.friendlyName),
|
||||||
|
commonName: z.string().optional().describe(PROJECTS.LIST_CAS.commonName),
|
||||||
|
offset: z.coerce.number().min(0).max(100).default(0).describe(PROJECTS.LIST_CAS.offset),
|
||||||
|
limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CAS.limit)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
@ -336,11 +340,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
orgId: req.permission.orgId,
|
orgId: req.permission.orgId,
|
||||||
type: ProjectFilterType.SLUG
|
type: ProjectFilterType.SLUG
|
||||||
},
|
},
|
||||||
status: req.query.status,
|
|
||||||
actorId: req.permission.id,
|
actorId: req.permission.id,
|
||||||
actorOrgId: req.permission.orgId,
|
actorOrgId: req.permission.orgId,
|
||||||
actorAuthMethod: req.permission.authMethod,
|
actorAuthMethod: req.permission.authMethod,
|
||||||
actor: req.permission.type
|
actor: req.permission.type,
|
||||||
|
...req.query
|
||||||
});
|
});
|
||||||
return { cas };
|
return { cas };
|
||||||
}
|
}
|
||||||
@ -354,11 +358,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
slug: slugSchema.describe("The slug of the project to list certificates.")
|
slug: slugSchema.describe(PROJECTS.LIST_CERTIFICATES.slug)
|
||||||
}),
|
}),
|
||||||
querystring: z.object({
|
querystring: z.object({
|
||||||
offset: z.coerce.number().min(0).max(100).default(0),
|
friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName),
|
||||||
limit: z.coerce.number().min(1).max(100).default(25)
|
commonName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.commonName),
|
||||||
|
offset: z.coerce.number().min(0).max(100).default(0).describe(PROJECTS.LIST_CERTIFICATES.offset),
|
||||||
|
limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CERTIFICATES.limit)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
@ -8,19 +8,35 @@ export type TCertificateDALFactory = ReturnType<typeof certificateDALFactory>;
|
|||||||
export const certificateDALFactory = (db: TDbClient) => {
|
export const certificateDALFactory = (db: TDbClient) => {
|
||||||
const certificateOrm = ormify(db, TableName.Certificate);
|
const certificateOrm = ormify(db, TableName.Certificate);
|
||||||
|
|
||||||
const countCertificatesInProject = async (projectId: string) => {
|
const countCertificatesInProject = async ({
|
||||||
|
projectId,
|
||||||
|
friendlyName,
|
||||||
|
commonName
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
commonName?: string;
|
||||||
|
}) => {
|
||||||
try {
|
try {
|
||||||
interface CountResult {
|
interface CountResult {
|
||||||
count: string;
|
count: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = await db
|
let query = db
|
||||||
.replicaNode()(TableName.Certificate)
|
.replicaNode()(TableName.Certificate)
|
||||||
.join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`)
|
.join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`)
|
||||||
.join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`)
|
.join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`)
|
||||||
.where(`${TableName.Project}.id`, projectId)
|
.where(`${TableName.Project}.id`, projectId);
|
||||||
.count("*")
|
|
||||||
.first();
|
if (friendlyName) {
|
||||||
|
query = query.andWhere(`${TableName.Certificate}.friendlyName`, friendlyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commonName) {
|
||||||
|
query = query.andWhere(`${TableName.Certificate}.commonName`, commonName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await query.count("*").first();
|
||||||
|
|
||||||
return parseInt((count as unknown as CountResult).count || "0", 10);
|
return parseInt((count as unknown as CountResult).count || "0", 10);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -162,12 +162,19 @@ export const projectRoleServiceFactory = ({
|
|||||||
actorOrgId
|
actorOrgId
|
||||||
);
|
);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Role);
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Role);
|
||||||
|
|
||||||
if (data?.slug) {
|
if (data?.slug) {
|
||||||
const existingRole = await projectRoleDAL.findOne({ slug: data.slug, projectId });
|
const existingRole = await projectRoleDAL.findOne({ slug: data.slug, projectId });
|
||||||
if (existingRole && existingRole.id !== roleId)
|
if (existingRole && existingRole.id !== roleId)
|
||||||
throw new BadRequestError({ name: "Update Role", message: "Duplicate role" });
|
throw new BadRequestError({ name: "Update Role", message: "Duplicate role" });
|
||||||
}
|
}
|
||||||
const [updatedRole] = await projectRoleDAL.update({ id: roleId, projectId }, data);
|
const [updatedRole] = await projectRoleDAL.update(
|
||||||
|
{ id: roleId, projectId },
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
permissions: data.permissions ? data.permissions : undefined
|
||||||
|
}
|
||||||
|
);
|
||||||
if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
|
if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
|
||||||
return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) };
|
return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) };
|
||||||
};
|
};
|
||||||
|
@ -575,6 +575,10 @@ export const projectServiceFactory = ({
|
|||||||
*/
|
*/
|
||||||
const listProjectCas = async ({
|
const listProjectCas = async ({
|
||||||
status,
|
status,
|
||||||
|
friendlyName,
|
||||||
|
commonName,
|
||||||
|
limit = 25,
|
||||||
|
offset = 0,
|
||||||
actorId,
|
actorId,
|
||||||
actorOrgId,
|
actorOrgId,
|
||||||
actorAuthMethod,
|
actorAuthMethod,
|
||||||
@ -596,10 +600,15 @@ export const projectServiceFactory = ({
|
|||||||
ProjectPermissionSub.CertificateAuthorities
|
ProjectPermissionSub.CertificateAuthorities
|
||||||
);
|
);
|
||||||
|
|
||||||
const cas = await certificateAuthorityDAL.find({
|
const cas = await certificateAuthorityDAL.find(
|
||||||
projectId: project.id,
|
{
|
||||||
...(status && { status })
|
projectId: project.id,
|
||||||
});
|
...(status && { status }),
|
||||||
|
...(friendlyName && { friendlyName }),
|
||||||
|
...(commonName && { commonName })
|
||||||
|
},
|
||||||
|
{ offset, limit, sort: [["updatedAt", "desc"]] }
|
||||||
|
);
|
||||||
|
|
||||||
return cas;
|
return cas;
|
||||||
};
|
};
|
||||||
@ -608,8 +617,10 @@ export const projectServiceFactory = ({
|
|||||||
* Return list of certificates for project
|
* Return list of certificates for project
|
||||||
*/
|
*/
|
||||||
const listProjectCertificates = async ({
|
const listProjectCertificates = async ({
|
||||||
offset,
|
limit = 25,
|
||||||
limit,
|
offset = 0,
|
||||||
|
friendlyName,
|
||||||
|
commonName,
|
||||||
actorId,
|
actorId,
|
||||||
actorOrgId,
|
actorOrgId,
|
||||||
actorAuthMethod,
|
actorAuthMethod,
|
||||||
@ -634,12 +645,18 @@ export const projectServiceFactory = ({
|
|||||||
{
|
{
|
||||||
$in: {
|
$in: {
|
||||||
caId: cas.map((ca) => ca.id)
|
caId: cas.map((ca) => ca.id)
|
||||||
}
|
},
|
||||||
|
...(friendlyName && { friendlyName }),
|
||||||
|
...(commonName && { commonName })
|
||||||
},
|
},
|
||||||
{ offset, limit, sort: [["updatedAt", "desc"]] }
|
{ offset, limit, sort: [["updatedAt", "desc"]] }
|
||||||
);
|
);
|
||||||
|
|
||||||
const count = await certificateDAL.countCertificatesInProject(project.id);
|
const count = await certificateDAL.countCertificatesInProject({
|
||||||
|
projectId: project.id,
|
||||||
|
friendlyName,
|
||||||
|
commonName
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
certificates,
|
certificates,
|
||||||
|
@ -89,6 +89,10 @@ export type AddUserToWsDTO = {
|
|||||||
|
|
||||||
export type TListProjectCasDTO = {
|
export type TListProjectCasDTO = {
|
||||||
status?: CaStatus;
|
status?: CaStatus;
|
||||||
|
friendlyName?: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
commonName?: string;
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
@ -96,4 +100,6 @@ export type TListProjectCertsDTO = {
|
|||||||
filter: Filter;
|
filter: Filter;
|
||||||
offset: number;
|
offset: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
friendlyName?: string;
|
||||||
|
commonName?: string;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
@ -10,6 +10,25 @@ export type TSecretSharingDALFactory = ReturnType<typeof secretSharingDALFactory
|
|||||||
export const secretSharingDALFactory = (db: TDbClient) => {
|
export const secretSharingDALFactory = (db: TDbClient) => {
|
||||||
const sharedSecretOrm = ormify(db, TableName.SecretSharing);
|
const sharedSecretOrm = ormify(db, TableName.SecretSharing);
|
||||||
|
|
||||||
|
const countAllUserOrgSharedSecrets = async ({ orgId, userId }: { orgId: string; userId: string }) => {
|
||||||
|
try {
|
||||||
|
interface CountResult {
|
||||||
|
count: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await db
|
||||||
|
.replicaNode()(TableName.SecretSharing)
|
||||||
|
.where(`${TableName.SecretSharing}.orgId`, orgId)
|
||||||
|
.where(`${TableName.SecretSharing}.userId`, userId)
|
||||||
|
.count("*")
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return parseInt((count as unknown as CountResult).count || "0", 10);
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Count all user-org shared secrets" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const pruneExpiredSharedSecrets = async (tx?: Knex) => {
|
const pruneExpiredSharedSecrets = async (tx?: Knex) => {
|
||||||
try {
|
try {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@ -19,8 +38,7 @@ export const secretSharingDALFactory = (db: TDbClient) => {
|
|||||||
.update({
|
.update({
|
||||||
encryptedValue: "",
|
encryptedValue: "",
|
||||||
tag: "",
|
tag: "",
|
||||||
iv: "",
|
iv: ""
|
||||||
hashedHex: ""
|
|
||||||
});
|
});
|
||||||
return docs;
|
return docs;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -50,8 +68,7 @@ export const secretSharingDALFactory = (db: TDbClient) => {
|
|||||||
await sharedSecretOrm.updateById(id, {
|
await sharedSecretOrm.updateById(id, {
|
||||||
encryptedValue: "",
|
encryptedValue: "",
|
||||||
iv: "",
|
iv: "",
|
||||||
tag: "",
|
tag: ""
|
||||||
hashedHex: ""
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new DatabaseError({
|
throw new DatabaseError({
|
||||||
@ -63,6 +80,7 @@ export const secretSharingDALFactory = (db: TDbClient) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...sharedSecretOrm,
|
...sharedSecretOrm,
|
||||||
|
countAllUserOrgSharedSecrets,
|
||||||
pruneExpiredSharedSecrets,
|
pruneExpiredSharedSecrets,
|
||||||
softDeleteById,
|
softDeleteById,
|
||||||
findActiveSharedSecrets
|
findActiveSharedSecrets
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||||
import { SecretSharingAccessType } from "@app/lib/types";
|
import { SecretSharingAccessType } from "@app/lib/types";
|
||||||
|
|
||||||
import { TOrgDALFactory } from "../org/org-dal";
|
import { TOrgDALFactory } from "../org/org-dal";
|
||||||
@ -8,7 +8,8 @@ import {
|
|||||||
TCreatePublicSharedSecretDTO,
|
TCreatePublicSharedSecretDTO,
|
||||||
TCreateSharedSecretDTO,
|
TCreateSharedSecretDTO,
|
||||||
TDeleteSharedSecretDTO,
|
TDeleteSharedSecretDTO,
|
||||||
TSharedSecretPermission
|
TGetActiveSharedSecretByIdDTO,
|
||||||
|
TGetSharedSecretsDTO
|
||||||
} from "./secret-sharing-types";
|
} from "./secret-sharing-types";
|
||||||
|
|
||||||
type TSecretSharingServiceFactoryDep = {
|
type TSecretSharingServiceFactoryDep = {
|
||||||
@ -24,21 +25,21 @@ export const secretSharingServiceFactory = ({
|
|||||||
secretSharingDAL,
|
secretSharingDAL,
|
||||||
orgDAL
|
orgDAL
|
||||||
}: TSecretSharingServiceFactoryDep) => {
|
}: TSecretSharingServiceFactoryDep) => {
|
||||||
const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => {
|
const createSharedSecret = async ({
|
||||||
const {
|
actor,
|
||||||
actor,
|
actorId,
|
||||||
actorId,
|
orgId,
|
||||||
orgId,
|
actorAuthMethod,
|
||||||
actorAuthMethod,
|
actorOrgId,
|
||||||
actorOrgId,
|
encryptedValue,
|
||||||
encryptedValue,
|
hashedHex,
|
||||||
iv,
|
iv,
|
||||||
tag,
|
tag,
|
||||||
accessType,
|
name,
|
||||||
hashedHex,
|
accessType,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
expiresAfterViews
|
expiresAfterViews
|
||||||
} = createSharedSecretInput;
|
}: TCreateSharedSecretDTO) => {
|
||||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||||
if (!permission) throw new UnauthorizedError({ name: "User not in org" });
|
if (!permission) throw new UnauthorizedError({ name: "User not in org" });
|
||||||
|
|
||||||
@ -60,21 +61,30 @@ export const secretSharingServiceFactory = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newSharedSecret = await secretSharingDAL.create({
|
const newSharedSecret = await secretSharingDAL.create({
|
||||||
|
name,
|
||||||
encryptedValue,
|
encryptedValue,
|
||||||
|
hashedHex,
|
||||||
iv,
|
iv,
|
||||||
tag,
|
tag,
|
||||||
hashedHex,
|
expiresAt: new Date(expiresAt),
|
||||||
expiresAt,
|
|
||||||
expiresAfterViews,
|
expiresAfterViews,
|
||||||
userId: actorId,
|
userId: actorId,
|
||||||
orgId,
|
orgId,
|
||||||
accessType
|
accessType
|
||||||
});
|
});
|
||||||
|
|
||||||
return { id: newSharedSecret.id };
|
return { id: newSharedSecret.id };
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPublicSharedSecret = async (createSharedSecretInput: TCreatePublicSharedSecretDTO) => {
|
const createPublicSharedSecret = async ({
|
||||||
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews, accessType } = createSharedSecretInput;
|
encryptedValue,
|
||||||
|
hashedHex,
|
||||||
|
iv,
|
||||||
|
tag,
|
||||||
|
expiresAt,
|
||||||
|
expiresAfterViews,
|
||||||
|
accessType
|
||||||
|
}: TCreatePublicSharedSecretDTO) => {
|
||||||
if (new Date(expiresAt) < new Date()) {
|
if (new Date(expiresAt) < new Date()) {
|
||||||
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
|
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
|
||||||
}
|
}
|
||||||
@ -94,53 +104,103 @@ export const secretSharingServiceFactory = ({
|
|||||||
|
|
||||||
const newSharedSecret = await secretSharingDAL.create({
|
const newSharedSecret = await secretSharingDAL.create({
|
||||||
encryptedValue,
|
encryptedValue,
|
||||||
|
hashedHex,
|
||||||
iv,
|
iv,
|
||||||
tag,
|
tag,
|
||||||
hashedHex,
|
expiresAt: new Date(expiresAt),
|
||||||
expiresAt,
|
|
||||||
expiresAfterViews,
|
expiresAfterViews,
|
||||||
accessType
|
accessType
|
||||||
});
|
});
|
||||||
return { id: newSharedSecret.id };
|
return { id: newSharedSecret.id };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSharedSecrets = async (getSharedSecretsInput: TSharedSecretPermission) => {
|
const getSharedSecrets = async ({
|
||||||
const { actor, actorId, orgId, actorAuthMethod, actorOrgId } = getSharedSecretsInput;
|
actor,
|
||||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
offset,
|
||||||
|
limit
|
||||||
|
}: TGetSharedSecretsDTO) => {
|
||||||
|
if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" });
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getOrgPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
if (!permission) throw new UnauthorizedError({ name: "User not in org" });
|
if (!permission) throw new UnauthorizedError({ name: "User not in org" });
|
||||||
const userSharedSecrets = await secretSharingDAL.findActiveSharedSecrets({ userId: actorId, orgId });
|
|
||||||
return userSharedSecrets;
|
const secrets = await secretSharingDAL.find(
|
||||||
|
{
|
||||||
|
userId: actorId,
|
||||||
|
orgId: actorOrgId
|
||||||
|
},
|
||||||
|
{ offset, limit, sort: [["createdAt", "desc"]] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const count = await secretSharingDAL.countAllUserOrgSharedSecrets({
|
||||||
|
orgId: actorOrgId,
|
||||||
|
userId: actorId
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
secrets,
|
||||||
|
totalCount: count
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string, orgId?: string) => {
|
const getActiveSharedSecretById = async ({ sharedSecretId, hashedHex, orgId }: TGetActiveSharedSecretByIdDTO) => {
|
||||||
const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex });
|
const sharedSecret = await secretSharingDAL.findOne({
|
||||||
if (!sharedSecret) return;
|
id: sharedSecretId,
|
||||||
|
hashedHex
|
||||||
|
});
|
||||||
|
if (!sharedSecret)
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: "Shared secret not found"
|
||||||
|
});
|
||||||
|
|
||||||
|
const { accessType, expiresAt, expiresAfterViews } = sharedSecret;
|
||||||
|
|
||||||
const orgName = sharedSecret.orgId ? (await orgDAL.findOrgById(sharedSecret.orgId))?.name : "";
|
const orgName = sharedSecret.orgId ? (await orgDAL.findOrgById(sharedSecret.orgId))?.name : "";
|
||||||
// Support organization level access for secret sharing
|
|
||||||
if (sharedSecret.accessType === SecretSharingAccessType.Organization && orgId !== sharedSecret.orgId) {
|
if (accessType === SecretSharingAccessType.Organization && orgId !== sharedSecret.orgId)
|
||||||
return {
|
throw new UnauthorizedError();
|
||||||
...sharedSecret,
|
|
||||||
encryptedValue: "",
|
if (expiresAt !== null && expiresAt < new Date()) {
|
||||||
iv: "",
|
// check lifetime expiry
|
||||||
tag: "",
|
await secretSharingDAL.softDeleteById(sharedSecretId);
|
||||||
orgName
|
throw new ForbiddenRequestError({
|
||||||
};
|
message: "Access denied: Secret has expired by lifetime"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) {
|
|
||||||
return;
|
if (expiresAfterViews !== null && expiresAfterViews === 0) {
|
||||||
|
// check view count expiry
|
||||||
|
await secretSharingDAL.softDeleteById(sharedSecretId);
|
||||||
|
throw new ForbiddenRequestError({
|
||||||
|
message: "Access denied: Secret has expired by view count"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (sharedSecret.expiresAfterViews != null && sharedSecret.expiresAfterViews >= 0) {
|
|
||||||
if (sharedSecret.expiresAfterViews === 0) {
|
if (expiresAfterViews) {
|
||||||
await secretSharingDAL.softDeleteById(sharedSecretId);
|
// decrement view count if view count expiry set
|
||||||
return;
|
|
||||||
}
|
|
||||||
await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } });
|
await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } });
|
||||||
}
|
}
|
||||||
if (sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId) {
|
|
||||||
return { ...sharedSecret, orgName };
|
await secretSharingDAL.updateById(sharedSecretId, {
|
||||||
}
|
lastViewedAt: new Date()
|
||||||
return { ...sharedSecret, orgName: undefined };
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...sharedSecret,
|
||||||
|
orgName:
|
||||||
|
sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId
|
||||||
|
? orgName
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => {
|
const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => {
|
||||||
@ -156,6 +216,6 @@ export const secretSharingServiceFactory = ({
|
|||||||
createPublicSharedSecret,
|
createPublicSharedSecret,
|
||||||
getSharedSecrets,
|
getSharedSecrets,
|
||||||
deleteSharedSecretById,
|
deleteSharedSecretById,
|
||||||
getActiveSharedSecretByIdAndHashedHex
|
getActiveSharedSecretById
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,12 @@
|
|||||||
import { SecretSharingAccessType } from "@app/lib/types";
|
import { SecretSharingAccessType, TGenericPermission } from "@app/lib/types";
|
||||||
|
|
||||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||||
|
|
||||||
|
export type TGetSharedSecretsDTO = {
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
} & TGenericPermission;
|
||||||
|
|
||||||
export type TSharedSecretPermission = {
|
export type TSharedSecretPermission = {
|
||||||
actor: ActorType;
|
actor: ActorType;
|
||||||
actorId: string;
|
actorId: string;
|
||||||
@ -9,18 +14,25 @@ export type TSharedSecretPermission = {
|
|||||||
actorOrgId: string;
|
actorOrgId: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
accessType?: SecretSharingAccessType;
|
accessType?: SecretSharingAccessType;
|
||||||
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TCreatePublicSharedSecretDTO = {
|
export type TCreatePublicSharedSecretDTO = {
|
||||||
encryptedValue: string;
|
encryptedValue: string;
|
||||||
|
hashedHex: string;
|
||||||
iv: string;
|
iv: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
hashedHex: string;
|
expiresAt: string;
|
||||||
expiresAt: Date;
|
expiresAfterViews?: number;
|
||||||
expiresAfterViews: number;
|
|
||||||
accessType: SecretSharingAccessType;
|
accessType: SecretSharingAccessType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TGetActiveSharedSecretByIdDTO = {
|
||||||
|
sharedSecretId: string;
|
||||||
|
hashedHex: string;
|
||||||
|
orgId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO;
|
export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO;
|
||||||
|
|
||||||
export type TDeleteSharedSecretDTO = {
|
export type TDeleteSharedSecretDTO = {
|
||||||
|
@ -24,7 +24,6 @@ import (
|
|||||||
"github.com/Infisical/infisical-merge/packages/models"
|
"github.com/Infisical/infisical-merge/packages/models"
|
||||||
"github.com/Infisical/infisical-merge/packages/srp"
|
"github.com/Infisical/infisical-merge/packages/srp"
|
||||||
"github.com/Infisical/infisical-merge/packages/util"
|
"github.com/Infisical/infisical-merge/packages/util"
|
||||||
"github.com/chzyer/readline"
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/go-resty/resty/v2"
|
"github.com/go-resty/resty/v2"
|
||||||
"github.com/manifoldco/promptui"
|
"github.com/manifoldco/promptui"
|
||||||
@ -205,6 +204,7 @@ var loginCmd = &cobra.Command{
|
|||||||
if !overrideDomain {
|
if !overrideDomain {
|
||||||
domainQuery = false
|
domainQuery = false
|
||||||
config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL_MANUAL_OVERRIDE)
|
config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL_MANUAL_OVERRIDE)
|
||||||
|
config.INFISICAL_LOGIN_URL = fmt.Sprintf("%s/login", strings.TrimSuffix(config.INFISICAL_URL, "/api"))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -713,7 +713,7 @@ func askForMFACode() string {
|
|||||||
return mfaVerifyCode
|
return mfaVerifyCode
|
||||||
}
|
}
|
||||||
|
|
||||||
func askToPasteJwtToken(stdin *readline.CancelableStdin, success chan models.UserCredentials, failure chan error) {
|
func askToPasteJwtToken(success chan models.UserCredentials, failure chan error) {
|
||||||
time.Sleep(time.Second * 5)
|
time.Sleep(time.Second * 5)
|
||||||
fmt.Println("\n\nOnce login is completed via browser, the CLI should be authenticated automatically.")
|
fmt.Println("\n\nOnce login is completed via browser, the CLI should be authenticated automatically.")
|
||||||
fmt.Println("However, if browser fails to communicate with the CLI, please paste the token from the browser below.")
|
fmt.Println("However, if browser fails to communicate with the CLI, please paste the token from the browser below.")
|
||||||
@ -807,26 +807,22 @@ func browserCliLogin() (models.UserCredentials, error) {
|
|||||||
|
|
||||||
log.Debug().Msgf("Callback server listening on port %d", callbackPort)
|
log.Debug().Msgf("Callback server listening on port %d", callbackPort)
|
||||||
|
|
||||||
stdin := readline.NewCancelableStdin(os.Stdin)
|
|
||||||
go http.Serve(listener, corsHandler)
|
go http.Serve(listener, corsHandler)
|
||||||
go askToPasteJwtToken(stdin, success, failure)
|
go askToPasteJwtToken(success, failure)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case loginResponse := <-success:
|
case loginResponse := <-success:
|
||||||
_ = closeListener(&listener)
|
_ = closeListener(&listener)
|
||||||
_ = stdin.Close()
|
|
||||||
fmt.Println("Browser login successful")
|
fmt.Println("Browser login successful")
|
||||||
return loginResponse, nil
|
return loginResponse, nil
|
||||||
|
|
||||||
case err := <-failure:
|
case err := <-failure:
|
||||||
serverErr := closeListener(&listener)
|
serverErr := closeListener(&listener)
|
||||||
stdErr := stdin.Close()
|
return models.UserCredentials{}, errors.Join(err, serverErr)
|
||||||
return models.UserCredentials{}, errors.Join(err, serverErr, stdErr)
|
|
||||||
|
|
||||||
case <-timeout:
|
case <-timeout:
|
||||||
_ = closeListener(&listener)
|
_ = closeListener(&listener)
|
||||||
_ = stdin.Close()
|
|
||||||
return models.UserCredentials{}, errors.New("server timeout")
|
return models.UserCredentials{}, errors.New("server timeout")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "List"
|
||||||
|
openapi: "GET /api/v2/workspace/{slug}/cas"
|
||||||
|
---
|
4
docs/api-reference/endpoints/certificates/list.mdx
Normal file
4
docs/api-reference/endpoints/certificates/list.mdx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
title: "List"
|
||||||
|
openapi: "GET /api/v2/workspace/{slug}/certificates"
|
||||||
|
---
|
@ -4,6 +4,30 @@ title: "Changelog"
|
|||||||
|
|
||||||
The changelog below reflects new product developments and updates on a monthly basis.
|
The changelog below reflects new product developments and updates on a monthly basis.
|
||||||
|
|
||||||
|
## July 2024
|
||||||
|
- Released the official [Ruby SDK](https://infisical.com/docs/sdks/languages/ruby).
|
||||||
|
- Increased the speed and efficiency of secret operations.
|
||||||
|
- Released AWS KMS wrapping (bring your own key).
|
||||||
|
- Users can now log in to CLI via SSO in non-browser environments.
|
||||||
|
- Released [Slack Webhooks](https://infisical.com/docs/documentation/platform/webhooks).
|
||||||
|
- Added [Dynamic Secrets with MS SQL](https://infisical.com/docs/documentation/platform/dynamic-secrets/mssql).
|
||||||
|
- Redesigned and simplified the Machine Identities page.
|
||||||
|
- Added the ability to move secrets/folders to another location.
|
||||||
|
- Added [OIDC](https://infisical.com/docs/documentation/platform/identities/oidc-auth/general) support to CLI, Go SDK, and more.
|
||||||
|
- Released [Linux installer for Infisical](https://infisical.com/docs/self-hosting/deployment-options/native/standalone-binary).
|
||||||
|
|
||||||
|
## June 2024
|
||||||
|
- Released [Infisical PKI](https://infisical.com/docs/documentation/platform/pki/overview).
|
||||||
|
- Released the official [Go SDK](https://infisical.com/docs/sdks/languages/go).
|
||||||
|
- Released [OIDC Authentication method](https://infisical.com/docs/documentation/platform/identities/oidc-auth/general).
|
||||||
|
- Allowed users to configure log retention periods on self-hosted instances.
|
||||||
|
- Added [tags](https://registry.terraform.io/providers/Infisical/infisical/latest/docs/resources/secret_tag) to terraform provider.
|
||||||
|
- Released [public secret sharing](https://share.infisical.com).
|
||||||
|
- Built a [native integration with Rundeck](https://infisical.com/docs/integrations/cicd/rundeck).
|
||||||
|
- Added list view for projects in the dashboard.
|
||||||
|
- Fixed offline coding mode in CLI.
|
||||||
|
- Users are now able to leave a particular project themselves.
|
||||||
|
|
||||||
## May 2024
|
## May 2024
|
||||||
- Released [AWS](https://infisical.com/docs/documentation/platform/identities/aws-auth), [GCP](https://infisical.com/docs/documentation/platform/identities/gcp-auth), [Azure](https://infisical.com/docs/documentation/platform/identities/azure-auth), and [Kubernetes](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth) Native Auth Methods.
|
- Released [AWS](https://infisical.com/docs/documentation/platform/identities/aws-auth), [GCP](https://infisical.com/docs/documentation/platform/identities/gcp-auth), [Azure](https://infisical.com/docs/documentation/platform/identities/azure-auth), and [Kubernetes](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth) Native Auth Methods.
|
||||||
- Added [Secret Sharing](https://infisical.com/docs/documentation/platform/secret-sharing) functionality for sharing sensitive data through encrypted links – within and outside of an organization.
|
- Added [Secret Sharing](https://infisical.com/docs/documentation/platform/secret-sharing) functionality for sharing sensitive data through encrypted links – within and outside of an organization.
|
||||||
|
23
docs/documentation/guides/migrating-from-envkey.mdx
Normal file
23
docs/documentation/guides/migrating-from-envkey.mdx
Normal 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.
|
@ -654,6 +654,7 @@
|
|||||||
{
|
{
|
||||||
"group": "Certificate Authorities",
|
"group": "Certificate Authorities",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
"api-reference/endpoints/certificate-authorities/list",
|
||||||
"api-reference/endpoints/certificate-authorities/create",
|
"api-reference/endpoints/certificate-authorities/create",
|
||||||
"api-reference/endpoints/certificate-authorities/read",
|
"api-reference/endpoints/certificate-authorities/read",
|
||||||
"api-reference/endpoints/certificate-authorities/update",
|
"api-reference/endpoints/certificate-authorities/update",
|
||||||
@ -669,6 +670,7 @@
|
|||||||
{
|
{
|
||||||
"group": "Certificates",
|
"group": "Certificates",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
"api-reference/endpoints/certificates/list",
|
||||||
"api-reference/endpoints/certificates/read",
|
"api-reference/endpoints/certificates/read",
|
||||||
"api-reference/endpoints/certificates/revoke",
|
"api-reference/endpoints/certificates/revoke",
|
||||||
"api-reference/endpoints/certificates/delete",
|
"api-reference/endpoints/certificates/delete",
|
||||||
|
@ -118,6 +118,10 @@ namespace Example
|
|||||||
Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`)
|
Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`)
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField query="SslCertificatePath" optional>
|
||||||
|
Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
<ParamField query="Auth" type="AuthenticationOptions">
|
<ParamField query="Auth" type="AuthenticationOptions">
|
||||||
The authentication object to use for the client. This is required unless you're using environment variables.
|
The authentication object to use for the client. This is required unless you're using environment variables.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
@ -122,6 +122,10 @@ public class App {
|
|||||||
Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`)
|
Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`)
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField query="setSSLCertificatePath()">
|
||||||
|
Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
<ParamField query="setAuth()" type="AuthenticationOptions">
|
<ParamField query="setAuth()" type="AuthenticationOptions">
|
||||||
The authentication object to use for the client. This is required unless you're using environment variables.
|
The authentication object to use for the client. This is required unless you're using environment variables.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
@ -137,6 +137,10 @@ Import the SDK and create a client instance with your [Machine Identity](/docume
|
|||||||
The level of logs you wish to log The logs are derived from Rust, as we have written our base SDK in Rust.
|
The level of logs you wish to log The logs are derived from Rust, as we have written our base SDK in Rust.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField query="sslCertificatePath" optional>
|
||||||
|
Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
<ParamField query="auth" type="AuthenticationOptions">
|
<ParamField query="auth" type="AuthenticationOptions">
|
||||||
The authentication object to use for the client. This is required unless you're using environment variables.
|
The authentication object to use for the client. This is required unless you're using environment variables.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
@ -97,16 +97,14 @@ client = InfisicalClient(ClientSettings(
|
|||||||
If manually set to 0, caching will be disabled, this is not recommended.
|
If manually set to 0, caching will be disabled, this is not recommended.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField query="site_url" type="string" default="https://app.infisical.com" optional>
|
||||||
<ParamField
|
|
||||||
query="site_url"
|
|
||||||
type="string"
|
|
||||||
default="https://app.infisical.com"
|
|
||||||
optional
|
|
||||||
>
|
|
||||||
Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`)
|
Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`)
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField query="ssl_certificate_path" optional>
|
||||||
|
Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
<ParamField query="auth" type="AuthenticationOptions">
|
<ParamField query="auth" type="AuthenticationOptions">
|
||||||
The authentication object to use for the client. This is required unless you're using environment variables.
|
The authentication object to use for the client. This is required unless you're using environment variables.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
@ -25,6 +25,10 @@ Used to configure platform-specific security and operational settings
|
|||||||
https://app.infisical.com).
|
https://app.infisical.com).
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField query="PORT" type="int" default="8080" optional>
|
||||||
|
Specifies the internal port on which the application listens.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
<ParamField query="TELEMETRY_ENABLED" type="string" default="true" optional>
|
<ParamField query="TELEMETRY_ENABLED" type="string" default="true" optional>
|
||||||
Telemetry helps us improve Infisical but if you want to dsiable it you may set this to `false`.
|
Telemetry helps us improve Infisical but if you want to dsiable it you may set this to `false`.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
@ -29,6 +29,7 @@ export * from "./secretFolders";
|
|||||||
export * from "./secretImports";
|
export * from "./secretImports";
|
||||||
export * from "./secretRotation";
|
export * from "./secretRotation";
|
||||||
export * from "./secrets";
|
export * from "./secrets";
|
||||||
|
export * from "./secretSharing";
|
||||||
export * from "./secretSnapshots";
|
export * from "./secretSnapshots";
|
||||||
export * from "./serverDetails";
|
export * from "./serverDetails";
|
||||||
export * from "./serviceTokens";
|
export * from "./serviceTokens";
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
TDeleteOrgRoleDTO,
|
TDeleteOrgRoleDTO,
|
||||||
TDeleteProjectRoleDTO,
|
TDeleteProjectRoleDTO,
|
||||||
TOrgRole,
|
TOrgRole,
|
||||||
|
TProjectRole,
|
||||||
TUpdateOrgRoleDTO,
|
TUpdateOrgRoleDTO,
|
||||||
TUpdateProjectRoleDTO
|
TUpdateProjectRoleDTO
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@ -17,9 +18,13 @@ import {
|
|||||||
export const useCreateProjectRole = () => {
|
export const useCreateProjectRole = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation<TProjectRole, {}, TCreateProjectRoleDTO>({
|
||||||
mutationFn: ({ projectSlug, ...dto }: TCreateProjectRoleDTO) =>
|
mutationFn: async ({ projectSlug, ...dto }: TCreateProjectRoleDTO) => {
|
||||||
apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto),
|
const {
|
||||||
|
data: { role }
|
||||||
|
} = await apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto);
|
||||||
|
return role;
|
||||||
|
},
|
||||||
onSuccess: (_, { projectSlug }) => {
|
onSuccess: (_, { projectSlug }) => {
|
||||||
queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug));
|
queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug));
|
||||||
}
|
}
|
||||||
@ -29,9 +34,13 @@ export const useCreateProjectRole = () => {
|
|||||||
export const useUpdateProjectRole = () => {
|
export const useUpdateProjectRole = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation<TProjectRole, {}, TUpdateProjectRoleDTO>({
|
||||||
mutationFn: ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) =>
|
mutationFn: async ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) => {
|
||||||
apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto),
|
const {
|
||||||
|
data: { role }
|
||||||
|
} = await apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto);
|
||||||
|
return role;
|
||||||
|
},
|
||||||
onSuccess: (_, { projectSlug }) => {
|
onSuccess: (_, { projectSlug }) => {
|
||||||
queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug));
|
queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug));
|
||||||
}
|
}
|
||||||
@ -40,10 +49,13 @@ export const useUpdateProjectRole = () => {
|
|||||||
|
|
||||||
export const useDeleteProjectRole = () => {
|
export const useDeleteProjectRole = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<TProjectRole, {}, TDeleteProjectRoleDTO>({
|
||||||
return useMutation({
|
mutationFn: async ({ projectSlug, id }: TDeleteProjectRoleDTO) => {
|
||||||
mutationFn: ({ projectSlug, id }: TDeleteProjectRoleDTO) =>
|
const {
|
||||||
apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`),
|
data: { role }
|
||||||
|
} = await apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`);
|
||||||
|
return role;
|
||||||
|
},
|
||||||
onSuccess: (_, { projectSlug }) => {
|
onSuccess: (_, { projectSlug }) => {
|
||||||
queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug));
|
queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug));
|
||||||
}
|
}
|
||||||
@ -79,7 +91,7 @@ export const useUpdateOrgRole = () => {
|
|||||||
data: { role }
|
data: { role }
|
||||||
} = await apiRequest.patch(`/api/v1/organization/${orgId}/roles/${id}`, {
|
} = await apiRequest.patch(`/api/v1/organization/${orgId}/roles/${id}`, {
|
||||||
...dto,
|
...dto,
|
||||||
permissions: permissions?.length ? packRules(permissions) : []
|
permissions: permissions?.length ? packRules(permissions) : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
return role;
|
return role;
|
||||||
|
@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { secretSharingKeys } from "./queries";
|
||||||
import { TCreateSharedSecretRequest, TDeleteSharedSecretRequest, TSharedSecret } from "./types";
|
import { TCreateSharedSecretRequest, TDeleteSharedSecretRequest, TSharedSecret } from "./types";
|
||||||
|
|
||||||
export const useCreateSharedSecret = () => {
|
export const useCreateSharedSecret = () => {
|
||||||
@ -11,7 +12,7 @@ export const useCreateSharedSecret = () => {
|
|||||||
const { data } = await apiRequest.post<TSharedSecret>("/api/v1/secret-sharing", inputData);
|
const { data } = await apiRequest.post<TSharedSecret>("/api/v1/secret-sharing", inputData);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"])
|
onSuccess: () => queryClient.invalidateQueries(secretSharingKeys.allSharedSecrets())
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -25,7 +26,7 @@ export const useCreatePublicSharedSecret = () => {
|
|||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"])
|
onSuccess: () => queryClient.invalidateQueries(secretSharingKeys.allSharedSecrets())
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -38,8 +39,6 @@ export const useDeleteSharedSecret = () => {
|
|||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => queryClient.invalidateQueries(secretSharingKeys.allSharedSecrets())
|
||||||
queryClient.invalidateQueries(["sharedSecrets"]);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -2,24 +2,59 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
import { SecretSharingAccessType, TSharedSecret, TViewSharedSecretResponse } from "./types";
|
import { TSharedSecret, TViewSharedSecretResponse } from "./types";
|
||||||
|
|
||||||
export const useGetSharedSecrets = () => {
|
export const secretSharingKeys = {
|
||||||
|
allSharedSecrets: () => ["sharedSecrets"] as const,
|
||||||
|
specificSharedSecrets: ({ offset, limit }: { offset: number; limit: number }) =>
|
||||||
|
[...secretSharingKeys.allSharedSecrets(), { offset, limit }] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetSharedSecrets = ({
|
||||||
|
offset = 0,
|
||||||
|
limit = 25
|
||||||
|
}: {
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["sharedSecrets"],
|
queryKey: secretSharingKeys.specificSharedSecrets({ offset, limit }),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiRequest.get<TSharedSecret[]>("/api/v1/secret-sharing/");
|
const params = new URLSearchParams({
|
||||||
|
offset: String(offset),
|
||||||
|
limit: String(limit)
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await apiRequest.get<{ secrets: TSharedSecret[]; totalCount: number }>(
|
||||||
|
"/api/v1/secret-sharing/",
|
||||||
|
{
|
||||||
|
params
|
||||||
|
}
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex: string) => {
|
export const useGetActiveSharedSecretById = ({
|
||||||
|
sharedSecretId,
|
||||||
|
hashedHex
|
||||||
|
}: {
|
||||||
|
sharedSecretId: string;
|
||||||
|
hashedHex: string;
|
||||||
|
}) => {
|
||||||
return useQuery<TViewSharedSecretResponse, [string]>({
|
return useQuery<TViewSharedSecretResponse, [string]>({
|
||||||
|
enabled: Boolean(sharedSecretId) && Boolean(hashedHex),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if(!id || !hashedHex) return Promise.resolve({ encryptedValue: "", iv: "", tag: "", accessType: SecretSharingAccessType.Organization, orgName: "" });
|
const params = new URLSearchParams({
|
||||||
|
hashedHex
|
||||||
|
});
|
||||||
|
|
||||||
const { data } = await apiRequest.get<TViewSharedSecretResponse>(
|
const { data } = await apiRequest.get<TViewSharedSecretResponse>(
|
||||||
`/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}`
|
`/api/v1/secret-sharing/public/${sharedSecretId}`,
|
||||||
|
{
|
||||||
|
params
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
encryptedValue: data.encryptedValue,
|
encryptedValue: data.encryptedValue,
|
||||||
|
@ -4,16 +4,24 @@ export type TSharedSecret = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
} & TCreateSharedSecretRequest;
|
name: string | null;
|
||||||
|
lastViewedAt?: Date;
|
||||||
export type TCreateSharedSecretRequest = {
|
expiresAt: Date;
|
||||||
|
expiresAfterViews: number | null;
|
||||||
encryptedValue: string;
|
encryptedValue: string;
|
||||||
iv: string;
|
iv: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCreateSharedSecretRequest = {
|
||||||
|
name?: string;
|
||||||
|
encryptedValue: string;
|
||||||
hashedHex: string;
|
hashedHex: string;
|
||||||
|
iv: string;
|
||||||
|
tag: string;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
expiresAfterViews: number;
|
expiresAfterViews?: number;
|
||||||
accessType: SecretSharingAccessType;
|
accessType?: SecretSharingAccessType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TViewSharedSecretResponse = {
|
export type TViewSharedSecretResponse = {
|
||||||
@ -31,4 +39,4 @@ export type TDeleteSharedSecretRequest = {
|
|||||||
export enum SecretSharingAccessType {
|
export enum SecretSharingAccessType {
|
||||||
Anyone = "anyone",
|
Anyone = "anyone",
|
||||||
Organization = "organization"
|
Organization = "organization"
|
||||||
}
|
}
|
||||||
|
20
frontend/src/pages/project/[id]/roles/[roleSlug]/index.tsx
Normal file
20
frontend/src/pages/project/[id]/roles/[roleSlug]/index.tsx
Normal 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;
|
@ -13,7 +13,7 @@ const ShareNewPublicSecretPage = () => {
|
|||||||
<meta name="og:description" content="" />
|
<meta name="og:description" content="" />
|
||||||
</Head>
|
</Head>
|
||||||
<div className="dark h-full">
|
<div className="dark h-full">
|
||||||
<ShareSecretPublicPage isNewSession />
|
<ShareSecretPublicPage />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
|
||||||
import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage";
|
import { ViewSecretPublicPage } from "@app/views/ViewSecretPublicPage";
|
||||||
|
|
||||||
const SecretSharedPublicPage = () => {
|
const SecretSharedPublicPage = () => {
|
||||||
return (
|
return (
|
||||||
@ -12,9 +12,7 @@ const SecretSharedPublicPage = () => {
|
|||||||
<meta property="og:title" content="" />
|
<meta property="og:title" content="" />
|
||||||
<meta name="og:description" content="" />
|
<meta name="og:description" content="" />
|
||||||
</Head>
|
</Head>
|
||||||
<div className="dark h-full">
|
<ViewSecretPublicPage />
|
||||||
<ShareSecretPublicPage isNewSession={false} />
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -168,16 +168,27 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => {
|
|||||||
const { token: newJwtToken } = await selectOrganization({ organizationId });
|
const { token: newJwtToken } = await selectOrganization({ organizationId });
|
||||||
|
|
||||||
const instance = axios.create();
|
const instance = axios.create();
|
||||||
await instance.post(cliUrl, {
|
const payload = {
|
||||||
...isCliLoginSuccessful.loginResponse,
|
...isCliLoginSuccessful.loginResponse,
|
||||||
JTWToken: newJwtToken
|
JTWToken: newJwtToken
|
||||||
|
};
|
||||||
|
await instance.post(cliUrl, payload).catch(() => {
|
||||||
|
// if error happens to communicate we set the token with an expiry in sessino storage
|
||||||
|
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
|
||||||
|
sessionStorage.setItem(
|
||||||
|
SessionStorageKeys.CLI_TERMINAL_TOKEN,
|
||||||
|
JSON.stringify({
|
||||||
|
expiry: formatISO(addSeconds(new Date(), 30)),
|
||||||
|
data: window.btoa(JSON.stringify(payload))
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
router.push("/cli-redirect");
|
||||||
await navigateUserToOrg(router, organizationId);
|
return;
|
||||||
}
|
}
|
||||||
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
|
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
|
||||||
// if the user has no orgs, navigate to the create org page
|
// if the user has no orgs, navigate to the create org page
|
||||||
else {
|
|
||||||
const userOrgs = await fetchOrganizations();
|
const userOrgs = await fetchOrganizations();
|
||||||
|
|
||||||
// case: user has orgs, so we navigate the user to select an org
|
// case: user has orgs, so we navigate the user to select an org
|
||||||
@ -189,7 +200,7 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => {
|
|||||||
else {
|
else {
|
||||||
await navigateUserToOrg(router);
|
await navigateUserToOrg(router);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const isLoginSuccessful = await attemptLoginMfa({
|
const isLoginSuccessful = await attemptLoginMfa({
|
||||||
|
@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { addSeconds, formatISO } from "date-fns";
|
||||||
import jwt_decode from "jwt-decode";
|
import jwt_decode from "jwt-decode";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
@ -12,6 +13,7 @@ import attemptLogin from "@app/components/utilities/attemptLogin";
|
|||||||
import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config";
|
import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config";
|
||||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||||
import { Button, Input, Spinner } from "@app/components/v2";
|
import { Button, Input, Spinner } from "@app/components/v2";
|
||||||
|
import { SessionStorageKeys } from "@app/const";
|
||||||
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
|
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
|
||||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
||||||
import { fetchMyPrivateKey } from "@app/hooks/api/users/queries";
|
import { fetchMyPrivateKey } from "@app/hooks/api/users/queries";
|
||||||
@ -79,11 +81,24 @@ export const PasswordStep = ({
|
|||||||
if (callbackPort) {
|
if (callbackPort) {
|
||||||
console.log("organization id was present. new JWT token to be used in CLI:", newJwtToken);
|
console.log("organization id was present. new JWT token to be used in CLI:", newJwtToken);
|
||||||
const instance = axios.create();
|
const instance = axios.create();
|
||||||
await instance.post(cliUrl, {
|
const payload = {
|
||||||
privateKey,
|
privateKey,
|
||||||
email,
|
email,
|
||||||
JTWToken: newJwtToken
|
JTWToken: newJwtToken
|
||||||
|
};
|
||||||
|
await instance.post(cliUrl, payload).catch(() => {
|
||||||
|
// if error happens to communicate we set the token with an expiry in sessino storage
|
||||||
|
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
|
||||||
|
sessionStorage.setItem(
|
||||||
|
SessionStorageKeys.CLI_TERMINAL_TOKEN,
|
||||||
|
JSON.stringify({
|
||||||
|
expiry: formatISO(addSeconds(new Date(), 30)),
|
||||||
|
data: window.btoa(JSON.stringify(payload))
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
router.push("/cli-redirect");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await navigateUserToOrg(router, organizationId);
|
await navigateUserToOrg(router, organizationId);
|
||||||
@ -165,26 +180,35 @@ export const PasswordStep = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const instance = axios.create();
|
const instance = axios.create();
|
||||||
await instance.post(cliUrl, {
|
const payload = {
|
||||||
...isCliLoginSuccessful.loginResponse,
|
...isCliLoginSuccessful.loginResponse,
|
||||||
JTWToken: newJwtToken
|
JTWToken: newJwtToken
|
||||||
|
};
|
||||||
|
await instance.post(cliUrl, payload).catch(() => {
|
||||||
|
// if error happens to communicate we set the token with an expiry in sessino storage
|
||||||
|
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
|
||||||
|
sessionStorage.setItem(
|
||||||
|
SessionStorageKeys.CLI_TERMINAL_TOKEN,
|
||||||
|
JSON.stringify({
|
||||||
|
expiry: formatISO(addSeconds(new Date(), 30)),
|
||||||
|
data: window.btoa(JSON.stringify(payload))
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
router.push("/cli-redirect");
|
||||||
await navigateUserToOrg(router, organizationId);
|
return;
|
||||||
}
|
}
|
||||||
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
|
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
|
||||||
// if the user has no orgs, navigate to the create org page
|
// if the user has no orgs, navigate to the create org page
|
||||||
else {
|
const userOrgs = await fetchOrganizations();
|
||||||
const userOrgs = await fetchOrganizations();
|
|
||||||
|
|
||||||
// case: user has orgs, so we navigate the user to select an org
|
// case: user has orgs, so we navigate the user to select an org
|
||||||
if (userOrgs.length > 0) {
|
if (userOrgs.length > 0) {
|
||||||
navigateToSelectOrganization(callbackPort);
|
navigateToSelectOrganization(callbackPort);
|
||||||
}
|
}
|
||||||
// case: no orgs found, so we navigate the user to create an org
|
// case: no orgs found, so we navigate the user to create an org
|
||||||
else {
|
else {
|
||||||
await navigateUserToOrg(router);
|
await navigateUserToOrg(router);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -24,6 +24,7 @@ import {
|
|||||||
useRevokeIdentityTokenAuthToken,
|
useRevokeIdentityTokenAuthToken,
|
||||||
useRevokeIdentityUniversalAuthClientSecret} from "@app/hooks/api";
|
useRevokeIdentityUniversalAuthClientSecret} from "@app/hooks/api";
|
||||||
import { usePopUp } from "@app/hooks/usePopUp";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
import { TabSections } from"@app/views/Org/Types";
|
||||||
|
|
||||||
import { IdentityAuthMethodModal } from "../MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal";
|
import { IdentityAuthMethodModal } from "../MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal";
|
||||||
import { IdentityModal } from "../MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal";
|
import { IdentityModal } from "../MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal";
|
||||||
@ -75,7 +76,7 @@ export const IdentityPage = withPermission(
|
|||||||
});
|
});
|
||||||
|
|
||||||
handlePopUpClose("deleteIdentity");
|
handlePopUpClose("deleteIdentity");
|
||||||
router.push(`/org/${orgId}/members`);
|
router.push(`/org/${orgId}/members?selectedTab=${TabSections.Identities}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
const error = err as any;
|
const error = err as any;
|
||||||
@ -154,7 +155,7 @@ export const IdentityPage = withPermission(
|
|||||||
type="submit"
|
type="submit"
|
||||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`/org/${orgId}/members`);
|
router.push(`/org/${orgId}/members?selectedTab=${TabSections.Identities}`);
|
||||||
}}
|
}}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
>
|
>
|
||||||
|
@ -25,7 +25,7 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
|
|||||||
return data ? (
|
return data ? (
|
||||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||||
<h3 className="text-lg font-semibold text-mineshaft-100">Details</h3>
|
<h3 className="text-lg font-semibold text-mineshaft-100">Identity Details</h3>
|
||||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
|
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
|
||||||
{(isAllowed) => {
|
{(isAllowed) => {
|
||||||
return (
|
return (
|
||||||
|
@ -10,6 +10,7 @@ import { useWorkspace } from "@app/context";
|
|||||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
import { TabSections } from "@app/views/Org/Types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
membership: IdentityMembership;
|
membership: IdentityMembership;
|
||||||
@ -47,11 +48,11 @@ export const IdentityProjectRow = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr
|
<Tr
|
||||||
className="group h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
|
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||||
key={`identity-project-membership-${id}`}
|
key={`identity-project-membership-${id}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isAccessible) {
|
if (isAccessible) {
|
||||||
router.push(`/project/${project.id}/members`);
|
router.push(`/project/${project.id}/members?selectedTab=${TabSections.Identities}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,23 +1,39 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||||
import { withPermission } from "@app/hoc";
|
import { withPermission } from "@app/hoc";
|
||||||
|
import { isTabSection, TabSections } from "@app/views/Org/Types";
|
||||||
|
|
||||||
import { OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components";
|
import { OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components";
|
||||||
|
|
||||||
enum TabSections {
|
|
||||||
Member = "members",
|
|
||||||
Roles = "roles",
|
|
||||||
Identities = "identities"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MembersPage = withPermission(
|
export const MembersPage = withPermission(
|
||||||
() => {
|
() => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { query } = router;
|
||||||
|
const selectedTab = query.selectedTab as string;
|
||||||
|
const [activeTab, setActiveTab] = useState<TabSections>(TabSections.Member);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTab && isTabSection(selectedTab)) {
|
||||||
|
setActiveTab(selectedTab);
|
||||||
|
}
|
||||||
|
}, [isTabSection, selectedTab]);
|
||||||
|
|
||||||
|
const updateSelectedTab = (tab: string) => {
|
||||||
|
router.push({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: { ...router.query, selectedTab: tab },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||||
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
|
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
|
||||||
<p className="mr-4 mb-4 text-3xl font-semibold text-white">Organization Access Control</p>
|
<p className="mr-4 mb-4 text-3xl font-semibold text-white">Organization Access Control</p>
|
||||||
<Tabs defaultValue={TabSections.Member}>
|
<Tabs value={activeTab} onValueChange={updateSelectedTab}>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value={TabSections.Member}>Users</Tab>
|
<Tab value={TabSections.Member}>Users</Tab>
|
||||||
<Tab value={TabSections.Identities}>
|
<Tab value={TabSections.Identities}>
|
||||||
@ -25,7 +41,7 @@ export const MembersPage = withPermission(
|
|||||||
<p>Machine Identities</p>
|
<p>Machine Identities</p>
|
||||||
</div>
|
</div>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab value={TabSections.Roles}>Organization Roles</Tab>
|
<Tab value={TabSections.Roles}>Organization Roles</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel value={TabSections.Member}>
|
<TabPanel value={TabSections.Member}>
|
||||||
<OrgMembersTab />
|
<OrgMembersTab />
|
||||||
|
@ -86,7 +86,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
data?.map(({ identity: { id, name }, role, customRole }) => {
|
data?.map(({ identity: { id, name }, role, customRole }) => {
|
||||||
return (
|
return (
|
||||||
<Tr
|
<Tr
|
||||||
className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
|
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||||
key={`identity-${id}`}
|
key={`identity-${id}`}
|
||||||
onClick={() => router.push(`/org/${orgId}/identities/${id}`)}
|
onClick={() => router.push(`/org/${orgId}/identities/${id}`)}
|
||||||
>
|
>
|
||||||
|
@ -184,7 +184,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
|
|||||||
return (
|
return (
|
||||||
<Tr
|
<Tr
|
||||||
key={`org-membership-${orgMembershipId}`}
|
key={`org-membership-${orgMembershipId}`}
|
||||||
className="h-10 w-full cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
|
className="h-10 w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||||
onClick={() => router.push(`/org/${orgId}/memberships/${orgMembershipId}`)}
|
onClick={() => router.push(`/org/${orgId}/memberships/${orgMembershipId}`)}
|
||||||
>
|
>
|
||||||
<Td className={isActive ? "" : "text-mineshaft-400"}>{name}</Td>
|
<Td className={isActive ? "" : "text-mineshaft-400"}>{name}</Td>
|
||||||
|
@ -93,7 +93,7 @@ export const OrgRoleTable = () => {
|
|||||||
return (
|
return (
|
||||||
<Tr
|
<Tr
|
||||||
key={`role-list-${id}`}
|
key={`role-list-${id}`}
|
||||||
className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
|
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||||
onClick={() => router.push(`/org/${orgId}/roles/${id}`)}
|
onClick={() => router.push(`/org/${orgId}/roles/${id}`)}
|
||||||
>
|
>
|
||||||
<Td>{name}</Td>
|
<Td>{name}</Td>
|
||||||
|
@ -19,6 +19,7 @@ import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@a
|
|||||||
import { withPermission } from "@app/hoc";
|
import { withPermission } from "@app/hoc";
|
||||||
import { useDeleteOrgRole, useGetOrgRole } from "@app/hooks/api";
|
import { useDeleteOrgRole, useGetOrgRole } from "@app/hooks/api";
|
||||||
import { usePopUp } from "@app/hooks/usePopUp";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
import { TabSections } from "@app/views/Org/Types";
|
||||||
|
|
||||||
import { RoleDetailsSection, RoleModal, RolePermissionsSection } from "./components";
|
import { RoleDetailsSection, RoleModal, RolePermissionsSection } from "./components";
|
||||||
|
|
||||||
@ -51,7 +52,7 @@ export const RolePage = withPermission(
|
|||||||
});
|
});
|
||||||
|
|
||||||
handlePopUpClose("deleteOrgRole");
|
handlePopUpClose("deleteOrgRole");
|
||||||
router.push(`/org/${orgId}/members`);
|
router.push(`/org/${orgId}/members?selectedTab=${TabSections.Roles}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
const error = err as any;
|
const error = err as any;
|
||||||
@ -75,7 +76,7 @@ export const RolePage = withPermission(
|
|||||||
type="submit"
|
type="submit"
|
||||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`/org/${orgId}/members`);
|
router.push(`/org/${orgId}/members?selectedTab=${TabSections.Roles}`);
|
||||||
}}
|
}}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
>
|
>
|
||||||
|
@ -26,7 +26,7 @@ export const RoleDetailsSection = ({ roleId, handlePopUpOpen }: Props) => {
|
|||||||
return data ? (
|
return data ? (
|
||||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||||
<h3 className="text-lg font-semibold text-mineshaft-100">Details</h3>
|
<h3 className="text-lg font-semibold text-mineshaft-100">Org Role Details</h3>
|
||||||
{isCustomRole && (
|
{isCustomRole && (
|
||||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Role}>
|
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Role}>
|
||||||
{(isAllowed) => {
|
{(isAllowed) => {
|
||||||
|
@ -150,7 +150,7 @@ export const RolePermissionRow = ({ isEditable, title, formName, control, setVal
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tr
|
<Tr
|
||||||
className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
|
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||||
onClick={() => setIsRowExpanded.toggle()}
|
onClick={() => setIsRowExpanded.toggle()}
|
||||||
>
|
>
|
||||||
<Td>
|
<Td>
|
||||||
|
@ -16,23 +16,23 @@ import { RolePermissionRow } from "./RolePermissionRow";
|
|||||||
|
|
||||||
const SIMPLE_PERMISSION_OPTIONS = [
|
const SIMPLE_PERMISSION_OPTIONS = [
|
||||||
{
|
{
|
||||||
title: "User management",
|
title: "User Management",
|
||||||
formName: "member"
|
formName: "member"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Group management",
|
title: "Group Management",
|
||||||
formName: "groups"
|
formName: "groups"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Machine identity management",
|
title: "Machine Identity Management",
|
||||||
formName: "identity"
|
formName: "identity"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Billing & usage",
|
title: "Usage & Billing",
|
||||||
formName: "billing"
|
formName: "billing"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Role management",
|
title: "Role Management",
|
||||||
formName: "role"
|
formName: "role"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -40,7 +40,7 @@ const SIMPLE_PERMISSION_OPTIONS = [
|
|||||||
formName: "incident-contact"
|
formName: "incident-contact"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Organization profile",
|
title: "Organization Profile",
|
||||||
formName: "settings"
|
formName: "settings"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
9
frontend/src/views/Org/Types/TabSections.ts
Normal file
9
frontend/src/views/Org/Types/TabSections.ts
Normal 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);
|
||||||
|
}
|
3
frontend/src/views/Org/Types/index.ts
Normal file
3
frontend/src/views/Org/Types/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { TabSections, isTabSection } from "./TabSections";
|
||||||
|
|
||||||
|
export { TabSections, isTabSection };
|
@ -29,6 +29,7 @@ import {
|
|||||||
useUpdateOrgMembership
|
useUpdateOrgMembership
|
||||||
} from "@app/hooks/api";
|
} from "@app/hooks/api";
|
||||||
import { usePopUp } from "@app/hooks/usePopUp";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
import { TabSections } from "@app/views/Org/Types";
|
||||||
|
|
||||||
import { UserDetailsSection, UserOrgMembershipModal, UserProjectsSection } from "./components";
|
import { UserDetailsSection, UserOrgMembershipModal, UserProjectsSection } from "./components";
|
||||||
|
|
||||||
@ -90,7 +91,7 @@ export const UserPage = withPermission(
|
|||||||
});
|
});
|
||||||
|
|
||||||
handlePopUpClose("removeMember");
|
handlePopUpClose("removeMember");
|
||||||
router.push(`/org/${orgId}/members`);
|
router.push(`/org/${orgId}/members?selectedTab=${TabSections.Member}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
createNotification({
|
createNotification({
|
||||||
@ -111,7 +112,7 @@ export const UserPage = withPermission(
|
|||||||
type="submit"
|
type="submit"
|
||||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`/org/${orgId}/members`);
|
router.push(`/org/${orgId}/members?selectedTab=${TabSections.Member}`);
|
||||||
}}
|
}}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
>
|
>
|
||||||
|
@ -3,7 +3,8 @@ import {
|
|||||||
faCheckCircle,
|
faCheckCircle,
|
||||||
faCircleXmark,
|
faCircleXmark,
|
||||||
faCopy,
|
faCopy,
|
||||||
faPencil} from "@fortawesome/free-solid-svg-icons";
|
faPencil
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
@ -82,7 +83,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
|
|||||||
return membership ? (
|
return membership ? (
|
||||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||||
<h3 className="text-lg font-semibold text-mineshaft-100">Details</h3>
|
<h3 className="text-lg font-semibold text-mineshaft-100">User Details</h3>
|
||||||
{userId !== membership.user.id && (
|
{userId !== membership.user.id && (
|
||||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
|
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Identity}>
|
||||||
{(isAllowed) => {
|
{(isAllowed) => {
|
||||||
|
@ -9,6 +9,7 @@ import { useWorkspace } from "@app/context";
|
|||||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
import { TabSections } from "@app/views/Org/Types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
membership: TWorkspaceUser;
|
membership: TWorkspaceUser;
|
||||||
@ -43,11 +44,11 @@ export const UserProjectRow = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr
|
<Tr
|
||||||
className="group h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
|
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||||
key={`user-project-membership-${id}`}
|
key={`user-project-membership-${id}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isAccessible) {
|
if (isAccessible) {
|
||||||
router.push(`/project/${project.id}/members`);
|
router.push(`/project/${project.id}/members?selectedTab=${TabSections.Member}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,25 +1,40 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
import { withProjectPermission } from "@app/hoc";
|
import { withProjectPermission } from "@app/hoc";
|
||||||
|
|
||||||
|
import { isTabSection,TabSections } from "../Types";
|
||||||
import { IdentityTab, MembersTab,ProjectRoleListTab, ServiceTokenTab } from "./components";
|
import { IdentityTab, MembersTab,ProjectRoleListTab, ServiceTokenTab } from "./components";
|
||||||
|
|
||||||
enum TabSections {
|
|
||||||
Member = "members",
|
|
||||||
Roles = "roles",
|
|
||||||
Groups = "groups",
|
|
||||||
Identities = "identities",
|
|
||||||
ServiceTokens = "service-tokens"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MembersPage = withProjectPermission(
|
export const MembersPage = withProjectPermission(
|
||||||
() => {
|
() => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { query } = router;
|
||||||
|
const selectedTab = query.selectedTab as string;
|
||||||
|
const [activeTab, setActiveTab] = useState<TabSections>(TabSections.Member);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTab && isTabSection(selectedTab)) {
|
||||||
|
setActiveTab(selectedTab);
|
||||||
|
}
|
||||||
|
}, [isTabSection, selectedTab]);
|
||||||
|
|
||||||
|
const updateSelectedTab = (tab: string) => {
|
||||||
|
router.push({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: { ...router.query, selectedTab: tab },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||||
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
|
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
|
||||||
<p className="mr-4 mb-4 text-3xl font-semibold text-white">Project Access Control</p>
|
<p className="mr-4 mb-4 text-3xl font-semibold text-white">Project Access Control</p>
|
||||||
<Tabs defaultValue={TabSections.Member}>
|
<Tabs value={activeTab} onValueChange={updateSelectedTab}>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value={TabSections.Member}>Users</Tab>
|
<Tab value={TabSections.Member}>Users</Tab>
|
||||||
<Tab value={TabSections.Identities}>
|
<Tab value={TabSections.Identities}>
|
||||||
|
@ -2,29 +2,12 @@ import { motion } from "framer-motion";
|
|||||||
|
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
import { withProjectPermission } from "@app/hoc";
|
import { withProjectPermission } from "@app/hoc";
|
||||||
import { usePopUp } from "@app/hooks";
|
|
||||||
|
|
||||||
import { ProjectRoleList } from "./components/ProjectRoleList";
|
import { ProjectRoleList } from "./components/ProjectRoleList";
|
||||||
import { ProjectRoleModifySection } from "./components/ProjectRoleModifySection";
|
|
||||||
|
|
||||||
export const ProjectRoleListTab = withProjectPermission(
|
export const ProjectRoleListTab = withProjectPermission(
|
||||||
() => {
|
() => {
|
||||||
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const);
|
return (
|
||||||
|
|
||||||
return popUp.editRole.isOpen ? (
|
|
||||||
<motion.div
|
|
||||||
key="role-modify"
|
|
||||||
transition={{ duration: 0.1 }}
|
|
||||||
initial={{ opacity: 0, translateX: 30 }}
|
|
||||||
animate={{ opacity: 1, translateX: 0 }}
|
|
||||||
exit={{ opacity: 0, translateX: 30 }}
|
|
||||||
>
|
|
||||||
<ProjectRoleModifySection
|
|
||||||
roleSlug={popUp.editRole.data as string}
|
|
||||||
onGoBack={() => handlePopUpClose("editRole")}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key="role-list"
|
key="role-list"
|
||||||
transition={{ duration: 0.1 }}
|
transition={{ duration: 0.1 }}
|
||||||
@ -32,7 +15,7 @@ export const ProjectRoleListTab = withProjectPermission(
|
|||||||
animate={{ opacity: 1, translateX: 0 }}
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
exit={{ opacity: 0, translateX: -30 }}
|
exit={{ opacity: 0, translateX: -30 }}
|
||||||
>
|
>
|
||||||
<ProjectRoleList onSelectRole={(slug) => handlePopUpOpen("editRole", slug)} />
|
<ProjectRoleList />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -1,14 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useRouter } from "next/router";
|
||||||
import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DeleteActionModal,
|
DeleteActionModal,
|
||||||
IconButton,
|
DropdownMenu,
|
||||||
Input,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
@ -22,17 +25,17 @@ import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@a
|
|||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api";
|
import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api";
|
||||||
import { TProjectRole } from "@app/hooks/api/roles/types";
|
import { TProjectRole } from "@app/hooks/api/roles/types";
|
||||||
|
import { RoleModal } from "@app/views/Project/RolePage/components";
|
||||||
|
|
||||||
type Props = {
|
export const ProjectRoleList = () => {
|
||||||
onSelectRole: (slug?: string) => void;
|
const router = useRouter();
|
||||||
};
|
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||||
|
"role",
|
||||||
export const ProjectRoleList = ({ onSelectRole }: Props) => {
|
"deleteRole"
|
||||||
const [searchRoles, setSearchRoles] = useState("");
|
] as const);
|
||||||
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
|
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const projectSlug = currentWorkspace?.slug || "";
|
const projectSlug = currentWorkspace?.slug || "";
|
||||||
|
const projectId = currentWorkspace?.id || "";
|
||||||
|
|
||||||
const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug);
|
const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug);
|
||||||
|
|
||||||
@ -54,21 +57,16 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="mb-4 flex">
|
<div className="mb-4 flex justify-between">
|
||||||
<div className="mr-4 flex-1">
|
<p className="text-xl font-semibold text-mineshaft-100">Project Roles</p>
|
||||||
<Input
|
|
||||||
value={searchRoles}
|
|
||||||
onChange={(e) => setSearchRoles(e.target.value)}
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
|
||||||
placeholder="Search roles..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}>
|
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<Button
|
<Button
|
||||||
|
colorSchema="primary"
|
||||||
|
type="submit"
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
onClick={() => onSelectRole()}
|
onClick={() => handlePopUpOpen("role")}
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
>
|
>
|
||||||
Add Role
|
Add Role
|
||||||
@ -76,77 +74,94 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<TableContainer>
|
||||||
<TableContainer>
|
<Table>
|
||||||
<Table>
|
<THead>
|
||||||
<THead>
|
<Tr>
|
||||||
<Tr>
|
<Th>Name</Th>
|
||||||
<Th>Name</Th>
|
<Th>Slug</Th>
|
||||||
<Th>Slug</Th>
|
<Th aria-label="actions" className="w-5" />
|
||||||
<Th aria-label="actions" />
|
</Tr>
|
||||||
</Tr>
|
</THead>
|
||||||
</THead>
|
<TBody>
|
||||||
<TBody>
|
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
|
||||||
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
|
{roles?.map((role) => {
|
||||||
{roles?.map((role) => {
|
const { id, name, slug } = role;
|
||||||
const { id, name, slug } = role;
|
const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug);
|
||||||
const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr key={`role-list-${id}`}>
|
<Tr
|
||||||
<Td>{name}</Td>
|
key={`role-list-${id}`}
|
||||||
<Td>{slug}</Td>
|
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||||
<Td>
|
onClick={() => router.push(`/project/${projectId}/roles/${slug}`)}
|
||||||
<div className="flex justify-end space-x-2">
|
>
|
||||||
|
<Td>{name}</Td>
|
||||||
|
<Td>{slug}</Td>
|
||||||
|
<Td>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||||
|
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||||
|
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="p-1">
|
||||||
<ProjectPermissionCan
|
<ProjectPermissionCan
|
||||||
I={ProjectPermissionActions.Edit}
|
I={ProjectPermissionActions.Edit}
|
||||||
a={ProjectPermissionSub.Role}
|
a={ProjectPermissionSub.Role}
|
||||||
renderTooltip
|
|
||||||
allowedLabel="Edit"
|
|
||||||
>
|
>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<IconButton
|
<DropdownMenuItem
|
||||||
isDisabled={!isAllowed}
|
className={twMerge(
|
||||||
ariaLabel="edit"
|
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||||
onClick={() => onSelectRole(role.slug)}
|
)}
|
||||||
variant="plain"
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/project/${projectId}/roles/${slug}`);
|
||||||
|
}}
|
||||||
|
disabled={!isAllowed}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faEdit} />
|
{`${isNonMutatable ? "View" : "Edit"} Role`}
|
||||||
</IconButton>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
<ProjectPermissionCan
|
{!isNonMutatable && (
|
||||||
I={ProjectPermissionActions.Delete}
|
<ProjectPermissionCan
|
||||||
a={ProjectPermissionSub.Role}
|
I={ProjectPermissionActions.Delete}
|
||||||
renderTooltip
|
a={ProjectPermissionSub.Role}
|
||||||
allowedLabel={
|
>
|
||||||
isNonMutatable ? "Reserved roles are non-removable" : "Delete"
|
{(isAllowed) => (
|
||||||
}
|
<DropdownMenuItem
|
||||||
>
|
className={twMerge(
|
||||||
{(isAllowed) => (
|
isAllowed
|
||||||
<IconButton
|
? "hover:!bg-red-500 hover:!text-white"
|
||||||
ariaLabel="delete"
|
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||||
onClick={() => handlePopUpOpen("deleteRole", role)}
|
)}
|
||||||
variant="plain"
|
onClick={(e) => {
|
||||||
isDisabled={isNonMutatable || !isAllowed}
|
e.stopPropagation();
|
||||||
>
|
handlePopUpOpen("deleteRole", role);
|
||||||
<FontAwesomeIcon icon={faTrash} />
|
}}
|
||||||
</IconButton>
|
disabled={!isAllowed}
|
||||||
)}
|
>
|
||||||
</ProjectPermissionCan>
|
Delete Role
|
||||||
</div>
|
</DropdownMenuItem>
|
||||||
</Td>
|
)}
|
||||||
</Tr>
|
</ProjectPermissionCan>
|
||||||
);
|
)}
|
||||||
})}
|
</DropdownMenuContent>
|
||||||
</TBody>
|
</DropdownMenu>
|
||||||
</Table>
|
</Td>
|
||||||
</TableContainer>
|
</Tr>
|
||||||
</div>
|
);
|
||||||
|
})}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
isOpen={popUp.deleteRole.isOpen}
|
isOpen={popUp.deleteRole.isOpen}
|
||||||
title={`Are you sure want to delete ${(popUp?.deleteRole?.data as TProjectRole)?.name || " "
|
title={`Are you sure want to delete ${
|
||||||
} role?`}
|
(popUp?.deleteRole?.data as TProjectRole)?.name || " "
|
||||||
|
} role?`}
|
||||||
deleteKey={(popUp?.deleteRole?.data as TProjectRole)?.slug || ""}
|
deleteKey={(popUp?.deleteRole?.data as TProjectRole)?.slug || ""}
|
||||||
onClose={() => handlePopUpClose("deleteRole")}
|
onClose={() => handlePopUpClose("deleteRole")}
|
||||||
onDeleteApproved={handleRoleDelete}
|
onDeleteApproved={handleRoleDelete}
|
||||||
|
@ -1,258 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form";
|
|
||||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import GlobPatternExamples from "@app/components/basic/popups/GlobPatternExamples";
|
|
||||||
import {
|
|
||||||
Checkbox,
|
|
||||||
FormControl,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
SelectItem,
|
|
||||||
Table,
|
|
||||||
TableContainer,
|
|
||||||
TBody,
|
|
||||||
Td,
|
|
||||||
Th,
|
|
||||||
THead,
|
|
||||||
Tr
|
|
||||||
} from "@app/components/v2";
|
|
||||||
import { useWorkspace } from "@app/context";
|
|
||||||
|
|
||||||
import { TFormSchema } from "./ProjectRoleModifySection.utils";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
formName: "secrets";
|
|
||||||
isNonEditable?: boolean;
|
|
||||||
setValue: UseFormSetValue<TFormSchema>;
|
|
||||||
getValue: UseFormGetValues<TFormSchema>;
|
|
||||||
control: Control<TFormSchema>;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
icon: IconProp;
|
|
||||||
};
|
|
||||||
|
|
||||||
enum Permission {
|
|
||||||
NoAccess = "no-access",
|
|
||||||
ReadOnly = "read-only",
|
|
||||||
FullAccess = "full-acess",
|
|
||||||
Custom = "custom"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MultiEnvProjectPermission = ({
|
|
||||||
isNonEditable,
|
|
||||||
setValue,
|
|
||||||
getValue,
|
|
||||||
control,
|
|
||||||
formName,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
icon
|
|
||||||
}: Props) => {
|
|
||||||
const { currentWorkspace } = useWorkspace();
|
|
||||||
|
|
||||||
const environments = currentWorkspace?.environments || [];
|
|
||||||
const customRule = useWatch({
|
|
||||||
control,
|
|
||||||
name: `permissions.${formName}.custom`
|
|
||||||
});
|
|
||||||
const isCustom = Boolean(customRule);
|
|
||||||
const allRule = useWatch({ control, name: `permissions.${formName}.all` });
|
|
||||||
|
|
||||||
const selectedPermissionCategory = useMemo(() => {
|
|
||||||
const { read, delete: del, edit, create } = allRule || {};
|
|
||||||
if (read && del && edit && create) return Permission.FullAccess;
|
|
||||||
if (read) return Permission.ReadOnly;
|
|
||||||
return Permission.NoAccess;
|
|
||||||
}, [allRule]);
|
|
||||||
|
|
||||||
const handlePermissionChange = (val: Permission) => {
|
|
||||||
if(!val) return
|
|
||||||
switch (val) {
|
|
||||||
case Permission.NoAccess: {
|
|
||||||
const permissions = getValue("permissions");
|
|
||||||
if (permissions) delete permissions[formName];
|
|
||||||
setValue("permissions", permissions, { shouldDirty: true });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case Permission.FullAccess:
|
|
||||||
setValue(
|
|
||||||
`permissions.${formName}`,
|
|
||||||
{ all: { read: true, edit: true, create: true, delete: true } },
|
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case Permission.ReadOnly:
|
|
||||||
setValue(
|
|
||||||
`permissions.${formName}`,
|
|
||||||
{ all: { read: true, edit: false, create: false, delete: false } },
|
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
setValue(
|
|
||||||
`permissions.${formName}`,
|
|
||||||
{ custom: { read: false, edit: false, create: false, delete: false } },
|
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={twMerge(
|
|
||||||
"rounded-md bg-mineshaft-800 px-10 py-6",
|
|
||||||
(selectedPermissionCategory !== Permission.NoAccess || isCustom) &&
|
|
||||||
"border-l-2 border-primary-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div>
|
|
||||||
<FontAwesomeIcon icon={icon} className="text-4xl" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-grow flex-col">
|
|
||||||
<div className="mb-1 text-lg font-medium">{title}</div>
|
|
||||||
<div className="text-xs font-light">{subtitle}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Select
|
|
||||||
defaultValue={Permission.NoAccess}
|
|
||||||
isDisabled={isNonEditable}
|
|
||||||
value={isCustom ? Permission.Custom : selectedPermissionCategory}
|
|
||||||
onValueChange={handlePermissionChange}
|
|
||||||
>
|
|
||||||
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
|
|
||||||
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
|
|
||||||
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
|
|
||||||
<SelectItem value={Permission.Custom}>Custom</SelectItem>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<motion.div
|
|
||||||
initial={false}
|
|
||||||
animate={{ height: isCustom ? "auto" : 0 }}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<TableContainer className="mt-6 border-mineshaft-500">
|
|
||||||
<Table>
|
|
||||||
<THead>
|
|
||||||
<Tr>
|
|
||||||
<Th />
|
|
||||||
<Th className="min-w-[8rem]">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
Secret Path
|
|
||||||
<span className="text-xs normal-case">
|
|
||||||
<GlobPatternExamples />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Th>
|
|
||||||
<Th className="text-center">View</Th>
|
|
||||||
<Th className="text-center">Create</Th>
|
|
||||||
<Th className="text-center">Modify</Th>
|
|
||||||
<Th className="text-center">Delete</Th>
|
|
||||||
</Tr>
|
|
||||||
</THead>
|
|
||||||
<TBody>
|
|
||||||
{isCustom &&
|
|
||||||
environments.map(({ name, slug }) => (
|
|
||||||
<Tr key={`custom-role-project-secret-${slug}`}>
|
|
||||||
<Td>{name}</Td>
|
|
||||||
<Td>
|
|
||||||
<Controller
|
|
||||||
name={`permissions.${formName}.${slug}.secretPath`}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
/* eslint-disable-next-line no-template-curly-in-string */
|
|
||||||
<FormControl helperText="Supports glob path pattern string">
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
className="w-full overflow-ellipsis"
|
|
||||||
placeholder="Glob patterns are supported"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Controller
|
|
||||||
name={`permissions.${formName}.${slug}.read`}
|
|
||||||
control={control}
|
|
||||||
defaultValue={false}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Checkbox
|
|
||||||
isChecked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
id={`permissions.${formName}.${slug}.read`}
|
|
||||||
isDisabled={isNonEditable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Controller
|
|
||||||
name={`permissions.${formName}.${slug}.create`}
|
|
||||||
control={control}
|
|
||||||
defaultValue={false}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Checkbox
|
|
||||||
isChecked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
id={`permissions.${formName}.${slug}.modify`}
|
|
||||||
isDisabled={isNonEditable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Controller
|
|
||||||
name={`permissions.${formName}.${slug}.edit`}
|
|
||||||
control={control}
|
|
||||||
defaultValue={false}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Checkbox
|
|
||||||
isChecked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
id={`permissions.${formName}.${slug}.modify`}
|
|
||||||
isDisabled={isNonEditable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Controller
|
|
||||||
defaultValue={false}
|
|
||||||
name={`permissions.${formName}.${slug}.delete`}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Checkbox
|
|
||||||
isChecked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
id={`permissions.${formName}.${slug}.delete`}
|
|
||||||
isDisabled={isNonEditable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
))}
|
|
||||||
</TBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,318 +0,0 @@
|
|||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { faElementor } from "@fortawesome/free-brands-svg-icons";
|
|
||||||
import {
|
|
||||||
faAnchorLock,
|
|
||||||
faArrowLeft,
|
|
||||||
faBook,
|
|
||||||
faCertificate,
|
|
||||||
faCog,
|
|
||||||
faKey,
|
|
||||||
faLock,
|
|
||||||
faNetworkWired,
|
|
||||||
faPuzzlePiece,
|
|
||||||
faServer,
|
|
||||||
faShield,
|
|
||||||
faTags,
|
|
||||||
faUser,
|
|
||||||
faUsers} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
|
||||||
import { Button, FormControl, Input, Spinner } from "@app/components/v2";
|
|
||||||
import { ProjectPermissionSub, useWorkspace } from "@app/context";
|
|
||||||
import {
|
|
||||||
useCreateProjectRole,
|
|
||||||
useGetProjectRoleBySlug,
|
|
||||||
useUpdateProjectRole
|
|
||||||
} from "@app/hooks/api";
|
|
||||||
import { TProjectRole } from "@app/hooks/api/roles/types";
|
|
||||||
|
|
||||||
import { MultiEnvProjectPermission } from "./MultiEnvProjectPermission";
|
|
||||||
import {
|
|
||||||
formRolePermission2API,
|
|
||||||
formSchema,
|
|
||||||
rolePermission2Form,
|
|
||||||
TFormSchema
|
|
||||||
} from "./ProjectRoleModifySection.utils";
|
|
||||||
import { SecretRollbackPermission } from "./SecretRollbackPermission";
|
|
||||||
import { SingleProjectPermission } from "./SingleProjectPermission";
|
|
||||||
import { WsProjectPermission } from "./WsProjectPermission";
|
|
||||||
|
|
||||||
const SINGLE_PERMISSION_LIST = [
|
|
||||||
{
|
|
||||||
title: "Integrations",
|
|
||||||
subtitle: "Integration management control",
|
|
||||||
icon: faPuzzlePiece,
|
|
||||||
formName: "integrations"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Secret Protect policy",
|
|
||||||
subtitle: "Manage policies for secret protection for unauthorized secret changes",
|
|
||||||
icon: faShield,
|
|
||||||
formName: ProjectPermissionSub.SecretApproval
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Roles",
|
|
||||||
subtitle: "Role management control",
|
|
||||||
icon: faUsers,
|
|
||||||
formName: "role"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "User management",
|
|
||||||
subtitle: "Add, view and remove users from the project",
|
|
||||||
icon: faUser,
|
|
||||||
formName: "member"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Group management",
|
|
||||||
subtitle: "Add, view and remove user groups from the project",
|
|
||||||
icon: faUsers,
|
|
||||||
formName: "groups"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Machine identity management",
|
|
||||||
subtitle: "Add, view, update and remove (machine) identities from the project",
|
|
||||||
icon: faServer,
|
|
||||||
formName: "identity"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Webhooks",
|
|
||||||
subtitle: "Webhook management control",
|
|
||||||
icon: faAnchorLock,
|
|
||||||
formName: "webhooks"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Service Tokens",
|
|
||||||
subtitle: "Token management control",
|
|
||||||
icon: faKey,
|
|
||||||
formName: "service-tokens"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Settings",
|
|
||||||
subtitle: "Settings control",
|
|
||||||
icon: faCog,
|
|
||||||
formName: "settings"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Environments",
|
|
||||||
subtitle: "Environment management control",
|
|
||||||
icon: faElementor,
|
|
||||||
formName: "environments"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Tags",
|
|
||||||
subtitle: "Tag management control",
|
|
||||||
icon: faTags,
|
|
||||||
formName: "tags"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Audit Logs",
|
|
||||||
subtitle: "Audit log management control",
|
|
||||||
icon: faBook,
|
|
||||||
formName: "audit-logs"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "IP Allowlist",
|
|
||||||
subtitle: "IP allowlist management control",
|
|
||||||
icon: faNetworkWired,
|
|
||||||
formName: "ip-allowlist"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Certificate Authorities",
|
|
||||||
subtitle: "CA management control",
|
|
||||||
icon: faCertificate,
|
|
||||||
formName: "certificate-authorities"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Certificates",
|
|
||||||
subtitle: "Certificate management control",
|
|
||||||
icon: faCertificate,
|
|
||||||
formName: "certificates"
|
|
||||||
}
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
roleSlug?: string;
|
|
||||||
onGoBack: VoidFunction;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProjectRoleModifySection = ({ roleSlug, onGoBack }: Props) => {
|
|
||||||
const isNonEditable = ["admin", "member", "viewer", "no-access"].includes(roleSlug || "");
|
|
||||||
const isNewRole = !roleSlug;
|
|
||||||
|
|
||||||
const { currentWorkspace } = useWorkspace();
|
|
||||||
const projectSlug = currentWorkspace?.slug || "";
|
|
||||||
const { data: roleDetails, isLoading: isRoleDetailsLoading } = useGetProjectRoleBySlug(
|
|
||||||
currentWorkspace?.slug || "",
|
|
||||||
roleSlug as string
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
handleSubmit,
|
|
||||||
register,
|
|
||||||
formState: { isSubmitting, isDirty, errors },
|
|
||||||
setValue,
|
|
||||||
getValues,
|
|
||||||
control
|
|
||||||
} = useForm<TFormSchema>({
|
|
||||||
values: roleDetails
|
|
||||||
? { ...roleDetails, permissions: rolePermission2Form(roleDetails.permissions) }
|
|
||||||
: ({} as TProjectRole),
|
|
||||||
resolver: zodResolver(formSchema)
|
|
||||||
});
|
|
||||||
const { mutateAsync: createRole } = useCreateProjectRole();
|
|
||||||
const { mutateAsync: updateRole } = useUpdateProjectRole();
|
|
||||||
|
|
||||||
const handleRoleUpdate = async (el: TFormSchema) => {
|
|
||||||
if (!roleDetails?.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateRole({
|
|
||||||
id: roleDetails?.id as string,
|
|
||||||
projectSlug,
|
|
||||||
...el,
|
|
||||||
permissions: formRolePermission2API(el.permissions)
|
|
||||||
});
|
|
||||||
createNotification({ type: "success", text: "Successfully updated role" });
|
|
||||||
onGoBack();
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
createNotification({ type: "error", text: "Failed to update role" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormSubmit = async (el: TFormSchema) => {
|
|
||||||
if (!isNewRole) {
|
|
||||||
await handleRoleUpdate(el);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createRole({
|
|
||||||
projectSlug,
|
|
||||||
...el,
|
|
||||||
permissions: formRolePermission2API(el.permissions)
|
|
||||||
});
|
|
||||||
createNotification({ type: "success", text: "Created new role" });
|
|
||||||
onGoBack();
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
createNotification({ type: "error", text: "Failed to create role" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isNewRole && isRoleDetailsLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex w-full items-center justify-center p-8">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h1 className="text-xl font-semibold text-mineshaft-100">
|
|
||||||
{isNewRole ? "New" : "Edit"} Role
|
|
||||||
</h1>
|
|
||||||
<Button
|
|
||||||
onClick={onGoBack}
|
|
||||||
variant="outline_bg"
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
|
|
||||||
>
|
|
||||||
Go back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="mb-8 text-gray-400">
|
|
||||||
Project-level roles allow you to define permissions for resources within projects at a
|
|
||||||
granular level
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col space-y-6">
|
|
||||||
<FormControl
|
|
||||||
label="Name"
|
|
||||||
isRequired
|
|
||||||
className="mb-0"
|
|
||||||
isError={Boolean(errors?.name)}
|
|
||||||
errorText={errors?.name?.message}
|
|
||||||
>
|
|
||||||
<Input {...register("name")} placeholder="Billing Team" isReadOnly={isNonEditable} />
|
|
||||||
</FormControl>
|
|
||||||
<FormControl
|
|
||||||
label="Slug"
|
|
||||||
isRequired
|
|
||||||
isError={Boolean(errors?.slug)}
|
|
||||||
errorText={errors?.slug?.message}
|
|
||||||
>
|
|
||||||
<Input {...register("slug")} placeholder="biller" isReadOnly={isNonEditable} />
|
|
||||||
</FormControl>
|
|
||||||
<FormControl
|
|
||||||
label="Description"
|
|
||||||
helperText="A short description about this role"
|
|
||||||
isError={Boolean(errors?.description)}
|
|
||||||
errorText={errors?.description?.message}
|
|
||||||
>
|
|
||||||
<Input {...register("description")} isReadOnly={isNonEditable} />
|
|
||||||
</FormControl>
|
|
||||||
<div className="flex items-center justify-between border-t border-t-mineshaft-800 pt-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-medium">Add Permission</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<MultiEnvProjectPermission
|
|
||||||
getValue={getValues}
|
|
||||||
isNonEditable={isNonEditable}
|
|
||||||
control={control}
|
|
||||||
setValue={setValue}
|
|
||||||
icon={faLock}
|
|
||||||
title="Secrets"
|
|
||||||
subtitle="Create, modify and remove secrets, folders and secret imports"
|
|
||||||
formName="secrets"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div key="permission-ws">
|
|
||||||
<WsProjectPermission
|
|
||||||
control={control}
|
|
||||||
setValue={setValue}
|
|
||||||
isNonEditable={isNonEditable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{SINGLE_PERMISSION_LIST.map(({ title, subtitle, icon, formName }) => (
|
|
||||||
<div key={`permission-${title}`}>
|
|
||||||
<SingleProjectPermission
|
|
||||||
isNonEditable={isNonEditable}
|
|
||||||
control={control}
|
|
||||||
setValue={setValue}
|
|
||||||
icon={icon}
|
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
formName={formName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div key="permission-secret-rollback">
|
|
||||||
<SecretRollbackPermission
|
|
||||||
control={control}
|
|
||||||
setValue={setValue}
|
|
||||||
isNonEditable={isNonEditable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-12 flex items-center space-x-4">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
isDisabled={isSubmitting || isNonEditable || !isDirty}
|
|
||||||
isLoading={isSubmitting}
|
|
||||||
>
|
|
||||||
{isNewRole ? "Create Role" : "Save Role"}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onGoBack} variant="outline_bg">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,147 +0,0 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
|
|
||||||
import { faPuzzlePiece } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import { Checkbox, Select, SelectItem } from "@app/components/v2";
|
|
||||||
import { useToggle } from "@app/hooks";
|
|
||||||
|
|
||||||
import { TFormSchema } from "./ProjectRoleModifySection.utils";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isNonEditable?: boolean;
|
|
||||||
setValue: UseFormSetValue<TFormSchema>;
|
|
||||||
control: Control<TFormSchema>;
|
|
||||||
};
|
|
||||||
|
|
||||||
enum Permission {
|
|
||||||
NoAccess = "no-access",
|
|
||||||
ReadOnly = "read-only",
|
|
||||||
FullAccess = "full-acess",
|
|
||||||
Custom = "custom"
|
|
||||||
}
|
|
||||||
|
|
||||||
const PERMISSIONS = [
|
|
||||||
{ action: "create", label: "Perform Rollback" },
|
|
||||||
{ action: "read", label: "View" }
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const SecretRollbackPermission = ({ isNonEditable, setValue, control }: Props) => {
|
|
||||||
const rule = useWatch({
|
|
||||||
control,
|
|
||||||
name: "permissions.secret-rollback"
|
|
||||||
});
|
|
||||||
const [isCustom, setIsCustom] = useToggle();
|
|
||||||
|
|
||||||
const selectedPermissionCategory = useMemo(() => {
|
|
||||||
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
|
|
||||||
const totalActions = PERMISSIONS.length;
|
|
||||||
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
|
|
||||||
|
|
||||||
if (isCustom) return Permission.Custom;
|
|
||||||
if (score === 0) return Permission.NoAccess;
|
|
||||||
if (score === totalActions) return Permission.FullAccess;
|
|
||||||
|
|
||||||
return Permission.Custom;
|
|
||||||
}, [rule, isCustom]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
|
|
||||||
else setIsCustom.off();
|
|
||||||
}, [selectedPermissionCategory]);
|
|
||||||
|
|
||||||
const handlePermissionChange = (val: Permission) => {
|
|
||||||
if(!val) return;
|
|
||||||
if (val === Permission.Custom) setIsCustom.on();
|
|
||||||
else setIsCustom.off();
|
|
||||||
|
|
||||||
switch (val) {
|
|
||||||
case Permission.NoAccess:
|
|
||||||
setValue(
|
|
||||||
"permissions.secret-rollback",
|
|
||||||
{ read: false, create: false },
|
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case Permission.FullAccess:
|
|
||||||
setValue(
|
|
||||||
"permissions.secret-rollback",
|
|
||||||
{ read: true, create: true },
|
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case Permission.ReadOnly:
|
|
||||||
setValue(
|
|
||||||
"permissions.secret-rollback",
|
|
||||||
{ read: true, create: false },
|
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
setValue(
|
|
||||||
"permissions.secret-rollback",
|
|
||||||
{ read: false, create: false },
|
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={twMerge(
|
|
||||||
"rounded-md bg-mineshaft-800 px-10 py-6",
|
|
||||||
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div>
|
|
||||||
<FontAwesomeIcon icon={faPuzzlePiece} className="text-4xl" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-grow flex-col">
|
|
||||||
<div className="mb-1 text-lg font-medium">Secret Rollback</div>
|
|
||||||
<div className="text-xs font-light">Secret rollback control actions</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Select
|
|
||||||
defaultValue={Permission.NoAccess}
|
|
||||||
isDisabled={isNonEditable}
|
|
||||||
value={selectedPermissionCategory}
|
|
||||||
onValueChange={handlePermissionChange}
|
|
||||||
>
|
|
||||||
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
|
|
||||||
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
|
|
||||||
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
|
|
||||||
<SelectItem value={Permission.Custom}>Custom</SelectItem>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<motion.div
|
|
||||||
initial={false}
|
|
||||||
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
|
|
||||||
className="grid auto-cols-min grid-flow-col gap-8 overflow-hidden"
|
|
||||||
>
|
|
||||||
{isCustom &&
|
|
||||||
PERMISSIONS.map(({ action, label }) => (
|
|
||||||
<Controller
|
|
||||||
name={`permissions.secret-rollback.${action}`}
|
|
||||||
key={`permissions.secret-rollback.${action}`}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Checkbox
|
|
||||||
isChecked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
id={`permissions.secret-rollback.${action}`}
|
|
||||||
isDisabled={isNonEditable}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Checkbox>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,194 +0,0 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
|
|
||||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import { Checkbox, Select, SelectItem } from "@app/components/v2";
|
|
||||||
import { ProjectPermissionSub } from "@app/context";
|
|
||||||
import { useToggle } from "@app/hooks";
|
|
||||||
|
|
||||||
import { TFormSchema } from "./ProjectRoleModifySection.utils";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
formName:
|
|
||||||
| "role"
|
|
||||||
| "member"
|
|
||||||
| "groups"
|
|
||||||
| "integrations"
|
|
||||||
| "webhooks"
|
|
||||||
| "service-tokens"
|
|
||||||
| "settings"
|
|
||||||
| "environments"
|
|
||||||
| "tags"
|
|
||||||
| "audit-logs"
|
|
||||||
| "ip-allowlist"
|
|
||||||
| "identity"
|
|
||||||
| "certificate-authorities"
|
|
||||||
| "certificates"
|
|
||||||
| ProjectPermissionSub.SecretApproval;
|
|
||||||
isNonEditable?: boolean;
|
|
||||||
setValue: UseFormSetValue<TFormSchema>;
|
|
||||||
control: Control<TFormSchema>;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
icon: IconProp;
|
|
||||||
};
|
|
||||||
|
|
||||||
enum Permission {
|
|
||||||
NoAccess = "no-access",
|
|
||||||
ReadOnly = "read-only",
|
|
||||||
FullAccess = "full-acess",
|
|
||||||
Custom = "custom"
|
|
||||||
}
|
|
||||||
|
|
||||||
const PERMISSIONS = [
|
|
||||||
{ action: "read", label: "View" },
|
|
||||||
{ action: "create", label: "Create" },
|
|
||||||
{ action: "edit", label: "Modify" },
|
|
||||||
{ action: "delete", label: "Remove" }
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const MEMBERS_PERMISSIONS = [
|
|
||||||
{ action: "read", label: "View all members" },
|
|
||||||
{ action: "create", label: "Invite members" },
|
|
||||||
{ action: "edit", label: "Edit members" },
|
|
||||||
{ action: "delete", label: "Remove members" }
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const getPermissionList = (option: Props["formName"]) => {
|
|
||||||
switch (option) {
|
|
||||||
case "member":
|
|
||||||
return MEMBERS_PERMISSIONS;
|
|
||||||
default:
|
|
||||||
return PERMISSIONS;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SingleProjectPermission = ({
|
|
||||||
isNonEditable,
|
|
||||||
setValue,
|
|
||||||
control,
|
|
||||||
formName,
|
|
||||||
subtitle,
|
|
||||||
title,
|
|
||||||
icon
|
|
||||||
}: Props) => {
|
|
||||||
const rule = useWatch({
|
|
||||||
control,
|
|
||||||
name: `permissions.${formName}`
|
|
||||||
});
|
|
||||||
const [isCustom, setIsCustom] = useToggle();
|
|
||||||
|
|
||||||
const selectedPermissionCategory = useMemo(() => {
|
|
||||||
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
|
|
||||||
const totalActions = PERMISSIONS.length;
|
|
||||||
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
|
|
||||||
|
|
||||||
if (isCustom) return Permission.Custom;
|
|
||||||
if (score === 0) return Permission.NoAccess;
|
|
||||||
if (score === totalActions) return Permission.FullAccess;
|
|
||||||
if (score === 1 && rule?.read) return Permission.ReadOnly;
|
|
||||||
|
|
||||||
return Permission.Custom;
|
|
||||||
}, [rule, isCustom]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
|
|
||||||
else setIsCustom.off();
|
|
||||||
}, [selectedPermissionCategory]);
|
|
||||||
|
|
||||||
const handlePermissionChange = (val: Permission) => {
|
|
||||||
if(!val) return;
|
|
||||||
if (val === Permission.Custom) setIsCustom.on();
|
|
||||||
else setIsCustom.off();
|
|
||||||
|
|
||||||
switch (val) {
|
|
||||||
case Permission.NoAccess:
|
|
||||||
setValue(
|
|
||||||
`permissions.${formName}`,
|
|
||||||
{ read: false, edit: false, create: false, delete: false },
|
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case Permission.FullAccess:
|
|
||||||
setValue(
|
|
||||||
`permissions.${formName}`,
|
|
||||||
{ read: true, edit: true, create: true, delete: true },
|
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case Permission.ReadOnly:
|
|
||||||
setValue(
|
|
||||||
`permissions.${formName}`,
|
|
||||||
{ read: true, edit: false, create: false, delete: false },
|
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
setValue(
|
|
||||||
`permissions.${formName}`,
|
|
||||||
{ read: false, edit: false, create: false, delete: false },
|
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={twMerge(
|
|
||||||
"rounded-md bg-mineshaft-800 px-10 py-6",
|
|
||||||
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div>
|
|
||||||
<FontAwesomeIcon icon={icon} className="text-4xl" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-grow flex-col">
|
|
||||||
<div className="mb-1 text-lg font-medium">{title}</div>
|
|
||||||
<div className="text-xs font-light">{subtitle}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Select
|
|
||||||
defaultValue={Permission.NoAccess}
|
|
||||||
isDisabled={isNonEditable}
|
|
||||||
value={selectedPermissionCategory}
|
|
||||||
onValueChange={handlePermissionChange}
|
|
||||||
>
|
|
||||||
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
|
|
||||||
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
|
|
||||||
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
|
|
||||||
<SelectItem value={Permission.Custom}>Custom</SelectItem>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<motion.div
|
|
||||||
initial={false}
|
|
||||||
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
|
|
||||||
className="grid auto-cols-min grid-flow-col gap-8 overflow-hidden"
|
|
||||||
>
|
|
||||||
{isCustom &&
|
|
||||||
getPermissionList(formName).map(({ action, label }) => (
|
|
||||||
<Controller
|
|
||||||
name={`permissions.${formName}.${action}`}
|
|
||||||
key={`permissions.${formName}.${action}`}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Checkbox
|
|
||||||
isChecked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
id={`permissions.${formName}.${action}`}
|
|
||||||
isDisabled={isNonEditable}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Checkbox>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,126 +0,0 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
|
|
||||||
import { faPuzzlePiece } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import { Checkbox, Select, SelectItem } from "@app/components/v2";
|
|
||||||
import { useToggle } from "@app/hooks";
|
|
||||||
|
|
||||||
import { TFormSchema } from "./ProjectRoleModifySection.utils";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isNonEditable?: boolean;
|
|
||||||
setValue: UseFormSetValue<TFormSchema>;
|
|
||||||
control: Control<TFormSchema>;
|
|
||||||
};
|
|
||||||
|
|
||||||
enum Permission {
|
|
||||||
NoAccess = "no-access",
|
|
||||||
FullAccess = "full-acess",
|
|
||||||
Custom = "custom"
|
|
||||||
}
|
|
||||||
|
|
||||||
const PERMISSIONS = [
|
|
||||||
{ action: "edit", label: "Update project details" },
|
|
||||||
{ action: "delete", label: "Delete projects" }
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props) => {
|
|
||||||
const rule = useWatch({
|
|
||||||
control,
|
|
||||||
name: "permissions.workspace"
|
|
||||||
});
|
|
||||||
const [isCustom, setIsCustom] = useToggle();
|
|
||||||
|
|
||||||
const selectedPermissionCategory = useMemo(() => {
|
|
||||||
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
|
|
||||||
const totalActions = PERMISSIONS.length;
|
|
||||||
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
|
|
||||||
|
|
||||||
if (isCustom) return Permission.Custom;
|
|
||||||
if (score === 0) return Permission.NoAccess;
|
|
||||||
if (score === totalActions) return Permission.FullAccess;
|
|
||||||
|
|
||||||
return Permission.Custom;
|
|
||||||
}, [rule, isCustom]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
|
|
||||||
else setIsCustom.off();
|
|
||||||
}, [selectedPermissionCategory]);
|
|
||||||
|
|
||||||
const handlePermissionChange = (val: Permission) => {
|
|
||||||
if(!val) return;
|
|
||||||
if (val === Permission.Custom) setIsCustom.on();
|
|
||||||
else setIsCustom.off();
|
|
||||||
|
|
||||||
switch (val) {
|
|
||||||
case Permission.NoAccess:
|
|
||||||
setValue("permissions.workspace", { edit: false, delete: false }, { shouldDirty: true });
|
|
||||||
break;
|
|
||||||
case Permission.FullAccess:
|
|
||||||
setValue("permissions.workspace", { edit: true, delete: true }, { shouldDirty: true });
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
setValue("permissions.workspace", { edit: false, delete: false }, { shouldDirty: true });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={twMerge(
|
|
||||||
"rounded-md bg-mineshaft-800 px-10 py-6",
|
|
||||||
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div>
|
|
||||||
<FontAwesomeIcon icon={faPuzzlePiece} className="text-4xl" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-grow flex-col">
|
|
||||||
<div className="mb-1 text-lg font-medium">Project</div>
|
|
||||||
<div className="text-xs font-light">Project control actions</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Select
|
|
||||||
defaultValue={Permission.NoAccess}
|
|
||||||
isDisabled={isNonEditable}
|
|
||||||
value={selectedPermissionCategory}
|
|
||||||
onValueChange={handlePermissionChange}
|
|
||||||
>
|
|
||||||
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
|
|
||||||
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
|
|
||||||
<SelectItem value={Permission.Custom}>Custom</SelectItem>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<motion.div
|
|
||||||
initial={false}
|
|
||||||
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
|
|
||||||
className="grid auto-cols-min grid-flow-col gap-8 overflow-hidden"
|
|
||||||
>
|
|
||||||
{isCustom &&
|
|
||||||
PERMISSIONS.map(({ action, label }) => (
|
|
||||||
<Controller
|
|
||||||
name={`permissions.workspace.${action}`}
|
|
||||||
key={`permissions.workspace.${action}`}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Checkbox
|
|
||||||
isChecked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
id={`permissions.workspace.${action}`}
|
|
||||||
isDisabled={isNonEditable}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Checkbox>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1 +0,0 @@
|
|||||||
export { ProjectRoleModifySection } from "./ProjectRoleModifySection";
|
|
158
frontend/src/views/Project/RolePage/RolePage.tsx
Normal file
158
frontend/src/views/Project/RolePage/RolePage.tsx
Normal 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 }
|
||||||
|
);
|
@ -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 />
|
||||||
|
);
|
||||||
|
};
|
196
frontend/src/views/Project/RolePage/components/RoleModal.tsx
Normal file
196
frontend/src/views/Project/RolePage/components/RoleModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
219
frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx
Normal file
219
frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
247
frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx
Normal file
247
frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionSecretsRow.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
198
frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx
Normal file
198
frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export { RolePermissionsSection } from "./RolePermissionsSection";
|
3
frontend/src/views/Project/RolePage/components/index.tsx
Normal file
3
frontend/src/views/Project/RolePage/components/index.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { RoleDetailsSection } from "./RoleDetailsSection";
|
||||||
|
export { RoleModal } from "./RoleModal";
|
||||||
|
export { RolePermissionsSection } from "./RolePermissionsSection";
|
1
frontend/src/views/Project/RolePage/index.tsx
Normal file
1
frontend/src/views/Project/RolePage/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { RolePage } from "./RolePage";
|
11
frontend/src/views/Project/Types/TabSections.ts
Normal file
11
frontend/src/views/Project/Types/TabSections.ts
Normal 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);
|
||||||
|
}
|
3
frontend/src/views/Project/Types/index.ts
Normal file
3
frontend/src/views/Project/Types/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { TabSections, isTabSection } from "./TabSections";
|
||||||
|
|
||||||
|
export { TabSections, isTabSection };
|
@ -404,12 +404,7 @@ export const SecretListView = ({
|
|||||||
isOpen={popUp.createTag.isOpen}
|
isOpen={popUp.createTag.isOpen}
|
||||||
onToggle={(isOpen) => handlePopUpToggle("createTag", isOpen)}
|
onToggle={(isOpen) => handlePopUpToggle("createTag", isOpen)}
|
||||||
/>
|
/>
|
||||||
<AddShareSecretModal
|
<AddShareSecretModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||||
popUp={popUp}
|
|
||||||
handlePopUpToggle={handlePopUpToggle}
|
|
||||||
isPublic={false}
|
|
||||||
inModal
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { subject } from "@casl/ability";
|
import { subject } from "@casl/ability";
|
||||||
import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
|
import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
@ -6,7 +7,7 @@ import { twMerge } from "tailwind-merge";
|
|||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import { IconButton, Tooltip } from "@app/components/v2";
|
import { IconButton, Tooltip, DeleteActionModal } from "@app/components/v2";
|
||||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
import { useToggle } from "@app/hooks";
|
import { useToggle } from "@app/hooks";
|
||||||
@ -59,6 +60,11 @@ export const SecretEditRow = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [isDeleting, setIsDeleting] = useToggle();
|
const [isDeleting, setIsDeleting] = useToggle();
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const toggleModal = useCallback(() => {
|
||||||
|
setIsModalOpen((prev) => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleFormReset = () => {
|
const handleFormReset = () => {
|
||||||
reset();
|
reset();
|
||||||
@ -94,18 +100,29 @@ export const SecretEditRow = ({
|
|||||||
reset({ value });
|
reset({ value });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSecret = async () => {
|
const handleDeleteSecret = useCallback(async () => {
|
||||||
setIsDeleting.on();
|
setIsDeleting.on();
|
||||||
|
setIsModalOpen(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSecretDelete(environment, secretName, secretId);
|
await onSecretDelete(environment, secretName, secretId);
|
||||||
reset({ value: null });
|
reset({ value: null });
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting.off();
|
setIsDeleting.off();
|
||||||
}
|
}
|
||||||
};
|
}, [onSecretDelete, environment, secretName, secretId, reset, setIsDeleting]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex w-full cursor-text items-center space-x-2">
|
<div className="group flex w-full cursor-text items-center space-x-2">
|
||||||
|
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={toggleModal}
|
||||||
|
title="Do you want to delete the selected secret?"
|
||||||
|
deleteKey="delete"
|
||||||
|
onDeleteApproved={handleDeleteSecret}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex-grow border-r border-r-mineshaft-600 pr-2 pl-1">
|
<div className="flex-grow border-r border-r-mineshaft-600 pr-2 pl-1">
|
||||||
<Controller
|
<Controller
|
||||||
disabled={isImportedSecret && !defaultValue}
|
disabled={isImportedSecret && !defaultValue}
|
||||||
@ -193,7 +210,7 @@ export const SecretEditRow = ({
|
|||||||
variant="plain"
|
variant="plain"
|
||||||
ariaLabel="delete-value"
|
ariaLabel="delete-value"
|
||||||
className="h-full"
|
className="h-full"
|
||||||
onClick={handleDeleteSecret}
|
onClick={toggleModal}
|
||||||
isDisabled={isDeleting || !isAllowed}
|
isDisabled={isDeleting || !isAllowed}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faTrash} />
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
@ -49,8 +49,9 @@ export const SelectionPanel = ({
|
|||||||
"bulkDeleteEntries"
|
"bulkDeleteEntries"
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
const selectedCount =
|
const selectedFolderCount = Object.keys(selectedEntries.folder).length
|
||||||
Object.keys(selectedEntries.folder).length + Object.keys(selectedEntries.secret).length;
|
const selectedKeysCount = Object.keys(selectedEntries.secret).length
|
||||||
|
const selectedCount = selectedFolderCount + selectedKeysCount
|
||||||
|
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
@ -68,6 +69,16 @@ export const SelectionPanel = ({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getDeleteModalTitle = () => {
|
||||||
|
if (selectedFolderCount > 0 && selectedKeysCount > 0) {
|
||||||
|
return "Do you want to delete the selected secrets and folders across environments?";
|
||||||
|
}
|
||||||
|
if (selectedKeysCount > 0) {
|
||||||
|
return "Do you want to delete the selected secrets across environments?";
|
||||||
|
}
|
||||||
|
return "Do you want to delete the selected folders across environments?";
|
||||||
|
}
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
const handleBulkDelete = async () => {
|
||||||
let processedEntries = 0;
|
let processedEntries = 0;
|
||||||
|
|
||||||
@ -180,7 +191,7 @@ export const SelectionPanel = ({
|
|||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
isOpen={popUp.bulkDeleteEntries.isOpen}
|
isOpen={popUp.bulkDeleteEntries.isOpen}
|
||||||
deleteKey="delete"
|
deleteKey="delete"
|
||||||
title="Do you want to delete the selected secrets and folders across envs?"
|
title={getDeleteModalTitle()}
|
||||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
||||||
onDeleteApproved={handleBulkDelete}
|
onDeleteApproved={handleBulkDelete}
|
||||||
/>
|
/>
|
||||||
|
@ -1,277 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { Controller } from "react-hook-form";
|
|
||||||
import { AxiosError } from "axios";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
|
||||||
import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto";
|
|
||||||
import { Button, Checkbox, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2";
|
|
||||||
import { SecretSharingAccessType, useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api/secretSharing";
|
|
||||||
|
|
||||||
const schema = yup.object({
|
|
||||||
value: yup.string().max(10000).required().label("Shared Secret Value"),
|
|
||||||
expiresAfterSingleView: yup.boolean().required().label("Expires After Views"),
|
|
||||||
expiresInValue: yup.number().min(1).required().label("Expiration Value"),
|
|
||||||
expiresInUnit: yup.string().required().label("Expiration Unit"),
|
|
||||||
accessType: yup.string().required().label("General Access")
|
|
||||||
});
|
|
||||||
|
|
||||||
export type FormData = yup.InferType<typeof schema>;
|
|
||||||
|
|
||||||
export const AddShareSecretForm = ({
|
|
||||||
isPublic,
|
|
||||||
inModal,
|
|
||||||
handleSubmit,
|
|
||||||
control,
|
|
||||||
isSubmitting,
|
|
||||||
setNewSharedSecret,
|
|
||||||
isInputDisabled
|
|
||||||
}: {
|
|
||||||
isPublic: boolean;
|
|
||||||
inModal: boolean;
|
|
||||||
handleSubmit: any;
|
|
||||||
control: any;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
setNewSharedSecret: (value: string) => void;
|
|
||||||
isInputDisabled?: boolean;
|
|
||||||
}) => {
|
|
||||||
const isMounted = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
isMounted.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const publicSharedSecretCreator = useCreatePublicSharedSecret();
|
|
||||||
const privateSharedSecretCreator = useCreateSharedSecret();
|
|
||||||
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator;
|
|
||||||
|
|
||||||
const expirationUnitsAndActions = [
|
|
||||||
{
|
|
||||||
unit: "Minutes",
|
|
||||||
action: (expiresAt: Date, expiresInValue: number) =>
|
|
||||||
expiresAt.setMinutes(expiresAt.getMinutes() + expiresInValue)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
unit: "Hours",
|
|
||||||
action: (expiresAt: Date, expiresInValue: number) =>
|
|
||||||
expiresAt.setHours(expiresAt.getHours() + expiresInValue)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
unit: "Days",
|
|
||||||
action: (expiresAt: Date, expiresInValue: number) =>
|
|
||||||
expiresAt.setDate(expiresAt.getDate() + expiresInValue)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
unit: "Weeks",
|
|
||||||
action: (expiresAt: Date, expiresInValue: number) =>
|
|
||||||
expiresAt.setDate(expiresAt.getDate() + expiresInValue * 7)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
const onFormSubmit = async ({
|
|
||||||
value,
|
|
||||||
expiresInValue,
|
|
||||||
expiresInUnit,
|
|
||||||
expiresAfterSingleView,
|
|
||||||
accessType
|
|
||||||
}: FormData) => {
|
|
||||||
try {
|
|
||||||
const key = crypto.randomBytes(16).toString("hex");
|
|
||||||
const hashedHex = crypto.createHash("sha256").update(key).digest("hex");
|
|
||||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
|
||||||
plaintext: value,
|
|
||||||
key
|
|
||||||
});
|
|
||||||
|
|
||||||
const expiresAt = new Date();
|
|
||||||
const updateExpiresAt = expirationUnitsAndActions.find(
|
|
||||||
(item) => item.unit === expiresInUnit
|
|
||||||
)?.action;
|
|
||||||
if (updateExpiresAt && expiresInValue) {
|
|
||||||
updateExpiresAt(expiresAt, expiresInValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await createSharedSecret.mutateAsync({
|
|
||||||
encryptedValue: ciphertext,
|
|
||||||
iv,
|
|
||||||
tag,
|
|
||||||
hashedHex,
|
|
||||||
expiresAt,
|
|
||||||
expiresAfterViews: expiresAfterSingleView ? 1 : 1000,
|
|
||||||
accessType: accessType as SecretSharingAccessType
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isMounted.current) {
|
|
||||||
setNewSharedSecret(
|
|
||||||
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(
|
|
||||||
hashedHex
|
|
||||||
)}-${encodeURIComponent(key)}`
|
|
||||||
);
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully created a shared secret",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
const axiosError = err as AxiosError;
|
|
||||||
if (axiosError?.response?.status === 401) {
|
|
||||||
createNotification({
|
|
||||||
text: "You do not have access to create shared secrets",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to create a shared secret",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<form className="flex w-full flex-col items-center px-4 sm:px-0" onSubmit={handleSubmit(onFormSubmit)}>
|
|
||||||
<div
|
|
||||||
className={`w-full ${!inModal && "rounded-md border border-mineshaft-600 bg-mineshaft-800 p-6"}`}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="value"
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
|
||||||
label="Shared Secret"
|
|
||||||
isError={Boolean(error)}
|
|
||||||
errorText={error?.message}
|
|
||||||
className="mb-2"
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
disabled={isInputDisabled}
|
|
||||||
placeholder="Enter sensitive data to share via an encrypted link..."
|
|
||||||
{...field}
|
|
||||||
className="h-40 min-h-[70px] w-full rounded-md border border-mineshaft-600 bg-mineshaft-900 py-1.5 px-2 text-bunker-300 outline-none transition-all placeholder:text-mineshaft-400 hover:border-primary-400/30 focus:border-primary-400/50 group-hover:mr-2"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col md:flex-row justify-stretch">
|
|
||||||
<div className="flex justify-start">
|
|
||||||
<div className="flex justify-start">
|
|
||||||
<div className="flex w-full justify-center pr-2">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="expiresInValue"
|
|
||||||
defaultValue={1}
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
|
||||||
label="Expires after Time"
|
|
||||||
isError={Boolean(error)}
|
|
||||||
errorText="Please enter a valid time duration"
|
|
||||||
className="w-32"
|
|
||||||
>
|
|
||||||
<Input {...field} type="number" min={0} />
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="expiresInUnit"
|
|
||||||
defaultValue={expirationUnitsAndActions[1].unit}
|
|
||||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
|
||||||
<FormControl label="Unit" errorText={error?.message} isError={Boolean(error)}>
|
|
||||||
<Select
|
|
||||||
defaultValue={field.value}
|
|
||||||
{...field}
|
|
||||||
onValueChange={(e) => onChange(e)}
|
|
||||||
className="w-full border border-mineshaft-600"
|
|
||||||
>
|
|
||||||
{expirationUnitsAndActions.map(({ unit }) => (
|
|
||||||
<SelectItem value={unit} key={unit}>
|
|
||||||
{unit}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sm:w-1/7 mx-auto items-center justify-center hidden md:flex">
|
|
||||||
<p className="mt-2 text-sm text-gray-400">AND</p>
|
|
||||||
</div>
|
|
||||||
<div className="items-center pb-4 md:pb-0 md:pt-2 flex">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="expiresAfterViews"
|
|
||||||
defaultValue={1}
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
|
||||||
className="mb-4 w-full hidden"
|
|
||||||
label="Expires after Views"
|
|
||||||
isError={Boolean(error)}
|
|
||||||
errorText="Please enter a valid number of views"
|
|
||||||
>
|
|
||||||
<Input {...field} type="number" min={1} />
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="bg-mineshaft-900 py-2 h-max rounded-md border border-mineshaft-600 px-4">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="expiresAfterSingleView"
|
|
||||||
defaultValue={false}
|
|
||||||
render={({ field: { onBlur, value, onChange } }) => (
|
|
||||||
<Checkbox
|
|
||||||
id="is-single-view"
|
|
||||||
isChecked={value}
|
|
||||||
onCheckedChange={onChange}
|
|
||||||
isDisabled={false}
|
|
||||||
onBlur={onBlur}
|
|
||||||
>
|
|
||||||
Can be viewed only 1 time
|
|
||||||
</Checkbox>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!isPublic && (
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="accessType"
|
|
||||||
defaultValue="organization"
|
|
||||||
render={({ field: { onChange, ...field } }) => (
|
|
||||||
<FormControl label="General Access">
|
|
||||||
<Select
|
|
||||||
{...field}
|
|
||||||
onValueChange={(e) => onChange(e)}
|
|
||||||
>
|
|
||||||
<SelectItem value="organization">People within your organization</SelectItem>
|
|
||||||
<SelectItem value="anyone">Anyone</SelectItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className={`flex items-center space-x-4 pt-2 ${!inModal && ""}`}>
|
|
||||||
<Button className="mr-0" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
|
|
||||||
{inModal ? "Create" : "Share Secret"}
|
|
||||||
</Button>
|
|
||||||
{inModal && (
|
|
||||||
<ModalClose asChild>
|
|
||||||
<Button variant="plain" colorSchema="secondary">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</ModalClose>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,23 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
import { Modal, ModalContent } from "@app/components/v2";
|
import { Modal, ModalContent } from "@app/components/v2";
|
||||||
import { useTimedReset } from "@app/hooks";
|
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
import { ShareSecretForm } from "@app/views/ShareSecretPublicPage/components";
|
||||||
import { AddShareSecretForm } from "./AddShareSecretForm";
|
|
||||||
import { ViewAndCopySharedSecret } from "./ViewAndCopySharedSecret";
|
|
||||||
|
|
||||||
const schema = yup.object({
|
|
||||||
value: yup.string().max(10000).required().label("Shared Secret Value"),
|
|
||||||
expiresAfterViews: yup.number().min(1).required().label("Expires After Views"),
|
|
||||||
expiresInValue: yup.number().min(1).required().label("Expiration Value"),
|
|
||||||
expiresInUnit: yup.string().required().label("Expiration Unit")
|
|
||||||
});
|
|
||||||
|
|
||||||
export type FormData = yup.InferType<typeof schema>;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
popUp: UsePopUpState<["createSharedSecret"]>;
|
popUp: UsePopUpState<["createSharedSecret"]>;
|
||||||
@ -25,97 +8,25 @@ type Props = {
|
|||||||
popUpName: keyof UsePopUpState<["createSharedSecret"]>,
|
popUpName: keyof UsePopUpState<["createSharedSecret"]>,
|
||||||
state?: boolean
|
state?: boolean
|
||||||
) => void;
|
) => void;
|
||||||
isPublic: boolean;
|
|
||||||
inModal: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddShareSecretModal = ({ popUp, handlePopUpToggle, isPublic, inModal }: Props) => {
|
export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||||
const {
|
return (
|
||||||
control,
|
|
||||||
reset,
|
|
||||||
handleSubmit,
|
|
||||||
setValue,
|
|
||||||
formState: { isSubmitting }
|
|
||||||
} = useForm<FormData>({
|
|
||||||
resolver: yupResolver(schema)
|
|
||||||
});
|
|
||||||
|
|
||||||
const [newSharedSecret, setNewSharedSecret] = useState("");
|
|
||||||
const hasSharedSecret = Boolean(newSharedSecret);
|
|
||||||
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
|
|
||||||
initialState: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isSecretInputDisabled, setIsSecretInputDisabled] = useState(false);
|
|
||||||
|
|
||||||
const copyUrlToClipboard = () => {
|
|
||||||
navigator.clipboard.writeText(newSharedSecret);
|
|
||||||
setIsUrlCopied(true);
|
|
||||||
};
|
|
||||||
useEffect(() => {
|
|
||||||
if (isUrlCopied) {
|
|
||||||
setTimeout(() => setIsUrlCopied(false), 2000);
|
|
||||||
}
|
|
||||||
}, [isUrlCopied]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (popUp.createSharedSecret.data) {
|
|
||||||
setValue("value", (popUp.createSharedSecret.data as { value: string }).value);
|
|
||||||
setIsSecretInputDisabled(true);
|
|
||||||
}
|
|
||||||
}, [popUp.createSharedSecret.data]);
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-nested-ternary
|
|
||||||
return inModal ? (
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={popUp?.createSharedSecret?.isOpen}
|
isOpen={popUp?.createSharedSecret?.isOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(isOpen) => {
|
||||||
handlePopUpToggle("createSharedSecret", open);
|
handlePopUpToggle("createSharedSecret", isOpen);
|
||||||
reset();
|
|
||||||
setNewSharedSecret("");
|
|
||||||
setIsSecretInputDisabled(false);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
title="Share a Secret"
|
title="Share a Secret"
|
||||||
subTitle="Once you share a secret, the share link is only accessible once."
|
subTitle="Once you share a secret, the share link is only accessible once."
|
||||||
>
|
>
|
||||||
{!hasSharedSecret ? (
|
<ShareSecretForm
|
||||||
<AddShareSecretForm
|
isPublic={false}
|
||||||
isPublic={isPublic}
|
value={(popUp.createSharedSecret.data as { value?: string })?.value}
|
||||||
inModal={inModal}
|
/>
|
||||||
control={control}
|
|
||||||
handleSubmit={handleSubmit}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
setNewSharedSecret={setNewSharedSecret}
|
|
||||||
isInputDisabled={isSecretInputDisabled}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ViewAndCopySharedSecret
|
|
||||||
inModal={inModal}
|
|
||||||
newSharedSecret={newSharedSecret}
|
|
||||||
isUrlCopied={isUrlCopied}
|
|
||||||
copyUrlToClipboard={copyUrlToClipboard}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
) : !hasSharedSecret ? (
|
|
||||||
<AddShareSecretForm
|
|
||||||
isPublic={isPublic}
|
|
||||||
inModal={inModal}
|
|
||||||
control={control}
|
|
||||||
handleSubmit={handleSubmit}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
setNewSharedSecret={setNewSharedSecret}
|
|
||||||
isInputDisabled={isSecretInputDisabled}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ViewAndCopySharedSecret
|
|
||||||
inModal={inModal}
|
|
||||||
newSharedSecret={newSharedSecret}
|
|
||||||
isUrlCopied={isUrlCopied}
|
|
||||||
copyUrlToClipboard={copyUrlToClipboard}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -46,9 +46,8 @@ export const ShareSecretSection = () => {
|
|||||||
<link rel="icon" href="/infisical.ico" />
|
<link rel="icon" href="/infisical.ico" />
|
||||||
<meta property="og:image" content="/images/message.png" />
|
<meta property="og:image" content="/images/message.png" />
|
||||||
</Head>
|
</Head>
|
||||||
<div className="mb-2 flex justify-between">
|
<div className="mb-4 flex justify-between">
|
||||||
<p className="text-xl font-semibold text-mineshaft-100">Shared Secrets</p>
|
<p className="text-xl font-semibold text-mineshaft-100">Shared Secrets</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
colorSchema="primary"
|
colorSchema="primary"
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
@ -60,12 +59,7 @@ export const ShareSecretSection = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ShareSecretsTable handlePopUpOpen={handlePopUpOpen} />
|
<ShareSecretsTable handlePopUpOpen={handlePopUpOpen} />
|
||||||
<AddShareSecretModal
|
<AddShareSecretModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||||
popUp={popUp}
|
|
||||||
handlePopUpToggle={handlePopUpToggle}
|
|
||||||
isPublic={false}
|
|
||||||
inModal
|
|
||||||
/>
|
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
isOpen={popUp.deleteSharedSecretConfirmation.isOpen}
|
isOpen={popUp.deleteSharedSecretConfirmation.isOpen}
|
||||||
title={`Delete ${
|
title={`Delete ${
|
||||||
|
@ -1,77 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { faEnvelope, faEnvelopeOpen, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
import { IconButton, Td, Tr } from "@app/components/v2";
|
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
|
||||||
|
// import { useToggle } from "@app/hooks";
|
||||||
|
import { Badge } from "@app/components/v2/Badge";
|
||||||
import { TSharedSecret } from "@app/hooks/api/secretSharing";
|
import { TSharedSecret } from "@app/hooks/api/secretSharing";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
const formatDate = (date: Date): string => (date ? new Date(date).toUTCString() : "");
|
|
||||||
|
|
||||||
const isExpired = (expiresAt: Date | number | undefined): boolean => {
|
|
||||||
if (typeof expiresAt === "number") {
|
|
||||||
return expiresAt <= 0;
|
|
||||||
}
|
|
||||||
if (expiresAt instanceof Date) {
|
|
||||||
return new Date(expiresAt) < new Date();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getValidityStatusText = (expiresAt: Date): string =>
|
|
||||||
isExpired(expiresAt) ? "Expired " : "Valid for ";
|
|
||||||
|
|
||||||
const timeAgo = (inputDate: Date, currentDate: Date): string => {
|
|
||||||
const now = new Date(currentDate).getTime();
|
|
||||||
const date = new Date(inputDate).getTime();
|
|
||||||
const elapsedMilliseconds = now - date;
|
|
||||||
const elapsedSeconds = Math.abs(Math.floor(elapsedMilliseconds / 1000));
|
|
||||||
const elapsedMinutes = Math.abs(Math.floor(elapsedSeconds / 60));
|
|
||||||
const elapsedHours = Math.abs(Math.floor(elapsedMinutes / 60));
|
|
||||||
const elapsedDays = Math.abs(Math.floor(elapsedHours / 24));
|
|
||||||
const elapsedWeeks = Math.abs(Math.floor(elapsedDays / 7));
|
|
||||||
const elapsedMonths = Math.abs(Math.floor(elapsedDays / 30));
|
|
||||||
const elapsedYears = Math.abs(Math.floor(elapsedDays / 365));
|
|
||||||
|
|
||||||
if (elapsedYears > 0) {
|
|
||||||
return `${elapsedYears} year${elapsedYears === 1 ? "" : "s"} ${
|
|
||||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
if (elapsedMonths > 0) {
|
|
||||||
return `${elapsedMonths} month${elapsedMonths === 1 ? "" : "s"} ${
|
|
||||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
if (elapsedWeeks > 0) {
|
|
||||||
return `${elapsedWeeks} week${elapsedWeeks === 1 ? "" : "s"} ${
|
|
||||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
if (elapsedDays > 0) {
|
|
||||||
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ${
|
|
||||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
if (elapsedHours > 0) {
|
|
||||||
return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ${
|
|
||||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
if (elapsedMinutes > 0) {
|
|
||||||
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ${
|
|
||||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${
|
|
||||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
|
||||||
}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ShareSecretsRow = ({
|
export const ShareSecretsRow = ({
|
||||||
row,
|
row,
|
||||||
handlePopUpOpen,
|
handlePopUpOpen
|
||||||
onSecretExpiration
|
|
||||||
}: {
|
}: {
|
||||||
row: TSharedSecret;
|
row: TSharedSecret;
|
||||||
handlePopUpOpen: (
|
handlePopUpOpen: (
|
||||||
@ -84,58 +23,72 @@ export const ShareSecretsRow = ({
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
) => void;
|
) => void;
|
||||||
onSecretExpiration: (expiredSecretId: string) => void;
|
|
||||||
}) => {
|
}) => {
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
// const [isRowExpanded, setIsRowExpanded] = useToggle();
|
||||||
|
const lastViewedAt = row.lastViewedAt
|
||||||
|
? format(new Date(row.lastViewedAt), "yyyy-MM-dd - HH:mm a")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
let isExpired = false;
|
||||||
const intervalId = setInterval(() => {
|
if (row.expiresAfterViews !== null && row.expiresAfterViews <= 0) {
|
||||||
setCurrentTime(new Date());
|
isExpired = true;
|
||||||
}, 1000);
|
}
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
if (row.expiresAt !== null && new Date(row.expiresAt) < new Date()) {
|
||||||
}, []);
|
isExpired = true;
|
||||||
|
}
|
||||||
useEffect(() => {
|
|
||||||
if (isExpired(row.expiresAt || row.expiresAfterViews)) {
|
|
||||||
onSecretExpiration(row.id);
|
|
||||||
}
|
|
||||||
}, [isExpired(row.expiresAt || row.expiresAfterViews)]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr key={row.id}>
|
<>
|
||||||
<Td>{`${row.encryptedValue.substring(0, 5)}...`}</Td>
|
<Tr
|
||||||
<Td>
|
key={row.id}
|
||||||
<p className="text-sm text-yellow-400">{timeAgo(row.createdAt, currentTime)}</p>
|
// className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
|
||||||
<p className="text-xs text-gray-500">{formatDate(row.createdAt)}</p>
|
// onClick={() => setIsRowExpanded.toggle()}
|
||||||
</Td>
|
>
|
||||||
<Td>
|
<Td>
|
||||||
<>
|
<Tooltip content={lastViewedAt ? `Last opened at ${lastViewedAt}` : "Not yet opened"}>
|
||||||
<p className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}>
|
<FontAwesomeIcon icon={lastViewedAt ? faEnvelopeOpen : faEnvelope} />
|
||||||
{getValidityStatusText(row.expiresAt!) + timeAgo(row.expiresAt!, currentTime)}
|
</Tooltip>
|
||||||
</p>
|
</Td>
|
||||||
<p className="text-xs text-gray-500">{formatDate(row.expiresAt!)}</p>
|
<Td>{row.name ? `${row.name}` : "-"}</Td>
|
||||||
</>
|
<Td>
|
||||||
</Td>
|
<Badge variant={isExpired ? "danger" : "success"}>
|
||||||
<Td>
|
{isExpired ? "Expired" : "Active"}
|
||||||
<p className={`text-sm ${row.expiresAfterViews <= 0 ? "text-red-500" : "text-green-500"}`}>
|
</Badge>
|
||||||
{row.expiresAfterViews}
|
</Td>
|
||||||
</p>
|
<Td>{`${format(new Date(row.createdAt), "yyyy-MM-dd - HH:mm a")}`}</Td>
|
||||||
</Td>
|
<Td>{format(new Date(row.expiresAt), "yyyy-MM-dd - HH:mm a")}</Td>
|
||||||
<Td>
|
<Td>{row.expiresAfterViews !== null ? row.expiresAfterViews : "-"}</Td>
|
||||||
<IconButton
|
<Td>
|
||||||
onClick={() =>
|
<IconButton
|
||||||
handlePopUpOpen("deleteSharedSecretConfirmation", {
|
onClick={(e) => {
|
||||||
name: "delete",
|
e.stopPropagation();
|
||||||
id: row.id
|
handlePopUpOpen("deleteSharedSecretConfirmation", {
|
||||||
})
|
name: "delete",
|
||||||
}
|
id: row.id
|
||||||
colorSchema="danger"
|
});
|
||||||
ariaLabel="delete"
|
}}
|
||||||
>
|
variant="plain"
|
||||||
<FontAwesomeIcon icon={faTrashCan} />
|
ariaLabel="delete"
|
||||||
</IconButton>
|
>
|
||||||
</Td>
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
</Tr>
|
</IconButton>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
{/* {isRowExpanded && (
|
||||||
|
<Tr>
|
||||||
|
<Td
|
||||||
|
colSpan={6}
|
||||||
|
className={`bg-bunker-600 px-0 py-0 ${isRowExpanded && " border-mineshaft-500 p-8"}`}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>Test 1</div>
|
||||||
|
<div>Test 2</div>
|
||||||
|
<div>Test 3</div>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)} */}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { faKey } from "@fortawesome/free-solid-svg-icons";
|
import { faKey } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
|
Pagination,
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
TBody,
|
TBody,
|
||||||
Td,
|
|
||||||
Th,
|
Th,
|
||||||
THead,
|
THead,
|
||||||
Tr
|
Tr
|
||||||
@ -30,47 +31,49 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
|
export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
|
||||||
const { isLoading, data = [] } = useGetSharedSecrets();
|
const [page, setPage] = useState(1);
|
||||||
|
const [perPage, setPerPage] = useState(10);
|
||||||
let tableData = data.filter(
|
const { isLoading, data } = useGetSharedSecrets({
|
||||||
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
|
offset: (page - 1) * perPage,
|
||||||
);
|
limit: perPage
|
||||||
const handleSecretExpiration = () => {
|
});
|
||||||
tableData = data.filter(
|
|
||||||
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<Table>
|
<Table>
|
||||||
<THead>
|
<THead>
|
||||||
<Tr>
|
<Tr>
|
||||||
<Th>Encrypted Secret</Th> <Th>Created</Th> <Th>Valid Until</Th> <Th>Views Left</Th>
|
<Th className="w-5" />
|
||||||
<Th aria-label="button" />
|
<Th>Name</Th>
|
||||||
|
<Th>Status</Th>
|
||||||
|
<Th>Created At</Th>
|
||||||
|
<Th>Valid Until</Th>
|
||||||
|
<Th>Views Left</Th>
|
||||||
|
<Th aria-label="button" className="w-5" />
|
||||||
</Tr>
|
</Tr>
|
||||||
</THead>
|
</THead>
|
||||||
<TBody>
|
<TBody>
|
||||||
{isLoading && <TableSkeleton columns={4} innerKey="shared-secrets" />}
|
{isLoading && <TableSkeleton columns={7} innerKey="shared-secrets" />}
|
||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
tableData &&
|
data?.secrets?.map((row) => (
|
||||||
tableData.map((row) => (
|
<ShareSecretsRow key={row.id} row={row} handlePopUpOpen={handlePopUpOpen} />
|
||||||
<ShareSecretsRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
handlePopUpOpen={handlePopUpOpen}
|
|
||||||
onSecretExpiration={handleSecretExpiration}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
{!isLoading && tableData && tableData?.length === 0 && (
|
|
||||||
<Tr>
|
|
||||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
|
|
||||||
<EmptyState title="No secrets shared yet" icon={faKey} />
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
)}
|
|
||||||
</TBody>
|
</TBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
{!isLoading &&
|
||||||
|
data?.secrets &&
|
||||||
|
data.secrets.length >= perPage &&
|
||||||
|
data?.totalCount !== undefined && (
|
||||||
|
<Pagination
|
||||||
|
count={data.totalCount}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePage={(newPage) => setPage(newPage)}
|
||||||
|
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isLoading && !data?.secrets?.length && (
|
||||||
|
<EmptyState title="No secrets shared yet" icon={faKey} />
|
||||||
|
)}
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,37 +0,0 @@
|
|||||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
|
|
||||||
import { IconButton } from "@app/components/v2";
|
|
||||||
|
|
||||||
export const ViewAndCopySharedSecret = ({
|
|
||||||
inModal,
|
|
||||||
newSharedSecret,
|
|
||||||
isUrlCopied,
|
|
||||||
copyUrlToClipboard
|
|
||||||
}: {
|
|
||||||
inModal: boolean;
|
|
||||||
newSharedSecret: string;
|
|
||||||
isUrlCopied: boolean;
|
|
||||||
copyUrlToClipboard: () => void;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={`flex w-full justify-center px-6 ${!inModal ? "mx-auto max-w-2xl" : ""}`}>
|
|
||||||
<div className={`${!inModal ? "border border-mineshaft-600 bg-mineshaft-800 rounded-md p-4" : ""}`}>
|
|
||||||
<div className="my-2 flex items-center justify-end rounded-md border border-mineshaft-500 bg-mineshaft-700 p-2 text-base text-gray-400">
|
|
||||||
<p className="mr-4 break-all">{newSharedSecret}</p>
|
|
||||||
<IconButton
|
|
||||||
ariaLabel="copy icon"
|
|
||||||
colorSchema="secondary"
|
|
||||||
className="group relative"
|
|
||||||
onClick={copyUrlToClipboard}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
|
|
||||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
|
||||||
Click to Copy
|
|
||||||
</span>
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,65 +1,16 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import Head from "next/head";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
|
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto";
|
import { ShareSecretForm } from "./components";
|
||||||
import { Button } from "@app/components/v2";
|
|
||||||
import { usePopUp, useTimedReset } from "@app/hooks";
|
|
||||||
import { useGetActiveSharedSecretByIdAndHashedHex } from "@app/hooks/api/secretSharing";
|
|
||||||
|
|
||||||
import { AddShareSecretModal } from "../ShareSecretPage/components/AddShareSecretModal";
|
|
||||||
import { SecretTable } from "./components";
|
|
||||||
|
|
||||||
export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean }) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { id, key: urlEncodedPublicKey } = router.query;
|
|
||||||
const [hashedHex, key] = urlEncodedPublicKey
|
|
||||||
? urlEncodedPublicKey.toString().split("-")
|
|
||||||
: ["", ""];
|
|
||||||
|
|
||||||
const publicKey = decodeURIComponent(urlEncodedPublicKey as string);
|
|
||||||
const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex(
|
|
||||||
id as string,
|
|
||||||
hashedHex as string
|
|
||||||
);
|
|
||||||
const accessType = data?.accessType;
|
|
||||||
const orgName = data?.orgName;
|
|
||||||
|
|
||||||
const decryptedSecret = useMemo(() => {
|
|
||||||
if (data && data.encryptedValue && publicKey) {
|
|
||||||
const res = decryptSymmetric({
|
|
||||||
ciphertext: data.encryptedValue,
|
|
||||||
iv: data.iv,
|
|
||||||
tag: data.tag,
|
|
||||||
key
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}, [data, publicKey]);
|
|
||||||
|
|
||||||
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
|
|
||||||
initialState: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyUrlToClipboard = () => {
|
|
||||||
navigator.clipboard.writeText(decryptedSecret);
|
|
||||||
setIsUrlCopied(true);
|
|
||||||
};
|
|
||||||
const { popUp, handlePopUpToggle } = usePopUp(["createSharedSecret"] as const);
|
|
||||||
|
|
||||||
|
export const ShareSecretPublicPage = () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col overflow-y-auto bg-gradient-to-tr from-mineshaft-700 to-bunker-800 text-gray-200 dark:[color-scheme:dark]">
|
<div className="flex h-screen flex-col justify-between overflow-auto bg-gradient-to-tr from-mineshaft-700 to-bunker-800 text-gray-200 dark:[color-scheme:dark]">
|
||||||
<Head>
|
<div />
|
||||||
<title>Secret Shared | Infisical</title>
|
<div className="mx-auto w-full max-w-xl p-4">
|
||||||
<link rel="icon" href="/infisical.ico" />
|
<div className="mb-8 text-center">
|
||||||
</Head>
|
|
||||||
<div className="flex w-full flex-grow items-center justify-center dark:[color-scheme:dark]">
|
|
||||||
<div className="relative">
|
|
||||||
<div className="mb-4 flex justify-center pt-8">
|
<div className="mb-4 flex justify-center pt-8">
|
||||||
<Link href="https://infisical.com">
|
<Link href="https://infisical.com">
|
||||||
<Image
|
<Image
|
||||||
@ -71,109 +22,66 @@ export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean
|
|||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-center">
|
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-4xl font-medium text-transparent">
|
||||||
<h1
|
Share a secret
|
||||||
className={`${
|
</h1>
|
||||||
id ? "mb-4 max-w-sm" : "mt-4 mb-6 max-w-md"
|
<p className="text-md">
|
||||||
} bg-gradient-to-b from-white to-bunker-200 bg-clip-text px-4 text-center text-3xl font-medium text-transparent`}
|
Powered by{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/infisical/infisical"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-bold bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent"
|
||||||
>
|
>
|
||||||
{id
|
Infisical →
|
||||||
? "Someone shared a secret via Infisical with you"
|
</a>
|
||||||
: "Share a secret via Infisical"}
|
</p>
|
||||||
</h1>
|
</div>
|
||||||
</div>
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4">
|
||||||
<div className="m-auto mt-4 flex w-full max-w-2xl justify-center px-6">
|
<ShareSecretForm isPublic />
|
||||||
{id && (
|
</div>
|
||||||
<SecretTable
|
<div className="m-auto my-8 flex w-full">
|
||||||
isLoading={isLoading}
|
<div className="w-full border-t border-mineshaft-600" />
|
||||||
decryptedSecret={decryptedSecret}
|
</div>
|
||||||
isUrlCopied={isUrlCopied}
|
<div className="m-auto flex w-full flex-col rounded-md border border-primary-500/30 bg-primary/5 p-6 pt-5">
|
||||||
copyUrlToClipboard={copyUrlToClipboard}
|
<p className="w-full pb-2 text-lg font-semibold text-mineshaft-100 md:pb-3 md:text-xl">
|
||||||
accessType={accessType}
|
Open source{" "}
|
||||||
orgName={orgName}
|
<span className="bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent">
|
||||||
/>
|
secret management
|
||||||
)}
|
</span>{" "}
|
||||||
</div>
|
for developers
|
||||||
|
</p>
|
||||||
{isNewSession && (
|
<div className="flex flex-col items-start sm:flex-row sm:items-center">
|
||||||
<div className="px-0 sm:px-6">
|
<p className="md:text-md text-md mr-4">
|
||||||
<AddShareSecretModal
|
|
||||||
popUp={popUp}
|
|
||||||
handlePopUpToggle={handlePopUpToggle}
|
|
||||||
inModal={false}
|
|
||||||
isPublic
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isNewSession && (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center px-6 pt-4">
|
|
||||||
<a
|
<a
|
||||||
href="https://share.infisical.com/"
|
href="https://github.com/infisical/infisical"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="mt-3 w-full text-sm font-normal leading-[1.2rem] text-mineshaft-300 hover:text-mineshaft-100"
|
className="text-bold bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent"
|
||||||
>
|
>
|
||||||
<Button
|
Infisical
|
||||||
className="w-full bg-mineshaft-700 py-3 text-bunker-200"
|
</a>{" "}
|
||||||
colorSchema="primary"
|
is the all-in-one secret management platform to securely manage secrets, configs, and
|
||||||
variant="outline_bg"
|
certificates across your team and infrastructure.
|
||||||
size="sm"
|
</p>
|
||||||
onClick={() => {}}
|
<div className="mt-4 cursor-pointer sm:mt-0">
|
||||||
rightIcon={<FontAwesomeIcon icon={faArrowRight} className="pl-2" />}
|
<Link href="https://infisical.com">
|
||||||
>
|
<div className="flex items-center justify-between rounded-md border border-mineshaft-400/40 bg-mineshaft-600 py-2 px-3 duration-200 hover:border-primary/60 hover:bg-primary/20 hover:text-white">
|
||||||
Share your own Secret
|
<p className="mr-4 whitespace-nowrap">Try Infisical</p>
|
||||||
</Button>
|
<FontAwesomeIcon icon={faArrowRight} />
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
)}
|
|
||||||
<div className="m-auto my-6 flex w-full max-w-xl justify-center px-4 sm:my-8">
|
|
||||||
<div className="w-full border-t border-mineshaft-600" />
|
|
||||||
</div>
|
|
||||||
<div className="m-auto flex max-w-2xl flex-col items-center justify-center px-4 sm:px-6">
|
|
||||||
<div className="m-auto mb-12 flex w-full max-w-2xl flex-col justify-center rounded-md border border-primary-500/30 bg-primary/5 p-6 pt-5">
|
|
||||||
<p className="w-full pb-2 text-lg font-semibold text-mineshaft-100 md:pb-3 md:text-xl">
|
|
||||||
Open source{" "}
|
|
||||||
<span className="bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent">
|
|
||||||
secret management
|
|
||||||
</span>{" "}
|
|
||||||
for developers
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-x-4 sm:flex-row">
|
|
||||||
<p className="md:text-md text-md">
|
|
||||||
<a
|
|
||||||
href="https://github.com/infisical/infisical"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-bold bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent"
|
|
||||||
>
|
|
||||||
Infisical
|
|
||||||
</a>{" "}
|
|
||||||
is the all-in-one secret management platform to securely manage secrets, configs,
|
|
||||||
and certificates across your team and infrastructure.
|
|
||||||
</p>
|
|
||||||
<Link href="https://infisical.com">
|
|
||||||
<span className="mt-4 h-min w-[17.5rem] cursor-pointer rounded-md border border-mineshaft-400/40 bg-mineshaft-600 py-2 px-3 duration-200 hover:border-primary/60 hover:bg-primary/20 hover:text-white">
|
|
||||||
Try Infisical <FontAwesomeIcon icon={faArrowRight} className="pl-1" />
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AddShareSecretModal
|
|
||||||
popUp={popUp}
|
|
||||||
handlePopUpToggle={handlePopUpToggle}
|
|
||||||
isPublic
|
|
||||||
inModal
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto flex w-full items-center justify-center bg-mineshaft-600 p-2">
|
|
||||||
|
<div className="w-full bg-mineshaft-600 p-2">
|
||||||
<p className="text-center text-sm text-mineshaft-300">
|
<p className="text-center text-sm text-mineshaft-300">
|
||||||
© 2024{" "}
|
Made with ❤️ by{" "}
|
||||||
<a className="text-primary" href="https://infisical.com">
|
<a className="text-primary" href="https://infisical.com">
|
||||||
Infisical
|
Infisical
|
||||||
</a>
|
</a>
|
||||||
. All rights reserved.
|
|
||||||
<br />
|
<br />
|
||||||
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
||||||
</p>
|
</p>
|
||||||
|
@ -1,110 +0,0 @@
|
|||||||
import { faArrowRight, faCheck, faCopy, faEye, faEyeSlash, faKey } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
|
|
||||||
import { Button, EmptyState, IconButton, Td, Tr } from "@app/components/v2";
|
|
||||||
import { useToggle } from "@app/hooks";
|
|
||||||
import { SecretSharingAccessType } from "@app/hooks/api/secretSharing/types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isLoading: boolean;
|
|
||||||
decryptedSecret: string;
|
|
||||||
isUrlCopied: boolean;
|
|
||||||
copyUrlToClipboard: () => void;
|
|
||||||
accessType?: SecretSharingAccessType;
|
|
||||||
orgName?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceContentWithDot = (str: string) => {
|
|
||||||
let finalStr = "";
|
|
||||||
for (let i = 0; i < str.length; i += 1) {
|
|
||||||
const char = str.at(i);
|
|
||||||
finalStr += char === "\n" ? "\n" : "*";
|
|
||||||
}
|
|
||||||
return finalStr;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SecretTable = ({
|
|
||||||
isLoading,
|
|
||||||
decryptedSecret,
|
|
||||||
isUrlCopied,
|
|
||||||
copyUrlToClipboard,
|
|
||||||
accessType,
|
|
||||||
orgName
|
|
||||||
}: Props) => {
|
|
||||||
const [isVisible, setIsVisible] = useToggle(false);
|
|
||||||
const title = orgName
|
|
||||||
? (<p>Someone from <strong>{orgName}</strong> organization has shared a secret with you</p>)
|
|
||||||
: (<p>You need to be logged in to view this secret</p>);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full items-center justify-center rounded-md border border-solid border-mineshaft-700 bg-mineshaft-800 p-2">
|
|
||||||
{isLoading && <div className="bg-mineshaft-800 text-center text-bunker-400">Loading...</div>}
|
|
||||||
{!isLoading && !decryptedSecret && accessType !== SecretSharingAccessType.Organization && (
|
|
||||||
<Tr>
|
|
||||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
|
|
||||||
<EmptyState title="Secret has either expired or does not exist!" icon={faKey} />
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
)}
|
|
||||||
{!isLoading && !decryptedSecret && accessType === SecretSharingAccessType.Organization && (
|
|
||||||
<Tr>
|
|
||||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-4000">
|
|
||||||
<EmptyState title={title} icon={faKey}>
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center pt-6">
|
|
||||||
<a
|
|
||||||
href="/login"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
colorSchema="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {}}
|
|
||||||
rightIcon={<FontAwesomeIcon icon={faArrowRight} className="ml-2" />}
|
|
||||||
>
|
|
||||||
Login into <strong>{orgName}</strong> to view this secret
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</EmptyState>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
)}
|
|
||||||
{!isLoading && decryptedSecret && (
|
|
||||||
<div className="dark relative flex h-full w-full items-center overflow-y-auto rounded-md border border-mineshaft-700 bg-mineshaft-900 p-2 pr-2 md:p-3">
|
|
||||||
<div
|
|
||||||
className={`thin-scrollbar flex h-full max-h-44 w-full flex-1 overflow-y-scroll ${
|
|
||||||
isVisible ? "break-words" : "break-all"
|
|
||||||
} pr-4 dark:[color-scheme:dark]`}
|
|
||||||
>
|
|
||||||
<div className="align-center flex w-full min-w-full whitespace-pre-line">
|
|
||||||
{isVisible ? decryptedSecret : replaceContentWithDot(decryptedSecret)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-1 right-0 mx-1 flex max-h-8 sm:top-2 sm:right-5">
|
|
||||||
<IconButton
|
|
||||||
variant="outline_bg"
|
|
||||||
colorSchema="primary"
|
|
||||||
ariaLabel="copy to clipboard"
|
|
||||||
onClick={copyUrlToClipboard}
|
|
||||||
className="mr-1 flex max-h-8 items-center rounded"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon className="pr-2" icon={isUrlCopied ? faCheck : faCopy} /> Copy
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
variant="outline_bg"
|
|
||||||
colorSchema="primary"
|
|
||||||
ariaLabel="toggle visibility"
|
|
||||||
onClick={() => setIsVisible.toggle()}
|
|
||||||
className="flex max-h-8 items-center rounded"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={isVisible ? faEyeSlash : faEye} />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -0,0 +1,254 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faCheck, faCopy, faRedo } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto";
|
||||||
|
import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
|
||||||
|
import { useTimedReset } from "@app/hooks";
|
||||||
|
import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api";
|
||||||
|
import { SecretSharingAccessType } from "@app/hooks/api/secretSharing";
|
||||||
|
|
||||||
|
// values in ms
|
||||||
|
const expiresInOptions = [
|
||||||
|
{ label: "5 min", value: 5 * 60 * 1000 },
|
||||||
|
{ label: "30 min", value: 30 * 60 * 1000 },
|
||||||
|
{ label: "1 hour", value: 60 * 60 * 1000 },
|
||||||
|
{ label: "1 day", value: 24 * 60 * 60 * 1000 },
|
||||||
|
{ label: "7 days", value: 7 * 24 * 60 * 60 * 1000 },
|
||||||
|
{ label: "14 days", value: 14 * 24 * 60 * 60 * 1000 },
|
||||||
|
{ label: "30 days", value: 30 * 24 * 60 * 60 * 1000 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const viewLimitOptions = [
|
||||||
|
{ label: "1", value: 1 },
|
||||||
|
{ label: "Unlimited", value: -1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
secret: z.string(),
|
||||||
|
expiresIn: z.string(),
|
||||||
|
viewLimit: z.string(),
|
||||||
|
accessType: z.nativeEnum(SecretSharingAccessType).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isPublic: boolean; // whether or not this is a public (non-authenticated) secret sharing form
|
||||||
|
value?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShareSecretForm = ({ isPublic, value }: Props) => {
|
||||||
|
const [secretLink, setSecretLink] = useState("");
|
||||||
|
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
||||||
|
initialState: "Copy to clipboard"
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicSharedSecretCreator = useCreatePublicSharedSecret();
|
||||||
|
const privateSharedSecretCreator = useCreateSharedSecret();
|
||||||
|
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator;
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting }
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
secret: value || ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ name, secret, expiresIn, viewLimit, accessType }: FormData) => {
|
||||||
|
try {
|
||||||
|
const expiresAt = new Date(new Date().getTime() + Number(expiresIn));
|
||||||
|
|
||||||
|
const key = crypto.randomBytes(16).toString("hex");
|
||||||
|
const hashedHex = crypto.createHash("sha256").update(key).digest("hex");
|
||||||
|
const { ciphertext, iv, tag } = encryptSymmetric({
|
||||||
|
plaintext: secret,
|
||||||
|
key
|
||||||
|
});
|
||||||
|
|
||||||
|
const { id } = await createSharedSecret.mutateAsync({
|
||||||
|
name,
|
||||||
|
encryptedValue: ciphertext,
|
||||||
|
hashedHex,
|
||||||
|
iv,
|
||||||
|
tag,
|
||||||
|
expiresAt,
|
||||||
|
expiresAfterViews: viewLimit === "-1" ? undefined : Number(viewLimit),
|
||||||
|
accessType
|
||||||
|
});
|
||||||
|
|
||||||
|
setSecretLink(
|
||||||
|
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(
|
||||||
|
hashedHex
|
||||||
|
)}-${encodeURIComponent(key)}`
|
||||||
|
);
|
||||||
|
reset();
|
||||||
|
|
||||||
|
setCopyTextSecret("secret");
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully created a shared secret",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to create a shared secret",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSecretLink = Boolean(secretLink);
|
||||||
|
|
||||||
|
return !hasSecretLink ? (
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
{!isPublic && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Name (Optional)"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="API Key" type="text" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="secret"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Your Secret"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
className="mb-2"
|
||||||
|
isRequired
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
placeholder="Enter sensitive data to share via an encrypted link..."
|
||||||
|
{...field}
|
||||||
|
className="h-40 min-h-[70px] w-full rounded-md border border-mineshaft-600 bg-mineshaft-900 py-1.5 px-2 text-bunker-300 outline-none transition-all placeholder:text-mineshaft-400 hover:border-primary-400/30 focus:border-primary-400/50 group-hover:mr-2"
|
||||||
|
disabled={value !== undefined}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="expiresIn"
|
||||||
|
defaultValue="3600000"
|
||||||
|
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Expires In" errorText={error?.message} isError={Boolean(error)}>
|
||||||
|
<Select
|
||||||
|
defaultValue={field.value}
|
||||||
|
{...field}
|
||||||
|
onValueChange={(e) => onChange(e)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{expiresInOptions.map(({ label, value: expiresInValue }) => (
|
||||||
|
<SelectItem value={String(expiresInValue || "")} key={label}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="viewLimit"
|
||||||
|
defaultValue="-1"
|
||||||
|
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Max Views" errorText={error?.message} isError={Boolean(error)}>
|
||||||
|
<Select
|
||||||
|
defaultValue={field.value}
|
||||||
|
{...field}
|
||||||
|
onValueChange={(e) => onChange(e)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{viewLimitOptions.map(({ label, value: viewLimitValue }) => (
|
||||||
|
<SelectItem value={String(viewLimitValue || "")} key={label}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!isPublic && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="accessType"
|
||||||
|
defaultValue={SecretSharingAccessType.Organization}
|
||||||
|
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||||
|
<FormControl label="General Access" errorText={error?.message} isError={Boolean(error)}>
|
||||||
|
<Select
|
||||||
|
defaultValue={field.value}
|
||||||
|
{...field}
|
||||||
|
onValueChange={(e) => onChange(e)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectItem value={SecretSharingAccessType.Anyone}>Anyone</SelectItem>
|
||||||
|
<SelectItem value={SecretSharingAccessType.Organization}>
|
||||||
|
People within your organization
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="mt-4"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Create secret link
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mr-2 flex items-center justify-end rounded-md bg-white/[0.05] p-2 text-base text-gray-400">
|
||||||
|
<p className="mr-4 break-all">{secretLink}</p>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="copy icon"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="group relative ml-2"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(secretLink);
|
||||||
|
setCopyTextSecret("Copied");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isCopyingSecret ? faCheck : faCopy} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="mt-4 w-full bg-mineshaft-700 py-3 text-bunker-200"
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSecretLink("")}
|
||||||
|
rightIcon={<FontAwesomeIcon icon={faRedo} className="pl-2" />}
|
||||||
|
>
|
||||||
|
Share another secret
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1 +1 @@
|
|||||||
export { SecretTable } from "./SecretTable";
|
export { ShareSecretForm } from "./ShareSecretForm";
|
||||||
|
104
frontend/src/views/ViewSecretPublicPage/ViewSecretPublicPage.tsx
Normal file
104
frontend/src/views/ViewSecretPublicPage/ViewSecretPublicPage.tsx
Normal 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 →
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{secret && key && <SecretContainer secret={secret} secretKey={key} />}
|
||||||
|
{error && <SecretErrorContainer />}
|
||||||
|
<div className="m-auto my-8 flex w-full">
|
||||||
|
<div className="w-full border-t border-mineshaft-600" />
|
||||||
|
</div>
|
||||||
|
<div className="m-auto flex w-full flex-col rounded-md border border-primary-500/30 bg-primary/5 p-6 pt-5">
|
||||||
|
<p className="w-full pb-2 text-lg font-semibold text-mineshaft-100 md:pb-3 md:text-xl">
|
||||||
|
Open source{" "}
|
||||||
|
<span className="bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent">
|
||||||
|
secret management
|
||||||
|
</span>{" "}
|
||||||
|
for developers
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col items-start sm:flex-row sm:items-center">
|
||||||
|
<p className="md:text-md text-md mr-4">
|
||||||
|
<a
|
||||||
|
href="https://github.com/infisical/infisical"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-bold bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent"
|
||||||
|
>
|
||||||
|
Infisical
|
||||||
|
</a>{" "}
|
||||||
|
is the all-in-one secret management platform to securely manage secrets, configs, and
|
||||||
|
certificates across your team and infrastructure.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 cursor-pointer sm:mt-0">
|
||||||
|
<Link href="https://infisical.com">
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-mineshaft-400/40 bg-mineshaft-600 py-2 px-3 duration-200 hover:border-primary/60 hover:bg-primary/20 hover:text-white">
|
||||||
|
<p className="mr-4 whitespace-nowrap">Try Infisical</p>
|
||||||
|
<FontAwesomeIcon icon={faArrowRight} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-mineshaft-600 p-2">
|
||||||
|
<p className="text-center text-sm text-mineshaft-300">
|
||||||
|
Made with ❤️ by{" "}
|
||||||
|
<a className="text-primary" href="https://infisical.com">
|
||||||
|
Infisical
|
||||||
|
</a>
|
||||||
|
<br />
|
||||||
|
156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,82 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
faArrowRight,
|
||||||
|
faCheck,
|
||||||
|
faCopy,
|
||||||
|
faEye,
|
||||||
|
faEyeSlash
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto";
|
||||||
|
import { Button, IconButton } from "@app/components/v2";
|
||||||
|
import { useTimedReset, useToggle } from "@app/hooks";
|
||||||
|
import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
secret: TViewSharedSecretResponse;
|
||||||
|
secretKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecretContainer = ({ secret, secretKey: key }: Props) => {
|
||||||
|
const [isVisible, setIsVisible] = useToggle(false);
|
||||||
|
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
|
||||||
|
initialState: "Copy to clipboard"
|
||||||
|
});
|
||||||
|
|
||||||
|
const decryptedSecret = useMemo(() => {
|
||||||
|
if (secret && secret.encryptedValue && key) {
|
||||||
|
const res = decryptSymmetric({
|
||||||
|
ciphertext: secret.encryptedValue,
|
||||||
|
iv: secret.iv,
|
||||||
|
tag: secret.tag,
|
||||||
|
key
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, [secret, key]);
|
||||||
|
|
||||||
|
const hiddenSecret = decryptedSecret ? "*".repeat(decryptedSecret.length) : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4">
|
||||||
|
<div className="flex items-center justify-between rounded-md bg-white/[0.05] p-2 text-base text-gray-400">
|
||||||
|
<p className="whitespace-pre-wrap break-all">
|
||||||
|
{isVisible ? decryptedSecret : hiddenSecret}
|
||||||
|
</p>
|
||||||
|
<div className="flex">
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="copy icon"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="group relative"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(decryptedSecret);
|
||||||
|
setCopyTextSecret("Copied");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isCopyingSecret ? faCheck : faCopy} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="copy icon"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="group relative ml-2"
|
||||||
|
onClick={() => setIsVisible.toggle()}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isVisible ? faEyeSlash : faEye} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="mt-4 w-full bg-mineshaft-700 py-3 text-bunker-200"
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open("https://app.infisical.com/share-secret", "_blank")}
|
||||||
|
rightIcon={<FontAwesomeIcon icon={faArrowRight} className="pl-2" />}
|
||||||
|
>
|
||||||
|
Share your own secret
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,13 @@
|
|||||||
|
import { faKey } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
export const SecretErrorContainer = () => {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<FontAwesomeIcon icon={faKey} size="2x" />
|
||||||
|
<p className="mt-4">The secret you are looking is missing or has expired</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,2 @@
|
|||||||
|
export { SecretContainer } from "./SecretContainer";
|
||||||
|
export { SecretErrorContainer } from "./SecretErrorContainer";
|
1
frontend/src/views/ViewSecretPublicPage/index.tsx
Normal file
1
frontend/src/views/ViewSecretPublicPage/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ViewSecretPublicPage } from "./ViewSecretPublicPage";
|
Reference in New Issue
Block a user