mirror of
https://github.com/Infisical/infisical.git
synced 2025-03-23 03:03:05 +00:00
Compare commits
22 Commits
org-role-c
...
secret-sha
Author | SHA1 | Date | |
---|---|---|---|
af864b456b | |||
a30e3874cd | |||
0fa6b7a08a | |||
29c5bf5491 | |||
4d711ae149 | |||
84af8e708e | |||
b39b5bd1a1 | |||
b3d9d91b52 | |||
5ad4061881 | |||
f29862eaf2 | |||
7cb174b644 | |||
bf00d16c80 | |||
e30a0fe8be | |||
6e6f0252ae | |||
2348df7a4d | |||
962cf67dfb | |||
32627c20c4 | |||
c50f8fd78c | |||
1cb4dc9e84 | |||
08d7dead8c | |||
a30e06e392 | |||
5cd0f665fa |
@ -107,7 +107,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
|
||||
}),
|
||||
name: z.string().trim().optional(),
|
||||
description: z.string().trim().optional(),
|
||||
permissions: z.any().array()
|
||||
permissions: z.any().array().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
@ -101,7 +101,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
|
||||
message: "Slug must be a valid"
|
||||
}),
|
||||
name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name),
|
||||
permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions)
|
||||
permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@ -120,7 +120,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
|
||||
roleId: req.params.roleId,
|
||||
data: {
|
||||
...req.body,
|
||||
permissions: JSON.stringify(packRules(req.body.permissions))
|
||||
permissions: req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined
|
||||
}
|
||||
});
|
||||
return { role };
|
||||
|
@ -425,6 +425,21 @@ export const PROJECTS = {
|
||||
},
|
||||
LIST_INTEGRATION_AUTHORIZATION: {
|
||||
workspaceId: "The ID of the project to list integration auths for."
|
||||
},
|
||||
LIST_CAS: {
|
||||
slug: "The slug of the project to list CAs for.",
|
||||
status: "The status of the CA to filter by.",
|
||||
friendlyName: "The friendly name of the CA to filter by.",
|
||||
commonName: "The common name of the CA to filter by.",
|
||||
offset: "The offset to start from. If you enter 10, it will start from the 10th CA.",
|
||||
limit: "The number of CAs to return."
|
||||
},
|
||||
LIST_CERTIFICATES: {
|
||||
slug: "The slug of the project to list certificates for.",
|
||||
friendlyName: "The friendly name of the certificate to filter by.",
|
||||
commonName: "The common name of the certificate to filter by.",
|
||||
offset: "The offset to start from. If you enter 10, it will start from the 10th certificate.",
|
||||
limit: "The number of certificates to return."
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
@ -95,7 +95,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
tag: z.string(),
|
||||
hashedHex: z.string(),
|
||||
expiresAt: z.string(),
|
||||
expiresAfterViews: z.number()
|
||||
expiresAfterViews: z.number().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@ -110,7 +110,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
iv,
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt: new Date(expiresAt),
|
||||
expiresAt,
|
||||
expiresAfterViews,
|
||||
accessType: SecretSharingAccessType.Anyone
|
||||
});
|
||||
@ -131,7 +131,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
tag: z.string(),
|
||||
hashedHex: z.string(),
|
||||
expiresAt: z.string(),
|
||||
expiresAfterViews: z.number(),
|
||||
expiresAfterViews: z.number().optional(),
|
||||
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization)
|
||||
}),
|
||||
response: {
|
||||
@ -153,7 +153,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
iv,
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt: new Date(expiresAt),
|
||||
expiresAt,
|
||||
expiresAfterViews,
|
||||
accessType: req.body.accessType
|
||||
});
|
||||
|
@ -317,10 +317,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
slug: slugSchema.describe("The slug of the project to list CAs.")
|
||||
slug: slugSchema.describe(PROJECTS.LIST_CAS.slug)
|
||||
}),
|
||||
querystring: z.object({
|
||||
status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional()
|
||||
status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional().describe(PROJECTS.LIST_CAS.status),
|
||||
friendlyName: z.string().optional().describe(PROJECTS.LIST_CAS.friendlyName),
|
||||
commonName: z.string().optional().describe(PROJECTS.LIST_CAS.commonName),
|
||||
offset: z.coerce.number().min(0).max(100).default(0).describe(PROJECTS.LIST_CAS.offset),
|
||||
limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CAS.limit)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@ -336,11 +340,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
orgId: req.permission.orgId,
|
||||
type: ProjectFilterType.SLUG
|
||||
},
|
||||
status: req.query.status,
|
||||
actorId: req.permission.id,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actor: req.permission.type
|
||||
actor: req.permission.type,
|
||||
...req.query
|
||||
});
|
||||
return { cas };
|
||||
}
|
||||
@ -354,11 +358,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
slug: slugSchema.describe("The slug of the project to list certificates.")
|
||||
slug: slugSchema.describe(PROJECTS.LIST_CERTIFICATES.slug)
|
||||
}),
|
||||
querystring: z.object({
|
||||
offset: z.coerce.number().min(0).max(100).default(0),
|
||||
limit: z.coerce.number().min(1).max(100).default(25)
|
||||
friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName),
|
||||
commonName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.commonName),
|
||||
offset: z.coerce.number().min(0).max(100).default(0).describe(PROJECTS.LIST_CERTIFICATES.offset),
|
||||
limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CERTIFICATES.limit)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
@ -8,19 +8,35 @@ export type TCertificateDALFactory = ReturnType<typeof certificateDALFactory>;
|
||||
export const certificateDALFactory = (db: TDbClient) => {
|
||||
const certificateOrm = ormify(db, TableName.Certificate);
|
||||
|
||||
const countCertificatesInProject = async (projectId: string) => {
|
||||
const countCertificatesInProject = async ({
|
||||
projectId,
|
||||
friendlyName,
|
||||
commonName
|
||||
}: {
|
||||
projectId: string;
|
||||
friendlyName?: string;
|
||||
commonName?: string;
|
||||
}) => {
|
||||
try {
|
||||
interface CountResult {
|
||||
count: string;
|
||||
}
|
||||
|
||||
const count = await db
|
||||
let query = db
|
||||
.replicaNode()(TableName.Certificate)
|
||||
.join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`)
|
||||
.join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`)
|
||||
.where(`${TableName.Project}.id`, projectId)
|
||||
.count("*")
|
||||
.first();
|
||||
.where(`${TableName.Project}.id`, projectId);
|
||||
|
||||
if (friendlyName) {
|
||||
query = query.andWhere(`${TableName.Certificate}.friendlyName`, friendlyName);
|
||||
}
|
||||
|
||||
if (commonName) {
|
||||
query = query.andWhere(`${TableName.Certificate}.commonName`, commonName);
|
||||
}
|
||||
|
||||
const count = await query.count("*").first();
|
||||
|
||||
return parseInt((count as unknown as CountResult).count || "0", 10);
|
||||
} catch (error) {
|
||||
|
@ -162,12 +162,19 @@ export const projectRoleServiceFactory = ({
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Role);
|
||||
|
||||
if (data?.slug) {
|
||||
const existingRole = await projectRoleDAL.findOne({ slug: data.slug, projectId });
|
||||
if (existingRole && existingRole.id !== roleId)
|
||||
throw new BadRequestError({ name: "Update Role", message: "Duplicate role" });
|
||||
}
|
||||
const [updatedRole] = await projectRoleDAL.update({ id: roleId, projectId }, data);
|
||||
const [updatedRole] = await projectRoleDAL.update(
|
||||
{ id: roleId, projectId },
|
||||
{
|
||||
...data,
|
||||
permissions: data.permissions ? data.permissions : undefined
|
||||
}
|
||||
);
|
||||
if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
|
||||
return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) };
|
||||
};
|
||||
|
@ -575,6 +575,10 @@ export const projectServiceFactory = ({
|
||||
*/
|
||||
const listProjectCas = async ({
|
||||
status,
|
||||
friendlyName,
|
||||
commonName,
|
||||
limit = 25,
|
||||
offset = 0,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
@ -596,10 +600,15 @@ export const projectServiceFactory = ({
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
const cas = await certificateAuthorityDAL.find({
|
||||
projectId: project.id,
|
||||
...(status && { status })
|
||||
});
|
||||
const cas = await certificateAuthorityDAL.find(
|
||||
{
|
||||
projectId: project.id,
|
||||
...(status && { status }),
|
||||
...(friendlyName && { friendlyName }),
|
||||
...(commonName && { commonName })
|
||||
},
|
||||
{ offset, limit, sort: [["updatedAt", "desc"]] }
|
||||
);
|
||||
|
||||
return cas;
|
||||
};
|
||||
@ -608,8 +617,10 @@ export const projectServiceFactory = ({
|
||||
* Return list of certificates for project
|
||||
*/
|
||||
const listProjectCertificates = async ({
|
||||
offset,
|
||||
limit,
|
||||
limit = 25,
|
||||
offset = 0,
|
||||
friendlyName,
|
||||
commonName,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
@ -634,12 +645,18 @@ export const projectServiceFactory = ({
|
||||
{
|
||||
$in: {
|
||||
caId: cas.map((ca) => ca.id)
|
||||
}
|
||||
},
|
||||
...(friendlyName && { friendlyName }),
|
||||
...(commonName && { commonName })
|
||||
},
|
||||
{ offset, limit, sort: [["updatedAt", "desc"]] }
|
||||
);
|
||||
|
||||
const count = await certificateDAL.countCertificatesInProject(project.id);
|
||||
const count = await certificateDAL.countCertificatesInProject({
|
||||
projectId: project.id,
|
||||
friendlyName,
|
||||
commonName
|
||||
});
|
||||
|
||||
return {
|
||||
certificates,
|
||||
|
@ -89,6 +89,10 @@ export type AddUserToWsDTO = {
|
||||
|
||||
export type TListProjectCasDTO = {
|
||||
status?: CaStatus;
|
||||
friendlyName?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
commonName?: string;
|
||||
filter: Filter;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
@ -96,4 +100,6 @@ export type TListProjectCertsDTO = {
|
||||
filter: Filter;
|
||||
offset: number;
|
||||
limit: number;
|
||||
friendlyName?: string;
|
||||
commonName?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
@ -64,12 +64,13 @@ export const secretSharingServiceFactory = ({
|
||||
iv,
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt,
|
||||
expiresAt: new Date(expiresAt),
|
||||
expiresAfterViews,
|
||||
userId: actorId,
|
||||
orgId,
|
||||
accessType
|
||||
});
|
||||
|
||||
return { id: newSharedSecret.id };
|
||||
};
|
||||
|
||||
@ -97,7 +98,7 @@ export const secretSharingServiceFactory = ({
|
||||
iv,
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt,
|
||||
expiresAt: new Date(expiresAt),
|
||||
expiresAfterViews,
|
||||
accessType
|
||||
});
|
||||
|
@ -16,8 +16,8 @@ export type TCreatePublicSharedSecretDTO = {
|
||||
iv: string;
|
||||
tag: string;
|
||||
hashedHex: string;
|
||||
expiresAt: Date;
|
||||
expiresAfterViews: number;
|
||||
expiresAt: string;
|
||||
expiresAfterViews?: number;
|
||||
accessType: SecretSharingAccessType;
|
||||
};
|
||||
|
||||
|
@ -24,7 +24,6 @@ import (
|
||||
"github.com/Infisical/infisical-merge/packages/models"
|
||||
"github.com/Infisical/infisical-merge/packages/srp"
|
||||
"github.com/Infisical/infisical-merge/packages/util"
|
||||
"github.com/chzyer/readline"
|
||||
"github.com/fatih/color"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/manifoldco/promptui"
|
||||
@ -205,6 +204,7 @@ var loginCmd = &cobra.Command{
|
||||
if !overrideDomain {
|
||||
domainQuery = false
|
||||
config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL_MANUAL_OVERRIDE)
|
||||
config.INFISICAL_LOGIN_URL = fmt.Sprintf("%s/login", strings.TrimSuffix(config.INFISICAL_URL, "/api"))
|
||||
}
|
||||
|
||||
}
|
||||
@ -713,7 +713,7 @@ func askForMFACode() string {
|
||||
return mfaVerifyCode
|
||||
}
|
||||
|
||||
func askToPasteJwtToken(stdin *readline.CancelableStdin, success chan models.UserCredentials, failure chan error) {
|
||||
func askToPasteJwtToken(success chan models.UserCredentials, failure chan error) {
|
||||
time.Sleep(time.Second * 5)
|
||||
fmt.Println("\n\nOnce login is completed via browser, the CLI should be authenticated automatically.")
|
||||
fmt.Println("However, if browser fails to communicate with the CLI, please paste the token from the browser below.")
|
||||
@ -807,26 +807,22 @@ func browserCliLogin() (models.UserCredentials, error) {
|
||||
|
||||
log.Debug().Msgf("Callback server listening on port %d", callbackPort)
|
||||
|
||||
stdin := readline.NewCancelableStdin(os.Stdin)
|
||||
go http.Serve(listener, corsHandler)
|
||||
go askToPasteJwtToken(stdin, success, failure)
|
||||
go askToPasteJwtToken(success, failure)
|
||||
|
||||
for {
|
||||
select {
|
||||
case loginResponse := <-success:
|
||||
_ = closeListener(&listener)
|
||||
_ = stdin.Close()
|
||||
fmt.Println("Browser login successful")
|
||||
return loginResponse, nil
|
||||
|
||||
case err := <-failure:
|
||||
serverErr := closeListener(&listener)
|
||||
stdErr := stdin.Close()
|
||||
return models.UserCredentials{}, errors.Join(err, serverErr, stdErr)
|
||||
return models.UserCredentials{}, errors.Join(err, serverErr)
|
||||
|
||||
case <-timeout:
|
||||
_ = closeListener(&listener)
|
||||
_ = stdin.Close()
|
||||
return models.UserCredentials{}, errors.New("server timeout")
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
---
|
@ -654,6 +654,7 @@
|
||||
{
|
||||
"group": "Certificate Authorities",
|
||||
"pages": [
|
||||
"api-reference/endpoints/certificate-authorities/list",
|
||||
"api-reference/endpoints/certificate-authorities/create",
|
||||
"api-reference/endpoints/certificate-authorities/read",
|
||||
"api-reference/endpoints/certificate-authorities/update",
|
||||
@ -669,6 +670,7 @@
|
||||
{
|
||||
"group": "Certificates",
|
||||
"pages": [
|
||||
"api-reference/endpoints/certificates/list",
|
||||
"api-reference/endpoints/certificates/read",
|
||||
"api-reference/endpoints/certificates/revoke",
|
||||
"api-reference/endpoints/certificates/delete",
|
||||
|
@ -25,6 +25,10 @@ Used to configure platform-specific security and operational settings
|
||||
https://app.infisical.com).
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="PORT" type="int" default="8080" optional>
|
||||
Specifies the internal port on which the application listens.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="TELEMETRY_ENABLED" type="string" default="true" optional>
|
||||
Telemetry helps us improve Infisical but if you want to dsiable it you may set this to `false`.
|
||||
</ParamField>
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
TDeleteOrgRoleDTO,
|
||||
TDeleteProjectRoleDTO,
|
||||
TOrgRole,
|
||||
TProjectRole,
|
||||
TUpdateOrgRoleDTO,
|
||||
TUpdateProjectRoleDTO
|
||||
} from "./types";
|
||||
@ -17,9 +18,13 @@ import {
|
||||
export const useCreateProjectRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ projectSlug, ...dto }: TCreateProjectRoleDTO) =>
|
||||
apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto),
|
||||
return useMutation<TProjectRole, {}, TCreateProjectRoleDTO>({
|
||||
mutationFn: async ({ projectSlug, ...dto }: TCreateProjectRoleDTO) => {
|
||||
const {
|
||||
data: { role }
|
||||
} = await apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto);
|
||||
return role;
|
||||
},
|
||||
onSuccess: (_, { projectSlug }) => {
|
||||
queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug));
|
||||
}
|
||||
@ -29,9 +34,13 @@ export const useCreateProjectRole = () => {
|
||||
export const useUpdateProjectRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) =>
|
||||
apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto),
|
||||
return useMutation<TProjectRole, {}, TUpdateProjectRoleDTO>({
|
||||
mutationFn: async ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) => {
|
||||
const {
|
||||
data: { role }
|
||||
} = await apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto);
|
||||
return role;
|
||||
},
|
||||
onSuccess: (_, { projectSlug }) => {
|
||||
queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug));
|
||||
}
|
||||
@ -40,10 +49,13 @@ export const useUpdateProjectRole = () => {
|
||||
|
||||
export const useDeleteProjectRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ projectSlug, id }: TDeleteProjectRoleDTO) =>
|
||||
apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`),
|
||||
return useMutation<TProjectRole, {}, TDeleteProjectRoleDTO>({
|
||||
mutationFn: async ({ projectSlug, id }: TDeleteProjectRoleDTO) => {
|
||||
const {
|
||||
data: { role }
|
||||
} = await apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`);
|
||||
return role;
|
||||
},
|
||||
onSuccess: (_, { projectSlug }) => {
|
||||
queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug));
|
||||
}
|
||||
@ -79,7 +91,7 @@ export const useUpdateOrgRole = () => {
|
||||
data: { role }
|
||||
} = await apiRequest.patch(`/api/v1/organization/${orgId}/roles/${id}`, {
|
||||
...dto,
|
||||
permissions: permissions?.length ? packRules(permissions) : []
|
||||
permissions: permissions?.length ? packRules(permissions) : undefined
|
||||
});
|
||||
|
||||
return role;
|
||||
|
@ -12,7 +12,7 @@ export type TCreateSharedSecretRequest = {
|
||||
tag: string;
|
||||
hashedHex: string;
|
||||
expiresAt: Date;
|
||||
expiresAfterViews: number;
|
||||
expiresAfterViews?: number;
|
||||
accessType: SecretSharingAccessType;
|
||||
};
|
||||
|
||||
@ -31,4 +31,4 @@ export type TDeleteSharedSecretRequest = {
|
||||
export enum SecretSharingAccessType {
|
||||
Anyone = "anyone",
|
||||
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;
|
@ -168,16 +168,27 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => {
|
||||
const { token: newJwtToken } = await selectOrganization({ organizationId });
|
||||
|
||||
const instance = axios.create();
|
||||
await instance.post(cliUrl, {
|
||||
const payload = {
|
||||
...isCliLoginSuccessful.loginResponse,
|
||||
JTWToken: newJwtToken
|
||||
};
|
||||
await instance.post(cliUrl, payload).catch(() => {
|
||||
// if error happens to communicate we set the token with an expiry in sessino storage
|
||||
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
|
||||
sessionStorage.setItem(
|
||||
SessionStorageKeys.CLI_TERMINAL_TOKEN,
|
||||
JSON.stringify({
|
||||
expiry: formatISO(addSeconds(new Date(), 30)),
|
||||
data: window.btoa(JSON.stringify(payload))
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await navigateUserToOrg(router, organizationId);
|
||||
router.push("/cli-redirect");
|
||||
return;
|
||||
}
|
||||
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
|
||||
// if the user has no orgs, navigate to the create org page
|
||||
else {
|
||||
|
||||
const userOrgs = await fetchOrganizations();
|
||||
|
||||
// case: user has orgs, so we navigate the user to select an org
|
||||
@ -189,7 +200,7 @@ export const MFAStep = ({ email, password, providerAuthToken }: Props) => {
|
||||
else {
|
||||
await navigateUserToOrg(router);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
const isLoginSuccessful = await attemptLoginMfa({
|
||||
|
@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||
import axios from "axios";
|
||||
import { addSeconds, formatISO } from "date-fns";
|
||||
import jwt_decode from "jwt-decode";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@ -12,6 +13,7 @@ import attemptLogin from "@app/components/utilities/attemptLogin";
|
||||
import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config";
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { Button, Input, Spinner } from "@app/components/v2";
|
||||
import { SessionStorageKeys } from "@app/const";
|
||||
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
|
||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
||||
import { fetchMyPrivateKey } from "@app/hooks/api/users/queries";
|
||||
@ -79,11 +81,24 @@ export const PasswordStep = ({
|
||||
if (callbackPort) {
|
||||
console.log("organization id was present. new JWT token to be used in CLI:", newJwtToken);
|
||||
const instance = axios.create();
|
||||
await instance.post(cliUrl, {
|
||||
const payload = {
|
||||
privateKey,
|
||||
email,
|
||||
JTWToken: newJwtToken
|
||||
};
|
||||
await instance.post(cliUrl, payload).catch(() => {
|
||||
// if error happens to communicate we set the token with an expiry in sessino storage
|
||||
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
|
||||
sessionStorage.setItem(
|
||||
SessionStorageKeys.CLI_TERMINAL_TOKEN,
|
||||
JSON.stringify({
|
||||
expiry: formatISO(addSeconds(new Date(), 30)),
|
||||
data: window.btoa(JSON.stringify(payload))
|
||||
})
|
||||
);
|
||||
});
|
||||
router.push("/cli-redirect");
|
||||
return;
|
||||
}
|
||||
|
||||
await navigateUserToOrg(router, organizationId);
|
||||
@ -165,26 +180,35 @@ export const PasswordStep = ({
|
||||
);
|
||||
|
||||
const instance = axios.create();
|
||||
await instance.post(cliUrl, {
|
||||
const payload = {
|
||||
...isCliLoginSuccessful.loginResponse,
|
||||
JTWToken: newJwtToken
|
||||
};
|
||||
await instance.post(cliUrl, payload).catch(() => {
|
||||
// if error happens to communicate we set the token with an expiry in sessino storage
|
||||
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
|
||||
sessionStorage.setItem(
|
||||
SessionStorageKeys.CLI_TERMINAL_TOKEN,
|
||||
JSON.stringify({
|
||||
expiry: formatISO(addSeconds(new Date(), 30)),
|
||||
data: window.btoa(JSON.stringify(payload))
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await navigateUserToOrg(router, organizationId);
|
||||
router.push("/cli-redirect");
|
||||
return;
|
||||
}
|
||||
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
|
||||
// if the user has no orgs, navigate to the create org page
|
||||
else {
|
||||
const userOrgs = await fetchOrganizations();
|
||||
const userOrgs = await fetchOrganizations();
|
||||
|
||||
// case: user has orgs, so we navigate the user to select an org
|
||||
if (userOrgs.length > 0) {
|
||||
navigateToSelectOrganization(callbackPort);
|
||||
}
|
||||
// case: no orgs found, so we navigate the user to create an org
|
||||
else {
|
||||
await navigateUserToOrg(router);
|
||||
}
|
||||
// case: user has orgs, so we navigate the user to select an org
|
||||
if (userOrgs.length > 0) {
|
||||
navigateToSelectOrganization(callbackPort);
|
||||
}
|
||||
// case: no orgs found, so we navigate the user to create an org
|
||||
else {
|
||||
await navigateUserToOrg(router);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -2,29 +2,12 @@ import { motion } from "framer-motion";
|
||||
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
|
||||
import { ProjectRoleList } from "./components/ProjectRoleList";
|
||||
import { ProjectRoleModifySection } from "./components/ProjectRoleModifySection";
|
||||
|
||||
export const ProjectRoleListTab = withProjectPermission(
|
||||
() => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const);
|
||||
|
||||
return popUp.editRole.isOpen ? (
|
||||
<motion.div
|
||||
key="role-modify"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<ProjectRoleModifySection
|
||||
roleSlug={popUp.editRole.data as string}
|
||||
onGoBack={() => handlePopUpClose("editRole")}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
return (
|
||||
<motion.div
|
||||
key="role-list"
|
||||
transition={{ duration: 0.1 }}
|
||||
@ -32,7 +15,7 @@ export const ProjectRoleListTab = withProjectPermission(
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<ProjectRoleList onSelectRole={(slug) => handlePopUpOpen("editRole", slug)} />
|
||||
<ProjectRoleList />
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
|
@ -1,14 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useRouter } from "next/router";
|
||||
import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
IconButton,
|
||||
Input,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
@ -22,17 +25,17 @@ import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@a
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api";
|
||||
import { TProjectRole } from "@app/hooks/api/roles/types";
|
||||
import { RoleModal } from "@app/views/Project/RolePage/components";
|
||||
|
||||
type Props = {
|
||||
onSelectRole: (slug?: string) => void;
|
||||
};
|
||||
|
||||
export const ProjectRoleList = ({ onSelectRole }: Props) => {
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
|
||||
export const ProjectRoleList = () => {
|
||||
const router = useRouter();
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"role",
|
||||
"deleteRole"
|
||||
] as const);
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectSlug = currentWorkspace?.slug || "";
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug);
|
||||
|
||||
@ -54,21 +57,16 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-4 flex">
|
||||
<div className="mr-4 flex-1">
|
||||
<Input
|
||||
value={searchRoles}
|
||||
onChange={(e) => setSearchRoles(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search roles..."
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Project Roles</p>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => onSelectRole()}
|
||||
onClick={() => handlePopUpOpen("role")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Role
|
||||
@ -76,77 +74,94 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => {
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Slug</Th>
|
||||
<Th aria-label="actions" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
|
||||
{roles?.map((role) => {
|
||||
const { id, name, slug } = role;
|
||||
const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug);
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Slug</Th>
|
||||
<Th aria-label="actions" className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
|
||||
{roles?.map((role) => {
|
||||
const { id, name, slug } = role;
|
||||
const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug);
|
||||
|
||||
return (
|
||||
<Tr key={`role-list-${id}`}>
|
||||
<Td>{name}</Td>
|
||||
<Td>{slug}</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end space-x-2">
|
||||
return (
|
||||
<Tr
|
||||
key={`role-list-${id}`}
|
||||
className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
|
||||
onClick={() => router.push(`/project/${projectId}/roles/${slug}`)}
|
||||
>
|
||||
<Td>{name}</Td>
|
||||
<Td>{slug}</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Role}
|
||||
renderTooltip
|
||||
allowedLabel="Edit"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
isDisabled={!isAllowed}
|
||||
ariaLabel="edit"
|
||||
onClick={() => onSelectRole(role.slug)}
|
||||
variant="plain"
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/project/${projectId}/roles/${slug}`);
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
{`${isNonMutatable ? "View" : "Edit"} Role`}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Role}
|
||||
renderTooltip
|
||||
allowedLabel={
|
||||
isNonMutatable ? "Reserved roles are non-removable" : "Delete"
|
||||
}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="delete"
|
||||
onClick={() => handlePopUpOpen("deleteRole", role)}
|
||||
variant="plain"
|
||||
isDisabled={isNonMutatable || !isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
{!isNonMutatable && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Role}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteRole", role);
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Delete Role
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteRole.isOpen}
|
||||
title={`Are you sure want to delete ${(popUp?.deleteRole?.data as TProjectRole)?.name || " "
|
||||
} role?`}
|
||||
title={`Are you sure want to delete ${
|
||||
(popUp?.deleteRole?.data as TProjectRole)?.name || " "
|
||||
} role?`}
|
||||
deleteKey={(popUp?.deleteRole?.data as TProjectRole)?.slug || ""}
|
||||
onClose={() => handlePopUpClose("deleteRole")}
|
||||
onDeleteApproved={handleRoleDelete}
|
||||
|
@ -14,7 +14,8 @@ import {
|
||||
faShield,
|
||||
faTags,
|
||||
faUser,
|
||||
faUsers} from "@fortawesome/free-solid-svg-icons";
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
|
157
frontend/src/views/Project/RolePage/RolePage.tsx
Normal file
157
frontend/src/views/Project/RolePage/RolePage.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
/* 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 { 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`);
|
||||
} 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`)}
|
||||
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">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>
|
||||
);
|
||||
};
|
@ -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/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/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-300 hover:bg-mineshaft-700"
|
||||
onClick={() => setIsRowExpanded.toggle()}
|
||||
>
|
||||
<Td>
|
||||
<FontAwesomeIcon icon={isRowExpanded ? faChevronDown : faChevronRight} />
|
||||
</Td>
|
||||
<Td>{title}</Td>
|
||||
<Td>
|
||||
<Select
|
||||
value={selectedPermissionCategory}
|
||||
className="w-40 bg-mineshaft-600"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={handlePermissionChange}
|
||||
isDisabled={!isEditable}
|
||||
>
|
||||
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
|
||||
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
|
||||
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
|
||||
<SelectItem value={Permission.Custom}>Custom</SelectItem>
|
||||
</Select>
|
||||
</Td>
|
||||
</Tr>
|
||||
{isRowExpanded && (
|
||||
<Tr>
|
||||
<Td
|
||||
colSpan={3}
|
||||
className={`bg-bunker-600 px-0 py-0 ${isRowExpanded && " border-mineshaft-500 p-8"}`}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{getPermissionList(formName).map(({ action, label }) => {
|
||||
const permissionName = `permissions.${formName}.${action}` as PermissionName;
|
||||
return (
|
||||
<Controller
|
||||
name={permissionName}
|
||||
key={permissionName}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(e) => {
|
||||
if (!isEditable) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update default role"
|
||||
});
|
||||
return;
|
||||
}
|
||||
field.onChange(e);
|
||||
}}
|
||||
id={permissionName}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,247 @@
|
||||
import { useMemo } from "react";
|
||||
import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form";
|
||||
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import GlobPatternExamples from "@app/components/basic/popups/GlobPatternExamples";
|
||||
import {
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { TFormSchema } from "@app/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
formName: "secrets";
|
||||
isEditable: boolean;
|
||||
setValue: UseFormSetValue<TFormSchema>;
|
||||
getValue: UseFormGetValues<TFormSchema>;
|
||||
control: Control<TFormSchema>;
|
||||
};
|
||||
|
||||
enum Permission {
|
||||
NoAccess = "no-access",
|
||||
ReadOnly = "read-only",
|
||||
FullAccess = "full-acess",
|
||||
Custom = "custom"
|
||||
}
|
||||
|
||||
export const RowPermissionSecretsRow = ({
|
||||
title,
|
||||
formName,
|
||||
isEditable,
|
||||
setValue,
|
||||
getValue,
|
||||
control
|
||||
}: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
|
||||
const customRule = useWatch({
|
||||
control,
|
||||
name: `permissions.${formName}.custom`
|
||||
});
|
||||
const isCustom = Boolean(customRule);
|
||||
|
||||
const allRule = useWatch({ control, name: `permissions.${formName}.all` });
|
||||
|
||||
const selectedPermissionCategory = useMemo(() => {
|
||||
const { read, delete: del, edit, create } = allRule || {};
|
||||
if (read && del && edit && create) return Permission.FullAccess;
|
||||
if (read) return Permission.ReadOnly;
|
||||
return Permission.NoAccess;
|
||||
}, [allRule]);
|
||||
|
||||
const handlePermissionChange = (val: Permission) => {
|
||||
if (!val) return;
|
||||
switch (val) {
|
||||
case Permission.NoAccess: {
|
||||
const permissions = getValue("permissions");
|
||||
if (permissions) delete permissions[formName];
|
||||
setValue("permissions", permissions, { shouldDirty: true });
|
||||
break;
|
||||
}
|
||||
case Permission.FullAccess:
|
||||
setValue(
|
||||
`permissions.${formName}`,
|
||||
{ all: { read: true, edit: true, create: true, delete: true } },
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
case Permission.ReadOnly:
|
||||
setValue(
|
||||
`permissions.${formName}`,
|
||||
{ all: { read: true, edit: false, create: false, delete: false } },
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
default:
|
||||
setValue(
|
||||
`permissions.${formName}`,
|
||||
{ custom: { read: false, edit: false, create: false, delete: false } },
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr>
|
||||
<Td>{isCustom && <FontAwesomeIcon icon={faChevronDown} />}</Td>
|
||||
<Td>{title}</Td>
|
||||
<Td>
|
||||
<Select
|
||||
value={isCustom ? Permission.Custom : selectedPermissionCategory}
|
||||
className="w-40 bg-mineshaft-600"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={handlePermissionChange}
|
||||
isDisabled={!isEditable}
|
||||
>
|
||||
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
|
||||
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
|
||||
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
|
||||
<SelectItem value={Permission.Custom}>Custom</SelectItem>
|
||||
</Select>
|
||||
</Td>
|
||||
</Tr>
|
||||
{isCustom && (
|
||||
<Tr>
|
||||
<Td
|
||||
colSpan={3}
|
||||
className={`bg-bunker-600 px-0 py-0 ${isCustom && " border-mineshaft-500 p-8"}`}
|
||||
>
|
||||
<div>
|
||||
<TableContainer className="border-mineshaft-500">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th />
|
||||
<Th className="min-w-[8rem]">
|
||||
<div className="flex items-center gap-2">
|
||||
Secret Path
|
||||
<span className="text-xs normal-case">
|
||||
<GlobPatternExamples />
|
||||
</span>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="text-center">View</Th>
|
||||
<Th className="text-center">Create</Th>
|
||||
<Th className="text-center">Modify</Th>
|
||||
<Th className="text-center">Delete</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isCustom &&
|
||||
environments.map(({ name, slug }) => (
|
||||
<Tr key={`custom-role-project-secret-${slug}`}>
|
||||
<Td>{name}</Td>
|
||||
<Td>
|
||||
<Controller
|
||||
name={`permissions.${formName}.${slug}.secretPath`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
/* eslint-disable-next-line no-template-curly-in-string */
|
||||
<FormControl helperText="Supports glob path pattern string">
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full overflow-ellipsis"
|
||||
placeholder="Glob patterns are supported"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<Controller
|
||||
name={`permissions.${formName}.${slug}.read`}
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
isChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`permissions.${formName}.${slug}.read`}
|
||||
isDisabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<Controller
|
||||
name={`permissions.${formName}.${slug}.create`}
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
isChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
id={`permissions.${formName}.${slug}.modify`}
|
||||
isDisabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<Controller
|
||||
name={`permissions.${formName}.${slug}.edit`}
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
isChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
id={`permissions.${formName}.${slug}.modify`}
|
||||
isDisabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<Controller
|
||||
defaultValue={false}
|
||||
name={`permissions.${formName}.${slug}.delete`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
isChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`permissions.${formName}.${slug}.delete`}
|
||||
isDisabled={!isEditable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,198 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2";
|
||||
import { ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
|
||||
import {
|
||||
formRolePermission2API,
|
||||
formSchema,
|
||||
rolePermission2Form,
|
||||
TFormSchema
|
||||
} from "@app/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/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";
|
@ -7,19 +7,38 @@ 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";
|
||||
import { Button, FormControl, 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"),
|
||||
expiresAfterViews: yup.string().required().label("Expires After Views"),
|
||||
expiresInValue: yup.string().min(1).required().label("Expiration Value"),
|
||||
accessType: yup.string().required().label("General Access")
|
||||
});
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
|
||||
// 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 }
|
||||
];
|
||||
|
||||
export const AddShareSecretForm = ({
|
||||
isPublic,
|
||||
inModal,
|
||||
@ -49,36 +68,15 @@ export const AddShareSecretForm = ({
|
||||
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,
|
||||
expiresAfterViews,
|
||||
accessType
|
||||
}: FormData) => {
|
||||
try {
|
||||
const expiresAt = new Date(new Date().getTime() + Number(expiresInValue));
|
||||
|
||||
const key = crypto.randomBytes(16).toString("hex");
|
||||
const hashedHex = crypto.createHash("sha256").update(key).digest("hex");
|
||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
||||
@ -86,21 +84,13 @@ export const AddShareSecretForm = ({
|
||||
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,
|
||||
expiresAfterViews: expiresAfterViews === "-1" ? undefined : Number(expiresAfterViews),
|
||||
accessType: accessType as SecretSharingAccessType
|
||||
});
|
||||
|
||||
@ -132,9 +122,14 @@ export const AddShareSecretForm = ({
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form className="flex w-full flex-col items-center px-4 sm:px-0" onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<form
|
||||
className="flex w-full max-w-7xl flex-col items-center"
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div
|
||||
className={`w-full ${!inModal && "rounded-md border border-mineshaft-600 bg-mineshaft-800 p-6"}`}
|
||||
className={`w-full ${
|
||||
!inModal && "rounded-md border border-mineshaft-600 bg-mineshaft-800 p-6"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<Controller
|
||||
@ -142,7 +137,7 @@ export const AddShareSecretForm = ({
|
||||
name="value"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Shared Secret"
|
||||
label="Your Secret"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="mb-2"
|
||||
@ -157,90 +152,48 @@ export const AddShareSecretForm = ({
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
<Controller
|
||||
control={control}
|
||||
name="expiresInValue"
|
||||
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 }) => (
|
||||
<SelectItem value={String(value || "")} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="expiresAfterViews"
|
||||
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 }) => (
|
||||
<SelectItem value={String(value || "")} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{!isPublic && (
|
||||
<Controller
|
||||
control={control}
|
||||
@ -248,10 +201,7 @@ export const AddShareSecretForm = ({
|
||||
defaultValue="organization"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormControl label="General Access">
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
>
|
||||
<Select {...field} onValueChange={(e) => onChange(e)} className="w-full">
|
||||
<SelectItem value="organization">People within your organization</SelectItem>
|
||||
<SelectItem value="anyone">Anyone</SelectItem>
|
||||
</Select>
|
||||
@ -261,7 +211,7 @@ export const AddShareSecretForm = ({
|
||||
)}
|
||||
<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"}
|
||||
{inModal ? "Create" : "Create secret link"}
|
||||
</Button>
|
||||
{inModal && (
|
||||
<ModalClose asChild>
|
||||
|
@ -12,9 +12,8 @@ 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")
|
||||
expiresInValue: yup.string().required().label("Expiration Value"),
|
||||
expiresAfterViews: yup.string().required().label("Expires After Views")
|
||||
});
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
|
@ -46,9 +46,8 @@ export const ShareSecretSection = () => {
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
</Head>
|
||||
<div className="mb-2 flex justify-between">
|
||||
<div className="mb-4 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Shared Secrets</p>
|
||||
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
|
@ -1,77 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { IconButton, Td, Tr } from "@app/components/v2";
|
||||
import { TSharedSecret } from "@app/hooks/api/secretSharing";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const formatDate = (date: Date): string => (date ? new Date(date).toUTCString() : "");
|
||||
|
||||
const isExpired = (expiresAt: Date | number | undefined): boolean => {
|
||||
if (typeof expiresAt === "number") {
|
||||
return expiresAt <= 0;
|
||||
}
|
||||
if (expiresAt instanceof Date) {
|
||||
return new Date(expiresAt) < new Date();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getValidityStatusText = (expiresAt: Date): string =>
|
||||
isExpired(expiresAt) ? "Expired " : "Valid for ";
|
||||
|
||||
const timeAgo = (inputDate: Date, currentDate: Date): string => {
|
||||
const now = new Date(currentDate).getTime();
|
||||
const date = new Date(inputDate).getTime();
|
||||
const elapsedMilliseconds = now - date;
|
||||
const elapsedSeconds = Math.abs(Math.floor(elapsedMilliseconds / 1000));
|
||||
const elapsedMinutes = Math.abs(Math.floor(elapsedSeconds / 60));
|
||||
const elapsedHours = Math.abs(Math.floor(elapsedMinutes / 60));
|
||||
const elapsedDays = Math.abs(Math.floor(elapsedHours / 24));
|
||||
const elapsedWeeks = Math.abs(Math.floor(elapsedDays / 7));
|
||||
const elapsedMonths = Math.abs(Math.floor(elapsedDays / 30));
|
||||
const elapsedYears = Math.abs(Math.floor(elapsedDays / 365));
|
||||
|
||||
if (elapsedYears > 0) {
|
||||
return `${elapsedYears} year${elapsedYears === 1 ? "" : "s"} ${
|
||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
}
|
||||
if (elapsedMonths > 0) {
|
||||
return `${elapsedMonths} month${elapsedMonths === 1 ? "" : "s"} ${
|
||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
}
|
||||
if (elapsedWeeks > 0) {
|
||||
return `${elapsedWeeks} week${elapsedWeeks === 1 ? "" : "s"} ${
|
||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
}
|
||||
if (elapsedDays > 0) {
|
||||
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ${
|
||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
}
|
||||
if (elapsedHours > 0) {
|
||||
return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ${
|
||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
}
|
||||
if (elapsedMinutes > 0) {
|
||||
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ${
|
||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
}
|
||||
return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${
|
||||
elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
};
|
||||
|
||||
export const ShareSecretsRow = ({
|
||||
row,
|
||||
handlePopUpOpen,
|
||||
onSecretExpiration
|
||||
handlePopUpOpen
|
||||
}: {
|
||||
row: TSharedSecret;
|
||||
handlePopUpOpen: (
|
||||
@ -84,44 +21,13 @@ export const ShareSecretsRow = ({
|
||||
id: string;
|
||||
}
|
||||
) => void;
|
||||
onSecretExpiration: (expiredSecretId: string) => void;
|
||||
}) => {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpired(row.expiresAt || row.expiresAfterViews)) {
|
||||
onSecretExpiration(row.id);
|
||||
}
|
||||
}, [isExpired(row.expiresAt || row.expiresAfterViews)]);
|
||||
|
||||
return (
|
||||
<Tr key={row.id}>
|
||||
<Tr key={row.id} className="h-10">
|
||||
<Td>{`${row.encryptedValue.substring(0, 5)}...`}</Td>
|
||||
<Td>
|
||||
<p className="text-sm text-yellow-400">{timeAgo(row.createdAt, currentTime)}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(row.createdAt)}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<>
|
||||
<p className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}>
|
||||
{getValidityStatusText(row.expiresAt!) + timeAgo(row.expiresAt!, currentTime)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(row.expiresAt!)}</p>
|
||||
</>
|
||||
</Td>
|
||||
<Td>
|
||||
<p className={`text-sm ${row.expiresAfterViews <= 0 ? "text-red-500" : "text-green-500"}`}>
|
||||
{row.expiresAfterViews}
|
||||
</p>
|
||||
</Td>
|
||||
<Td>{format(new Date(row.createdAt), "yyyy-MM-dd - HH:mm a")}</Td>
|
||||
<Td>{format(new Date(row.expiresAt), "yyyy-MM-dd - HH:mm a")}</Td>
|
||||
<Td>{row.expiresAfterViews ? row.expiresAfterViews : "-"}</Td>
|
||||
<Td>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
@ -130,10 +36,10 @@ export const ShareSecretsRow = ({
|
||||
id: row.id
|
||||
})
|
||||
}
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="delete"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashCan} />
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
@ -31,38 +31,25 @@ type Props = {
|
||||
|
||||
export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { isLoading, data = [] } = useGetSharedSecrets();
|
||||
|
||||
let tableData = data.filter(
|
||||
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
|
||||
);
|
||||
const handleSecretExpiration = () => {
|
||||
tableData = data.filter(
|
||||
(secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Encrypted Secret</Th> <Th>Created</Th> <Th>Valid Until</Th> <Th>Views Left</Th>
|
||||
<Th aria-label="button" />
|
||||
<Th>Encrypted Secret</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Valid Until</Th>
|
||||
<Th>Views Left</Th>
|
||||
<Th aria-label="button" className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} innerKey="shared-secrets" />}
|
||||
{!isLoading &&
|
||||
tableData &&
|
||||
tableData.map((row) => (
|
||||
<ShareSecretsRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
onSecretExpiration={handleSecretExpiration}
|
||||
/>
|
||||
data?.map((row) => (
|
||||
<ShareSecretsRow key={row.id} row={row} handlePopUpOpen={handlePopUpOpen} />
|
||||
))}
|
||||
{!isLoading && tableData && tableData?.length === 0 && (
|
||||
{!isLoading && data?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
|
||||
<EmptyState title="No secrets shared yet" icon={faKey} />
|
||||
|
@ -14,6 +14,8 @@ import { useGetActiveSharedSecretByIdAndHashedHex } from "@app/hooks/api/secretS
|
||||
import { AddShareSecretModal } from "../ShareSecretPage/components/AddShareSecretModal";
|
||||
import { SecretTable } from "./components";
|
||||
|
||||
// note: isNewSession: controls if the user is sharing a new secret or viewing a shared secret
|
||||
|
||||
export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean }) => {
|
||||
const router = useRouter();
|
||||
const { id, key: urlEncodedPublicKey } = router.query;
|
||||
@ -55,7 +57,7 @@ export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-y-auto bg-gradient-to-tr from-mineshaft-700 to-bunker-800 text-gray-200 dark:[color-scheme:dark]">
|
||||
<Head>
|
||||
<title>Secret Shared | Infisical</title>
|
||||
<title>Infisical | Secret Sharing</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Head>
|
||||
<div className="flex w-full flex-grow items-center justify-center dark:[color-scheme:dark]">
|
||||
@ -94,7 +96,6 @@ export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isNewSession && (
|
||||
<div className="px-0 sm:px-6">
|
||||
<AddShareSecretModal
|
||||
|
Reference in New Issue
Block a user