Compare commits

...

101 Commits

Author SHA1 Message Date
Scott Wilson
d659250ce8 improvement: change selected project icon from eye to chevron 2024-12-02 10:20:57 -08:00
Scott Wilson
87363eabfe chore: remove comments 2024-12-02 09:46:53 -08:00
Scott Wilson
d1b9c316d8 improvement: use multi-select for environment selection on create secret 2024-12-02 09:45:43 -08:00
Scott Wilson
39f7354fec Merge pull request #2814 from Infisical/add-group-to-project-filterable-selects
Improvement: User/Group/Identity Modals Dropdown to Filterable Select Refactor + User Groups and Secret Tags Table Pagination
2024-12-02 08:20:01 -08:00
Scott Wilson
c46c0cb1e8 Merge pull request #2824 from Infisical/environment-select-refactor
Improvement: Copy Secrets Modal & Environment Selects Improvements
2024-12-02 08:05:12 -08:00
Scott Wilson
6905ffba4e improvement: handle overflow and improve ui 2024-11-29 13:43:06 -08:00
Scott Wilson
64fd423c61 improvement: update import secret env select 2024-11-29 13:34:36 -08:00
Scott Wilson
da1a7466d1 improvement: change label 2024-11-29 13:28:53 -08:00
Scott Wilson
d3f3f34129 improvement: update copy secrets from env select and secret selection 2024-11-29 13:27:24 -08:00
Scott Wilson
c8fba7ce4c improvement: align pagination left on grid view project overview 2024-11-29 11:17:54 -08:00
Maidul Islam
82c3e943eb Merge pull request #2822 from Infisical/daniel/fix-cli-tests-2
fix(cli): tests failing
2024-11-29 14:02:46 -05:00
Daniel Hougaard
dc3903ff15 fix(cli): disabled test 2024-11-29 23:02:18 +04:00
Scott Wilson
a9c01dcf1f Merge pull request #2810 from Infisical/project-sidebar-dropdown-filter
Improvement: Sidebar Project Selection Filter Support
2024-11-29 10:59:51 -08:00
Daniel Hougaard
586b9d9a56 fix(cli): tests failing 2024-11-29 22:48:39 +04:00
Maidul Islam
6d709fba62 Merge pull request #2820 from Infisical/daniel/fix-cli-tests
fix(cli): CLI tests failing due to dynamic request ID
2024-11-29 13:43:13 -05:00
Daniel Hougaard
27beca7099 fix(cli): request filter bug 2024-11-29 22:38:28 +04:00
Maidul Islam
28e7e4c52d Merge pull request #2818 from Infisical/doc/k8-infisical-csi-provider
doc: added docs for infisical csi provider
2024-11-29 13:37:37 -05:00
Daniel Hougaard
cfc0ca1f03 fix(cli): filter out dynamically generated request ID 2024-11-29 22:31:31 +04:00
Daniel Hougaard
b96593d0ab fix(cli): re-enabled disabled test 2024-11-29 22:30:52 +04:00
Daniel Hougaard
2de5896ba4 fix(cli): update snapshots 2024-11-29 22:23:42 +04:00
Sheen Capadngan
3455ad3898 misc: correct faq 1 2024-11-30 02:17:11 +08:00
Sheen Capadngan
c7a32a3b05 misc: updated docs 2024-11-30 02:13:43 +08:00
Daniel Hougaard
1ebfed8c11 Merge pull request #2808 from Infisical/daniel/copy-secret-path
improvement: copy full secret path
2024-11-29 20:33:29 +04:00
Scott Wilson
16d215b588 add todo(author) to previous existing comment 2024-11-29 08:17:34 -08:00
Daniel Hougaard
cacd9041b0 Merge pull request #2790 from Infisical/daniel/paths-tip
improvement(ui): approval policy modal
2024-11-29 20:10:13 +04:00
Maidul Islam
cfeffebd46 Merge pull request #2819 from akhilmhdh/fix/cli-broken
fix: dynamic secret broken due to merge of another issue
2024-11-29 11:05:34 -05:00
=
1dceedcdb4 fix: dynamic secret broken due to merge of another issue 2024-11-29 21:27:11 +05:30
Akhil Mohan
14f03c38c3 Merge pull request #2709 from akhilmhdh/feat/recursive-secret-test
Added testing for secret recursive operation
2024-11-29 21:09:17 +05:30
Maidul Islam
be9f096e75 Merge pull request #2817 from akhilmhdh/feat/org-permission-issue
feat: removed unusued permission from org admin
2024-11-29 10:28:52 -05:00
=
49133a044f feat: resolved an issue without recursive matching 2024-11-29 19:59:34 +05:30
=
b7fe3743db feat: resolved recursive testcase change failing test 2024-11-29 19:45:10 +05:30
=
c5fded361c feat: added e2ee test for recursive secret operation 2024-11-29 19:45:10 +05:30
=
e676acbadf feat: added e2ee test for recursive secret operation 2024-11-29 19:45:10 +05:30
Sheen Capadngan
9b31a7bbb1 misc: added important note 2024-11-29 22:13:47 +08:00
Sheen Capadngan
345be85825 misc: finalized flag desc 2024-11-29 21:39:37 +08:00
Sheen Capadngan
f82b11851a misc: made snippet into info 2024-11-29 21:10:55 +08:00
Sheen Capadngan
b466b3073b misc: updated snippet to be copy+paste friendly 2024-11-29 21:09:37 +08:00
Sheen Capadngan
46105fc315 doc: added docs for infisical csi provider 2024-11-29 20:33:03 +08:00
=
3cf8fd2ff8 feat: removed unusued permission from org admin 2024-11-29 15:12:20 +05:30
Daniel Hougaard
5277a50b3e Update NavHeader.tsx 2024-11-29 05:48:40 +04:00
Scott Wilson
dab8f0b261 improvement: secret tags table pagination 2024-11-28 14:29:41 -08:00
Scott Wilson
4293665130 improvement: user groups table pagination 2024-11-28 14:10:45 -08:00
Scott Wilson
8afa65c272 improvements: minor refactoring 2024-11-28 13:47:09 -08:00
Scott Wilson
4c739fd57f chore: revert license 2024-11-28 13:36:42 -08:00
Scott Wilson
bcc2840020 improvement: filterable role selection on create/edit group 2024-11-28 13:13:23 -08:00
Scott Wilson
8b3af92d23 improvement: edit user role filterable select 2024-11-28 12:58:03 -08:00
Scott Wilson
9ca58894f0 improvement: filter select for create identity role 2024-11-28 12:58:03 -08:00
Scott Wilson
d131314de0 improvement: filter select for invite users to org 2024-11-28 12:58:03 -08:00
Scott Wilson
9c03144f19 improvement: use filterable multi-select for add users to project role select 2024-11-28 12:58:03 -08:00
Scott Wilson
5495ffd78e improvement: update add group to project modal to use filterable selects 2024-11-28 12:58:03 -08:00
Sheen
a200469c72 Merge pull request #2811 from Infisical/fix/address-custom-audience-kube-native-auth
fix: address custom audience issue
2024-11-29 03:55:50 +08:00
Maidul Islam
85c3074216 Merge pull request #2776 from Infisical/misc/unbinded-scim-from-saml
misc: unbinded scim from saml
2024-11-28 14:15:31 -05:00
Scott Wilson
cfc55ff283 Merge pull request #2804 from Infisical/users-projects-table-pagination
Improvement: Users, Groups and Projects Table Pagination
2024-11-28 09:40:19 -08:00
Scott Wilson
7179b7a540 Merge pull request #2798 from Infisical/project-overview-pagination
Improvement: Add Pagination to the Project Overview Page
2024-11-28 08:59:04 -08:00
Scott Wilson
6c4cb5e084 improvements: address feedback 2024-11-28 08:54:27 -08:00
Sheen Capadngan
9cfb044178 misc: removed outdated faq section 2024-11-28 20:41:30 +08:00
Sheen Capadngan
105eb70fd9 fix: address custom audience issue 2024-11-28 13:32:40 +08:00
Scott Wilson
18a2547b24 improvement: move user groups to own tab and add pagination/search/sort to groups tables 2024-11-27 20:35:15 -08:00
Scott Wilson
588b3c77f9 improvement: add pagination/sort to org members table 2024-11-27 19:23:54 -08:00
Scott Wilson
a04834c7c9 improvement: add pagination to project members table 2024-11-27 18:41:20 -08:00
Scott Wilson
9df9f4a5da improvement: adjust add project button margins 2024-11-27 17:54:00 -08:00
Scott Wilson
afdc704423 improvement: improve select styling 2024-11-27 17:50:42 -08:00
Scott Wilson
57261cf0c8 improvement: adjust contrast for selected project 2024-11-27 15:42:07 -08:00
Scott Wilson
06f6004993 improvement: refactor sidebar project select to support filtering with UI adjustments 2024-11-27 15:37:17 -08:00
Scott Wilson
f3bfb9cc5a Merge pull request #2802 from Infisical/audit-logs-project-select-filter
Improvement:  Filterable Project Select for Audit Logs
2024-11-27 13:10:53 -08:00
Scott Wilson
48fb77be49 improvement: typed date format 2024-11-27 12:17:30 -08:00
Scott Wilson
c3956c60e9 improvement: add pagination, sort and filtering to identity projects table with minor UI adjustments 2024-11-27 12:05:46 -08:00
McPizza
f55bcb93ba improve: Folder name input validation text (#2809) 2024-11-27 19:55:46 +01:00
Scott Wilson
d3fb2a6a74 Merge pull request #2803 from Infisical/invite-users-project-multi-select-filter
Improvement: Filterable Multi-Select Project Input on Invite Users
2024-11-27 10:55:20 -08:00
Scott Wilson
6a23b74481 Merge pull request #2806 from Infisical/add-identity-to-project-modals-filter-selects
Improvement: Filter Selects on Add Identity to Project Modals
2024-11-27 10:48:10 -08:00
Scott Wilson
602cf4b3c4 improvement: disable tab select by default on filterable select 2024-11-27 08:40:00 -08:00
Akhil Mohan
84ff71fef2 Merge pull request #2740 from akhilmhdh/feat/dynamic-secret-cli
Dynamic secret commands in CLI
2024-11-27 14:07:40 +05:30
Scott Wilson
add5742b8c improvement: change create to add 2024-11-26 18:48:59 -08:00
Scott Wilson
68f3964206 fix: incorrect plurals 2024-11-26 18:46:16 -08:00
Scott Wilson
90374971ae improvement: add filter select to add identity to project modals 2024-11-26 18:40:45 -08:00
Maidul Islam
3a1eadba8c Merge pull request #2805 from Infisical/vmatsiiako-leave-patch-1
Update time-off.mdx
2024-11-26 21:05:26 -05:00
Vlad Matsiiako
5305017ce2 Update time-off.mdx 2024-11-26 18:01:55 -08:00
Scott Wilson
cf5f49d14e chore: use toggle order 2024-11-26 17:26:49 -08:00
Scott Wilson
4f4b5be8ea fix: lowercase name compare for sort 2024-11-26 17:25:13 -08:00
Scott Wilson
ecea79f040 fix: hide pagination when no search match 2024-11-26 17:20:49 -08:00
Scott Wilson
586b901318 improvement: add pagination, filtering and sort to users projects table with minor UI improvements 2024-11-26 17:17:18 -08:00
Maidul Islam
ad8d247cdc Merge pull request #2801 from Infisical/omar/eng-1952-address-key-vault-integration-failing-due-to-disabled-secret
Fix(Azure Key Vault): Ignore disabled secrets
2024-11-26 18:54:28 -05:00
Scott Wilson
3b47d7698b improvement: start align components 2024-11-26 15:51:06 -08:00
Scott Wilson
aa9a86df71 improvement: use filter multi-select for adding users to projects on invite 2024-11-26 15:47:23 -08:00
McPizza0
33411335ed avoid syncing disabled azure keys 2024-11-27 00:15:10 +01:00
Scott Wilson
ca55f19926 improvement: add placeholder 2024-11-26 15:10:05 -08:00
Scott Wilson
3794521c56 improvement: project select filterable on audit logs with minor UI revisions 2024-11-26 15:07:20 -08:00
McPizza0
728f023263 remove superfolous trycatch 2024-11-26 23:46:23 +01:00
McPizza0
229706f57f improve filtering 2024-11-26 23:35:32 +01:00
McPizza0
6cf2488326 Fix(Azure Key Vault): Ignore disabled secrets 2024-11-26 23:22:07 +01:00
Daniel Hougaard
2c402fbbb6 Update NavHeader.tsx 2024-11-27 01:31:11 +04:00
McPizza
92ce05283b feat: Add new tag when creating secret (#2791)
* feat: Add new tag when creating secret
2024-11-26 21:10:14 +01:00
Maidul Islam
39d92ce6ff Merge pull request #2799 from Infisical/misc/finalize-env-default
misc: finalized env schema handling
2024-11-26 15:10:06 -05:00
Scott Wilson
bbf52c9a48 improvement: add pagination to the project overview page with minor UI adjustments 2024-11-26 11:47:59 -08:00
=
3d6ea3251e feat: renamed dynamic_secrets to match with the command 2024-11-25 23:36:32 +05:30
=
be39e63832 feat: updated pr based on review 2024-11-25 20:28:43 +05:30
Daniel Hougaard
464a3ccd53 Update AccessPolicyModal.tsx 2024-11-25 15:55:44 +04:00
Sheen Capadngan
63fac39fff doc: added tip for SCIM 2024-11-23 03:11:06 +08:00
Sheen Capadngan
7c62a776fb misc: unbinded scim from saml 2024-11-23 01:59:07 +08:00
=
ed7fc0e5cd docs: updated dynamic secret command cli docs 2024-11-15 20:33:06 +05:30
=
1ae6213387 feat: completed dynamic secret support in cli 2024-11-15 20:32:37 +05:30
82 changed files with 4292 additions and 1934 deletions

View File

@@ -0,0 +1,86 @@
import { createFolder, deleteFolder } from "e2e-test/testUtils/folders";
import { createSecretV2, deleteSecretV2, getSecretsV2 } from "e2e-test/testUtils/secrets";
import { seedData1 } from "@app/db/seed-data";
describe("Secret Recursive Testing", async () => {
const projectId = seedData1.projectV3.id;
const folderAndSecretNames = [
{ name: "deep1", path: "/", expectedSecretCount: 4 },
{ name: "deep21", path: "/deep1", expectedSecretCount: 2 },
{ name: "deep3", path: "/deep1/deep2", expectedSecretCount: 1 },
{ name: "deep22", path: "/deep2", expectedSecretCount: 1 }
];
beforeAll(async () => {
const rootFolderIds: string[] = [];
for (const folder of folderAndSecretNames) {
// eslint-disable-next-line no-await-in-loop
const createdFolder = await createFolder({
authToken: jwtAuthToken,
environmentSlug: "prod",
workspaceId: projectId,
secretPath: folder.path,
name: folder.name
});
if (folder.path === "/") {
rootFolderIds.push(createdFolder.id);
}
// eslint-disable-next-line no-await-in-loop
await createSecretV2({
secretPath: folder.path,
authToken: jwtAuthToken,
environmentSlug: "prod",
workspaceId: projectId,
key: folder.name,
value: folder.name
});
}
return async () => {
await Promise.all(
rootFolderIds.map((id) =>
deleteFolder({
authToken: jwtAuthToken,
secretPath: "/",
id,
workspaceId: projectId,
environmentSlug: "prod"
})
)
);
await deleteSecretV2({
authToken: jwtAuthToken,
secretPath: "/",
workspaceId: projectId,
environmentSlug: "prod",
key: folderAndSecretNames[0].name
});
};
});
test.each(folderAndSecretNames)("$path recursive secret fetching", async ({ path, expectedSecretCount }) => {
const secrets = await getSecretsV2({
authToken: jwtAuthToken,
secretPath: path,
workspaceId: projectId,
environmentSlug: "prod",
recursive: true
});
expect(secrets.secrets.length).toEqual(expectedSecretCount);
expect(secrets.secrets.sort((a, b) => a.secretKey.localeCompare(b.secretKey))).toEqual(
folderAndSecretNames
.filter((el) => el.path.startsWith(path))
.sort((a, b) => a.name.localeCompare(b.name))
.map((el) =>
expect.objectContaining({
secretKey: el.name,
secretValue: el.name
})
)
);
});
});

View File

@@ -97,6 +97,7 @@ export const getSecretsV2 = async (dto: {
environmentSlug: string;
secretPath: string;
authToken: string;
recursive?: boolean;
}) => {
const getSecretsResponse = await testServer.inject({
method: "GET",
@@ -109,7 +110,8 @@ export const getSecretsV2 = async (dto: {
environment: dto.environmentSlug,
secretPath: dto.secretPath,
expandSecretReferences: "true",
include_imports: "true"
include_imports: "true",
recursive: String(dto.recursive || false)
}
});
expect(getSecretsResponse.statusCode).toBe(200);

View File

@@ -112,7 +112,7 @@ export const dynamicSecretLeaseServiceFactory = ({
})
) as object;
const selectedTTL = ttl ?? dynamicSecretCfg.defaultTTL;
const selectedTTL = ttl || dynamicSecretCfg.defaultTTL;
const { maxTTL } = dynamicSecretCfg;
const expireAt = new Date(new Date().getTime() + ms(selectedTTL));
if (maxTTL) {
@@ -187,7 +187,7 @@ export const dynamicSecretLeaseServiceFactory = ({
})
) as object;
const selectedTTL = ttl ?? dynamicSecretCfg.defaultTTL;
const selectedTTL = ttl || dynamicSecretCfg.defaultTTL;
const { maxTTL } = dynamicSecretCfg;
const expireAt = new Date(dynamicSecretLease.expireAt.getTime() + ms(selectedTTL));
if (maxTTL) {

View File

@@ -31,7 +31,6 @@ export enum OrgPermissionSubjects {
}
export type OrgPermissionSet =
| [OrgPermissionActions.Read, OrgPermissionSubjects.Workspace]
| [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace]
| [OrgPermissionActions, OrgPermissionSubjects.Role]
| [OrgPermissionActions, OrgPermissionSubjects.Member]
@@ -52,7 +51,6 @@ export type OrgPermissionSet =
const buildAdminPermission = () => {
const { can, rules } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
// ws permissions
can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
// role permission
can(OrgPermissionActions.Read, OrgPermissionSubjects.Role);
@@ -135,7 +133,6 @@ export const orgAdminPermissions = buildAdminPermission();
const buildMemberPermission = () => {
const { can, rules } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Member);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Groups);

View File

@@ -18,6 +18,7 @@ import { TGroupProjectDALFactory } from "@app/services/group-project/group-proje
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { deleteOrgMembershipFn } from "@app/services/org/org-fns";
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
import { OrgAuthMethod } from "@app/services/org/org-types";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
@@ -71,6 +72,7 @@ type TScimServiceFactoryDep = {
| "deleteMembershipById"
| "transaction"
| "updateMembershipById"
| "findOrgById"
>;
orgMembershipDAL: Pick<
TOrgMembershipDALFactory,
@@ -288,8 +290,7 @@ export const scimServiceFactory = ({
const createScimUser = async ({ externalId, email, firstName, lastName, orgId }: TCreateScimUserDTO) => {
if (!email) throw new ScimRequestError({ detail: "Invalid request. Missing email.", status: 400 });
const org = await orgDAL.findById(orgId);
const org = await orgDAL.findOrgById(orgId);
if (!org)
throw new ScimRequestError({
detail: "Organization not found",
@@ -302,13 +303,24 @@ export const scimServiceFactory = ({
status: 403
});
if (!org.orgAuthMethod) {
throw new ScimRequestError({
detail: "Neither SAML or OIDC SSO is configured",
status: 400
});
}
const appCfg = getConfig();
const serverCfg = await getServerCfg();
const aliasType = org.orgAuthMethod === OrgAuthMethod.OIDC ? UserAliasType.OIDC : UserAliasType.SAML;
const trustScimEmails =
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails;
const userAlias = await userAliasDAL.findOne({
externalId,
orgId,
aliasType: UserAliasType.SAML
aliasType
});
const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => {
@@ -349,7 +361,7 @@ export const scimServiceFactory = ({
);
}
} else {
if (serverCfg.trustSamlEmails) {
if (trustScimEmails) {
user = await userDAL.findOne(
{
email,
@@ -367,9 +379,9 @@ export const scimServiceFactory = ({
);
user = await userDAL.create(
{
username: serverCfg.trustSamlEmails ? email : uniqueUsername,
username: trustScimEmails ? email : uniqueUsername,
email,
isEmailVerified: serverCfg.trustSamlEmails,
isEmailVerified: trustScimEmails,
firstName,
lastName,
authMethods: [],
@@ -382,7 +394,7 @@ export const scimServiceFactory = ({
await userAliasDAL.create(
{
userId: user.id,
aliasType: UserAliasType.SAML,
aliasType,
externalId,
emails: email ? [email] : [],
orgId
@@ -437,7 +449,7 @@ export const scimServiceFactory = ({
recipients: [email],
substitutions: {
organizationName: org.name,
callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}`
callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/organizations/${org.slug}`
}
});
}
@@ -456,6 +468,14 @@ export const scimServiceFactory = ({
// partial
const updateScimUser = async ({ orgMembershipId, orgId, operations }: TUpdateScimUserDTO) => {
const org = await orgDAL.findOrgById(orgId);
if (!org.orgAuthMethod) {
throw new ScimRequestError({
detail: "Neither SAML or OIDC SSO is configured",
status: 400
});
}
const [membership] = await orgDAL
.findMembership({
[`${TableName.OrgMembership}.id` as "id"]: orgMembershipId,
@@ -493,6 +513,9 @@ export const scimServiceFactory = ({
scimPatch(scimUser, operations);
const serverCfg = await getServerCfg();
const trustScimEmails =
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails;
await userDAL.transaction(async (tx) => {
await orgMembershipDAL.updateById(
membership.id,
@@ -508,7 +531,7 @@ export const scimServiceFactory = ({
firstName: scimUser.name.givenName,
email: scimUser.emails[0].value,
lastName: scimUser.name.familyName,
isEmailVerified: hasEmailChanged ? serverCfg.trustSamlEmails : true
isEmailVerified: hasEmailChanged ? trustScimEmails : true
},
tx
);
@@ -526,6 +549,14 @@ export const scimServiceFactory = ({
email,
externalId
}: TReplaceScimUserDTO) => {
const org = await orgDAL.findOrgById(orgId);
if (!org.orgAuthMethod) {
throw new ScimRequestError({
detail: "Neither SAML or OIDC SSO is configured",
status: 400
});
}
const [membership] = await orgDAL
.findMembership({
[`${TableName.OrgMembership}.id` as "id"]: orgMembershipId,
@@ -555,7 +586,7 @@ export const scimServiceFactory = ({
await userAliasDAL.update(
{
orgId,
aliasType: UserAliasType.SAML,
aliasType: org.orgAuthMethod === OrgAuthMethod.OIDC ? UserAliasType.OIDC : UserAliasType.SAML,
userId: membership.userId
},
{
@@ -576,7 +607,8 @@ export const scimServiceFactory = ({
firstName,
email,
lastName,
isEmailVerified: serverCfg.trustSamlEmails
isEmailVerified:
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails
},
tx
);

View File

@@ -14,10 +14,12 @@ import { Strategy as GoogleStrategy } from "passport-google-oauth20";
import { z } from "zod";
import { getConfig } from "@app/lib/config/env";
import { NotFoundError } from "@app/lib/errors";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { fetchGithubEmails } from "@app/lib/requests/github";
import { authRateLimit } from "@app/server/config/rateLimiter";
import { AuthMethod } from "@app/services/auth/auth-type";
import { OrgAuthMethod } from "@app/services/org/org-types";
export const registerSsoRouter = async (server: FastifyZodProvider) => {
const appCfg = getConfig();
@@ -196,6 +198,44 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
handler: () => {}
});
server.route({
url: "/redirect/organizations/:orgSlug",
method: "GET",
config: {
rateLimit: authRateLimit
},
schema: {
params: z.object({
orgSlug: z.string().trim()
}),
querystring: z.object({
callback_port: z.string().optional()
})
},
handler: async (req, res) => {
const org = await server.services.org.findOrgBySlug(req.params.orgSlug);
if (org.orgAuthMethod === OrgAuthMethod.SAML) {
return res.redirect(
`${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}?${
req.query.callback_port ? `callback_port=${req.query.callback_port}` : ""
}`
);
}
if (org.orgAuthMethod === OrgAuthMethod.OIDC) {
return res.redirect(
`${appCfg.SITE_URL}/api/v1/sso/oidc/login?orgSlug=${org.slug}${
req.query.callback_port ? `&callbackPort=${req.query.callback_port}` : ""
}`
);
}
throw new BadRequestError({
message: "The organization does not have any SSO configured."
});
}
});
server.route({
url: "/github",
method: "GET",

View File

@@ -120,7 +120,8 @@ export const identityKubernetesAuthServiceFactory = ({
apiVersion: "authentication.k8s.io/v1",
kind: "TokenReview",
spec: {
token: serviceAccountJwt
token: serviceAccountJwt,
...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {})
}
},
{

View File

@@ -473,7 +473,7 @@ const syncSecretsAzureKeyVault = async ({
id: string; // secret URI
value: string;
attributes: {
enabled: true;
enabled: boolean;
created: number;
updated: number;
recoveryLevel: string;
@@ -509,10 +509,19 @@ const syncSecretsAzureKeyVault = async ({
const getAzureKeyVaultSecrets = await paginateAzureKeyVaultSecrets(`${integration.app}/secrets?api-version=7.3`);
const enabledAzureKeyVaultSecrets = getAzureKeyVaultSecrets.filter((secret) => secret.attributes.enabled);
// disabled keys to skip sending updates to
const disabledAzureKeyVaultSecretKeys = getAzureKeyVaultSecrets
.filter(({ attributes }) => !attributes.enabled)
.map((getAzureKeyVaultSecret) => {
return getAzureKeyVaultSecret.id.substring(getAzureKeyVaultSecret.id.lastIndexOf("/") + 1);
});
let lastSlashIndex: number;
const res = (
await Promise.all(
getAzureKeyVaultSecrets.map(async (getAzureKeyVaultSecret) => {
enabledAzureKeyVaultSecrets.map(async (getAzureKeyVaultSecret) => {
if (!lastSlashIndex) {
lastSlashIndex = getAzureKeyVaultSecret.id.lastIndexOf("/");
}
@@ -658,6 +667,7 @@ const syncSecretsAzureKeyVault = async ({
}) => {
let isSecretSet = false;
let maxTries = 6;
if (disabledAzureKeyVaultSecretKeys.includes(key)) return;
while (!isSecretSet && maxTries > 0) {
// try to set secret

View File

@@ -14,6 +14,8 @@ import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex";
import { generateKnexQueryFromScim } from "@app/lib/knex/scim";
import { OrgAuthMethod } from "./org-types";
export type TOrgDALFactory = ReturnType<typeof orgDALFactory>;
export const orgDALFactory = (db: TDbClient) => {
@@ -21,13 +23,78 @@ export const orgDALFactory = (db: TDbClient) => {
const findOrgById = async (orgId: string) => {
try {
const org = await db.replicaNode()(TableName.Organization).where({ id: orgId }).first();
const org = (await db
.replicaNode()(TableName.Organization)
.where({ [`${TableName.Organization}.id` as "id"]: orgId })
.leftJoin(TableName.SamlConfig, (qb) => {
qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn(
`${TableName.SamlConfig}.isActive`,
"=",
db.raw("true")
);
})
.leftJoin(TableName.OidcConfig, (qb) => {
qb.on(`${TableName.OidcConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn(
`${TableName.OidcConfig}.isActive`,
"=",
db.raw("true")
);
})
.select(selectAllTableCols(TableName.Organization))
.select(
db.raw(`
CASE
WHEN ${TableName.SamlConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.SAML}'
WHEN ${TableName.OidcConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.OIDC}'
ELSE ''
END as "orgAuthMethod"
`)
)
.first()) as TOrganizations & { orgAuthMethod?: string };
return org;
} catch (error) {
throw new DatabaseError({ error, name: "Find org by id" });
}
};
const findOrgBySlug = async (orgSlug: string) => {
try {
const org = (await db
.replicaNode()(TableName.Organization)
.where({ [`${TableName.Organization}.slug` as "slug"]: orgSlug })
.leftJoin(TableName.SamlConfig, (qb) => {
qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn(
`${TableName.SamlConfig}.isActive`,
"=",
db.raw("true")
);
})
.leftJoin(TableName.OidcConfig, (qb) => {
qb.on(`${TableName.OidcConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn(
`${TableName.OidcConfig}.isActive`,
"=",
db.raw("true")
);
})
.select(selectAllTableCols(TableName.Organization))
.select(
db.raw(`
CASE
WHEN ${TableName.SamlConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.SAML}'
WHEN ${TableName.OidcConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.OIDC}'
ELSE ''
END as "orgAuthMethod"
`)
)
.first()) as TOrganizations & { orgAuthMethod?: string };
return org;
} catch (error) {
throw new DatabaseError({ error, name: "Find org by slug" });
}
};
// special query
const findAllOrgsByUserId = async (userId: string): Promise<(TOrganizations & { orgAuthMethod: string })[]> => {
try {
@@ -398,6 +465,7 @@ export const orgDALFactory = (db: TDbClient) => {
findAllOrgMembers,
countAllOrgMembers,
findOrgById,
findOrgBySlug,
findAllOrgsByUserId,
ghostUserExists,
findOrgMembersByUsername,

View File

@@ -187,6 +187,15 @@ export const orgServiceFactory = ({
return members;
};
const findOrgBySlug = async (slug: string) => {
const org = await orgDAL.findOrgBySlug(slug);
if (!org) {
throw new NotFoundError({ message: `Organization with slug '${slug}' not found` });
}
return org;
};
const findAllWorkspaces = async ({ actor, actorId, orgId }: TFindAllWorkspacesDTO) => {
const organizationWorkspaceIds = new Set((await projectDAL.find({ orgId })).map((workspace) => workspace.id));
@@ -275,6 +284,7 @@ export const orgServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
const plan = await licenseService.getPlan(orgId);
const currentOrg = await orgDAL.findOrgById(actorOrgId);
if (enforceMfa !== undefined) {
if (!plan.enforceMfa) {
@@ -305,6 +315,11 @@ export const orgServiceFactory = ({
"Failed to enable/disable SCIM provisioning due to plan restriction. Upgrade plan to enable/disable SCIM provisioning."
});
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
if (scimEnabled && !currentOrg.orgAuthMethod) {
throw new BadRequestError({
message: "Cannot enable SCIM when neither SAML or OIDC is configured."
});
}
}
if (authEnforced) {
@@ -1132,6 +1147,7 @@ export const orgServiceFactory = ({
createIncidentContact,
deleteIncidentContact,
getOrgGroups,
listProjectMembershipsByOrgMembershipId
listProjectMembershipsByOrgMembershipId,
findOrgBySlug
};
};

View File

@@ -74,3 +74,8 @@ export type TGetOrgGroupsDTO = TOrgPermission;
export type TListProjectMembershipsByOrgMembershipIdDTO = {
orgMembershipId: string;
} & TOrgPermission;
export enum OrgAuthMethod {
OIDC = "oidc",
SAML = "saml"
}

View File

@@ -365,9 +365,8 @@ export const recursivelyGetSecretPaths = async ({
folderId: p.folderId
}));
const pathsInCurrentDirectory = paths.filter((folder) =>
folder.path.startsWith(currentPath === "/" ? "" : currentPath)
);
// path relative will start with ../ if its outside directory
const pathsInCurrentDirectory = paths.filter((folder) => !path.relative(currentPath, folder.path).startsWith(".."));
return pathsInCurrentDirectory;
};

View File

@@ -10,7 +10,7 @@ require (
github.com/fatih/semgroup v1.2.0
github.com/gitleaks/go-gitdiff v0.8.0
github.com/h2non/filetype v1.1.3
github.com/infisical/go-sdk v0.3.8
github.com/infisical/go-sdk v0.4.3
github.com/mattn/go-isatty v0.0.20
github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a
github.com/muesli/mango-cobra v1.2.0

View File

@@ -265,8 +265,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/infisical/go-sdk v0.3.8 h1:0dGOhF3cwt0q5QzpnUs4lxwBiEza+DQYOyvEn7AfrM0=
github.com/infisical/go-sdk v0.3.8/go.mod h1:HHW7DgUqoolyQIUw/9HdpkZ3bDLwWyZ0HEtYiVaDKQw=
github.com/infisical/go-sdk v0.4.3 h1:O5ZJ2eCBAZDE9PIAfBPq9Utb2CgQKrhmj9R0oFTRu4U=
github.com/infisical/go-sdk v0.4.3/go.mod h1:6fWzAwTPIoKU49mQ2Oxu+aFnJu9n7k2JcNrZjzhHM2M=
github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo=
github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=

View File

@@ -205,6 +205,25 @@ func CallGetAllWorkSpacesUserBelongsTo(httpClient *resty.Client) (GetWorkSpacesR
return workSpacesResponse, nil
}
func CallGetProjectById(httpClient *resty.Client, id string) (Project, error) {
var projectResponse GetProjectByIdResponse
response, err := httpClient.
R().
SetResult(&projectResponse).
SetHeader("User-Agent", USER_AGENT).
Get(fmt.Sprintf("%v/v1/workspace/%s", config.INFISICAL_URL, id))
if err != nil {
return Project{}, err
}
if response.IsError() {
return Project{}, fmt.Errorf("CallGetProjectById: Unsuccessful response: [response=%v]", response)
}
return projectResponse.Project, nil
}
func CallIsAuthenticated(httpClient *resty.Client) bool {
var workSpacesResponse GetWorkSpacesResponse
response, err := httpClient.

View File

@@ -128,6 +128,10 @@ type GetWorkSpacesResponse struct {
} `json:"workspaces"`
}
type GetProjectByIdResponse struct {
Project Project `json:"workspace"`
}
type GetOrganizationsResponse struct {
Organizations []struct {
ID string `json:"id"`
@@ -163,6 +167,12 @@ type Secret struct {
PlainTextKey string `json:"plainTextKey"`
}
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type RawSecret struct {
SecretKey string `json:"secretKey,omitempty"`
SecretValue string `json:"secretValue,omitempty"`

View File

@@ -0,0 +1,571 @@
/*
Copyright (c) 2023 Infisical Inc.
*/
package cmd
import (
"context"
"fmt"
"github.com/Infisical/infisical-merge/packages/api"
"github.com/Infisical/infisical-merge/packages/config"
"github.com/Infisical/infisical-merge/packages/visualize"
// "github.com/Infisical/infisical-merge/packages/models"
"github.com/Infisical/infisical-merge/packages/util"
// "github.com/Infisical/infisical-merge/packages/visualize"
"github.com/go-resty/resty/v2"
"github.com/posthog/posthog-go"
"github.com/spf13/cobra"
infisicalSdk "github.com/infisical/go-sdk"
infisicalSdkModels "github.com/infisical/go-sdk/packages/models"
)
var dynamicSecretCmd = &cobra.Command{
Example: `infisical dynamic-secrets`,
Short: "Used to list dynamic secrets",
Use: "dynamic-secrets",
DisableFlagsInUseLine: true,
Args: cobra.NoArgs,
Run: getDynamicSecretList,
}
func getDynamicSecretList(cmd *cobra.Command, args []string) {
environmentName, _ := cmd.Flags().GetString("env")
if !cmd.Flags().Changed("env") {
environmentFromWorkspace := util.GetEnvFromWorkspaceFile()
if environmentFromWorkspace != "" {
environmentName = environmentFromWorkspace
}
}
token, err := util.GetInfisicalToken(cmd)
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
projectId, err := cmd.Flags().GetString("projectId")
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
secretsPath, err := cmd.Flags().GetString("path")
if err != nil {
util.HandleError(err, "Unable to parse path flag")
}
var infisicalToken string
httpClient := resty.New()
if projectId == "" {
workspaceFile, err := util.GetWorkSpaceFromFile()
if err != nil {
util.HandleError(err, "Unable to get local project details")
}
projectId = workspaceFile.WorkspaceId
}
if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) {
infisicalToken = token.Token
} else {
util.RequireLogin()
util.RequireLocalWorkspaceFile()
loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
if err != nil {
util.HandleError(err, "Unable to authenticate")
}
if loggedInUserDetails.LoginExpired {
util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again")
}
infisicalToken = loggedInUserDetails.UserCredentials.JTWToken
}
httpClient.SetAuthToken(infisicalToken)
infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{
SiteUrl: config.INFISICAL_URL,
UserAgent: api.USER_AGENT,
AutoTokenRefresh: false,
})
infisicalClient.Auth().SetAccessToken(infisicalToken)
projectDetails, err := api.CallGetProjectById(httpClient, projectId)
if err != nil {
util.HandleError(err, "To fetch project details")
}
dynamicSecretRootCredentials, err := infisicalClient.DynamicSecrets().List(infisicalSdk.ListDynamicSecretsRootCredentialsOptions{
ProjectSlug: projectDetails.Slug,
SecretPath: secretsPath,
EnvironmentSlug: environmentName,
})
if err != nil {
util.HandleError(err, "To fetch dynamic secret root credentials details")
}
visualize.PrintAllDynamicRootCredentials(dynamicSecretRootCredentials)
Telemetry.CaptureEvent("cli-command:dynamic-secrets", posthog.NewProperties().Set("count", len(dynamicSecretRootCredentials)).Set("version", util.CLI_VERSION))
}
var dynamicSecretLeaseCmd = &cobra.Command{
Example: `lease`,
Short: "Manage leases for dynamic secrets",
Use: "lease",
DisableFlagsInUseLine: true,
}
var dynamicSecretLeaseCreateCmd = &cobra.Command{
Example: `lease create <dynamic secret name>"`,
Short: "Used to lease dynamic secret by name",
Use: "create [dynamic-secret]",
DisableFlagsInUseLine: true,
Args: cobra.ExactArgs(1),
Run: createDynamicSecretLeaseByName,
}
func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) {
dynamicSecretRootCredentialName := args[0]
environmentName, _ := cmd.Flags().GetString("env")
if !cmd.Flags().Changed("env") {
environmentFromWorkspace := util.GetEnvFromWorkspaceFile()
if environmentFromWorkspace != "" {
environmentName = environmentFromWorkspace
}
}
token, err := util.GetInfisicalToken(cmd)
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
projectId, err := cmd.Flags().GetString("projectId")
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
ttl, err := cmd.Flags().GetString("ttl")
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
secretsPath, err := cmd.Flags().GetString("path")
if err != nil {
util.HandleError(err, "Unable to parse path flag")
}
plainOutput, err := cmd.Flags().GetBool("plain")
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
var infisicalToken string
httpClient := resty.New()
if projectId == "" {
workspaceFile, err := util.GetWorkSpaceFromFile()
if err != nil {
util.HandleError(err, "Unable to get local project details")
}
projectId = workspaceFile.WorkspaceId
}
if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) {
infisicalToken = token.Token
} else {
util.RequireLogin()
util.RequireLocalWorkspaceFile()
loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
if err != nil {
util.HandleError(err, "Unable to authenticate")
}
if loggedInUserDetails.LoginExpired {
util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again")
}
infisicalToken = loggedInUserDetails.UserCredentials.JTWToken
}
httpClient.SetAuthToken(infisicalToken)
infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{
SiteUrl: config.INFISICAL_URL,
UserAgent: api.USER_AGENT,
AutoTokenRefresh: false,
})
infisicalClient.Auth().SetAccessToken(infisicalToken)
projectDetails, err := api.CallGetProjectById(httpClient, projectId)
if err != nil {
util.HandleError(err, "To fetch project details")
}
dynamicSecretRootCredential, err := infisicalClient.DynamicSecrets().GetByName(infisicalSdk.GetDynamicSecretRootCredentialByNameOptions{
DynamicSecretName: dynamicSecretRootCredentialName,
ProjectSlug: projectDetails.Slug,
SecretPath: secretsPath,
EnvironmentSlug: environmentName,
})
if err != nil {
util.HandleError(err, "To fetch dynamic secret root credentials details")
}
leaseCredentials, _, leaseDetails, err := infisicalClient.DynamicSecrets().Leases().Create(infisicalSdk.CreateDynamicSecretLeaseOptions{
DynamicSecretName: dynamicSecretRootCredential.Name,
ProjectSlug: projectDetails.Slug,
TTL: ttl,
SecretPath: secretsPath,
EnvironmentSlug: environmentName,
})
if err != nil {
util.HandleError(err, "To lease dynamic secret")
}
if plainOutput {
for key, value := range leaseCredentials {
if cred, ok := value.(string); ok {
fmt.Printf("%s=%s\n", key, cred)
}
}
} else {
fmt.Println("Dynamic Secret Leasing")
fmt.Printf("Name: %s\n", dynamicSecretRootCredential.Name)
fmt.Printf("Provider: %s\n", dynamicSecretRootCredential.Type)
fmt.Printf("Lease ID: %s\n", leaseDetails.Id)
fmt.Printf("Expire At: %s\n", leaseDetails.ExpireAt.Local().Format("02-Jan-2006 03:04:05 PM"))
visualize.PrintAllDyamicSecretLeaseCredentials(leaseCredentials)
}
Telemetry.CaptureEvent("cli-command:dynamic-secrets lease", posthog.NewProperties().Set("type", dynamicSecretRootCredential.Type).Set("version", util.CLI_VERSION))
}
var dynamicSecretLeaseRenewCmd = &cobra.Command{
Example: `lease renew <dynamic secret name>"`,
Short: "Used to renew dynamic secret lease by name",
Use: "renew [lease-id]",
DisableFlagsInUseLine: true,
Args: cobra.ExactArgs(1),
Run: renewDynamicSecretLeaseByName,
}
func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) {
dynamicSecretLeaseId := args[0]
environmentName, _ := cmd.Flags().GetString("env")
if !cmd.Flags().Changed("env") {
environmentFromWorkspace := util.GetEnvFromWorkspaceFile()
if environmentFromWorkspace != "" {
environmentName = environmentFromWorkspace
}
}
token, err := util.GetInfisicalToken(cmd)
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
projectId, err := cmd.Flags().GetString("projectId")
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
ttl, err := cmd.Flags().GetString("ttl")
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
secretsPath, err := cmd.Flags().GetString("path")
if err != nil {
util.HandleError(err, "Unable to parse path flag")
}
var infisicalToken string
httpClient := resty.New()
if projectId == "" {
workspaceFile, err := util.GetWorkSpaceFromFile()
if err != nil {
util.HandleError(err, "Unable to get local project details")
}
projectId = workspaceFile.WorkspaceId
}
if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) {
infisicalToken = token.Token
} else {
util.RequireLogin()
util.RequireLocalWorkspaceFile()
loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
if err != nil {
util.HandleError(err, "Unable to authenticate")
}
if loggedInUserDetails.LoginExpired {
util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again")
}
infisicalToken = loggedInUserDetails.UserCredentials.JTWToken
}
httpClient.SetAuthToken(infisicalToken)
infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{
SiteUrl: config.INFISICAL_URL,
UserAgent: api.USER_AGENT,
AutoTokenRefresh: false,
})
infisicalClient.Auth().SetAccessToken(infisicalToken)
projectDetails, err := api.CallGetProjectById(httpClient, projectId)
if err != nil {
util.HandleError(err, "To fetch project details")
}
if err != nil {
util.HandleError(err, "To fetch dynamic secret root credentials details")
}
leaseDetails, err := infisicalClient.DynamicSecrets().Leases().RenewById(infisicalSdk.RenewDynamicSecretLeaseOptions{
ProjectSlug: projectDetails.Slug,
TTL: ttl,
SecretPath: secretsPath,
EnvironmentSlug: environmentName,
LeaseId: dynamicSecretLeaseId,
})
if err != nil {
util.HandleError(err, "To renew dynamic secret lease")
}
fmt.Println("Successfully renewed dynamic secret lease")
visualize.PrintAllDynamicSecretLeases([]infisicalSdkModels.DynamicSecretLease{leaseDetails})
Telemetry.CaptureEvent("cli-command:dynamic-secrets lease renew", posthog.NewProperties().Set("version", util.CLI_VERSION))
}
var dynamicSecretLeaseRevokeCmd = &cobra.Command{
Example: `lease delete <dynamic secret name>"`,
Short: "Used to delete dynamic secret lease by name",
Use: "delete [lease-id]",
DisableFlagsInUseLine: true,
Args: cobra.ExactArgs(1),
Run: revokeDynamicSecretLeaseByName,
}
func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) {
dynamicSecretLeaseId := args[0]
environmentName, _ := cmd.Flags().GetString("env")
if !cmd.Flags().Changed("env") {
environmentFromWorkspace := util.GetEnvFromWorkspaceFile()
if environmentFromWorkspace != "" {
environmentName = environmentFromWorkspace
}
}
token, err := util.GetInfisicalToken(cmd)
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
projectId, err := cmd.Flags().GetString("projectId")
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
secretsPath, err := cmd.Flags().GetString("path")
if err != nil {
util.HandleError(err, "Unable to parse path flag")
}
var infisicalToken string
httpClient := resty.New()
if projectId == "" {
workspaceFile, err := util.GetWorkSpaceFromFile()
if err != nil {
util.HandleError(err, "Unable to get local project details")
}
projectId = workspaceFile.WorkspaceId
}
if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) {
infisicalToken = token.Token
} else {
util.RequireLogin()
util.RequireLocalWorkspaceFile()
loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
if err != nil {
util.HandleError(err, "Unable to authenticate")
}
if loggedInUserDetails.LoginExpired {
util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again")
}
infisicalToken = loggedInUserDetails.UserCredentials.JTWToken
}
httpClient.SetAuthToken(infisicalToken)
infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{
SiteUrl: config.INFISICAL_URL,
UserAgent: api.USER_AGENT,
AutoTokenRefresh: false,
})
infisicalClient.Auth().SetAccessToken(infisicalToken)
projectDetails, err := api.CallGetProjectById(httpClient, projectId)
if err != nil {
util.HandleError(err, "To fetch project details")
}
if err != nil {
util.HandleError(err, "To fetch dynamic secret root credentials details")
}
leaseDetails, err := infisicalClient.DynamicSecrets().Leases().DeleteById(infisicalSdk.DeleteDynamicSecretLeaseOptions{
ProjectSlug: projectDetails.Slug,
SecretPath: secretsPath,
EnvironmentSlug: environmentName,
LeaseId: dynamicSecretLeaseId,
})
if err != nil {
util.HandleError(err, "To revoke dynamic secret lease")
}
fmt.Println("Successfully revoked dynamic secret lease")
visualize.PrintAllDynamicSecretLeases([]infisicalSdkModels.DynamicSecretLease{leaseDetails})
Telemetry.CaptureEvent("cli-command:dynamic-secrets lease revoke", posthog.NewProperties().Set("version", util.CLI_VERSION))
}
var dynamicSecretLeaseListCmd = &cobra.Command{
Example: `lease list <dynamic secret name>"`,
Short: "Used to list leases of a dynamic secret by name",
Use: "list [dynamic-secret]",
DisableFlagsInUseLine: true,
Args: cobra.ExactArgs(1),
Run: listDynamicSecretLeaseByName,
}
func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) {
dynamicSecretRootCredentialName := args[0]
environmentName, _ := cmd.Flags().GetString("env")
if !cmd.Flags().Changed("env") {
environmentFromWorkspace := util.GetEnvFromWorkspaceFile()
if environmentFromWorkspace != "" {
environmentName = environmentFromWorkspace
}
}
token, err := util.GetInfisicalToken(cmd)
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
projectId, err := cmd.Flags().GetString("projectId")
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
secretsPath, err := cmd.Flags().GetString("path")
if err != nil {
util.HandleError(err, "Unable to parse path flag")
}
var infisicalToken string
httpClient := resty.New()
if projectId == "" {
workspaceFile, err := util.GetWorkSpaceFromFile()
if err != nil {
util.HandleError(err, "Unable to get local project details")
}
projectId = workspaceFile.WorkspaceId
}
if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) {
infisicalToken = token.Token
} else {
util.RequireLogin()
util.RequireLocalWorkspaceFile()
loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true)
if err != nil {
util.HandleError(err, "Unable to authenticate")
}
if loggedInUserDetails.LoginExpired {
util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again")
}
infisicalToken = loggedInUserDetails.UserCredentials.JTWToken
}
httpClient.SetAuthToken(infisicalToken)
infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{
SiteUrl: config.INFISICAL_URL,
UserAgent: api.USER_AGENT,
AutoTokenRefresh: false,
})
infisicalClient.Auth().SetAccessToken(infisicalToken)
projectDetails, err := api.CallGetProjectById(httpClient, projectId)
if err != nil {
util.HandleError(err, "To fetch project details")
}
dynamicSecretLeases, err := infisicalClient.DynamicSecrets().Leases().List(infisicalSdk.ListDynamicSecretLeasesOptions{
DynamicSecretName: dynamicSecretRootCredentialName,
ProjectSlug: projectDetails.Slug,
SecretPath: secretsPath,
EnvironmentSlug: environmentName,
})
if err != nil {
util.HandleError(err, "To fetch dynamic secret leases list")
}
visualize.PrintAllDynamicSecretLeases(dynamicSecretLeases)
Telemetry.CaptureEvent("cli-command:dynamic-secrets lease list", posthog.NewProperties().Set("lease-count", len(dynamicSecretLeases)).Set("version", util.CLI_VERSION))
}
func init() {
dynamicSecretLeaseCreateCmd.Flags().StringP("path", "p", "/", "The path from where dynamic secret should be leased from")
dynamicSecretLeaseCreateCmd.Flags().String("token", "", "Create dynamic secret leases using machine identity access token")
dynamicSecretLeaseCreateCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth")
dynamicSecretLeaseCreateCmd.Flags().String("ttl", "", "The lease lifetime TTL. If not provided the default TTL of dynamic secret will be used.")
dynamicSecretLeaseCreateCmd.Flags().Bool("plain", false, "Print leased credentials without formatting, one per line")
dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseCreateCmd)
dynamicSecretLeaseListCmd.Flags().StringP("path", "p", "/", "The path from where dynamic secret should be leased from")
dynamicSecretLeaseListCmd.Flags().String("token", "", "Fetch dynamic secret leases machine identity access token")
dynamicSecretLeaseListCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth")
dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseListCmd)
dynamicSecretLeaseRenewCmd.Flags().StringP("path", "p", "/", "The path from where dynamic secret should be leased from")
dynamicSecretLeaseRenewCmd.Flags().String("token", "", "Renew dynamic secrets machine identity access token")
dynamicSecretLeaseRenewCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth")
dynamicSecretLeaseRenewCmd.Flags().String("ttl", "", "The lease lifetime TTL. If not provided the default TTL of dynamic secret will be used.")
dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseRenewCmd)
dynamicSecretLeaseRevokeCmd.Flags().StringP("path", "p", "/", "The path from where dynamic secret should be leased from")
dynamicSecretLeaseRevokeCmd.Flags().String("token", "", "Delete dynamic secrets using machine identity access token")
dynamicSecretLeaseRevokeCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth")
dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseRevokeCmd)
dynamicSecretCmd.AddCommand(dynamicSecretLeaseCmd)
dynamicSecretCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token")
dynamicSecretCmd.Flags().String("projectId", "", "Manually set the projectId to fetch dynamic-secret when using machine identity based auth")
dynamicSecretCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on")
dynamicSecretCmd.Flags().String("path", "/", "get dynamic secret within a folder path")
rootCmd.AddCommand(dynamicSecretCmd)
}

View File

@@ -0,0 +1,39 @@
package visualize
import infisicalModels "github.com/infisical/go-sdk/packages/models"
func PrintAllDyamicSecretLeaseCredentials(leaseCredentials map[string]any) {
rows := [][]string{}
for key, value := range leaseCredentials {
if cred, ok := value.(string); ok {
rows = append(rows, []string{key, cred})
}
}
headers := []string{"Key", "Value"}
GenericTable(headers, rows)
}
func PrintAllDynamicRootCredentials(dynamicRootCredentials []infisicalModels.DynamicSecret) {
rows := [][]string{}
for _, el := range dynamicRootCredentials {
rows = append(rows, []string{el.Name, el.Type, el.DefaultTTL, el.MaxTTL})
}
headers := []string{"Name", "Provider", "Default TTL", "Max TTL"}
GenericTable(headers, rows)
}
func PrintAllDynamicSecretLeases(dynamicSecretLeases []infisicalModels.DynamicSecretLease) {
rows := [][]string{}
const timeformat = "02-Jan-2006 03:04:05 PM"
for _, el := range dynamicSecretLeases {
rows = append(rows, []string{el.Id, el.ExpireAt.Local().Format(timeformat), el.CreatedAt.Local().Format(timeformat)})
}
headers := []string{"ID", "Expire At", "Created At"}
GenericTable(headers, rows)
}

View File

@@ -94,6 +94,33 @@ func getLongestValues(rows [][3]string) (longestSecretName, longestSecretType in
return
}
func GenericTable(headers []string, rows [][]string) {
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.SetStyle(table.StyleLight)
// t.SetTitle(tableOptions.Title)
t.Style().Options.DrawBorder = true
t.Style().Options.SeparateHeader = true
t.Style().Options.SeparateColumns = true
tableHeaders := table.Row{}
for _, header := range headers {
tableHeaders = append(tableHeaders, header)
}
t.AppendHeader(tableHeaders)
for _, row := range rows {
tableRow := table.Row{}
for _, val := range row {
tableRow = append(tableRow, val)
}
t.AppendRow(tableRow)
}
t.Render()
}
// stringWidth returns the width of a string.
// ANSI escape sequences are ignored and double-width characters are handled correctly.
func stringWidth(str string) (width int) {

View File

@@ -1,4 +1,4 @@
error: CallGetRawSecretsV3: Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&expandSecretReferences=true&include_imports=true&recursive=true&secretPath=%2F&workspaceId=bef697d4-849b-4a75-b284-0922f87f8ba2] [status-code=404] [response={"statusCode":404,"message":"Environment with slug 'invalid-env' in project with ID bef697d4-849b-4a75-b284-0922f87f8ba2 not found","error":"NotFound"}]
error: CallGetRawSecretsV3: Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&expandSecretReferences=true&include_imports=true&recursive=true&secretPath=%2F&workspaceId=bef697d4-849b-4a75-b284-0922f87f8ba2] [status-code=404] [response={"error":"NotFound","message":"Environment with slug 'invalid-env' in project with ID bef697d4-849b-4a75-b284-0922f87f8ba2 not found","statusCode":404}]
If this issue continues, get support at https://infisical.com/slack

View File

@@ -1,4 +1,4 @@
Warning: Unable to fetch the latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug
Warning: Unable to fetch the latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug
┌───────────────┬──────────────┬─────────────┐
│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │
├───────────────┼──────────────┼─────────────┤

View File

@@ -1,6 +1,7 @@
package tests
import (
"encoding/json"
"fmt"
"log"
"os"
@@ -41,11 +42,12 @@ var creds = Credentials{
func ExecuteCliCommand(command string, args ...string) (string, error) {
cmd := exec.Command(command, args...)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Println(fmt.Sprint(err) + ": " + string(output))
return strings.TrimSpace(string(output)), err
fmt.Println(fmt.Sprint(err) + ": " + FilterRequestID(strings.TrimSpace(string(output))))
return FilterRequestID(strings.TrimSpace(string(output))), err
}
return strings.TrimSpace(string(output)), nil
return FilterRequestID(strings.TrimSpace(string(output))), nil
}
func SetupCli() {
@@ -67,3 +69,34 @@ func SetupCli() {
}
}
func FilterRequestID(input string) string {
// Find the JSON part of the error message
start := strings.Index(input, "{")
end := strings.LastIndex(input, "}") + 1
if start == -1 || end == -1 {
return input
}
jsonPart := input[:start] // Pre-JSON content
// Parse the JSON object
var errorObj map[string]interface{}
if err := json.Unmarshal([]byte(input[start:end]), &errorObj); err != nil {
return input
}
// Remove requestId field
delete(errorObj, "requestId")
delete(errorObj, "reqId")
// Convert back to JSON
filtered, err := json.Marshal(errorObj)
if err != nil {
return input
}
// Reconstruct the full string
return jsonPart + string(filtered) + input[end:]
}

View File

@@ -3,7 +3,6 @@ package tests
import (
"testing"
"github.com/Infisical/infisical-merge/packages/util"
"github.com/bradleyjkemp/cupaloy/v2"
)
@@ -96,28 +95,29 @@ func TestUserAuth_SecretsGetAll(t *testing.T) {
// testUserAuth_SecretsGetAllWithoutConnection(t)
}
func testUserAuth_SecretsGetAllWithoutConnection(t *testing.T) {
originalConfigFile, err := util.GetConfigFile()
if err != nil {
t.Fatalf("error getting config file")
}
newConfigFile := originalConfigFile
// disabled for the time being
// func testUserAuth_SecretsGetAllWithoutConnection(t *testing.T) {
// originalConfigFile, err := util.GetConfigFile()
// if err != nil {
// t.Fatalf("error getting config file")
// }
// newConfigFile := originalConfigFile
// set it to a URL that will always be unreachable
newConfigFile.LoggedInUserDomain = "http://localhost:4999"
util.WriteConfigFile(&newConfigFile)
// // set it to a URL that will always be unreachable
// newConfigFile.LoggedInUserDomain = "http://localhost:4999"
// util.WriteConfigFile(&newConfigFile)
// restore config file
defer util.WriteConfigFile(&originalConfigFile)
// // restore config file
// defer util.WriteConfigFile(&originalConfigFile)
output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent")
if err != nil {
t.Fatalf("error running CLI command: %v", err)
}
// output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent")
// if err != nil {
// t.Fatalf("error running CLI command: %v", err)
// }
// Use cupaloy to snapshot test the output
err = cupaloy.Snapshot(output)
if err != nil {
t.Fatalf("snapshot failed: %v", err)
}
}
// // Use cupaloy to snapshot test the output
// err = cupaloy.Snapshot(output)
// if err != nil {
// t.Fatalf("snapshot failed: %v", err)
// }
// }

View File

@@ -12,6 +12,18 @@ To request time off, just submit a request in Rippling and let Maidul know at le
Since Infisical's team is globally distributed, it is hard for us to keep track of all the various national holidays across many different countries. Whether you'd like to celebrate Christmas or National Brisket Day (which, by the way, is on May 28th), you are welcome to take PTO on those days  just let Maidul know at least a week ahead so that we can adjust our planning.
## Winter Break
## Winter break
Every year, Infisical team goes on a company-wide vacation during winter holidays. This year, the winter break period starts on December 21st, 2024 and ends on January 5th, 2025. You should expect to do no scheduled work during this period, but we will have a rotation process for [high and urgent service disruptions](https://infisical.com/sla).
Every year, Infisical team goes on a company-wide vacation during winter holidays. This year, the winter break period starts on December 21st, 2024 and ends on January 5th, 2025. You should expect to do no scheduled work during this period, but we will have a rotation process for [high and urgent service disruptions](https://infisical.com/sla).
## Parental leave
At Infisical, we recognize that parental leave is a special and important time, significantly different from a typical vacation. Were proud to offer parental leave to everyone, regardless of gender, and whether youve become a parent through childbirth or adoption.
For team members who have been with Infisical for over a year by the time of your childs birth or adoption, you are eligible for up to 12 weeks of paid parental leave. This leave will be provided in one continuous block to allow you uninterrupted time with your family. If you have been with Infisical for less than a year, we will follow the parental leave provisions required by your local jurisdiction.
While we trust your judgment, parental leave is intended to be a distinct benefit and is not designed to be combined with our unlimited PTO policy. To ensure fairness and balance, we generally discourage combining parental leave with an extended vacation.
When youre ready, please notify Maidul about your plans for parental leave, ideally at least four months in advance. This allows us to support you fully and arrange any necessary logistics, including salary adjustments and statutory paperwork.
Were here to support you as you embark on this exciting new chapter in your life!

View File

@@ -0,0 +1,295 @@
---
title: "infisical dynamic-secrets"
description: "Perform dynamic secret operations directly with the CLI"
---
```
infisical dynamic-secrets
```
## Description
Dynamic secrets are unique secrets generated on demand based on the provided configuration settings. For more details, refer to [dynamics secrets section](/documentation/platform/dynamic-secrets/overview).
This command enables you to perform list, lease, renew lease, and revoke lease operations on dynamic secrets within your Infisical project.
### Sub-commands
<Accordion title="infisical dynamic-secrets">
Use this command to print out all of the dynamic secrets in your project.
```bash
$ infisical dynamic-secrets
```
### Environment variables
<Accordion title="INFISICAL_TOKEN">
Used to fetch dynamic secrets via a [machine identity](/documentation/platform/identities/machine-identities) instead of logged-in credentials. Simply, export this variable in the terminal before running this command.
```bash
# Example
export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id=<identity-client-id> --client-secret=<identity-client-secret> --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages.
```
</Accordion>
<Accordion title="INFISICAL_DISABLE_UPDATE_CHECK">
Used to disable the check for new CLI versions. This can improve the time it takes to run this command. Recommended for production environments.
To use, simply export this variable in the terminal before running this command.
```bash
# Example
export INFISICAL_DISABLE_UPDATE_CHECK=true
```
</Accordion>
### Flags
<Accordion title="--projectId">
The project ID to fetch dynamic secrets from. This is required when using a machine identity to authenticate.
```bash
# Example
infisical dynamic-secrets --projectId=<project-id>
```
</Accordion>
<Accordion title="--token">
The authenticated token to fetch dynamic secrets from. This is required when using a machine identity to authenticate.
```bash
# Example
infisical dynamic-secrets --token=<token>
```
</Accordion>
<Accordion title="--env">
Used to select the environment name on which actions should be taken. Default
value: `dev`
</Accordion>
<Accordion title="--path">
Use to select the project folder on which dynamic secrets will be accessed.
```bash
# Example
infisical dynamic-secrets --path="/" --env=dev
```
</Accordion>
</Accordion>
<Accordion title="infisical dynamic-secrets lease create">
This command is used to create a new lease for a dynamic secret.
```bash
$ infisical dynamic-secrets lease create <dynamic-secret-name>
```
### Flags
<Accordion title="--env">
Used to select the environment name on which actions should be taken. Default
value: `dev`
</Accordion>
<Accordion title="--plain">
The `--plain` flag will output dynamic secret lease credentials values without formatting, one per line.
Default value: `false`
```bash
# Example
infisical dynamic-secrets lease create dynamic-secret-postgres --plain
```
</Accordion>
<Accordion title="--path">
The `--path` flag indicates which project folder dynamic secrets will be injected from.
```bash
# Example
infisical dynamic-secrets lease create <dynamic-secret-name> --path="/" --env=dev
```
</Accordion>
<Accordion title="--projectId">
The project ID of the dynamic secrets to lease from. This is required when using a machine identity to authenticate.
```bash
# Example
infisical dynamic-secrets lease create <dynamic-secret-name> --projectId=<project-id>
```
</Accordion>
<Accordion title="--token">
The authenticated token to create dynamic secret leases. This is required when using a machine identity to authenticate.
```bash
# Example
infisical dynamic-secrets lease create <dynamic-secret-name> --token=<token>
```
</Accordion>
<Accordion title="--ttl">
The lease lifetime. If not provided, the default TTL of the dynamic secret root credential will be used.
```bash
# Example
infisical dynamic-secrets lease create <dynamic-secret-name> --ttl=<ttl>
```
</Accordion>
</Accordion>
<Accordion title="infisical dynamic-secrets lease list">
This command is used to list leases for a dynamic secret.
```bash
$ infisical dynamic-secrets lease list <dynamic-secret-name>
```
### Flags
<Accordion title="--env">
Used to select the environment name on which actions should be taken. Default
value: `dev`
</Accordion>
<Accordion title="--path">
The `--path` flag indicates which project folder dynamic secrets will be injected from.
```bash
# Example
infisical dynamic-secrets lease list <dynamic-secret-name> --path="/" --env=dev
```
</Accordion>
<Accordion title="--projectId">
The project ID of the dynamic secrets to list leases from. This is required when using a machine identity to authenticate.
```bash
# Example
infisical dynamic-secrets lease list <dynamic-secret-name> --projectId=<project-id>
```
</Accordion>
<Accordion title="--token">
The authenticated token to list dynamic secret leases. This is required when using a machine identity to authenticate.
```bash
# Example
infisical dynamic-secrets lease list <dynamic-secret-name> --token=<token>
```
</Accordion>
</Accordion>
<Accordion title="infisical dynamic-secrets lease renew">
This command is used to renew a lease before it expires.
```bash
$ infisical dynamic-secrets lease renew <lease-id>
```
### Flags
<Accordion title="--env">
Used to select the environment name on which actions should be taken. Default
value: `dev`
</Accordion>
<Accordion title="--path">
The `--path` flag indicates which project folder dynamic secrets will be renewed from.
```bash
# Example
infisical dynamic-secrets lease renew <lease-id> --path="/" --env=dev
```
</Accordion>
<Accordion title="--projectId">
The project ID of the dynamic secret's lease from. This is required when using a machine identity to authenticate.
```bash
# Example
infisical dynamic-secrets lease renew <lease-id> --projectId=<project-id>
```
</Accordion>
<Accordion title="--token">
The authenticated token to create dynamic secret leases. This is required when using a machine identity to authenticate.
```bash
# Example
infisical dynamic-secrets lease renew <lease-id> --token=<token>
```
</Accordion>
<Accordion title="--ttl">
The lease lifetime. If not provided, the default TTL of the dynamic secret root credential will be used.
```bash
# Example
infisical dynamic-secrets lease renew <lease-id> --ttl=<ttl>
```
</Accordion>
</Accordion>
<Accordion title="infisical dynamic-secrets lease delete">
This command is used to delete a lease.
```bash
$ infisical dynamic-secrets lease delete <lease-id>
```
### Flags
<Accordion title="--env">
Used to select the environment name on which actions should be taken. Default
value: `dev`
</Accordion>
<Accordion title="--path">
The `--path` flag indicates which project folder dynamic secrets will be deleted from.
```bash
# Example
infisical dynamic-secrets lease delete <lease-id> --path="/" --env=dev
```
</Accordion>
<Accordion title="--projectId">
The project ID of the dynamic secret's lease from. This is required when using a machine identity to authenticate.
```bash
# Example
infisical dynamic-secrets lease delete <lease-id> --projectId=<project-id>
```
</Accordion>
<Accordion title="--token">
The authenticated token to delete dynamic secret leases. This is required when using a machine identity to authenticate.
```bash
# Example
infisical dynamic-secrets lease delete <lease-id> --token=<token>
```
</Accordion>
</Accordion>

View File

@@ -3,11 +3,15 @@ title: "SCIM Overview"
description: "Learn how to provision users for Infisical via SCIM."
---
<Note>
SCIM provisioning can only be enabled when either SAML or OIDC is setup for
the organization.
</Note>
<Info>
SCIM provisioning is a paid feature.
If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical,
then you should contact sales@infisical.com to purchase an enterprise license to use it.
SCIM provisioning is a paid feature. If you're using Infisical Cloud, then it
is available under the **Enterprise Tier**. If you're self-hosting Infisical,
then you should contact sales@infisical.com to purchase an enterprise license
to use it.
</Info>
You can configure your organization in Infisical to have users and user groups be provisioned/deprovisioned using [SCIM](https://scim.cloud/#Implementations2) via providers like Okta, Azure, JumpCloud, etc.
@@ -20,13 +24,3 @@ SCIM providers:
- [Okta SCIM](/documentation/platform/scim/okta)
- [Azure SCIM](/documentation/platform/scim/azure)
- [JumpCloud SCIM](/documentation/platform/scim/jumpcloud)
**FAQ**
<AccordionGroup>
<Accordion title="Why do SCIM-provisioned users have to finish setting up their account?">
Infisical's SCIM implementation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform.
For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,281 @@
---
title: "Kubernetes CSI"
description: "How to use Infisical to inject secrets directly into Kubernetes pods."
---
## Overview
The Infisical CSI provider allows you to use Infisical with the [Secrets Store CSI driver](https://secrets-store-csi-driver.sigs.k8s.io) to inject secrets directly into your Kubernetes pods through a volume mount.
In contrast to the [Infisical Kubernetes Operator](https://infisical.com/docs/integrations/platforms/kubernetes), the Infisical CSI provider will allow you to sync Infisical secrets directly to pods as files, removing the need for Kubernetes secret resources.
```mermaid
flowchart LR
subgraph Secrets Management
SS(Infisical) --> CSP(Infisical CSI Provider)
CSP --> CSD(Secrets Store CSI Driver)
end
subgraph Application
CSD --> V(Volume)
V <--> P(Pod)
end
```
## Features
The following features are supported by the Infisical CSI Provider:
- Integration with Secrets Store CSI Driver for direct pod mounting
- Authentication using Kubernetes service accounts via machine identities
- Auto-syncing secrets when enabled via CSI Driver
- Configurable secret paths and file mounting locations
- Installation via Helm
## Prerequisites
The Infisical CSI provider is only supported for Kubernetes clusters with version >= 1.20.
## Limitations
Currently, the Infisical CSI provider only supports static secrets.
## Deploy to Kubernetes cluster
### Install Secrets Store CSI Driver
In order to use the Infisical CSI provider, you will first have to install the [Secrets Store CSI driver](https://secrets-store-csi-driver.sigs.k8s.io/getting-started/installation) to your cluster. It is important that you define
the audience value for token requests as demonstrated below. The Infisical CSI provider will **NOT WORK** if this is not set.
```bash
helm repo add secrets-store-csi-driver https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts
```
```bash
helm install csi secrets-store-csi-driver/secrets-store-csi-driver \
--namespace=kube-system \
--set "tokenRequests[0].audience=infisical" \
--set enableSecretRotation=true \
--set rotationPollInterval=2m \
--set "syncSecret.enabled=true" \
```
The flags configure the following:
- `tokenRequests[0].audience=infisical`: Sets the audience value for service account token authentication (required)
- `enableSecretRotation=true`: Enables automatic secret updates from Infisical
- `rotationPollInterval=2m`: Checks for secret updates every 2 minutes
- `syncSecret.enabled=true`: Enables syncing secrets to Kubernetes secrets
<Info>
If you do not wish to use the auto-syncing feature of the secrets store CSI
driver, you can omit the `enableSecretRotation` and the `rotationPollInterval`
flags. Do note that by default, secrets from Infisical are only fetched and
mounted during pod creation. If there are any changes made to the secrets in
Infisical, they will not propagate to the pods unless auto-syncing is enabled
for the CSI driver.
</Info>
### Install Infisical CSI Provider
You would then have to install the Infisical CSI provider to your cluster.
**Install the latest Infisical Helm repository**
```bash
helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/'
helm repo update
```
**Install the Helm Chart**
```bash
helm install infisical-csi-provider infisical-helm-charts/infisical-csi-provider
```
For a list of all supported arguments for the helm installation, you can run the following:
```bash
helm show values infisical-helm-charts/infisical-csi-provider
```
### Authentication
In order for the Infisical CSI provider to pull secrets from your Infisical project, you will have to configure
a machine identity with [Kubernetes authentication](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth) configured with your cluster.
You can refer to the documentation for setting it up [here](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth#guide).
<Warning>
The allowed audience field of the Kubernetes authentication settings should
match the audience specified for the Secrets Store CSI driver during
installation.
</Warning>
### Creating Secret Provider Class
With the Secrets Store CSI driver and the Infisical CSI provider installed, create a Kubernetes [SecretProviderClass](https://secrets-store-csi-driver.sigs.k8s.io/concepts.html#secretproviderclass) resource to establish
the connection between the CSI driver and the Infisical CSI provider for secret retrieval. You can create as many Secret Provider Classes as needed for your cluster.
```yaml
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: my-infisical-app-csi-provider
spec:
provider: infisical
parameters:
infisicalUrl: "https://app.infisical.com"
authMethod: "kubernetes"
identityId: "ad2f8c67-cbe2-417a-b5eb-1339776ec0b3"
projectId: "09eda1f8-85a3-47a9-8a6f-e27f133b2a36"
envSlug: "prod"
secrets: |
- secretPath: "/"
fileName: "dbPassword"
secretKey: "DB_PASSWORD"
- secretPath: "/app"
fileName: "appSecret"
secretKey: "APP_SECRET"
```
<Note>
The SecretProviderClass should be provisioned in the same namespace as the pod
you intend to mount secrets to.
</Note>
#### Supported Parameters
<Accordion title="infisicalUrl">
The base URL of your Infisical instance. If you're using Infisical Cloud US,
this should be set to `https://app.infisical.com`. If you're using Infisical
Cloud EU, then this should be set to `https://eu.infisical.com`.
</Accordion>
<Accordion title="caCertificate">
The CA certificate of the Infisical instance in order to establish SSL/TLS
when the instance uses a private or self-signed certificate. Unless necessary,
this should be omitted.
</Accordion>
<Accordion title="authMethod">
The auth method to use for authenticating the Infisical CSI provider with
Infisical. For now, the only supported method is `kubernetes`.
</Accordion>
<Accordion title="identityId">
The ID of the machine identity to use for authenticating the Infisical CSI
provider with your Infisical organization. This should be the machine identity
configured with Kubernetes authentication.
</Accordion>
<Accordion title="projectId">
The project ID of the Infisical project to pull secrets from.
</Accordion>
<Accordion title="envSlug">
The slug of the project environment to pull secrets from.
</Accordion>
<Accordion title="secrets">
An array that defines which secrets to retrieve and how to mount them. Each
entry requires three properties: `secretPath` and `secretKey` work together to
identify the source secret to fetch, while `fileName` specifies the path where
the secret's value will be mounted within the pod's filesystem.
</Accordion>
<Accordion title="audience">
The custom audience value configured for the CSI driver. This defaults to
`infisical`.
</Accordion>
### Using Secret Provider Class
A pod can use the Secret Provider Class by mounting it as a CSI volume:
```yaml
apiVersion: v1
kind: Pod
metadata:
name: nginx-secrets-store
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx
volumeMounts:
- name: secrets-store-inline
mountPath: "/mnt/secrets-store"
readOnly: true
volumes:
- name: secrets-store-inline
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: "my-infisical-app-csi-provider"
```
When the pod is created, the secrets are mounted as individual files in the /mnt/secrets-store directory.
### Verifying Secret Mounts
To verify your secrets are mounted correctly:
```bash
# Check pod status
kubectl get pod nginx-secrets-store
# View mounted secrets
kubectl exec -it nginx-secrets-store -- ls -l /mnt/secrets-store
```
### Troubleshooting
To troubleshoot issues with the Infisical CSI provider, refer to the logs of the Infisical CSI provider running on the same node as your pod.
```bash
kubectl logs infisical-csi-provider-7x44t
```
You can also refer to the logs of the secrets store CSI driver. Modify the command below with the appropriate pod and namespace of your secrets store CSI driver installation.
```bash
kubectl logs csi-secrets-store-csi-driver-7h4jp -n=kube-system
```
**Common issues include:**
- Mismatch in the audience value of the CSI driver with the machine identity's Kubernetes auth configuration
- SecretProviderClass in the wrong namespace
- Invalid machine identity configuration
- Incorrect secret paths or keys
## Best Practices
For additional guidance on setting this up for your production cluster, you can refer to the Secrets Store CSI driver documentation [here](https://secrets-store-csi-driver.sigs.k8s.io/topics/best-practices).
## Frequently Asked Questions
<AccordionGroup>
<Accordion title="Is it possible to sync Infisical secrets as environment variables?">
Yes, but it requires an indirect approach:
1. First enable syncing to Kubernetes secrets by setting `syncSecret.enabled=true` in the CSI driver installation
2. Configure the Secret Provider Class to sync specific secrets to Kubernetes secrets
3. Use the resulting Kubernetes secrets in your pod's environment variables
This means secrets are first synced to Kubernetes secrets before they can be used as environment variables. You can find detailed examples in the [Secrets Store CSI driver documentation](https://secrets-store-csi-driver.sigs.k8s.io/topics/set-as-env-var).
</Accordion>
</AccordionGroup>
<AccordionGroup>
<Accordion title="Do I have to list out every Infisical single secret that I want to sync?">
Yes, you will need to explicitly list each secret you want to sync in the
Secret Provider Class configuration. This is a common requirement across all
CSI providers as the Secrets Store CSI Driver architecture requires specific
mapping of secrets to their mounted file locations.
</Accordion>
</AccordionGroup>

View File

@@ -316,6 +316,7 @@
"cli/commands/init",
"cli/commands/run",
"cli/commands/secrets",
"cli/commands/dynamic-secrets",
"cli/commands/export",
"cli/commands/token",
"cli/commands/service-token",
@@ -344,6 +345,7 @@
"group": "Container orchestrators",
"pages": [
"integrations/platforms/kubernetes",
"integrations/platforms/kubernetes-csi",
"integrations/platforms/docker-swarm-with-agent",
"integrations/platforms/ecs-with-agent"
]

View File

@@ -89,7 +89,7 @@
"react-mailchimp-subscribe": "^2.1.3",
"react-markdown": "^8.0.3",
"react-redux": "^8.0.2",
"react-select": "^5.8.1",
"react-select": "^5.8.3",
"react-table": "^7.8.0",
"react-toastify": "^9.1.3",
"sanitize-html": "^2.12.1",
@@ -21259,9 +21259,9 @@
}
},
"node_modules/react-select": {
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.1.tgz",
"integrity": "sha512-RT1CJmuc+ejqm5MPgzyZujqDskdvB9a9ZqrdnVLsvAHjJ3Tj0hELnLeVPQlmYdVKCdCpxanepl6z7R5KhXhWzg==",
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.3.tgz",
"integrity": "sha512-lVswnIq8/iTj1db7XCG74M/3fbGB6ZaluCzvwPGT5ZOjCdL/k0CLWhEK0vCBLuU5bHTEf6Gj8jtSvi+3v+tO1w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.0",

View File

@@ -162,4 +162,4 @@
"tailwindcss": "3.2",
"typescript": "^4.9.3"
}
}
}

View File

@@ -1,11 +1,17 @@
import { ParsedUrlQuery } from "querystring";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { faAngleRight, faLock } from "@fortawesome/free-solid-svg-icons";
import { faAngleRight, faCheck, faCopy, faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { useOrganization, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import { Select, SelectItem, Tooltip } from "../v2";
import { createNotification } from "../notifications";
import { IconButton, Select, SelectItem, Tooltip } from "../v2";
type Props = {
pageName: string;
@@ -50,6 +56,10 @@ export default function NavHeader({
}: Props): JSX.Element {
const { currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization();
const [isCopied, { timedToggle: toggleIsCopied }] = useToggle(false);
const [isHoveringCopyButton, setIsHoveringCopyButton] = useState(false);
const router = useRouter();
const secretPathSegments = secretPath.split("/").filter(Boolean);
@@ -132,8 +142,10 @@ export default function NavHeader({
)}
{isFolderMode &&
secretPathSegments?.map((folderName, index) => {
const query = { ...router.query };
query.secretPath = `/${secretPathSegments.slice(0, index + 1).join("/")}`;
const query: ParsedUrlQuery & { secretPath: string } = {
...router.query,
secretPath: `/${secretPathSegments.slice(0, index + 1).join("/")}`
};
return (
<div
@@ -142,14 +154,59 @@ export default function NavHeader({
>
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
{index + 1 === secretPathSegments?.length ? (
<span className="text-sm font-semibold text-bunker-300">{folderName}</span>
<div className="flex items-center space-x-2">
<span
className={twMerge(
"text-sm font-semibold transition-all",
isHoveringCopyButton ? "text-bunker-200" : "text-bunker-300"
)}
>
{folderName}
</span>
<Tooltip
className="relative right-2"
position="bottom"
content="Copy secret path"
>
<IconButton
variant="plain"
ariaLabel="copy"
onMouseEnter={() => setIsHoveringCopyButton(true)}
onMouseLeave={() => setIsHoveringCopyButton(false)}
onClick={() => {
if (isCopied) return;
navigator.clipboard.writeText(query.secretPath);
createNotification({
text: "Copied secret path to clipboard",
type: "info"
});
toggleIsCopied(2000);
}}
className="hover:bg-bunker-100/10"
>
<FontAwesomeIcon
icon={!isCopied ? faCopy : faCheck}
size="sm"
className="cursor-pointer"
/>
</IconButton>
</Tooltip>
</div>
) : (
<Link
passHref
legacyBehavior
href={{ pathname: "/project/[id]/secrets/[env]", query }}
>
<a className="text-sm font-semibold text-primary/80 hover:text-primary">
<a
className={twMerge(
"text-sm font-semibold transition-all hover:text-primary",
isHoveringCopyButton ? "text-primary" : "text-primary/80"
)}
>
{folderName}
</a>
</Link>

View File

@@ -0,0 +1,68 @@
import { GroupBase } from "react-select";
import ReactSelectCreatable, { CreatableProps } from "react-select/creatable";
import { twMerge } from "tailwind-merge";
import { ClearIndicator, DropdownIndicator, MultiValueRemove, Option } from "../Select/components";
export const CreatableSelect = <T,>({
isMulti,
closeMenuOnSelect,
...props
}: CreatableProps<T, boolean, GroupBase<T>>) => {
return (
<ReactSelectCreatable
isMulti={isMulti}
closeMenuOnSelect={closeMenuOnSelect ?? !isMulti}
hideSelectedOptions={false}
unstyled
styles={{
input: (base) => ({
...base,
"input:focus": {
boxShadow: "none"
}
}),
multiValueLabel: (base) => ({
...base,
whiteSpace: "normal",
overflow: "visible"
}),
control: (base) => ({
...base,
transition: "none"
})
}}
components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }}
classNames={{
container: () => "w-full font-inter",
control: ({ isFocused }) =>
twMerge(
isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400",
"border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 hover:cursor-pointer"
),
placeholder: () => "text-mineshaft-400 text-sm pl-1 py-0.5",
input: () => "pl-1 py-0.5",
valueContainer: () => `p-1 max-h-[14rem] ${isMulti ? "!overflow-y-scroll" : ""} gap-1`,
singleValue: () => "leading-7 ml-1",
multiValue: () => "bg-mineshaft-600 rounded items-center py-0.5 px-2 gap-1.5",
multiValueLabel: () => "leading-6 text-sm",
multiValueRemove: () => "hover:text-red text-bunker-400",
indicatorsContainer: () => "p-1 gap-1",
clearIndicator: () => "p-1 hover:text-red text-bunker-400",
indicatorSeparator: () => "bg-bunker-400",
dropdownIndicator: () => "text-bunker-200 p-1",
menu: () =>
"mt-2 border text-sm text-mineshaft-200 bg-mineshaft-900 border-mineshaft-600 rounded-md",
groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm",
option: ({ isFocused, isSelected }) =>
twMerge(
isFocused && "bg-mineshaft-700 active:bg-mineshaft-600",
isSelected && "text-mineshaft-200",
"hover:cursor-pointer text-xs px-3 py-2"
),
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
}}
{...props}
/>
);
};

View File

@@ -0,0 +1 @@
export * from "./CreatableSelect";

View File

@@ -14,6 +14,7 @@ export type DatePickerProps = Omit<DayPickerProps, "selected"> & {
onChange: (date?: Date) => void;
popUpProps: PopoverProps;
popUpContentProps: PopoverContentProps;
dateFormat?: "PPP" | "PP" | "P"; // extend as needed
};
// Doc: https://react-day-picker.js.org/
@@ -22,6 +23,7 @@ export const DatePicker = ({
onChange,
popUpProps,
popUpContentProps,
dateFormat = "PPP",
...props
}: DatePickerProps) => {
const [timeValue, setTimeValue] = useState<string>(value ? format(value, "HH:mm") : "00:00");
@@ -53,7 +55,7 @@ export const DatePicker = ({
<Popover {...popUpProps}>
<PopoverTrigger asChild>
<Button variant="outline_bg" leftIcon={<FontAwesomeIcon icon={faCalendar} />}>
{value ? format(value, "PPP") : "Pick a date and time"}
{value ? format(value, dateFormat) : "Pick a date and time"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-fit p-2" {...popUpContentProps}>

View File

@@ -1,52 +1,14 @@
import Select, {
ClearIndicatorProps,
components,
DropdownIndicatorProps,
MultiValueRemoveProps,
OptionProps,
Props
} from "react-select";
import { faCheckCircle, faCircleXmark } from "@fortawesome/free-regular-svg-icons";
import { faChevronDown, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Select, { Props } from "react-select";
import { twMerge } from "tailwind-merge";
const DropdownIndicator = <T,>(props: DropdownIndicatorProps<T>) => {
return (
<components.DropdownIndicator {...props}>
<FontAwesomeIcon icon={faChevronDown} size="xs" />
</components.DropdownIndicator>
);
};
import { ClearIndicator, DropdownIndicator, MultiValueRemove, Option } from "../Select/components";
const ClearIndicator = <T,>(props: ClearIndicatorProps<T>) => {
return (
<components.ClearIndicator {...props}>
<FontAwesomeIcon icon={faCircleXmark} />
</components.ClearIndicator>
);
};
const MultiValueRemove = (props: MultiValueRemoveProps) => {
return (
<components.MultiValueRemove {...props}>
<FontAwesomeIcon icon={faXmark} size="xs" />
</components.MultiValueRemove>
);
};
const Option = <T,>({ isSelected, children, ...props }: OptionProps<T>) => {
return (
<components.Option isSelected={isSelected} {...props}>
{children}
{isSelected && (
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
)}
</components.Option>
);
};
export const FilterableSelect = <T,>({ isMulti, closeMenuOnSelect, ...props }: Props<T>) => (
export const FilterableSelect = <T,>({
isMulti,
closeMenuOnSelect,
tabSelectsValue = false,
...props
}: Props<T>) => (
<Select
isMulti={isMulti}
closeMenuOnSelect={closeMenuOnSelect ?? !isMulti}
@@ -69,7 +31,14 @@ export const FilterableSelect = <T,>({ isMulti, closeMenuOnSelect, ...props }: P
transition: "none"
})
}}
components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }}
tabSelectsValue={tabSelectsValue}
components={{
DropdownIndicator,
ClearIndicator,
MultiValueRemove,
Option,
...props.components
}}
classNames={{
container: () => "w-full font-inter",
control: ({ isFocused }) =>
@@ -89,14 +58,15 @@ export const FilterableSelect = <T,>({ isMulti, closeMenuOnSelect, ...props }: P
clearIndicator: () => "p-1 hover:text-red text-bunker-400",
indicatorSeparator: () => "bg-bunker-400",
dropdownIndicator: () => "text-bunker-200 p-1",
menuList: () => "flex flex-col gap-1",
menu: () =>
"mt-2 border text-sm text-mineshaft-200 bg-mineshaft-900 border-mineshaft-600 rounded-md",
"mt-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md",
groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm",
option: ({ isFocused, isSelected }) =>
twMerge(
isFocused && "bg-mineshaft-700 active:bg-mineshaft-600",
isSelected && "text-mineshaft-200",
"hover:cursor-pointer text-xs px-3 py-2"
"hover:cursor-pointer rounded text-xs px-3 py-2"
),
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
}}

View File

@@ -54,7 +54,7 @@ export const Pagination = ({
)}
>
{startAdornment}
<div className="ml-auto mr-6 flex items-center space-x-2">
<div className={twMerge("mr-4 flex items-center space-x-2", startAdornment && "ml-auto")}>
<div className="text-xs">
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
</div>

View File

@@ -0,0 +1,45 @@
import {
ClearIndicatorProps,
components,
DropdownIndicatorProps,
MultiValueRemoveProps,
OptionProps
} from "react-select";
import { faCheckCircle, faCircleXmark } from "@fortawesome/free-regular-svg-icons";
import { faChevronDown, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export const DropdownIndicator = <T,>(props: DropdownIndicatorProps<T>) => {
return (
<components.DropdownIndicator {...props}>
<FontAwesomeIcon icon={faChevronDown} size="xs" />
</components.DropdownIndicator>
);
};
export const ClearIndicator = <T,>(props: ClearIndicatorProps<T>) => {
return (
<components.ClearIndicator {...props}>
<FontAwesomeIcon icon={faCircleXmark} />
</components.ClearIndicator>
);
};
export const MultiValueRemove = (props: MultiValueRemoveProps) => {
return (
<components.MultiValueRemove {...props}>
<FontAwesomeIcon icon={faXmark} size="xs" />
</components.MultiValueRemove>
);
};
export const Option = <T,>({ isSelected, children, ...props }: OptionProps<T>) => {
return (
<components.Option isSelected={isSelected} {...props}>
{children}
{isSelected && (
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
)}
</components.Option>
);
};

View File

@@ -1,4 +1,4 @@
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types";
enum OrgMembershipRole {
Admin = "admin",
@@ -23,3 +23,8 @@ export const formatProjectRoleName = (name: string) => {
export const isCustomProjectRole = (slug: string) =>
!Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole);
export const findOrgMembershipRole = (roles: TOrgRole[], roleIdOrSlug: string) =>
isCustomOrgRole(roleIdOrSlug)
? roles.find((r) => r.id === roleIdOrSlug)
: roles.find((r) => r.slug === roleIdOrSlug);

View File

@@ -3,9 +3,16 @@ import { useState } from "react";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useDebounce } from "@app/hooks/useDebounce";
export const usePagination = <T extends string>(initialOrderBy: T) => {
export const usePagination = <T extends string>(
initialOrderBy: T,
{
initPerPage = 100
}: {
initPerPage?: number;
} = {}
) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(100);
const [perPage, setPerPage] = useState(initPerPage);
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
const [orderBy, setOrderBy] = useState<T>(initialOrderBy);
const [search, setSearch] = useState("");
@@ -26,6 +33,10 @@ export const usePagination = <T extends string>(initialOrderBy: T) => {
search,
setSearch,
orderBy,
setOrderBy
setOrderBy,
toggleOrderDirection: () =>
setOrderDirection((prev) =>
prev === OrderByDirection.DESC ? OrderByDirection.ASC : OrderByDirection.DESC
)
};
};

View File

@@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons";
import { faStar } from "@fortawesome/free-regular-svg-icons";
import {
faAngleDown,
faArrowLeft,
@@ -22,15 +21,11 @@ import {
faInfo,
faMobile,
faPlus,
faQuestion,
faStar as faSolidStar
faQuestion
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { tempLocalStorage } from "@app/components/utilities/checks/tempLocalStorage";
import SecurityClient from "@app/components/utilities/SecurityClient";
import {
@@ -39,20 +34,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
Menu,
MenuItem,
Select,
SelectItem,
UpgradePlanModal
MenuItem
} from "@app/components/v2";
import { NewProjectModal } from "@app/components/v2/projects/NewProjectModal";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription,
useUser,
useWorkspace
} from "@app/context";
import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
import {
useGetAccessRequestsCount,
@@ -62,11 +46,9 @@ import {
useSelectOrganization
} from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { Workspace } from "@app/hooks/api/types";
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
import { AuthMethod } from "@app/hooks/api/users/types";
import { InsecureConnectionBanner } from "@app/layouts/AppLayout/components/InsecureConnectionBanner";
import { ProjectSelect } from "@app/layouts/AppLayout/components/ProjectSelect";
import { navigateUserToOrg } from "@app/views/Login/Login.utils";
import { Mfa } from "@app/views/Login/Mfa";
import { CreateOrgModal } from "@app/views/Org/components";
@@ -108,23 +90,10 @@ export const AppLayout = ({ children }: LayoutProps) => {
const { workspaces, currentWorkspace } = useWorkspace();
const { orgs, currentOrg } = useOrganization();
const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!);
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const workspacesWithFaveProp = useMemo(
() =>
workspaces
.map((w): Workspace & { isFavorite: boolean } => ({
...w,
isFavorite: Boolean(projectFavorites?.includes(w.id))
}))
.sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite)),
[workspaces, projectFavorites]
);
const { user } = useUser();
const { subscription } = useSubscription();
const workspaceId = currentWorkspace?.id || "";
@@ -137,17 +106,9 @@ export const AppLayout = ({ children }: LayoutProps) => {
return (secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0);
}, [secretApprovalReqCount, accessApprovalRequestCount]);
const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
: true;
const infisicalPlatformVersion = process.env.NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION;
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"addNewWs",
"upgradePlan",
"createOrg"
] as const);
const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const);
const { t } = useTranslation();
@@ -230,38 +191,6 @@ export const AppLayout = ({ children }: LayoutProps) => {
putUserInOrg();
}, [router.query.id]);
const addProjectToFavorites = async (projectId: string) => {
try {
if (currentOrg?.id) {
await updateUserProjectFavorites({
orgId: currentOrg?.id,
projectFavorites: [...(projectFavorites || []), projectId]
});
}
} catch (err) {
createNotification({
text: "Failed to add project to favorites.",
type: "error"
});
}
};
const removeProjectFromFavorites = async (projectId: string) => {
try {
if (currentOrg?.id) {
await updateUserProjectFavorites({
orgId: currentOrg?.id,
projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)]
});
}
} catch (err) {
createNotification({
text: "Failed to remove project from favorites.",
type: "error"
});
}
};
if (shouldShowMfa) {
return (
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
@@ -448,97 +377,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
)}
{!router.asPath.includes("org") &&
(!router.asPath.includes("personal") && currentWorkspace ? (
<div className="mt-5 mb-4 w-full p-3">
<p className="ml-1.5 mb-1 text-xs font-semibold uppercase text-gray-400">
Project
</p>
<Select
defaultValue={currentWorkspace?.id}
value={currentWorkspace?.id}
className="w-full bg-mineshaft-600 py-2.5 font-medium [&>*:first-child]:truncate"
onValueChange={(value) => {
localStorage.setItem("projectData.id", value);
// this is not using react query because react query in overview is throwing error when envs are not exact same count
// to reproduce change this back to router.push and switch between two projects with different env count
// look into this on dashboard revamp
window.location.assign(`/project/${value}/secrets/overview`);
}}
position="popper"
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50 max-h-96 border-gray-700"
>
<div className="no-scrollbar::-webkit-scrollbar h-full no-scrollbar">
{workspacesWithFaveProp
.filter((ws) => ws.orgId === currentOrg?.id)
.map(({ id, name, isFavorite }) => (
<div
className={twMerge(
"mb-1 grid grid-cols-7 rounded-md hover:bg-mineshaft-500",
id === currentWorkspace?.id && "bg-mineshaft-500"
)}
key={id}
>
<div className="col-span-6">
<SelectItem
key={`ws-layout-list-${id}`}
value={id}
className="transition-none data-[highlighted]:bg-mineshaft-500"
>
{name}
</SelectItem>
</div>
<div className="col-span-1 flex items-center">
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="text-sm text-mineshaft-300 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={(e) => {
e.stopPropagation();
addProjectToFavorites(id);
}}
/>
)}
</div>
</div>
))}
</div>
<hr className="mt-1 mb-1 h-px border-0 bg-gray-700" />
<div className="w-full">
<OrgPermissionCan
I={OrgPermissionActions.Create}
a={OrgPermissionSubjects.Workspace}
>
{(isAllowed) => (
<Button
className="w-full bg-mineshaft-700 py-2 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="sm"
isDisabled={!isAllowed}
onClick={() => {
if (isAddingProjectsAllowed) {
handlePopUpOpen("addNewWs");
} else {
handlePopUpOpen("upgradePlan");
}
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Project
</Button>
)}
</OrgPermissionCan>
</div>
</Select>
</div>
<ProjectSelect />
) : (
<Link href={`/org/${currentOrg?.id}/overview`}>
<div className="my-6 flex cursor-default items-center justify-center pr-2 text-sm text-mineshaft-300 hover:text-mineshaft-100">
@@ -816,15 +655,6 @@ export const AppLayout = ({ children }: LayoutProps) => {
</div>
</nav>
</aside>
<NewProjectModal
isOpen={popUp.addNewWs.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addNewWs", isOpen)}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You have exceeded the number of projects allowed on the free plan."
/>
<CreateOrgModal
isOpen={popUp?.createOrg?.isOpen}
onClose={() => handlePopUpToggle("createOrg", false)}

View File

@@ -0,0 +1,212 @@
import { useMemo } from "react";
import { components, MenuProps, OptionProps } from "react-select";
import { faStar } from "@fortawesome/free-regular-svg-icons";
import { faChevronRight, faPlus, faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FilterableSelect, UpgradePlanModal } from "@app/components/v2";
import { NewProjectModal } from "@app/components/v2/projects";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
import { Workspace } from "@app/hooks/api/workspace/types";
type TWorkspaceWithFaveProp = Workspace & { isFavorite: boolean };
const ProjectsMenu = ({ children, ...props }: MenuProps<TWorkspaceWithFaveProp>) => {
return (
<components.Menu {...props}>
{children}
<hr className="mb-2 h-px border-0 bg-mineshaft-500" />
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Workspace}>
{(isAllowed) => (
<Button
className="w-full bg-mineshaft-700 pt-2 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
onClick={() => props.clearValue()}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Project
</Button>
)}
</OrgPermissionCan>
</components.Menu>
);
};
const ProjectOption = ({
isSelected,
children,
data,
...props
}: OptionProps<TWorkspaceWithFaveProp>) => {
const { currentOrg } = useOrganization();
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!);
const removeProjectFromFavorites = async (projectId: string) => {
try {
await updateUserProjectFavorites({
orgId: currentOrg!.id,
projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)]
});
} catch (err) {
createNotification({
text: "Failed to remove project from favorites.",
type: "error"
});
}
};
const addProjectToFavorites = async (projectId: string) => {
try {
await updateUserProjectFavorites({
orgId: currentOrg!.id,
projectFavorites: [...(projectFavorites || []), projectId]
});
} catch (err) {
createNotification({
text: "Failed to add project to favorites.",
type: "error"
});
}
};
return (
<components.Option
isSelected={isSelected}
data={data}
{...props}
className={twMerge(props.className, isSelected && "bg-mineshaft-500")}
>
<div className="flex w-full items-center">
{isSelected && (
<FontAwesomeIcon className="mr-2 text-primary" icon={faChevronRight} size="xs" />
)}
<p className="truncate">{children}</p>
{data.isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="ml-auto text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={async (e) => {
e.stopPropagation();
await removeProjectFromFavorites(data.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="ml-auto text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={async (e) => {
e.stopPropagation();
await addProjectToFavorites(data.id);
}}
/>
)}
</div>
</components.Option>
);
};
export const ProjectSelect = () => {
const { workspaces, currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization();
const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!);
const { subscription } = useSubscription();
const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
: true;
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"addNewWs",
"upgradePlan"
] as const);
const { options, value } = useMemo(() => {
const projectOptions = workspaces
.map((w): Workspace & { isFavorite: boolean } => ({
...w,
isFavorite: Boolean(projectFavorites?.includes(w.id))
}))
.sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite));
const currentOption = projectOptions.find((option) => option.id === currentWorkspace?.id);
if (!currentOption) {
return {
options: projectOptions,
value: null
};
}
return {
options: [
currentOption,
...projectOptions.filter((option) => option.id !== currentOption.id)
],
value: currentOption
};
}, [workspaces, projectFavorites, currentWorkspace]);
return (
<div className="mt-5 mb-4 w-full p-3">
<p className="ml-1.5 mb-1 text-xs font-semibold uppercase text-gray-400">Project</p>
<FilterableSelect
className="text-sm"
value={value}
filterOption={(option, inputValue) =>
option.data.name.toLowerCase().includes(inputValue.toLowerCase())
}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
onChange={(newValue) => {
// hacky use of null as indication to create project
if (!newValue) {
if (isAddingProjectsAllowed) {
handlePopUpOpen("addNewWs");
} else {
handlePopUpOpen("upgradePlan");
}
return;
}
const project = newValue as TWorkspaceWithFaveProp;
localStorage.setItem("projectData.id", project.id);
// todo(akhi): this is not using react query because react query in overview is throwing error when envs are not exact same count
// to reproduce change this back to router.push and switch between two projects with different env count
// look into this on dashboard revamp
window.location.assign(`/project/${project.id}/secrets/overview`);
}}
options={options}
components={{
Option: ProjectOption,
Menu: ProjectsMenu
}}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You have exceeded the number of projects allowed on the free plan."
/>
<NewProjectModal
isOpen={popUp.addNewWs.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addNewWs", isOpen)}
/>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./ProjectSelect";

View File

@@ -1,6 +1,6 @@
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
import { useEffect, useMemo, useState } from "react";
import { ReactNode, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Head from "next/head";
import Link from "next/link";
@@ -9,20 +9,22 @@ import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { faSlack } from "@fortawesome/free-brands-svg-icons";
import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons";
import {
faArrowDownAZ,
faArrowRight,
faArrowUpRightFromSquare,
faArrowUpZA,
faBorderAll,
faCheck,
faCheckCircle,
faClipboard,
faExclamationCircle,
faFileShield,
faHandPeace,
faList,
faMagnifyingGlass,
faNetworkWired,
faPlug,
faPlus,
faSearch,
faStar as faSolidStar,
faUserPlus
} from "@fortawesome/free-solid-svg-icons";
@@ -32,7 +34,15 @@ import * as Tabs from "@radix-ui/react-tabs";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import onboardingCheck from "@app/components/utilities/checks/OnboardingCheck";
import { Button, IconButton, Input, Skeleton, UpgradePlanModal } from "@app/components/v2";
import {
Button,
IconButton,
Input,
Pagination,
Skeleton,
Tooltip,
UpgradePlanModal
} from "@app/components/v2";
import { NewProjectModal } from "@app/components/v2/projects";
import {
OrgPermissionActions,
@@ -42,7 +52,9 @@ import {
useUser,
useWorkspace
} from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useRegisterUserAction } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
// import { fetchUserWsKey } from "@app/hooks/api/keys/queries";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { Workspace } from "@app/hooks/api/types";
@@ -81,6 +93,10 @@ enum ProjectsViewMode {
LIST = "list"
}
enum ProjectOrderBy {
Name = "name"
}
function copyToClipboard(id: string, setState: (value: boolean) => void) {
// Get the text field
const copyText = document.getElementById(id) as HTMLInputElement;
@@ -496,26 +512,48 @@ const OrganizationPage = () => {
});
}, []);
const isWorkspaceEmpty = !isWorkspaceLoading && orgWorkspaces?.length === 0;
const filteredWorkspaces = orgWorkspaces.filter((ws) =>
ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())
const isWorkspaceEmpty = !isProjectViewLoading && orgWorkspaces?.length === 0;
const {
setPage,
perPage,
setPerPage,
page,
offset,
limit,
toggleOrderDirection,
orderDirection
} = usePagination(ProjectOrderBy.Name, { initPerPage: 24 });
const filteredWorkspaces = useMemo(
() =>
orgWorkspaces
.filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()))
.sort((a, b) =>
orderDirection === OrderByDirection.ASC
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
),
[searchFilter, page, perPage, orderDirection, offset, limit]
);
const { workspacesWithFaveProp, favoriteWorkspaces, nonFavoriteWorkspaces } = useMemo(() => {
useResetPageHelper({
setPage,
offset,
totalCount: filteredWorkspaces.length
});
const { workspacesWithFaveProp } = useMemo(() => {
const workspacesWithFav = filteredWorkspaces
.map((w): Workspace & { isFavorite: boolean } => ({
...w,
isFavorite: Boolean(projectFavorites?.includes(w.id))
}))
.sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite));
const favWorkspaces = workspacesWithFav.filter((w) => w.isFavorite);
const nonFavWorkspaces = workspacesWithFav.filter((w) => !w.isFavorite);
.sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite))
.slice(offset, limit * page);
return {
workspacesWithFaveProp: workspacesWithFav,
favoriteWorkspaces: favWorkspaces,
nonFavoriteWorkspaces: nonFavWorkspaces
workspacesWithFaveProp: workspacesWithFav
};
}, [filteredWorkspaces, projectFavorites]);
@@ -566,7 +604,7 @@ const OrganizationPage = () => {
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="text-sm text-mineshaft-300 hover:text-mineshaft-400"
className="text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(workspace.id);
@@ -623,11 +661,10 @@ const OrganizationPage = () => {
key={workspace.id}
className={`min-w-72 group grid h-14 cursor-pointer grid-cols-6 border-t border-l border-r border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
index === 0 && "rounded-t-md"
} ${index === filteredWorkspaces.length - 1 && "rounded-b-md border-b"}`}
}`}
>
<div className="flex items-center sm:col-span-3 lg:col-span-4">
<FontAwesomeIcon icon={faFileShield} className="text-sm text-primary/70" />
<div className="ml-5 truncate text-sm text-mineshaft-100">{workspace.name}</div>
<div className="truncate text-sm text-mineshaft-100">{workspace.name}</div>
</div>
<div className="flex items-center justify-end sm:col-span-3 lg:col-span-2">
<div className="text-center text-sm text-mineshaft-300">
@@ -636,7 +673,7 @@ const OrganizationPage = () => {
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="ml-6 text-sm text-mineshaft-300 hover:text-mineshaft-400"
className="ml-6 text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(workspace.id);
@@ -656,63 +693,75 @@ const OrganizationPage = () => {
</div>
);
const projectsGridView = (
<>
{favoriteWorkspaces.length > 0 && (
<>
<p className="mt-6 text-xl font-semibold text-white">Favorites</p>
<div
className={`b grid w-full grid-cols-1 gap-4 ${
nonFavoriteWorkspaces.length > 0 && "border-b border-mineshaft-600"
} py-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4`}
>
{favoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, true))}
</div>
</>
)}
<div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className="min-w-72 flex h-40 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="mt-0 text-lg text-mineshaft-100">
<Skeleton className="w-3/4 bg-mineshaft-600" />
</div>
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
<div className="flex justify-end">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
</div>
))}
{!isProjectViewLoading &&
nonFavoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, false))}
</div>
</>
);
let projectsComponents: ReactNode;
const projectsListView = (
<div className="mt-4 w-full rounded-md">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className={`min-w-72 group flex h-12 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
i === 0 && "rounded-t-md"
} ${i === 2 && "rounded-b-md border-b"}`}
>
<Skeleton className="w-full bg-mineshaft-600" />
if (filteredWorkspaces.length || isProjectViewLoading) {
switch (projectsViewMode) {
case ProjectsViewMode.GRID:
projectsComponents = (
<div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className="min-w-72 flex h-40 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="mt-0 text-lg text-mineshaft-100">
<Skeleton className="w-3/4 bg-mineshaft-600" />
</div>
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
<div className="flex justify-end">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
</div>
))}
{!isProjectViewLoading && (
<>
{workspacesWithFaveProp.map((workspace) =>
renderProjectGridItem(workspace, workspace.isFavorite)
)}
</>
)}
</div>
))}
{!isProjectViewLoading &&
workspacesWithFaveProp.map((workspace, ind) =>
renderProjectListItem(workspace, workspace.isFavorite, ind)
)}
</div>
);
);
break;
case ProjectsViewMode.LIST:
default:
projectsComponents = (
<div className="mt-4 w-full rounded-md">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className={`min-w-72 group flex h-12 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
i === 0 && "rounded-t-md"
} ${i === 2 && "rounded-b-md border-b"}`}
>
<Skeleton className="w-full bg-mineshaft-600" />
</div>
))}
{!isProjectViewLoading &&
workspacesWithFaveProp.map((workspace, ind) =>
renderProjectListItem(workspace, workspace.isFavorite, ind)
)}
</div>
);
break;
}
} else if (orgWorkspaces.length) {
projectsComponents = (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faSearch}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">No projects match search...</div>
</div>
);
}
return (
<div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800 md:h-screen">
@@ -754,6 +803,24 @@ const OrganizationPage = () => {
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip content="Toggle Sort Direction">
<IconButton
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
ariaLabel={`Sort ${
orderDirection === OrderByDirection.ASC ? "descending" : "ascending"
}`}
variant="plain"
size="xs"
colorSchema="secondary"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.ASC ? faArrowDownAZ : faArrowUpZA}
/>
</IconButton>
</Tooltip>
</div>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<IconButton
variant="outline_bg"
@@ -804,9 +871,24 @@ const OrganizationPage = () => {
)}
</OrgPermissionCan>
</div>
{projectsViewMode === ProjectsViewMode.LIST ? projectsListView : projectsGridView}
{projectsComponents}
{!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
<Pagination
className={
projectsViewMode === ProjectsViewMode.GRID
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
: "rounded-b-md border border-mineshaft-600"
}
perPage={perPage}
perPageList={[12, 24, 48, 96]}
count={filteredWorkspaces.length}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{isWorkspaceEmpty && (
<div className="w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faFolderOpen}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"

View File

@@ -12,6 +12,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FilterableSelect,
FormControl,
Select,
SelectItem
@@ -64,7 +65,7 @@ export const LogsFilter = ({
useEffect(() => {
if (workspacesInOrg.length) {
setValue("projectId", workspacesInOrg[0].id);
setValue("project", workspacesInOrg[0]);
}
}, [workspaces]);
@@ -111,11 +112,34 @@ export const LogsFilter = ({
return (
<div
className={twMerge(
"sticky top-20 z-10 flex items-center justify-between bg-bunker-800",
"sticky top-20 z-10 flex flex-wrap items-center justify-between bg-bunker-800",
className
)}
>
<div className="flex items-center space-x-2">
{isOrgAuditLogs && workspacesInOrg.length > 0 && (
<Controller
control={control}
name="project"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
isError={Boolean(error)}
className="mr-12 w-64"
>
<FilterableSelect
value={value}
onChange={onChange}
placeholder="Select a project..."
options={workspacesInOrg.map(({ name, id }) => ({ name, id }))}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
)}
<div className="mt-1 flex items-center space-x-2">
<Controller
control={control}
name="eventType"
@@ -123,7 +147,7 @@ export const LogsFilter = ({
<FormControl label="Events">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
<div className="inline-flex w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
{selectedEventTypes?.length === 1
? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0])
?.label
@@ -235,37 +259,6 @@ export const LogsFilter = ({
</FormControl>
)}
/>
{isOrgAuditLogs && workspacesInOrg.length > 0 && (
<Controller
control={control}
name="projectId"
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
isError={Boolean(error)}
className="w-40"
>
<Select
value={value}
{...field}
onValueChange={(e) => onChange(e)}
className={twMerge(
"w-full border border-mineshaft-500 bg-mineshaft-700 ",
value === undefined && "text-mineshaft-400"
)}
>
{workspacesInOrg.map((project) => (
<SelectItem value={String(project.id || "")} key={project.id}>
{project.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
<Controller
name="startDate"
control={control}
@@ -275,6 +268,7 @@ export const LogsFilter = ({
<DatePicker
value={field.value || undefined}
onChange={onChange}
dateFormat="P"
popUpProps={{
open: isStartDatePickerOpen,
onOpenChange: setIsStartDatePickerOpen
@@ -294,6 +288,7 @@ export const LogsFilter = ({
<DatePicker
value={field.value || undefined}
onChange={onChange}
dateFormat="P"
popUpProps={{
open: isEndDatePickerOpen,
onOpenChange: setIsEndDatePickerOpen
@@ -304,27 +299,27 @@ export const LogsFilter = ({
);
}}
/>
<Button
isLoading={false}
colorSchema="primary"
variant="outline_bg"
className="mt-[0.45rem]"
type="submit"
leftIcon={<FontAwesomeIcon icon={faFilterCircleXmark} />}
onClick={() =>
reset({
eventType: presets?.eventType || [],
actor: presets?.actorId,
userAgentType: undefined,
startDate: undefined,
endDate: undefined,
project: null
})
}
>
Clear filters
</Button>
</div>
<Button
isLoading={false}
colorSchema="primary"
variant="outline_bg"
className="mt-1.5"
type="submit"
leftIcon={<FontAwesomeIcon icon={faFilterCircleXmark} />}
onClick={() =>
reset({
eventType: presets?.eventType || [],
actor: presets?.actorId,
userAgentType: undefined,
startDate: undefined,
endDate: undefined,
projectId: undefined
})
}
>
Clear filters
</Button>
</div>
);
};

View File

@@ -47,7 +47,7 @@ export const LogsSection = ({
const { control, reset, watch, setValue } = useForm<AuditLogFilterFormData>({
resolver: yupResolver(auditLogFilterFormSchema),
defaultValues: {
projectId: undefined,
project: null,
actor: presets?.actorId,
eventType: presets?.eventType || [],
page: 1,
@@ -66,7 +66,7 @@ export const LogsSection = ({
const eventType = watch("eventType") as EventType[] | undefined;
const userAgentType = watch("userAgentType") as UserAgentType | undefined;
const actor = watch("actor");
const projectId = watch("projectId");
const projectId = watch("project")?.id;
const startDate = watch("startDate");
const endDate = watch("endDate");

View File

@@ -5,7 +5,7 @@ import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
export const auditLogFilterFormSchema = yup
.object({
eventMetadata: yup.object({}).optional(),
projectId: yup.string().optional(),
project: yup.object({ id: yup.string().required(), name: yup.string().required() }).nullable(),
eventType: yup.array(yup.string().oneOf(Object.values(EventType), "Invalid event type")),
actor: yup.string(),
userAgentType: yup.string().oneOf(Object.values(UserAgentType), "Invalid user agent type"),

View File

@@ -4,7 +4,14 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalClose,
ModalContent
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
useAddIdentityToWorkspace,
@@ -16,8 +23,8 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
projectId: z.string(),
role: z.string()
project: z.object({ name: z.string(), id: z.string() }),
role: z.object({ name: z.string(), slug: z.string() })
})
.required();
@@ -32,7 +39,9 @@ type Props = {
) => void;
};
export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle }: Props) => {
// TODO: eventually refactor to support adding to multiple projects at once? would lose role granularity unique to project
const Content = ({ identityId, handlePopUpToggle }: Omit<Props, "popUp">) => {
const { currentOrg } = useOrganization();
const { workspaces } = useWorkspace();
const { mutateAsync: addIdentityToWorkspace } = useAddIdentityToWorkspace();
@@ -47,10 +56,10 @@ export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle
resolver: zodResolver(schema)
});
const projectId = watch("projectId");
const projectId = watch("project")?.id;
const { data: projectMemberships } = useGetIdentityProjectMemberships(identityId);
const { data: project } = useGetWorkspaceById(projectId);
const { data: roles } = useGetProjectRoles(project?.id ?? "");
const { data: project, isLoading: isProjectLoading } = useGetWorkspaceById(projectId);
const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(project?.id ?? "");
const filteredWorkspaces = useMemo(() => {
const wsWorkspaceIds = new Map();
@@ -64,12 +73,12 @@ export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle
);
}, [workspaces, projectMemberships]);
const onFormSubmit = async ({ projectId: workspaceId, role }: FormData) => {
const onFormSubmit = async ({ project: selectedProject, role }: FormData) => {
try {
await addIdentityToWorkspace({
workspaceId,
workspaceId: selectedProject.id,
identityId,
role: role || undefined
role: role.slug || undefined
});
createNotification({
@@ -91,87 +100,85 @@ export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle
}
};
const isProjectSelected = Boolean(projectId);
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="project"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
value={value}
onChange={onChange}
options={filteredWorkspaces}
placeholder="Select project..."
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
isLoading={isProjectSelected && isProjectLoading}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Role"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
isDisabled={!isProjectSelected}
value={value}
onChange={onChange}
options={roles}
isLoading={isProjectSelected && isRolesLoading}
placeholder="Select role..."
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};
export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle }: Props) => {
return (
<Modal
isOpen={popUp?.addIdentityToProject?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addIdentityToProject", isOpen);
reset();
}}
>
<ModalContent title="Add Identity to Project">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="projectId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(filteredWorkspaces || []).map(({ id, name }) => (
<SelectItem value={id} key={`project-${id}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Role"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`project-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("addIdentityToProject", false)}
>
Cancel
</Button>
</div>
</form>
<ModalContent bodyClassName="overflow-visible" title="Add Identity to Project">
<Content identityId={identityId} handlePopUpToggle={handlePopUpToggle} />
</ModalContent>
</Modal>
);

View File

@@ -62,7 +62,7 @@ export const IdentityProjectRow = ({
});
}}
>
<Td>{project.name}</Td>
<Td className="max-w-0 truncate">{project.name}</Td>
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
roles.length > 1 ? ` (+${roles.length - 1})` : ""
}`}</Td>

View File

@@ -1,7 +1,18 @@
import { faFolder } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -10,7 +21,9 @@ import {
THead,
Tr
} from "@app/components/v2";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetIdentityProjectMemberships } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityProjectRow } from "./IdentityProjectRow";
@@ -23,36 +36,115 @@ type Props = {
) => void;
};
enum IdentityProjectsOrderBy {
Name = "name"
}
export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => {
const { data: projectMemberships, isLoading } = useGetIdentityProjectMemberships(identityId);
const { data: projectMemberships = [], isLoading } = useGetIdentityProjectMemberships(identityId);
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection
} = usePagination(IdentityProjectsOrderBy.Name, { initPerPage: 10 });
const filteredProjectMemberships = useMemo(
() =>
projectMemberships
?.filter((membership) =>
membership.project.name.toLowerCase().includes(search.trim().toLowerCase())
)
.sort((a, b) => {
const [membershipOne, membershipTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
return membershipOne.project.name
.toLowerCase()
.localeCompare(membershipTwo.project.name.toLowerCase());
}),
[projectMemberships, orderDirection, search]
);
useResetPageHelper({
totalCount: filteredProjectMemberships.length,
offset,
setPage
});
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Role</Th>
<Th>Added On</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="identity-project-memberships" />}
{!isLoading &&
projectMemberships?.map((membership) => {
return (
<IdentityProjectRow
key={`identity-project-membership-${membership.id}`}
membership={membership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{!isLoading && !projectMemberships?.length && (
<EmptyState title="This identity has not been assigned to any projects" icon={faFolder} />
)}
</TableContainer>
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search projects..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-2/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th>
<Th>Added On</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="identity-project-memberships" />}
{!isLoading &&
filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
return (
<IdentityProjectRow
key={`identity-project-membership-${membership.id}`}
membership={membership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredProjectMemberships.length) && (
<Pagination
count={filteredProjectMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredProjectMemberships?.length && (
<EmptyState
title={
projectMemberships.length
? "No projects match search..."
: "This identity has not been assigned to any projects"
}
icon={projectMemberships.length ? faSearch : faFolder}
/>
)}
</TableContainer>
</div>
);
};

View File

@@ -7,7 +7,7 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { isTabSection, TabSections } from "@app/views/Org/Types";
import { OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components";
import { OrgGroupsTab, OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components";
export const MembersPage = withPermission(
() => {
@@ -25,9 +25,9 @@ export const MembersPage = withPermission(
const updateSelectedTab = (tab: string) => {
router.push({
pathname: router.pathname,
query: { ...router.query, selectedTab: tab },
query: { ...router.query, selectedTab: tab }
});
}
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
@@ -36,16 +36,20 @@ export const MembersPage = withPermission(
<Tabs value={activeTab} onValueChange={updateSelectedTab}>
<TabList>
<Tab value={TabSections.Member}>Users</Tab>
<Tab value={TabSections.Groups}>Groups</Tab>
<Tab value={TabSections.Identities}>
<div className="flex items-center">
<p>Machine Identities</p>
</div>
</Tab>
<Tab value={TabSections.Roles}>Organization Roles</Tab>
<Tab value={TabSections.Roles}>Organization Roles</Tab>
</TabList>
<TabPanel value={TabSections.Member}>
<OrgMembersTab />
</TabPanel>
<TabPanel value={TabSections.Groups}>
<OrgGroupsTab />
</TabPanel>
<TabPanel value={TabSections.Identities}>
<OrgIdentityTab />
</TabPanel>

View File

@@ -6,14 +6,14 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
ModalContent
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { findOrgMembershipRole } from "@app/helpers/roles";
import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -23,7 +23,7 @@ const GroupFormSchema = z.object({
.string()
.min(5, "Slug must be at least 5 characters long")
.max(36, "Slug must be 36 characters or fewer"),
role: z.string()
role: z.object({ name: z.string(), slug: z.string() })
});
export type TGroupFormData = z.infer<typeof GroupFormSchema>;
@@ -62,13 +62,13 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
reset({
name: group.name,
slug: group.slug,
role: group?.customRole?.slug ?? group.role
role: group?.customRole ?? findOrgMembershipRole(roles, group.role)
});
} else {
reset({
name: "",
slug: "",
role: roles[0].slug
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
});
}
}, [popUp?.group?.data, roles]);
@@ -88,14 +88,14 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
id: group.groupId,
name,
slug,
role: role || undefined
role: role.slug || undefined
});
} else {
await createMutateAsync({
name,
slug,
organizationId: currentOrg.id,
role: role || undefined
role: role.slug || undefined
});
}
handlePopUpToggle("group", false);
@@ -121,7 +121,10 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
reset();
}}
>
<ModalContent title={`${popUp?.group?.data ? "Update" : "Create"} Group`}>
<ModalContent
bodyClassName="overflow-visible"
title={`${popUp?.group?.data ? "Update" : "Create"} Group`}
>
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
<Controller
control={control}
@@ -144,26 +147,21 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
<Controller
control={control}
name="role"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.group?.data ? "Update" : ""} Role`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`org-group-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
<FilterableSelect
options={roles}
placeholder="Select role..."
onChange={onChange}
value={value}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>

View File

@@ -57,7 +57,7 @@ export const OrgGroupsSection = () => {
return (
<div className="mb-6 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">User Groups</p>
<p className="text-xl font-semibold text-mineshaft-100">Groups</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<Button

View File

@@ -1,9 +1,10 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faEllipsis,
faMagnifyingGlass,
faSearch,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -19,6 +20,7 @@ import {
EmptyState,
IconButton,
Input,
Pagination,
Select,
SelectItem,
Table,
@@ -31,7 +33,7 @@ import {
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useDebounce } from "@app/hooks";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetOrganizationGroups, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -59,14 +61,10 @@ enum GroupsOrderBy {
}
export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
const [searchGroupsFilter, setSearchGroupsFilter] = useState("");
const [debouncedSearch] = useDebounce(searchGroupsFilter.trim());
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { isLoading, data: groups } = useGetOrganizationGroups(orgId);
const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId);
const { mutateAsync: updateMutateAsync } = useUpdateGroup();
const [orderBy, setOrderBy] = useState(GroupsOrderBy.Name);
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
const { data: roles } = useGetOrgRoles(orgId);
@@ -90,12 +88,27 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
}
};
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<GroupsOrderBy>(GroupsOrderBy.Name, { initPerPage: 20 });
const filteredGroups = useMemo(() => {
const filtered = debouncedSearch
const filtered = search
? groups?.filter(
({ name, slug }) =>
name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
slug.toLowerCase().includes(debouncedSearch.toLowerCase())
name.toLowerCase().includes(search.toLowerCase()) ||
slug.toLowerCase().includes(search.toLowerCase())
)
: groups;
@@ -113,13 +126,11 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
});
return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse();
}, [debouncedSearch, groups, orderBy, orderDirection]);
}, [search, groups, orderBy, orderDirection]);
const handleSort = (column: GroupsOrderBy) => {
if (column === orderBy) {
setOrderDirection((prev) =>
prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
);
toggleOrderDirection();
return;
}
@@ -127,11 +138,17 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
setOrderDirection(OrderByDirection.ASC);
};
useResetPageHelper({
totalCount: filteredGroups.length,
offset,
setPage
});
return (
<div>
<Input
value={searchGroupsFilter}
onChange={(e) => setSearchGroupsFilter(e.target.value)}
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search groups..."
/>
@@ -202,143 +219,160 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="org-groups" />}
{!isLoading &&
filteredGroups?.map(({ id, name, slug, role, customRole }) => {
return (
<Tr className="h-10" key={`org-group-${id}`}>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Select
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-48 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
id,
role: selectedRole
})
}
filteredGroups
.slice(offset, perPage * page)
.map(({ id, name, slug, role, customRole }) => {
return (
<Tr className="h-10" key={`org-group-${id}`}>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Select
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-48 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
id,
role: selectedRole
})
}
>
{(roles || []).map(({ slug: roleSlug, name: roleName }) => (
<SelectItem value={roleSlug} key={`role-option-${roleSlug}`}>
{roleName}
</SelectItem>
))}
</Select>
);
}}
</OrgPermissionCan>
</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">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
createNotification({
text: "Copied group ID to clipboard",
type: "info"
});
navigator.clipboard.writeText(id);
}}
>
{(roles || []).map(({ slug: roleSlug, name: roleName }) => (
<SelectItem value={roleSlug} key={`role-option-${roleSlug}`}>
{roleName}
</SelectItem>
))}
</Select>
);
}}
</OrgPermissionCan>
</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">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
createNotification({
text: "Copied group ID to clipboard",
type: "info"
});
navigator.clipboard.writeText(id);
}}
>
Copy Group ID
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("groupMembers", {
groupId: id,
slug
});
}}
disabled={!isAllowed}
>
Manage Users
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("group", {
groupId: id,
name,
slug,
role,
customRole
});
}}
disabled={!isAllowed}
>
Edit Group
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Groups}
>
{(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("deleteGroup", {
groupId: id,
name
});
}}
disabled={!isAllowed}
>
Delete Group
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
Copy Group ID
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("groupMembers", {
groupId: id,
slug
});
}}
disabled={!isAllowed}
>
Manage Users
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("group", {
groupId: id,
name,
slug,
role,
customRole
});
}}
disabled={!isAllowed}
>
Edit Group
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Groups}
>
{(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("deleteGroup", {
groupId: id,
name
});
}}
disabled={!isAllowed}
>
Delete Group
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{filteredGroups?.length === 0 && (
{Boolean(filteredGroups.length) && (
<Pagination
count={filteredGroups.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredGroups?.length && (
<EmptyState
title={groups?.length === 0 ? "No groups found" : "No groups match search"}
icon={faUsers}
title={
groups.length
? "No organization groups match search..."
: "No organization groups found"
}
icon={groups.length ? faSearch : faUsers}
/>
)}
</TableContainer>

View File

@@ -9,27 +9,24 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
FormLabel,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem
ModalContent
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { findOrgMembershipRole } from "@app/helpers/roles";
import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
import {
// IdentityAuthMethod,
useAddIdentityUniversalAuth
} from "@app/hooks/api/identities";
import { useAddIdentityUniversalAuth } from "@app/hooks/api/identities";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
name: z.string(),
role: z.string(),
name: z.string().min(1, "Required"),
role: z.object({ slug: z.string(), name: z.string() }),
metadata: z
.object({
key: z.string().trim().min(1),
@@ -101,13 +98,13 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
if (identity) {
reset({
name: identity.name,
role: identity?.customRole?.slug ?? identity.role,
role: identity.customRole ?? findOrgMembershipRole(roles, identity.role),
metadata: identity.metadata
});
} else {
reset({
name: "",
role: roles[0].slug
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
});
}
}, [popUp?.identity?.data, roles]);
@@ -126,7 +123,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
await updateMutateAsync({
identityId: identity.identityId,
name,
role: role || undefined,
role: role.slug || undefined,
organizationId: orgId,
metadata
});
@@ -137,7 +134,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const { id: createdId } = await createMutateAsync({
name,
role: role || undefined,
role: role.slug || undefined,
organizationId: orgId,
metadata
});
@@ -184,7 +181,10 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
reset();
}}
>
<ModalContent title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}>
<ModalContent
bodyClassName="overflow-visible"
title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
@@ -199,26 +199,21 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
name="role"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.identity?.data ? "Update" : ""} Role`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`st-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
<FilterableSelect
placeholder="Select role..."
options={roles}
onChange={onChange}
value={value}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>

View File

@@ -42,7 +42,7 @@ export const IdentitySection = withPermission(
? subscription.identitiesUsed < subscription.identityLimit
: true;
const isEnterprise = subscription?.slug === "enterprise"
const isEnterprise = subscription?.slug === "enterprise";
const onDeleteIdentitySubmit = async (identityId: string) => {
try {
@@ -105,7 +105,7 @@ export const IdentitySection = withPermission(
}}
isDisabled={!isAllowed}
>
Create identity
Create Identity
</Button>
)}
</OrgPermissionCan>

View File

@@ -1,6 +1,5 @@
import { motion } from "framer-motion";
import { OrgGroupsSection } from "../OrgGroupsTab/components";
import { OrgMembersSection } from "./components";
export const OrgMembersTab = () => {
@@ -13,7 +12,6 @@ export const OrgMembersTab = () => {
exit={{ opacity: 0, translateX: 30 }}
>
<OrgMembersSection />
<OrgGroupsSection />
</motion.div>
);
};

View File

@@ -1,31 +1,21 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import {
faCheckCircle,
faChevronDown,
faExclamationCircle
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FilterableSelect,
FormControl,
Modal,
ModalContent,
Select,
SelectItem,
TextArea,
Tooltip
TextArea
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { isCustomOrgRole } from "@app/helpers/roles";
import { findOrgMembershipRole } from "@app/helpers/roles";
import {
useAddUsersToOrg,
useFetchServerStatus,
@@ -44,9 +34,18 @@ const EmailSchema = z.string().email().min(1).trim().toLowerCase();
const addMemberFormSchema = z.object({
emails: z.string().min(1).trim().toLowerCase(),
projectIds: z.array(z.string().min(1).trim().toLowerCase()).default([]),
projects: z
.array(
z.object({
name: z.string(),
id: z.string(),
slug: z.string(),
version: z.nativeEnum(ProjectVersion)
})
)
.default([]),
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG)
organizationRole: z.object({ name: z.string(), slug: z.string() })
});
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
@@ -72,7 +71,7 @@ export const AddOrgMemberModal = ({
const { data: organizationRoles } = useGetOrgRoles(currentOrg?.id ?? "");
const { data: serverDetails } = useFetchServerStatus();
const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg();
const { data: projects } = useGetUserWorkspaces(true);
const { data: projects, isLoading: isProjectsLoading } = useGetUserWorkspaces(true);
const {
control,
@@ -88,25 +87,22 @@ export const AddOrgMemberModal = ({
useEffect(() => {
if (organizationRoles) {
reset({
organizationRoleSlug: isCustomOrgRole(currentOrg?.defaultMembershipRole!)
? organizationRoles?.find((role) => role.id === currentOrg?.defaultMembershipRole)?.slug!
: currentOrg?.defaultMembershipRole
organizationRole: findOrgMembershipRole(
organizationRoles,
currentOrg?.defaultMembershipRole!
)
});
}
}, [organizationRoles]);
const selectedProjectIds = watch("projectIds", []);
const onAddMembers = async ({
emails,
organizationRoleSlug,
projectIds,
organizationRole,
projects: selectedProjects,
projectRoleSlug
}: TAddMemberForm) => {
if (!currentOrg?.id) return;
const selectedProjects = projects?.filter((project) => projectIds.includes(String(project.id)));
if (selectedProjects?.length) {
// eslint-disable-next-line no-restricted-syntax
for (const project of selectedProjects) {
@@ -143,8 +139,8 @@ export const AddOrgMemberModal = ({
const { data } = await addUsersMutateAsync({
organizationId: currentOrg?.id,
inviteeEmails: emails.split(",").map((email) => email.trim()),
organizationRoleSlug,
projects: projectIds.map((id) => ({ id, projectRoleSlug: [projectRoleSlug] }))
organizationRoleSlug: organizationRole.slug,
projects: selectedProjects.map(({ id }) => ({ id, projectRoleSlug: [projectRoleSlug] }))
});
setCompleteInviteLinks(data?.completeInviteLinks ?? null);
@@ -182,6 +178,7 @@ export const AddOrgMemberModal = ({
}}
>
<ModalContent
bodyClassName="overflow-visible"
title={`Invite others to ${currentOrg?.name}`}
subTitle={
<div>
@@ -211,123 +208,53 @@ export const AddOrgMemberModal = ({
<Controller
control={control}
name="organizationRoleSlug"
render={({ field, fieldState: { error } }) => (
name="organizationRole"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText="Select which organization role you want to assign to the user."
label="Assign organization role"
isError={Boolean(error)}
errorText={error?.message}
>
<div>
<Select
className="w-full"
{...field}
onValueChange={(val) => field.onChange(val)}
>
{organizationRoles?.map((role) => (
<SelectItem key={role.id} value={role.slug}>
{role.name}
</SelectItem>
))}
</Select>
</div>
<FilterableSelect
placeholder="Select role..."
options={organizationRoles}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-start justify-between gap-2">
<div className="w-full">
<Controller
control={control}
name="projectIds"
render={({ field, fieldState: { error } }) => (
name="projects"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Assign users to projects (optional)"
label="Assign users to projects"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{projects && projects.length > 0 ? (
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
{/* eslint-disable-next-line no-nested-ternary */}
{selectedProjectIds.length === 1
? projects.find((project) => project.id === selectedProjectIds[0])
?.name
: selectedProjectIds.length === 0
? "No projects selected"
: `${selectedProjectIds.length} projects selected`}
<FontAwesomeIcon icon={faChevronDown} className="text-xs" />
</div>
) : (
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
No projects found
</div>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="thin-scrollbar z-[100] max-h-80"
>
{projects && projects.length > 0 ? (
projects.map((project) => {
const isSelected = selectedProjectIds.includes(String(project.id));
return (
<DropdownMenuItem
onSelect={(event) =>
projects.length > 1 && event.preventDefault()
}
onClick={() => {
if (selectedProjectIds.includes(String(project.id))) {
field.onChange(
selectedProjectIds.filter(
(projectId: string) => projectId !== String(project.id)
)
);
} else {
field.onChange([...selectedProjectIds, String(project.id)]);
}
}}
key={`project-id-${project.id}`}
icon={
isSelected ? (
<FontAwesomeIcon
icon={faCheckCircle}
className="pr-0.5 text-primary"
/>
) : (
<div className="pl-[1.01rem]" />
)
}
iconPos="left"
className="w-[28.4rem] text-sm"
>
<div className="flex items-center gap-2 capitalize">
{project.name}
{project.version !== ProjectVersion.V3 && (
<Tooltip content="Project is not compatible with this action, please upgrade this project.">
<FontAwesomeIcon
icon={faExclamationCircle}
className="text-xs opacity-50"
/>
</Tooltip>
)}
</div>
</DropdownMenuItem>
);
})
) : (
<div />
)}
</DropdownMenuContent>
</DropdownMenu>
<FilterableSelect
isMulti
value={value}
onChange={onChange}
isLoading={isProjectsLoading}
getOptionLabel={(project) => project.name}
getOptionValue={(project) => project.id}
options={projects}
placeholder="Select projects..."
/>
</FormControl>
)}
/>
</div>
<div className="flex min-w-fit justify-end">
<div className="mt-[0.15rem] flex min-w-fit justify-end">
<Controller
control={control}
name="projectRoleSlug"
@@ -340,7 +267,7 @@ export const AddOrgMemberModal = ({
>
<div>
<Select
isDisabled={selectedProjectIds.length === 0}
isDisabled={watch("projects", []).length === 0}
defaultValue={DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG}
{...field}
onValueChange={(val) => field.onChange(val)}

View File

@@ -1,6 +1,13 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import { useRouter } from "next/router";
import { faEllipsis, faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
import {
faArrowDown,
faArrowUp,
faEllipsis,
faMagnifyingGlass,
faSearch,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
@@ -14,7 +21,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Select,
SelectItem,
Table,
@@ -33,6 +42,7 @@ import {
useSubscription,
useUser
} from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import {
useAddUsersToOrg,
useFetchServerStatus,
@@ -40,6 +50,7 @@ import {
useGetOrgUsers,
useUpdateOrgMembership
} from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -54,6 +65,11 @@ type Props = {
setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void;
};
enum OrgMembersOrderBy {
Name = "firstName",
Email = "email"
}
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Props) => {
const router = useRouter();
const { subscription } = useSubscription();
@@ -64,10 +80,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
const { data: roles, isLoading: isRolesLoading } = useGetOrgRoles(orgId);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const { data: serverDetails } = useFetchServerStatus();
const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId);
const { data: members = [], isLoading: isMembersLoading } = useGetOrgUsers(orgId);
const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
@@ -144,24 +158,78 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
[roles]
);
const filterdUser = useMemo(
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<OrgMembersOrderBy>(OrgMembersOrderBy.Name, { initPerPage: 20 });
const filteredUsers = useMemo(
() =>
members?.filter(
({ user: u, inviteEmail }) =>
u?.firstName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
u?.lastName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
u?.username?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
u?.email?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
inviteEmail?.includes(searchMemberFilter.toLowerCase())
),
[members, searchMemberFilter]
members
?.filter(
({ user: u, inviteEmail }) =>
u?.firstName?.toLowerCase().includes(search.toLowerCase()) ||
u?.lastName?.toLowerCase().includes(search.toLowerCase()) ||
u?.username?.toLowerCase().includes(search.toLowerCase()) ||
u?.email?.toLowerCase().includes(search.toLowerCase()) ||
inviteEmail?.toLowerCase().includes(search.toLowerCase())
)
.sort((a, b) => {
const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
let valueOne: string;
let valueTwo: string;
switch (orderBy) {
case OrgMembersOrderBy.Email:
valueOne = memberOne.user.email || memberOne.inviteEmail;
valueTwo = memberTwo.user.email || memberTwo.inviteEmail;
break;
case OrgMembersOrderBy.Name:
default:
valueOne = memberOne.user.firstName;
valueTwo = memberTwo.user.firstName;
}
if (!valueOne) return 1;
if (!valueTwo) return -1;
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
}),
[members, search, orderDirection, orderBy]
);
const handleSort = (column: OrgMembersOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
useResetPageHelper({
totalCount: filteredUsers.length,
offset,
setPage
});
return (
<div>
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
@@ -169,8 +237,46 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Username</Th>
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgMembersOrderBy.Name ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(OrgMembersOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === OrgMembersOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Email
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgMembersOrderBy.Email ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(OrgMembersOrderBy.Email)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === OrgMembersOrderBy.Email
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th>
<Th className="w-5" />
</Tr>
@@ -178,212 +284,231 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
<TBody>
{isLoading && <TableSkeleton columns={5} innerKey="org-members" />}
{!isLoading &&
filterdUser?.map(
({ user: u, inviteEmail, role, roleId, id: orgMembershipId, status, isActive }) => {
const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
const username = u?.username ?? inviteEmail ?? "-";
return (
<Tr
key={`org-membership-${orgMembershipId}`}
className="h-10 w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => router.push(`/org/${orgId}/memberships/${orgMembershipId}`)}
>
<Td className={isActive ? "" : "text-mineshaft-400"}>
{name}
{u.superAdmin && (
<Badge variant="primary" className="ml-2">
Server Admin
</Badge>
)}
</Td>
<Td className={isActive ? "" : "text-mineshaft-400"}>{username}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<>
{!isActive && (
<Button
isDisabled
className="w-40"
colorSchema="primary"
variant="outline_bg"
onClick={() => {}}
>
Suspended
</Button>
)}
{isActive && status === "accepted" && (
<Select
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
isDisabled={userId === u?.id || !isAllowed}
className="w-48 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
onRoleChange(orgMembershipId, selectedRole)
}
>
{(roles || [])
.filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)}
{isActive &&
(status === "invited" || status === "verified") &&
email &&
serverDetails?.emailConfigured && (
filteredUsers
.slice(offset, perPage * page)
.map(
({
user: u,
inviteEmail,
role,
roleId,
id: orgMembershipId,
status,
isActive
}) => {
const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
const username = u?.username ?? inviteEmail ?? "-";
return (
<Tr
key={`org-membership-${orgMembershipId}`}
className="h-10 w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => router.push(`/org/${orgId}/memberships/${orgMembershipId}`)}
>
<Td className={isActive ? "" : "text-mineshaft-400"}>
{name}
{u.superAdmin && (
<Badge variant="primary" className="ml-2">
Server Admin
</Badge>
)}
</Td>
<Td className={isActive ? "" : "text-mineshaft-400"}>{username}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<>
{!isActive && (
<Button
isDisabled={!isAllowed}
className="w-48"
isDisabled
className="w-40"
colorSchema="primary"
variant="outline_bg"
onClick={() => onResendInvite(email)}
onClick={() => {}}
>
Resend invite
Suspended
</Button>
)}
</>
)}
</OrgPermissionCan>
</Td>
<Td>
{userId !== u?.id && (
<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">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
router.push(`/org/${orgId}/memberships/${orgMembershipId}`);
}}
disabled={!isAllowed}
>
Edit User
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={
isActive
? twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
{isActive && status === "accepted" && (
<Select
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
isDisabled={userId === u?.id || !isAllowed}
className="w-48 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
onRoleChange(orgMembershipId, selectedRole)
}
onClick={async (e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
if (!isActive) {
// activate user
await updateOrgMembership({
organizationId: orgId,
membershipId: orgMembershipId,
isActive: true
});
return;
}
// deactivate user
handlePopUpOpen("deactivateMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
>
{`${isActive ? "Deactivate" : "Activate"} User`}
</DropdownMenuItem>
{(roles || [])
.filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
{isActive &&
(status === "invited" || status === "verified") &&
email &&
serverDetails?.emailConfigured && (
<Button
isDisabled={!isAllowed}
className="w-48"
colorSchema="primary"
variant="outline_bg"
onClick={() => onResendInvite(email)}
>
Resend invite
</Button>
)}
</>
)}
</OrgPermissionCan>
</Td>
<Td>
{userId !== u?.id && (
<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">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
router.push(`/org/${orgId}/memberships/${orgMembershipId}`);
}}
disabled={!isAllowed}
>
Edit User
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={
isActive
? twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
}
onClick={async (e) => {
e.stopPropagation();
handlePopUpOpen("removeMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
>
Remove User
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</Td>
</Tr>
);
}
)}
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
if (!isActive) {
// activate user
await updateOrgMembership({
organizationId: orgId,
membershipId: orgMembershipId,
isActive: true
});
return;
}
// deactivate user
handlePopUpOpen("deactivateMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
>
{`${isActive ? "Deactivate" : "Activate"} User`}
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
handlePopUpOpen("removeMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
>
Remove User
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</Td>
</Tr>
);
}
)}
</TBody>
</Table>
{!isLoading && filterdUser?.length === 0 && (
{Boolean(filteredUsers.length) && (
<Pagination
count={filteredUsers.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isMembersLoading && !filteredUsers?.length && (
<EmptyState
title={
members?.length === 0
? "No organization members found"
: "No organization members match search"
members.length
? "No organization members match search..."
: "No organization members found"
}
icon={faUsers}
icon={members.length ? faSearch : faUsers}
/>
)}
</TableContainer>

View File

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

View File

@@ -148,7 +148,8 @@ export const UserPage = withPermission(
onClick={() =>
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role
role: membership.role,
roleId: membership.roleId
})
}
disabled={!isAllowed}

View File

@@ -100,6 +100,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role,
roleId: membership.roleId,
metadata: membership.metadata
});
}}

View File

@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { SingleValue } from "react-select";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -8,21 +9,21 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
FormLabel,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem
ModalContent
} from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import { findOrgMembershipRole, isCustomOrgRole } from "@app/helpers/roles";
import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
role: z.string(),
role: z.object({ name: z.string(), slug: z.string() }),
metadata: z
.object({
key: z.string().trim().min(1),
@@ -45,7 +46,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { data: roles } = useGetOrgRoles(orgId);
const { data: roles = [] } = useGetOrgRoles(orgId);
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
@@ -66,6 +67,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
const popUpData = popUp?.orgMembership?.data as {
membershipId: string;
role: string;
roleId?: string;
metadata: { key: string; value: string }[];
};
@@ -74,12 +76,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
if (popUpData) {
reset({
role: popUpData.role,
role: findOrgMembershipRole(roles, popUpData.roleId ?? popUpData.role),
metadata: popUpData.metadata
});
} else {
reset({
role: roles[0].slug
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole!)
});
}
}, [popUp?.orgMembership?.data, roles]);
@@ -91,7 +93,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
await updateOrgMembership({
organizationId: orgId,
membershipId: popUpData.membershipId,
role,
role: role.slug,
metadata
});
@@ -123,23 +125,26 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
reset();
}}
>
<ModalContent title="Update Membership">
<ModalContent bodyClassName="overflow-visible" title="Update Membership">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="role"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Update Organization Role"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
const isCustomRole = !["admin", "member", "no-access"].includes(e);
<FilterableSelect
placeholder="Select role..."
options={roles}
onChange={(newValue) => {
const role = newValue as SingleValue<(typeof roles)[number]>;
if (!role) return;
const isCustomRole = isCustomOrgRole(role.slug);
if (isCustomRole && subscription && !subscription?.rbac) {
handlePopUpOpen("upgradePlan", {
@@ -149,16 +154,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
return;
}
onChange(e);
onChange(role);
}}
className="w-full"
>
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`st-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
value={value}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>

View File

@@ -1,6 +1,27 @@
import { faFolder } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faMagnifyingGlass,
faSearch,
faUser
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { EmptyState, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { OrgUser } from "@app/hooks/api/types";
import { useListUserGroupMemberships } from "@app/hooks/api/users/queries";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -12,31 +33,106 @@ type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void;
};
enum UserGroupsOrderBy {
Name = "name"
}
export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
const { data: groups, isLoading } = useListUserGroupMemberships(orgMembership.user.username);
const { data: groupMemberships = [], isLoading } = useListUserGroupMemberships(
orgMembership.user.username
);
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection
} = usePagination(UserGroupsOrderBy.Name, { initPerPage: 10 });
const filteredGroupMemberships = useMemo(
() =>
groupMemberships
.filter((group) => group.name.toLowerCase().includes(search.trim().toLowerCase()))
.sort((a, b) => {
const [membershipOne, membershipTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
return membershipOne.name.toLowerCase().localeCompare(membershipTwo.name.toLowerCase());
}),
[groupMemberships, orderDirection, search]
);
useResetPageHelper({
totalCount: filteredGroupMemberships.length,
offset,
setPage
});
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{groups?.map((group) => (
<UserGroupsRow
key={`user-group-${group.id}`}
group={group}
handlePopUpOpen={handlePopUpOpen}
/>
))}
</TBody>
</Table>
{!isLoading && !groups?.length && (
<EmptyState title="This user has not been assigned to any groups" icon={faFolder} />
)}
</TableContainer>
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search groups..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-full">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{filteredGroupMemberships.slice(offset, perPage * page).map((group) => (
<UserGroupsRow
key={`user-group-${group.id}`}
group={group}
handlePopUpOpen={handlePopUpOpen}
/>
))}
</TBody>
</Table>
{Boolean(filteredGroupMemberships.length) && (
<Pagination
count={filteredGroupMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredGroupMemberships?.length && (
<EmptyState
title={
groupMemberships.length
? "No groups match search..."
: "This user has not been assigned to any groups"
}
icon={groupMemberships.length ? faSearch : faUser}
/>
)}
</TableContainer>
</div>
);
};

View File

@@ -58,7 +58,7 @@ export const UserProjectRow = ({
});
}}
>
<Td>{project.name}</Td>
<Td className="max-w-0 truncate">{project.name}</Td>
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
roles.length > 1 ? ` (+${roles.length - 1})` : ""
}`}</Td>

View File

@@ -1,7 +1,18 @@
import { faFolder } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -11,7 +22,9 @@ import {
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { UserProjectRow } from "./UserProjectRow";
@@ -21,42 +34,118 @@ type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void;
};
enum UserProjectsOrderBy {
Name = "Name"
}
export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection
} = usePagination(UserProjectsOrderBy.Name, { initPerPage: 10 });
const { data: projectMemberships, isLoading } = useGetOrgMembershipProjectMemberships(
const { data: projectMemberships = [], isLoading } = useGetOrgMembershipProjectMemberships(
orgId,
membershipId
);
const filteredProjectMemberships = useMemo(
() =>
projectMemberships
?.filter((membership) =>
membership.project.name.toLowerCase().includes(search.trim().toLowerCase())
)
.sort((a, b) => {
const [membershipOne, membershipTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
return membershipOne.project.name
.toLowerCase()
.localeCompare(membershipTwo.project.name.toLowerCase());
}),
[projectMemberships, orderDirection, search]
);
useResetPageHelper({
totalCount: filteredProjectMemberships.length,
offset,
setPage
});
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Role</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
{!isLoading &&
projectMemberships?.map((membership) => {
return (
<UserProjectRow
key={`user-project-membership-${membership.id}`}
membership={membership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{!isLoading && !projectMemberships?.length && (
<EmptyState title="This user has not been assigned to any projects" icon={faFolder} />
)}
</TableContainer>
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search projects..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-2/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
{!isLoading &&
filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
return (
<UserProjectRow
key={`user-project-membership-${membership.id}`}
membership={membership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredProjectMemberships.length) && (
<Pagination
count={filteredProjectMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredProjectMemberships?.length && (
<EmptyState
title={
projectMemberships.length
? "No projects match search..."
: "This user has not been assigned to any projects"
}
icon={projectMemberships.length ? faSearch : faFolder}
/>
)}
</TableContainer>
</div>
);
};

View File

@@ -6,9 +6,14 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { isTabSection,TabSections } from "../Types";
import { IdentityTab, MembersTab,ProjectRoleListTab, ServiceTokenTab } from "./components";
import { isTabSection, TabSections } from "../Types";
import {
GroupsTab,
IdentityTab,
MembersTab,
ProjectRoleListTab,
ServiceTokenTab
} from "./components";
export const MembersPage = withProjectPermission(
() => {
@@ -26,9 +31,9 @@ export const MembersPage = withProjectPermission(
const updateSelectedTab = (tab: string) => {
router.push({
pathname: router.pathname,
query: { ...router.query, selectedTab: tab },
query: { ...router.query, selectedTab: tab }
});
}
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
@@ -37,6 +42,7 @@ export const MembersPage = withProjectPermission(
<Tabs value={activeTab} onValueChange={updateSelectedTab}>
<TabList>
<Tab value={TabSections.Member}>Users</Tab>
<Tab value={TabSections.Groups}>Groups</Tab>
<Tab value={TabSections.Identities}>
<div className="flex items-center">
<p>Machine Identities</p>
@@ -48,6 +54,9 @@ export const MembersPage = withProjectPermission(
<TabPanel value={TabSections.Member}>
<MembersTab />
</TabPanel>
<TabPanel value={TabSections.Groups}>
<GroupsTab />
</TabPanel>
<TabPanel value={TabSections.Identities}>
<IdentityTab />
</TabPanel>

View File

@@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
useAddGroupToWorkspace,
@@ -16,8 +16,8 @@ import {
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
id: z.string(),
role: z.string()
group: z.object({ id: z.string(), name: z.string() }),
role: z.object({ slug: z.string(), name: z.string() })
});
export type FormData = z.infer<typeof schema>;
@@ -27,7 +27,9 @@ type Props = {
handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void;
};
export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
// TODO: update backend to support adding multiple roles at once
const Content = ({ popUp, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
@@ -59,12 +61,12 @@ export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
resolver: zodResolver(schema)
});
const onFormSubmit = async ({ id, role }: FormData) => {
const onFormSubmit = async ({ group, role }: FormData) => {
try {
await addGroupToWorkspaceMutateAsync({
projectId: currentWorkspace?.id || "",
groupId: id,
role: role || undefined
groupId: group.id,
role: role.slug || undefined
});
reset();
@@ -82,95 +84,84 @@ export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
}
};
return filteredGroupMembershipOrgs.length ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="group"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl label="Group" errorText={error?.message} isError={Boolean(error)}>
<FilterableSelect
value={value}
onChange={onChange}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
options={filteredGroupMembershipOrgs}
placeholder="Select group..."
/>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Role"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
value={value}
onChange={onChange}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
options={roles}
placeholder="Select role..."
/>
</FormControl>
)}
/>
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.group?.data ? "Update" : "Add"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("group", false)}
>
Cancel
</Button>
</div>
</form>
) : (
<div className="flex flex-col space-y-4">
<div className="text-sm">
All groups in your organization have already been added to this project.
</div>
<Link href={`/org/${currentWorkspace?.orgId}/members`}>
<Button variant="outline_bg">Create a new group</Button>
</Link>
</div>
);
};
export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
return (
<Modal
isOpen={popUp?.group?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("group", isOpen);
reset();
}}
onOpenChange={(isOpen) => handlePopUpToggle("group", isOpen)}
>
<ModalContent title="Add Group to Project">
{filteredGroupMembershipOrgs.length ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="id"
defaultValue={filteredGroupMembershipOrgs?.[0]?.id}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Group" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mineshaft-600"
placeholder="Select group..."
>
{filteredGroupMembershipOrgs.map(({ name, id }) => (
<SelectItem value={id} key={`org-group-${id}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Role"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
placeholder="Select role..."
>
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`st-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.group?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("group", false)}
>
Cancel
</Button>
</div>
</form>
) : (
<div className="flex flex-col space-y-4">
<div className="text-sm">
All groups in your organization have already been added to this project.
</div>
<Link href={`/org/${currentWorkspace?.orgId}/members`}>
<Button variant="outline_bg">Create a new group</Button>
</Link>
</div>
)}
<ModalContent bodyClassName="overflow-visible" title="Add Group to Project">
<Content popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
</ModalContent>
</Modal>
);

View File

@@ -1,4 +1,12 @@
import { faServer, faTrash } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faMagnifyingGlass,
faSearch,
faTrash,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
@@ -6,6 +14,8 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -17,7 +27,9 @@ import {
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useListWorkspaceGroups } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { GroupRoles } from "./GroupRoles";
@@ -32,76 +44,159 @@ type Props = {
) => void;
};
enum GroupsOrderBy {
Name = "name"
}
export const GroupTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useListWorkspaceGroups(currentWorkspace?.id || "");
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
toggleOrderDirection
} = usePagination(GroupsOrderBy.Name, { initPerPage: 20 });
const { data: groupMemberships = [], isLoading } = useListWorkspaceGroups(
currentWorkspace?.id || ""
);
const filteredGroupMemberships = useMemo(() => {
const filtered = search
? groupMemberships?.filter(
({ group: { name, slug } }) =>
name.toLowerCase().includes(search.toLowerCase()) ||
slug.toLowerCase().includes(search.toLowerCase())
)
: groupMemberships;
const ordered = filtered?.sort((a, b) =>
a.group.name.toLowerCase().localeCompare(b.group.name.toLowerCase())
);
return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse();
}, [search, groupMemberships, orderBy, orderDirection]);
useResetPageHelper({
totalCount: filteredGroupMemberships.length,
offset,
setPage
});
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Role</Th>
<Th>Added on</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="project-groups" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map(({ group: { id, name }, roles, createdAt }) => {
return (
<Tr className="group h-10" key={`st-v3-${id}`}>
<Td>{name}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => (
<GroupRoles roles={roles} disableEdit={!isAllowed} groupId={id} />
)}
</ProjectPermissionCan>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => (
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content="Remove">
<IconButton
onClick={() => {
handlePopUpOpen("deleteGroup", {
id,
name
});
}}
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data?.length === 0 && (
<EmptyState title="No groups have been added to this project" icon={faServer} />
)}
</TableContainer>
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th>
<Th>Added on</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="project-groups" />}
{!isLoading &&
filteredGroupMemberships &&
filteredGroupMemberships.length > 0 &&
filteredGroupMemberships
.slice(offset, perPage * page)
.map(({ group: { id, name }, roles, createdAt }) => {
return (
<Tr className="group h-10" key={`st-v3-${id}`}>
<Td>{name}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => (
<GroupRoles roles={roles} disableEdit={!isAllowed} groupId={id} />
)}
</ProjectPermissionCan>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => (
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content="Remove">
<IconButton
onClick={() => {
handlePopUpOpen("deleteGroup", {
id,
name
});
}}
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{Boolean(filteredGroupMemberships.length) && (
<Pagination
count={filteredGroupMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredGroupMemberships?.length && (
<EmptyState
title={
groupMemberships.length
? "No project groups match search..."
: "No project groups found"
}
icon={groupMemberships.length ? faSearch : faUsers}
/>
)}
</TableContainer>
</div>
);
};

View File

@@ -181,7 +181,7 @@ export const IdentityTab = withProjectPermission(
onClick={() => handlePopUpOpen("identity")}
isDisabled={!isAllowed}
>
Add identity
Add Identity
</Button>
)}
</ProjectPermissionCan>

View File

@@ -1,12 +1,19 @@
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import Link from "next/link";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { ComboBox } from "@app/components/v2/ComboBox";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalClose,
ModalContent,
Spinner
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
useAddIdentityToWorkspace,
@@ -16,37 +23,30 @@ import {
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = yup
.object({
identity: yup.object({
id: yup.string().required("Identity id is required"),
name: yup.string().required("Identity name is required")
}),
role: yup.object({
slug: yup.string().required("role slug is required"),
name: yup.string().required("role name is required")
})
})
.required();
const schema = z.object({
identity: z.object({ name: z.string(), id: z.string() }),
role: z.object({ name: z.string(), slug: z.string() })
});
export type FormData = yup.InferType<typeof schema>;
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["identity"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void;
};
export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const Content = ({ popUp, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const organizationId = currentOrg?.id || "";
const workspaceId = currentWorkspace?.id || "";
const { data: identityMembershipOrgsData } = useGetIdentityMembershipOrgs({
organizationId,
limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will replace with combobox in separate PR
});
const { data: identityMembershipOrgsData, isLoading: isMembershipsLoading } =
useGetIdentityMembershipOrgs({
organizationId,
limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will replace with combobox in separate PR
});
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships;
const { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({
workspaceId,
@@ -54,11 +54,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
});
const identityMemberships = identityMembershipsData?.identityMemberships;
const {
data: roles,
isLoading: isRolesLoading,
isFetched: isRolesFetched
} = useGetProjectRoles(workspaceId);
const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace();
@@ -76,18 +72,11 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
control,
handleSubmit,
reset,
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: yupResolver(schema)
resolver: zodResolver(schema)
});
useEffect(() => {
if (!isRolesFetched || !roles) return;
setValue("role", { name: roles[0]?.name, slug: roles[0]?.slug });
}, [isRolesFetched, roles]);
const onFormSubmit = async ({ identity, role }: FormData) => {
try {
await addIdentityToWorkspaceMutateAsync({
@@ -125,104 +114,93 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
}
};
if (isMembershipsLoading || isRolesLoading)
return (
<div className="flex w-full items-center justify-center py-10">
<Spinner className="text-mineshaft-400" />
</div>
);
return filteredIdentityMembershipOrgs.length ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="identity"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}>
<FilterableSelect
value={value}
onChange={onChange}
placeholder="Select identity..."
options={filteredIdentityMembershipOrgs.map((membership) => membership.identity)}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Role"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
value={value}
onChange={onChange}
options={roles}
placeholder="Select role..."
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.identity?.data ? "Update" : "Add"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="flex flex-col space-y-4">
<div className="text-sm">
All identities in your organization have already been added to this project.
</div>
<Link href={`/org/${currentWorkspace?.orgId}/members`}>
<Button isDisabled={isRolesLoading} isLoading={isRolesLoading} variant="outline_bg">
Create a new identity
</Button>
</Link>
</div>
);
};
export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
return (
<Modal
isOpen={popUp?.identity?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("identity", isOpen);
reset();
}}
>
<ModalContent title="Add Identity to Project" bodyClassName="overflow-visible">
{filteredIdentityMembershipOrgs.length ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="identity"
defaultValue={{
id: filteredIdentityMembershipOrgs?.[0]?.identity?.id,
name: filteredIdentityMembershipOrgs?.[0]?.identity?.name
}}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}>
<ComboBox
className="w-full"
by="id"
value={{ id: field.value.id, name: field.value.name }}
defaultValue={{ id: field.value.id, name: field.value.name }}
onSelectChange={(value) => onChange({ id: value.id, name: value.name })}
displayValue={(el) => el.name}
onFilter={({ value }, filterQuery) =>
value.name.toLowerCase().includes(filterQuery.toLowerCase())
}
items={filteredIdentityMembershipOrgs.map(({ identity }) => ({
key: identity.id,
value: { id: identity.id, name: identity.name },
label: identity.name
}))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
defaultValue={{ name: "", slug: "" }}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Role"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<ComboBox
className="w-full"
by="slug"
value={{ slug: field.value.slug, name: field.value.name }}
defaultValue={{ slug: field.value.slug, name: field.value.name }}
onSelectChange={(value) => onChange({ slug: value.slug, name: value.name })}
displayValue={(el) => el.name}
onFilter={({ value }, filterQuery) =>
value.name.toLowerCase().includes(filterQuery.toLowerCase())
}
items={(roles || []).map(({ slug, name }) => ({
key: slug,
value: { slug, name },
label: name
}))}
/>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.identity?.data ? "Update" : "Create"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="flex flex-col space-y-4">
<div className="text-sm">
All identities in your organization have already been added to this project.
</div>
<Link href={`/org/${currentWorkspace?.orgId}/members`}>
<Button isDisabled={isRolesLoading} isLoading={isRolesLoading} variant="outline_bg">
Create a new identity
</Button>
</Link>
</div>
)}
<Content popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
</ModalContent>
</Modal>
);

View File

@@ -1,12 +1,8 @@
import { motion } from "framer-motion";
import { useWorkspace } from "@app/context";
import { GroupsSection } from "../GroupsTab/components";
import { MembersSection } from "./components";
export const MembersTab = () => {
const { currentWorkspace } = useWorkspace();
return (
<motion.div
key="panel-project-members"
@@ -16,7 +12,6 @@ export const MembersTab = () => {
exit={{ opacity: 0, translateX: 30 }}
>
<MembersSection />
{currentWorkspace?.version && currentWorkspace.version > 1 && <GroupsSection />}
</motion.div>
);
};

View File

@@ -2,24 +2,11 @@ import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import { faCheckCircle, faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FilterableSelect,
FormControl,
Modal,
ModalContent
} from "@app/components/v2";
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
useAddUsersToOrg,
@@ -33,7 +20,7 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const addMemberFormSchema = z.object({
orgMemberships: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).min(1),
projectRoleSlugs: z.array(z.string().trim().min(1)).min(1)
projectRoleSlugs: z.array(z.object({ slug: z.string().trim(), name: z.string().trim() })).min(1)
});
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
@@ -64,7 +51,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
formState: { isSubmitting, errors }
} = useForm<TAddMemberForm>({
resolver: zodResolver(addMemberFormSchema),
defaultValues: { orgMemberships: [], projectRoleSlugs: [ProjectMembershipRole.Member] }
defaultValues: { orgMemberships: [], projectRoleSlugs: [] }
});
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
@@ -94,7 +81,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
{
slug: currentWorkspace.slug,
id: currentWorkspace.id,
projectRoleSlug: projectRoleSlugs
projectRoleSlug: projectRoleSlugs.map((role) => role.slug)
}
]
});
@@ -121,9 +108,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
});
return (orgUsers || [])
.filter(({ user: u }) => !wsUserUsernames.has(u.username))
.map((member) => ({
value: member.id,
label: `${member.user.firstName} ${member.user.lastName}`
.map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({
value: id,
label:
firstName && lastName
? `${firstName} ${lastName}`
: firstName || lastName || email || inviteEmail
}));
}, [orgUsers, members]);
@@ -169,78 +159,23 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
name="projectRoleSlugs"
render={({ field }) => (
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
className="w-full"
label="Select roles"
tooltipText="Select the roles that you wish to assign to the users"
errorText={error?.message}
isError={Boolean(error)}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{roles && roles.length > 0 ? (
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
{/* eslint-disable-next-line no-nested-ternary */}
{selectedRoleSlugs.length === 1
? roles.find((role) => role.slug === selectedRoleSlugs[0])?.name
: selectedRoleSlugs.length === 0
? "Select at least one role"
: `${selectedRoleSlugs.length} roles selected`}
<FontAwesomeIcon
icon={faChevronDown}
className={twMerge("ml-2 text-xs")}
/>
</div>
) : (
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
No roles found
</div>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="thin-scrollbar z-[100] max-h-80"
>
{roles && roles.length > 0 ? (
roles.map((role) => {
const isSelected = selectedRoleSlugs.includes(role.slug);
return (
<DropdownMenuItem
onSelect={(event) => roles.length > 1 && event.preventDefault()}
onClick={() => {
if (selectedRoleSlugs.includes(String(role.slug))) {
field.onChange(
selectedRoleSlugs.filter(
(roleSlug: string) => roleSlug !== String(role.slug)
)
);
} else {
field.onChange([...selectedRoleSlugs, role.slug]);
}
}}
key={`role-slug-${role.slug}`}
icon={
isSelected ? (
<FontAwesomeIcon
icon={faCheckCircle}
className="pr-0.5 text-primary"
/>
) : (
<div className="pl-[1.01rem]" />
)
}
iconPos="left"
className="w-[28.4rem] text-sm"
>
{role.name}
</DropdownMenuItem>
);
})
) : (
<div />
)}
</DropdownMenuContent>
</DropdownMenu>
<FilterableSelect
options={roles}
placeholder="Select roles..."
value={value}
onChange={onChange}
isMulti
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>

View File

@@ -1,9 +1,12 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { useRouter } from "next/router";
import {
faArrowDown,
faArrowUp,
faClock,
faEllipsisV,
faMagnifyingGlass,
faSearch,
faTrash,
faUsers
} from "@fortawesome/free-solid-svg-icons";
@@ -18,6 +21,7 @@ import {
HoverCardTrigger,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -35,7 +39,9 @@ import {
useUser,
useWorkspace
} from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetWorkspaceUsers } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -54,9 +60,12 @@ type Props = {
) => void;
};
export const MembersTable = ({ handlePopUpOpen }: Props) => {
const [searchMemberFilter, setSearchMemberFilter] = useState("");
enum MembersOrderBy {
Name = "firstName",
Email = "email"
}
export const MembersTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { user } = useUser();
const router = useRouter();
@@ -64,26 +73,80 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
const userId = user?.id || "";
const workspaceId = currentWorkspace?.id || "";
const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, { initPerPage: 20 });
const filterdUsers = useMemo(
const { data: members = [], isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
const filteredUsers = useMemo(
() =>
members?.filter(
({ user: u, inviteEmail }) =>
u?.firstName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
u?.lastName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
u?.username?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
u?.email?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
inviteEmail?.includes(searchMemberFilter.toLowerCase())
),
[members, searchMemberFilter]
members
?.filter(
({ user: u, inviteEmail }) =>
u?.firstName?.toLowerCase().includes(search.toLowerCase()) ||
u?.lastName?.toLowerCase().includes(search.toLowerCase()) ||
u?.username?.toLowerCase().includes(search.toLowerCase()) ||
u?.email?.toLowerCase().includes(search.toLowerCase()) ||
inviteEmail?.toLowerCase().includes(search.toLowerCase())
)
.sort((a, b) => {
const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
let valueOne: string;
let valueTwo: string;
switch (orderBy) {
case MembersOrderBy.Email:
valueOne = memberOne.user.email || memberOne.inviteEmail;
valueTwo = memberTwo.user.email || memberTwo.inviteEmail;
break;
case MembersOrderBy.Name:
default:
valueOne = memberOne.user.firstName;
valueTwo = memberTwo.user.firstName;
}
if (!valueOne) return 1;
if (!valueTwo) return -1;
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
}),
[members, search, orderDirection, orderBy]
);
useResetPageHelper({
totalCount: filteredUsers.length,
offset,
setPage
});
const handleSort = (column: MembersOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
return (
<div>
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
@@ -91,8 +154,44 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Username</Th>
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={`ml-2 ${orderBy === MembersOrderBy.Name ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(MembersOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Email
<IconButton
variant="plain"
className={`ml-2 ${orderBy === MembersOrderBy.Email ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(MembersOrderBy.Email)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Email
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th>
<Th className="w-5" />
</Tr>
@@ -100,7 +199,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
<TBody>
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
{!isMembersLoading &&
filterdUsers?.map((projectMember) => {
filteredUsers.slice(offset, perPage * page).map((projectMember) => {
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-";
const email = u?.email || inviteEmail;
@@ -239,8 +338,22 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
})}
</TBody>
</Table>
{!isMembersLoading && filterdUsers?.length === 0 && (
<EmptyState title="No project members found" icon={faUsers} />
{Boolean(filteredUsers.length) && (
<Pagination
count={filteredUsers.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isMembersLoading && !filteredUsers?.length && (
<EmptyState
title={
members.length ? "No project members match search..." : "No project members found"
}
icon={members.length ? faSearch : faUsers}
/>
)}
</TableContainer>
</div>

View File

@@ -22,7 +22,11 @@ import {
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { policyDetails } from "@app/helpers/policies";
import { useCreateSecretApprovalPolicy, useListWorkspaceGroups, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
import {
useCreateSecretApprovalPolicy,
useListWorkspaceGroups,
useUpdateSecretApprovalPolicy
} from "@app/hooks/api";
import {
useCreateAccessApprovalPolicy,
useUpdateAccessApprovalPolicy
@@ -46,7 +50,11 @@ const formSchema = z
name: z.string().optional(),
secretPath: z.string().optional(),
approvals: z.number().min(1),
approvers: z.object({type: z.nativeEnum(ApproverType), id: z.string()}).array().min(1).default([]),
approvers: z
.object({ type: z.nativeEnum(ApproverType), id: z.string() })
.array()
.min(1)
.default([]),
policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel)
})
@@ -100,6 +108,8 @@ export const AccessPolicyForm = ({
const policyName = policyDetails[watch("policyType")]?.name || "Policy";
const approversRequired = watch("approvals") || 1;
const handleCreatePolicy = async (data: TFormSchema) => {
if (!projectId) return;
@@ -169,12 +179,6 @@ export const AccessPolicyForm = ({
}
};
const formatEnforcementLevel = (level: EnforcementLevel) => {
if (level === EnforcementLevel.Hard) return "Hard";
if (level === EnforcementLevel.Soft) return "Soft";
return level;
};
return (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent title={isEditMode ? `Edit ${policyName}` : "Create Policy"}>
@@ -257,14 +261,15 @@ export const AccessPolicyForm = ({
name="secretPath"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
/>
<Controller
control={control}
name="approvals"
@@ -295,9 +300,11 @@ export const AccessPolicyForm = ({
errorText={error?.message}
tooltipText="Determines the level of enforcement for required approvers of a request"
helperText={
field.value === EnforcementLevel.Hard
? "All approvers must approve the request."
: "All approvers must approve the request; however, the requester can bypass approval requirements in emergencies."
<div className="ml-1">
{field.value === EnforcementLevel.Hard
? `Hard enforcement requires at least ${approversRequired} approver(s) to approve the request.`
: `At least ${approversRequired} approver(s) must approve the request; however, the requester can bypass approval requirements in emergencies.`}
</div>
}
>
<Select
@@ -307,12 +314,8 @@ export const AccessPolicyForm = ({
>
{Object.values(EnforcementLevel).map((level) => {
return (
<SelectItem
value={level}
key={`enforcement-level-${level}`}
className="text-xs"
>
{formatEnforcementLevel(level)}
<SelectItem value={level} key={`enforcement-level-${level}`}>
<span className="capitalize">{level}</span>
</SelectItem>
);
})}
@@ -320,7 +323,12 @@ export const AccessPolicyForm = ({
</FormControl>
)}
/>
<p>Approvers</p>
<div className="mb-2">
<p>Approvers</p>
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
Select members or groups that are allowed to approve requests from this policy.
</p>
</div>
<Controller
control={control}
name="approvers"
@@ -334,7 +342,11 @@ export const AccessPolicyForm = ({
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={value?.filter((e) => e.type=== ApproverType.User).length ? `${value.filter((e) => e.type=== ApproverType.User).length} selected` : "None"}
value={
value?.filter((e) => e.type === ApproverType.User).length
? `${value.filter((e) => e.type === ApproverType.User).length} selected`
: "None"
}
className="text-left"
/>
</DropdownMenuTrigger>
@@ -347,15 +359,22 @@ export const AccessPolicyForm = ({
</DropdownMenuLabel>
{members.map(({ user }) => {
const { id: userId } = user;
const isChecked = value?.filter((el: {id: string, type: ApproverType}) => el.id === userId && el.type === ApproverType.User).length > 0;
const isChecked =
value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id === userId && el.type === ApproverType.User
).length > 0;
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el: {id: string, type: ApproverType}) => el.id !== userId && el.type !== ApproverType.User)
: [...(value || []), {id:userId, type: ApproverType.User}]
? value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id !== userId && el.type !== ApproverType.User
)
: [...(value || []), { id: userId, type: ApproverType.User }]
);
}}
key={`create-policy-members-${userId}`}
@@ -384,7 +403,13 @@ export const AccessPolicyForm = ({
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={value?.filter((e) => e.type=== ApproverType.Group).length ? `${value?.filter((e) => e.type=== ApproverType.Group).length} selected` : "None"}
value={
value?.filter((e) => e.type === ApproverType.Group).length
? `${
value?.filter((e) => e.type === ApproverType.Group).length
} selected`
: "None"
}
className="text-left"
/>
</DropdownMenuTrigger>
@@ -395,28 +420,36 @@ export const AccessPolicyForm = ({
<DropdownMenuLabel>
Select groups that are allowed to approve requests
</DropdownMenuLabel>
{groups && groups.map(({ group }) => {
const { id } = group;
const isChecked = value?.filter((el: {id: string, type: ApproverType}) => el.id === id && el.type === ApproverType.Group).length > 0;
{groups &&
groups.map(({ group }) => {
const { id } = group;
const isChecked =
value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id === id && el.type === ApproverType.Group
).length > 0;
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el: {id: string, type: ApproverType}) => el.id !== id && el.type !== ApproverType.Group)
: [...(value || []), {id, type: ApproverType.Group}]
);
}}
key={`create-policy-members-${id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{group.name}
</DropdownMenuItem>
);
})}
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id !== id && el.type !== ApproverType.Group
)
: [...(value || []), { id, type: ApproverType.Group }]
);
}}
key={`create-policy-members-${id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{group.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>

View File

@@ -6,6 +6,7 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalContent,
@@ -17,7 +18,7 @@ import { useSubscription, useWorkspace } from "@app/context";
import { useCreateSecretImport } from "@app/hooks/api";
const typeSchema = z.object({
environment: z.string().trim(),
environment: z.object({ name: z.string(), slug: z.string() }),
secretPath: z
.string()
.trim()
@@ -80,7 +81,7 @@ export const CreateSecretImportForm = ({
path: secretPath,
isReplication,
import: {
environment: importedEnv,
environment: importedEnv.slug,
path: importedSecPath
}
});
@@ -88,8 +89,9 @@ export const CreateSecretImportForm = ({
reset();
createNotification({
type: "success",
text: `Successfully linked. ${isReplication ? "Please refresh the dashboard to view changes" : ""
}`
text: `Successfully linked. ${
isReplication ? "Please refresh the dashboard to view changes" : ""
}`
});
} catch (err) {
console.error(err);
@@ -111,6 +113,7 @@ export const CreateSecretImportForm = ({
return (
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
<ModalContent
bodyClassName="overflow-visible"
title="Add Secret Link"
subTitle="To inherit secrets from another environment or folder"
>
@@ -118,21 +121,16 @@ export const CreateSecretImportForm = ({
<Controller
control={control}
name="environment"
defaultValue={environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
<FilterableSelect
options={environments}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
placeholder="Select environment..."
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
@@ -142,7 +140,7 @@ export const CreateSecretImportForm = ({
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
<SecretPathInput {...field} environment={selectedEnvironment} />
<SecretPathInput {...field} environment={selectedEnvironment?.slug} />
</FormControl>
)}
/>

View File

@@ -15,7 +15,10 @@ const formSchema = z.object({
name: z
.string()
.trim()
.regex(/^[a-zA-Z0-9-_]+$/, "Folder name cannot contain spaces. Only underscore and dashes")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Folder name can only contain letters, numbers, dashes, and underscores"
)
});
type TFormData = z.infer<typeof formSchema>;

View File

@@ -6,11 +6,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
import { Button, FormControl, Input } from "@app/components/v2";
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { getKeyValue } from "@app/helpers/parseEnvVar";
import { useCreateSecretV3, useGetWsTags } from "@app/hooks/api";
import { useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
import { SecretType } from "@app/hooks/api/types";
import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store";
@@ -50,12 +51,32 @@ export const CreateSecretForm = ({
const { closePopUp } = usePopUpAction();
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
const createWsTag = useCreateWsTag();
const { permission } = useProjectPermission();
const canReadTags = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
const { data: projectTags, isLoading: isTagsLoading } = useGetWsTags(
canReadTags ? workspaceId : ""
);
const slugSchema = z.string().trim().toLowerCase().min(1);
const createNewTag = async (slug: string) => {
// TODO: Replace with slugSchema generic
try {
const parsedSlug = slugSchema.parse(slug);
await createWsTag.mutateAsync({
workspaceID: workspaceId,
tagSlug: parsedSlug,
tagColor: ""
});
} catch {
createNotification({
type: "error",
text: "Failed to create new tag"
});
}
};
const handleFormSubmit = async ({ key, value, tags }: TFormSchema) => {
try {
await createSecretV3({
@@ -148,16 +169,18 @@ export const CreateSecretForm = ({
)
}
>
<FilterableSelect
<CreatableSelect
isMulti
className="w-full"
placeholder="Select tags to assign to secret..."
isMulti
isValidNewOption={(v) => slugSchema.safeParse(v).success}
name="tagIds"
isDisabled={!canReadTags}
isLoading={isTagsLoading && canReadTags}
options={projectTags?.map((el) => ({ label: el.slug, value: el.id }))}
value={field.value}
onChange={field.onChange}
onCreateOption={createNewTag}
/>
</FormControl>
)}

View File

@@ -1,14 +1,7 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import {
faClone,
faFileImport,
faKey,
faSearch,
faSquareCheck,
faSquareXmark
} from "@fortawesome/free-solid-svg-icons";
import { faClone, faFileImport, faSquareCheck } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@@ -16,17 +9,13 @@ import { z } from "zod";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
Checkbox,
EmptyState,
FilterableSelect,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
ModalTrigger,
Select,
SelectItem,
Skeleton,
Switch,
Tooltip
} from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
@@ -35,14 +24,17 @@ import { useDebounce } from "@app/hooks";
import { useGetProjectSecrets } from "@app/hooks/api";
const formSchema = z.object({
environment: z.string().trim(),
environment: z.object({ name: z.string(), slug: z.string() }),
secretPath: z
.string()
.trim()
.transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
),
secrets: z.record(z.string().optional().nullable())
secrets: z
.object({ key: z.string(), value: z.string().optional() })
.array()
.min(1, "Select one or more secrets to copy")
});
type TFormSchema = z.infer<typeof formSchema>;
@@ -68,7 +60,6 @@ export const CopySecretsFromBoard = ({
onToggle,
onParsedEnv
}: Props) => {
const [searchFilter, setSearchFilter] = useState("");
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
const {
@@ -80,7 +71,7 @@ export const CopySecretsFromBoard = ({
formState: { isDirty }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
defaultValues: { secretPath: "/", environment: environments?.[0]?.slug }
defaultValues: { secretPath: "/", environment: environments?.[0] }
});
const envCopySecPath = watch("secretPath");
@@ -89,7 +80,7 @@ export const CopySecretsFromBoard = ({
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
workspaceId,
environment: selectedEnvSlug,
environment: selectedEnvSlug.slug,
secretPath: debouncedEnvCopySecretPath,
options: {
enabled:
@@ -101,29 +92,22 @@ export const CopySecretsFromBoard = ({
});
useEffect(() => {
setValue("secrets", {});
setSearchFilter("");
}, [debouncedEnvCopySecretPath]);
setValue("secrets", []);
}, [debouncedEnvCopySecretPath, selectedEnvSlug]);
const handleSecSelectAll = () => {
if (secrets) {
setValue(
"secrets",
secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}),
{ shouldDirty: true }
);
setValue("secrets", secrets, { shouldDirty: true });
}
};
const handleFormSubmit = async (data: TFormSchema) => {
const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
Object.keys(data.secrets || {}).forEach((key) => {
if (data.secrets[key]) {
secretsToBePulled[key] = {
value: (shouldIncludeValues && data.secrets[key]) || "",
comments: [""]
};
}
data.secrets.forEach(({ key, value }) => {
secretsToBePulled[key] = {
value: (shouldIncludeValues && value) || "",
comments: [""]
};
});
onParsedEnv(secretsToBePulled);
onToggle(false);
@@ -136,7 +120,6 @@ export const CopySecretsFromBoard = ({
onOpenChange={(state) => {
onToggle(state);
reset();
setSearchFilter("");
}}
>
<ModalTrigger asChild>
@@ -165,6 +148,7 @@ export const CopySecretsFromBoard = ({
</div>
</ModalTrigger>
<ModalContent
bodyClassName="overflow-visible"
className="max-w-2xl"
title="Copy Secret From An Environment"
subTitle="Copy/paste secrets from other environments into this context"
@@ -176,22 +160,14 @@ export const CopySecretsFromBoard = ({
name="environment"
render={({ field: { value, onChange } }) => (
<FormControl label="Environment" isRequired className="w-1/3">
<Select
<FilterableSelect
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
defaultValue={environments?.[0]?.slug}
position="popper"
>
{environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`source-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
onChange={onChange}
options={environments}
placeholder="Select environment..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
/>
</FormControl>
)}
/>
@@ -203,7 +179,7 @@ export const CopySecretsFromBoard = ({
<SecretPathInput
{...field}
placeholder="Provide a path, default is /"
environment={selectedEnvSlug}
environment={selectedEnvSlug?.slug}
/>
</FormControl>
)}
@@ -212,72 +188,57 @@ export const CopySecretsFromBoard = ({
<div className="border-t border-mineshaft-600 pt-4">
<div className="mb-4 flex items-center justify-between">
<div>Secrets</div>
<div className="flex w-1/2 items-center space-x-2">
<Input
placeholder="Search for secret"
value={searchFilter}
</div>
<div className="flex w-full items-start gap-3">
<Controller
control={control}
name="secrets"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className="flex-1"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
placeholder={
// eslint-disable-next-line no-nested-ternary
isSecretsLoading
? "Loading secrets..."
: secrets?.length
? "Select secrets..."
: "No secrets found..."
}
isLoading={isSecretsLoading}
options={secrets}
value={value}
onChange={onChange}
isMulti
getOptionValue={(option) => option.key}
getOptionLabel={(option) => option.key}
/>
</FormControl>
)}
/>
<Tooltip content="Select All">
<IconButton
className="mt-1 h-9 w-9"
ariaLabel="Select all"
variant="outline_bg"
size="xs"
leftIcon={<FontAwesomeIcon icon={faSearch} />}
onChange={(evt) => setSearchFilter(evt.target.value)}
/>
<Tooltip content="Select All">
<IconButton
ariaLabel="Select all"
variant="outline_bg"
size="xs"
onClick={handleSecSelectAll}
>
<FontAwesomeIcon icon={faSquareCheck} size="lg" />
</IconButton>
</Tooltip>
<Tooltip content="Unselect All">
<IconButton
ariaLabel="UnSelect all"
variant="outline_bg"
size="xs"
onClick={() => reset()}
>
<FontAwesomeIcon icon={faSquareXmark} size="lg" />
</IconButton>
</Tooltip>
</div>
onClick={handleSecSelectAll}
>
<FontAwesomeIcon icon={faSquareCheck} size="lg" />
</IconButton>
</Tooltip>
</div>
{!isSecretsLoading && !secrets?.length && (
<EmptyState title="No secrets found" icon={faKey} />
)}
<div className="thin-scrollbar grid max-h-64 grid-cols-2 gap-4 overflow-auto ">
{isSecretsLoading &&
Array.apply(0, Array(2)).map((_x, i) => (
<Skeleton key={`secret-pull-loading-${i + 1}`} className="bg-mineshaft-700" />
))}
{secrets
?.filter(({ key }) => key.toLowerCase().includes(searchFilter.toLowerCase()))
?.map(({ id, key, value: secVal }) => (
<Controller
key={`pull-secret--${id}`}
control={control}
name={`secrets.${key}`}
render={({ field: { value, onChange } }) => (
<Checkbox
id={`pull-secret-${id}`}
isChecked={Boolean(value)}
onCheckedChange={(isChecked) => onChange(isChecked ? secVal : "")}
>
{key}
</Checkbox>
)}
/>
))}
</div>
<div className="mt-6 mb-4">
<Checkbox
<div className="my-6 ml-2">
<Switch
id="populate-include-value"
isChecked={shouldIncludeValues}
onCheckedChange={(isChecked) => setShouldIncludeValues(isChecked as boolean)}
>
Include secret values
</Checkbox>
</Switch>
</div>
<div className="flex items-center space-x-4">
<Button
@@ -285,7 +246,7 @@ export const CopySecretsFromBoard = ({
type="submit"
isDisabled={!isDirty}
>
Paste Secrets
Copy Secrets
</Button>
<Button variant="plain" colorSchema="secondary" onClick={() => onToggle(false)}>
Cancel

View File

@@ -1128,7 +1128,6 @@ export const SecretOverviewPage = () => {
>
<CreateSecretForm
secretPath={secretPath}
getSecretByKey={getSecretByKey}
onClose={() => handlePopUpClose("addSecretsInAllEnvs")}
/>
</ModalContent>

View File

@@ -1,21 +1,14 @@
import { ClipboardEvent } from "react";
import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
FilterableSelect,
FormControl,
FormLabel,
Input,
Tooltip
} from "@app/components/v2";
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import {
ProjectPermissionActions,
@@ -24,19 +17,14 @@ import {
useWorkspace
} from "@app/context";
import { getKeyValue } from "@app/helpers/parseEnvVar";
import {
useCreateFolder,
useCreateSecretV3,
useGetWsTags,
useUpdateSecretV3
} from "@app/hooks/api";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types";
import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
import { SecretType } from "@app/hooks/api/types";
const typeSchema = z
.object({
key: z.string().trim().min(1, "Key is required"),
value: z.string().optional(),
environments: z.record(z.boolean().optional()),
environments: z.object({ name: z.string(), slug: z.string() }).array(),
tags: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).optional()
})
.refine((data) => data.key !== undefined, {
@@ -47,22 +35,19 @@ type TFormSchema = z.infer<typeof typeSchema>;
type Props = {
secretPath?: string;
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
// modal props
onClose: () => void;
};
export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }: Props) => {
export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
const {
register,
handleSubmit,
control,
reset,
watch,
setValue,
formState: { isSubmitting, errors }
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
const newSecretKey = watch("key");
const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
@@ -71,22 +56,13 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }:
const environments = currentWorkspace?.environments || [];
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
const { mutateAsync: createFolder } = useCreateFolder();
const { data: projectTags, isLoading: isTagsLoading } = useGetWsTags(
canReadTags ? workspaceId : ""
);
const handleFormSubmit = async ({ key, value, environments: selectedEnv, tags }: TFormSchema) => {
const environmentsSelected = environments.filter(({ slug }) => selectedEnv[slug]);
const isEnvironmentsSelected = environmentsSelected.length;
if (!isEnvironmentsSelected) {
createNotification({ type: "error", text: "Select at least one environment" });
return;
}
const promises = environmentsSelected.map(async (env) => {
const promises = selectedEnv.map(async (env) => {
const environment = env.slug;
// create folder if not existing
if (secretPath !== "/") {
@@ -112,21 +88,7 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }:
}
}
const isEdit = getSecretByKey(environment, key) !== undefined;
if (isEdit) {
return {
...(await updateSecretV3({
environment,
workspaceId,
secretPath,
secretKey: key,
secretValue: value || "",
type: SecretType.Shared,
tagIds: tags?.map((el) => el.value)
})),
environment
};
}
// TODO: add back ability to overwrite - need to fetch secrets by key to check for conflicts as previous method broke with pagination
return {
...(await createSecretV3({
@@ -199,6 +161,25 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }:
setValue("value", value);
};
const createWsTag = useCreateWsTag();
const slugSchema = z.string().trim().toLowerCase().min(1);
const createNewTag = async (slug: string) => {
// TODO: Replace with slugSchema generic
try {
const parsedSlug = slugSchema.parse(slug);
await createWsTag.mutateAsync({
workspaceID: workspaceId,
tagSlug: parsedSlug,
tagColor: ""
});
} catch {
createNotification({
type: "error",
text: "Failed to create new tag"
});
}
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} noValidate>
<FormControl
@@ -249,68 +230,49 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }:
)
}
>
<FilterableSelect
className="w-full"
placeholder="Select tags to assign to secrets..."
<CreatableSelect
isMulti
className="w-full"
placeholder="Select tags to assign to secret..."
isValidNewOption={(v) => slugSchema.safeParse(v).success}
name="tagIds"
isDisabled={!canReadTags}
isLoading={isTagsLoading && canReadTags}
options={projectTags?.map((el) => ({ label: el.slug, value: el.id }))}
value={field.value}
onChange={field.onChange}
onCreateOption={createNewTag}
/>
</FormControl>
)}
/>
<FormLabel label="Environments" className="mb-2" />
<div className="thin-scrollbar grid max-h-64 grid-cols-3 gap-4 overflow-auto py-2">
{environments
.filter((environmentSlug) =>
permission.can(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, {
environment: environmentSlug.slug,
secretPath,
secretName: "*",
secretTags: ["*"]
})
)
)
.map((env) => {
return (
<Controller
name={`environments.${env.slug}`}
key={`secret-input-${env.slug}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`secret-input-${env.slug}`}
className="!justify-start"
>
<span className="flex w-full flex-row items-center justify-start whitespace-pre-wrap">
<span title={env.name} className="truncate">
{env.name}
</span>
<span>
{getSecretByKey(env.slug, newSecretKey) && (
<Tooltip
className="max-w-[150px]"
content="Secret already exists, and it will be overwritten"
>
<FontAwesomeIcon icon={faWarning} className="ml-1 text-yellow-400" />
</Tooltip>
)}
</span>
</span>
</Checkbox>
)}
/>
);
})}
</div>
<Controller
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl label="Environments" isError={Boolean(error)} errorText={error?.message}>
<FilterableSelect
isMulti
options={environments.filter((environment) =>
permission.can(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, {
environment: environment.slug,
secretPath,
secretName: "*",
secretTags: ["*"]
})
)
)}
value={value}
onChange={onChange}
placeholder="Select environments to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
/>
</FormControl>
)}
name="environments"
/>
<div className="mt-7 flex items-center">
<Button
isDisabled={isSubmitting}

View File

@@ -19,7 +19,6 @@ import { SecretTagsTable } from "./SecretTagsTable";
type DeleteModalData = { name: string; id: string };
export const SecretTagsSection = (): JSX.Element => {
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
"CreateSecretTag",
"deleteTagConfirmation"
@@ -65,7 +64,7 @@ export const SecretTagsSection = (): JSX.Element => {
}}
isDisabled={!isAllowed}
>
Create tag
Create Tag
</Button>
)}
</ProjectPermissionCan>

View File

@@ -1,10 +1,20 @@
import { faTags, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faMagnifyingGlass,
faSearch,
faTag,
faTrashCan
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -15,7 +25,9 @@ import {
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetWsTags } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -31,59 +43,124 @@ type Props = {
) => void;
};
enum TagsOrderBy {
Slug = "slug"
}
export const SecretTagsTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useGetWsTags(currentWorkspace?.id ?? "");
const { data: tags = [], isLoading } = useGetWsTags(currentWorkspace?.id ?? "");
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection
} = usePagination(TagsOrderBy.Slug, { initPerPage: 10 });
const filteredTags = useMemo(
() =>
tags
.filter((tag) => tag.slug.toLowerCase().includes(search.trim().toLowerCase()))
.sort((a, b) => {
const [tagOne, tagTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
return tagOne.slug.toLowerCase().localeCompare(tagTwo.slug.toLowerCase());
}),
[tags, orderDirection, search]
);
useResetPageHelper({
totalCount: filteredTags.length,
offset,
setPage
});
return (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Slug</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="secret-tags" />}
{!isLoading &&
data &&
data.map(({ id, slug }) => (
<Tr key={id}>
<Td>{slug}</Td>
<Td className="flex items-center justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Tags}
>
{(isAllowed) => (
<IconButton
onClick={() =>
handlePopUpOpen("deleteTagConfirmation", {
name: slug,
id
})
}
colorSchema="danger"
ariaLabel="update"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
))}
{!isLoading && data && data?.length === 0 && (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search tags..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Td colSpan={3}>
<EmptyState title="No secret tags found" icon={faTags} />
</Td>
<Th className="w-full">
<div className="flex items-center">
Slug
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th aria-label="button" />
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="secret-tags" />}
{!isLoading &&
filteredTags.slice(offset, perPage * page).map(({ id, slug }) => (
<Tr key={id}>
<Td>{slug}</Td>
<Td className="flex items-center justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Tags}
>
{(isAllowed) => (
<IconButton
onClick={() =>
handlePopUpOpen("deleteTagConfirmation", {
name: slug,
id
})
}
size="xs"
colorSchema="danger"
ariaLabel="update"
variant="plain"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
))}
</TBody>
</Table>
{Boolean(filteredTags.length) && (
<Pagination
count={filteredTags.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredTags?.length && (
<EmptyState
title={tags.length ? "No tags match search..." : "No tags found for project"}
icon={tags.length ? faSearch : faTag}
/>
)}
</TableContainer>
</div>
);
};