Compare commits

..

22 Commits

Author SHA1 Message Date
af864b456b Adjust secret sharing screen form padding 2024-07-27 07:32:56 -07:00
a30e3874cd Adjustments to secret sharing styling 2024-07-27 07:31:30 -07:00
0fa6b7a08a Merge pull request #2183 from Infisical/project-role-concept
Project Role Page
2024-07-26 11:27:25 -07:00
29c5bf5491 Remove top margin from RolePermissionSecretsRow 2024-07-26 11:22:15 -07:00
4d711ae149 Finish project role page 2024-07-26 11:00:47 -07:00
84af8e708e Merge remote-tracking branch 'origin' into project-role-concept 2024-07-26 07:28:17 -07:00
b39b5bd1a1 Merge pull request #2181 from Infisical/patch-org-role-update
Fix updating org role details should not send empty array of permissions
2024-07-26 07:27:51 -07:00
b3d9d91b52 Fix updating org role details should not send empty array of permissions 2024-07-26 06:52:21 -07:00
5ad4061881 Continue project role page 2024-07-26 06:43:09 -07:00
f29862eaf2 Merge pull request #2180 from Infisical/list-ca-endpoint-descriptions
Add descriptions for parameters for LIST (GET) CAs / certificates endpoints
2024-07-25 17:59:57 -04:00
7cb174b644 Add descriptions for list cas/certs endpoints 2024-07-25 14:53:41 -07:00
bf00d16c80 Continue progress on project role page 2024-07-25 14:45:02 -07:00
e30a0fe8be Merge pull request #2178 from Infisical/cert-search-filtering
Add List CAs / Certificates to Documentation + Filter Options
2024-07-25 09:40:44 -07:00
6e6f0252ae Adjust default offsets for cas/certs query 2024-07-25 08:09:21 -07:00
2348df7a4d Add list cert, ca + logical filters to docs 2024-07-25 08:06:18 -07:00
962cf67dfb Merge pull request #2173 from felixtrav/patch-1
Update envars.mdx - Added PORT
2024-07-25 10:21:06 -04:00
32627c20c4 Merge pull request #2176 from Infisical/org-role-cleanup
Cleanup frontend unused org role logic (moved)
2024-07-25 07:17:56 -07:00
c50f8fd78c Merge pull request #2175 from akhilmhdh/feat/cli-login-fallback-missing
Missing paste token option in CLI brower login flow
2024-07-25 10:08:57 -04:00
1cb4dc9e84 Start project role concept 2024-07-25 06:47:18 -07:00
=
08d7dead8c fix(cli): resolved not printing the url on api override 2024-07-25 15:28:54 +05:30
=
a30e06e392 feat: added back missing token paste option in cli login from browser 2024-07-25 15:28:29 +05:30
5cd0f665fa Update envars.mdx - Added PORT
Added the PORT configuration option to the documentation which controls the port the application listens on.
2024-07-24 19:17:33 -04:00
39 changed files with 1539 additions and 436 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,8 +16,8 @@ export type TCreatePublicSharedSecretDTO = {
iv: string;
tag: string;
hashedHex: string;
expiresAt: Date;
expiresAfterViews: number;
expiresAt: string;
expiresAfterViews?: number;
accessType: SecretSharingAccessType;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -0,0 +1,219 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "@app/views/Project/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>
)}
</>
);
};

View File

@ -0,0 +1,247 @@
import { useMemo } from "react";
import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import GlobPatternExamples from "@app/components/basic/popups/GlobPatternExamples";
import {
Checkbox,
FormControl,
Input,
Select,
SelectItem,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { TFormSchema } from "@app/views/Project/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>
)}
</>
);
};

View File

@ -0,0 +1,198 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications";
import { Button, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2";
import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
import {
formRolePermission2API,
formSchema,
rolePermission2Form,
TFormSchema
} from "@app/views/Project/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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,9 +46,8 @@ export const ShareSecretSection = () => {
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Head>
<div className="mb-2 flex justify-between">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Shared Secrets</p>
<Button
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}

View File

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

View File

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

View File

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