From eae5e57346c0c691f90da611affbf626c4ee3f57 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Mon, 7 Oct 2024 15:02:14 -0700 Subject: [PATCH] feat: default org membership role --- ...1007202149_default-org-membership-roles.ts | 29 ++++ backend/src/db/schemas/organizations.ts | 3 +- .../ldap-config/ldap-config-service.ts | 20 +-- .../ee/services/oidc/oidc-config-service.ts | 13 +- .../saml-config/saml-config-service.ts | 12 +- backend/src/ee/services/scim/scim-service.ts | 11 +- backend/src/server/routes/index.ts | 2 +- .../server/routes/v1/organization-router.ts | 11 +- backend/src/services/org/org-dal.ts | 1 + backend/src/services/org/org-role-fns.ts | 56 ++++++++ backend/src/services/org/org-role-service.ts | 18 ++- backend/src/services/org/org-service.ts | 16 ++- backend/src/services/org/org-types.ts | 8 +- .../src/hooks/api/organization/queries.tsx | 5 +- frontend/src/hooks/api/organization/types.ts | 2 + .../OrgRoleTabSection/OrgRoleTable.tsx | 125 +++++++++++++++--- .../OrgNameChangeSection.tsx | 89 +++++++++++-- 17 files changed, 367 insertions(+), 54 deletions(-) create mode 100644 backend/src/db/migrations/20241007202149_default-org-membership-roles.ts create mode 100644 backend/src/services/org/org-role-fns.ts diff --git a/backend/src/db/migrations/20241007202149_default-org-membership-roles.ts b/backend/src/db/migrations/20241007202149_default-org-membership-roles.ts new file mode 100644 index 000000000..d80853a8f --- /dev/null +++ b/backend/src/db/migrations/20241007202149_default-org-membership-roles.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + // org default role + if (await knex.schema.hasTable(TableName.Organization)) { + const hasDefaultRoleCol = await knex.schema.hasColumn(TableName.Organization, "defaultMembershipRole"); + + if (!hasDefaultRoleCol) { + await knex.schema.alterTable(TableName.Organization, (tb) => { + tb.string("defaultMembershipRole").notNullable().defaultTo("member"); + }); + } + } +} + +export async function down(knex: Knex): Promise { + // org default role + if (await knex.schema.hasTable(TableName.Organization)) { + const hasDefaultRoleCol = await knex.schema.hasColumn(TableName.Organization, "defaultMembershipRole"); + + if (hasDefaultRoleCol) { + await knex.schema.alterTable(TableName.Organization, (tb) => { + tb.dropColumn("defaultMembershipRole"); + }); + } + } +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index aa63423c9..7bd20d94d 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -19,7 +19,8 @@ export const OrganizationsSchema = z.object({ authEnforced: z.boolean().default(false).nullable().optional(), scimEnabled: z.boolean().default(false).nullable().optional(), kmsDefaultKeyId: z.string().uuid().nullable().optional(), - kmsEncryptedDataKey: zodBuffer.nullable().optional() + kmsEncryptedDataKey: zodBuffer.nullable().optional(), + defaultMembershipRole: z.string().default("member") }); export type TOrganizations = z.infer; diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index ad2da3e23..c8b49d5d8 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -1,14 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import jwt from "jsonwebtoken"; -import { - OrgMembershipRole, - OrgMembershipStatus, - SecretKeyEncoding, - TableName, - TLdapConfigsUpdate, - TUsers -} from "@app/db/schemas"; +import { OrgMembershipStatus, SecretKeyEncoding, TableName, TLdapConfigsUpdate, TUsers } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; @@ -28,6 +21,7 @@ import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { getDefaultOrgMembershipRoleDto } from "@app/services/org/org-role-fns"; 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"; @@ -444,11 +438,14 @@ export const ldapConfigServiceFactory = ({ { tx } ); if (!orgMembership) { + const { role, roleId } = await getDefaultOrgMembershipRoleDto(organization.defaultMembershipRole); + await orgDAL.createMembership( { userId: userAlias.userId, orgId, - role: OrgMembershipRole.Member, + role, + roleId, status: OrgMembershipStatus.Accepted, isActive: true }, @@ -529,12 +526,15 @@ export const ldapConfigServiceFactory = ({ ); if (!orgMembership) { + const { role, roleId } = await getDefaultOrgMembershipRoleDto(organization.defaultMembershipRole); + await orgMembershipDAL.create( { userId: newUser.id, inviteEmail: email, orgId, - role: OrgMembershipRole.Member, + role, + roleId, status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later isActive: true }, diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index a8589b309..74c640733 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -3,7 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import jwt from "jsonwebtoken"; import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; -import { OrgMembershipRole, OrgMembershipStatus, SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; +import { OrgMembershipStatus, SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; import { TOidcConfigsUpdate } from "@app/db/schemas/oidc-configs"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; @@ -23,6 +23,7 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { getDefaultOrgMembershipRoleDto } from "@app/services/org/org-role-fns"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -187,12 +188,15 @@ export const oidcConfigServiceFactory = ({ { tx } ); if (!orgMembership) { + const { role, roleId } = await getDefaultOrgMembershipRoleDto(organization.defaultMembershipRole); + await orgMembershipDAL.create( { userId: userAlias.userId, inviteEmail: email, orgId, - role: OrgMembershipRole.Member, + role, + roleId, status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later isActive: true }, @@ -261,12 +265,15 @@ export const oidcConfigServiceFactory = ({ ); if (!orgMembership) { + const { role, roleId } = await getDefaultOrgMembershipRoleDto(organization.defaultMembershipRole); + await orgMembershipDAL.create( { userId: newUser.id, inviteEmail: email, orgId, - role: OrgMembershipRole.Member, + role, + roleId, status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later isActive: true }, diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 0c979d023..76de6aef5 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -2,7 +2,6 @@ import { ForbiddenError } from "@casl/ability"; import jwt from "jsonwebtoken"; import { - OrgMembershipRole, OrgMembershipStatus, SecretKeyEncoding, TableName, @@ -26,6 +25,7 @@ import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { getDefaultOrgMembershipRoleDto } from "@app/services/org/org-role-fns"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -369,12 +369,15 @@ export const samlConfigServiceFactory = ({ { tx } ); if (!orgMembership) { + const { role, roleId } = await getDefaultOrgMembershipRoleDto(organization.defaultMembershipRole); + await orgMembershipDAL.create( { userId: userAlias.userId, inviteEmail: email, orgId, - role: OrgMembershipRole.Member, + role, + roleId, status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later isActive: true }, @@ -472,12 +475,15 @@ export const samlConfigServiceFactory = ({ ); if (!orgMembership) { + const { role, roleId } = await getDefaultOrgMembershipRoleDto(organization.defaultMembershipRole); + await orgMembershipDAL.create( { userId: newUser.id, inviteEmail: email, orgId, - role: OrgMembershipRole.Member, + role, + roleId, status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later isActive: true }, diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 732f37f42..4795d14d4 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -16,6 +16,7 @@ import { AuthTokenType } from "@app/services/auth/auth-type"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { deleteOrgMembershipFn } from "@app/services/org/org-fns"; +import { getDefaultOrgMembershipRoleDto } from "@app/services/org/org-role-fns"; 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"; @@ -318,12 +319,15 @@ export const scimServiceFactory = ({ ); if (!orgMembership) { + const { role, roleId } = await getDefaultOrgMembershipRoleDto(org.defaultMembershipRole); + orgMembership = await orgMembershipDAL.create( { userId: userAlias.userId, inviteEmail: email, orgId, - role: OrgMembershipRole.NoAccess, + role, + roleId, status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later isActive: true }, @@ -391,12 +395,15 @@ export const scimServiceFactory = ({ orgMembership = foundOrgMembership; if (!orgMembership) { + const { role, roleId } = await getDefaultOrgMembershipRoleDto(org.defaultMembershipRole); + orgMembership = await orgMembershipDAL.create( { userId: user.id, inviteEmail: email, orgId, - role: OrgMembershipRole.Member, + role, + roleId, status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later isActive: true }, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b8b4303dd..86b8c3e05 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -531,7 +531,7 @@ export const registerRoutes = async ( orgService, licenseService }); - const orgRoleService = orgRoleServiceFactory({ permissionService, orgRoleDAL }); + const orgRoleService = orgRoleServiceFactory({ permissionService, orgRoleDAL, orgDAL }); const superAdminService = superAdminServiceFactory({ userDAL, authService: loginService, diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index b113b9f9d..e1b4c55b0 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -1,3 +1,4 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { @@ -229,7 +230,15 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { .regex(/^[a-zA-Z0-9-]+$/, "Slug must only contain alphanumeric characters or hyphens") .optional(), authEnforced: z.boolean().optional(), - scimEnabled: z.boolean().optional() + scimEnabled: z.boolean().optional(), + defaultMembershipRoleSlug: z + .string() + .min(1) + .trim() + .refine((v) => slugify(v) === v, { + message: "Membership role must be a valid slug" + }) + .optional() }), response: { 200: z.object({ diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 0093c4f69..ced47d70d 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -370,6 +370,7 @@ export const orgDALFactory = (db: TDbClient) => { db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), db.ref("scimEnabled").withSchema(TableName.Organization), + db.ref("defaultMembershipRole").withSchema(TableName.Organization), db.ref("externalId").withSchema(TableName.UserAliases) ) .where({ isGhost: false }); diff --git a/backend/src/services/org/org-role-fns.ts b/backend/src/services/org/org-role-fns.ts new file mode 100644 index 000000000..fe55a793d --- /dev/null +++ b/backend/src/services/org/org-role-fns.ts @@ -0,0 +1,56 @@ +import { OrgMembershipRole } from "@app/db/schemas"; +import { TFeatureSet } from "@app/ee/services/license/license-types"; +import { BadRequestError } from "@app/lib/errors"; +import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; + +const RESERVED_ORG_ROLES = Object.values(OrgMembershipRole).filter((role) => role !== "custom"); + +// this is only for updating an org +export const getDefaultOrgMembershipRoleForUpdateOrg = async ({ + membershipRoleSlug, + orgRoleDAL, + plan, + orgId +}: { + orgId: string; + membershipRoleSlug: string; + orgRoleDAL: TOrgRoleDALFactory; + plan: TFeatureSet; +}) => { + const isCustomRole = !RESERVED_ORG_ROLES.includes(membershipRoleSlug as OrgMembershipRole); + + if (isCustomRole) { + // verify rbac enabled + if (!plan?.rbac) + throw new BadRequestError({ + message: + "Failed to set custom default role due to plan RBAC restriction. Upgrade plan to set custom default org membership role." + }); + + // check that custom role exists + const customRole = await orgRoleDAL.findOne({ slug: membershipRoleSlug, orgId }); + if (!customRole) throw new BadRequestError({ name: "UpdateOrg", message: "Organization role not found" }); + + // use ID for default role + return customRole.id; + } + + // not custom, use reserved slug + return membershipRoleSlug; +}; + +// this is only for creating an org membership +export const getDefaultOrgMembershipRoleDto = async ( + defaultOrgMembershipRole: string // can either be ID or reserved slug +) => { + const isCustomRole = !RESERVED_ORG_ROLES.includes(defaultOrgMembershipRole as OrgMembershipRole); + + if (isCustomRole) + return { + roleId: defaultOrgMembershipRole, + role: OrgMembershipRole.Custom + }; + + // will be reserved slug + return { roleId: undefined, role: defaultOrgMembershipRole as OrgMembershipRole }; +}; diff --git a/backend/src/services/org/org-role-service.ts b/backend/src/services/org/org-role-service.ts index 023cbeccf..99028f032 100644 --- a/backend/src/services/org/org-role-service.ts +++ b/backend/src/services/org/org-role-service.ts @@ -11,6 +11,7 @@ import { } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; import { ActorAuthMethod } from "../auth/auth-type"; import { TOrgRoleDALFactory } from "./org-role-dal"; @@ -18,11 +19,12 @@ import { TOrgRoleDALFactory } from "./org-role-dal"; type TOrgRoleServiceFactoryDep = { orgRoleDAL: TOrgRoleDALFactory; permissionService: TPermissionServiceFactory; + orgDAL: TOrgDALFactory; }; export type TOrgRoleServiceFactory = ReturnType; -export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRoleServiceFactoryDep) => { +export const orgRoleServiceFactory = ({ orgRoleDAL, orgDAL, permissionService }: TOrgRoleServiceFactoryDep) => { const createRole = async ( userId: string, orgId: string, @@ -129,6 +131,20 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol ) => { const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); + + const org = await orgDAL.findOrgById(orgId); + + if (!org) + throw new NotFoundError({ + message: "Failed to find organization" + }); + + // prevent deletion of custom role if set as default org membership role + if (org.defaultMembershipRole === roleId) + throw new BadRequestError({ + message: "Cannot delete default org membership role. Please re-assign and try again." + }); + const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId }); if (!deletedRole) throw new NotFoundError({ message: "Organization role not found", name: "Update role" }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 4d6681669..fd9b8106b 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -32,6 +32,7 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedErro import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; +import { getDefaultOrgMembershipRoleForUpdateOrg } from "@app/services/org/org-role-fns"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; @@ -264,7 +265,7 @@ export const orgServiceFactory = ({ actorOrgId, actorAuthMethod, orgId, - data: { name, slug, authEnforced, scimEnabled } + data: { name, slug, authEnforced, scimEnabled, defaultMembershipRoleSlug } }: TUpdateOrgDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); @@ -298,11 +299,22 @@ export const orgServiceFactory = ({ }); } + let defaultMembershipRole: string | undefined; + if (defaultMembershipRoleSlug) { + defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({ + membershipRoleSlug: defaultMembershipRoleSlug, + orgId, + orgRoleDAL, + plan + }); + } + const org = await orgDAL.updateById(orgId, { name, slug: slug ? slugify(slug) : undefined, authEnforced, - scimEnabled + scimEnabled, + defaultMembershipRole }); if (!org) throw new NotFoundError({ message: "Organization not found" }); return org; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 085226be0..9f1973349 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -63,7 +63,13 @@ export type TFindAllWorkspacesDTO = { }; export type TUpdateOrgDTO = { - data: Partial<{ name: string; slug: string; authEnforced: boolean; scimEnabled: boolean }>; + data: Partial<{ + name: string; + slug: string; + authEnforced: boolean; + scimEnabled: boolean; + defaultMembershipRoleSlug: string; + }>; } & TOrgPermission; export type TGetOrgGroupsDTO = TOrgPermission; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 670f39146..1de64e058 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -82,12 +82,13 @@ export const useCreateOrg = (options: { invalidate: boolean } = { invalidate: tr export const useUpdateOrg = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, UpdateOrgDTO>({ - mutationFn: ({ name, authEnforced, scimEnabled, slug, orgId }) => { + mutationFn: ({ name, authEnforced, scimEnabled, slug, orgId, defaultMembershipRoleSlug }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, authEnforced, scimEnabled, - slug + slug, + defaultMembershipRoleSlug }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 6be780b77..3d8d5474b 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -10,6 +10,7 @@ export type Organization = { orgAuthMethod: string; scimEnabled: boolean; slug: string; + defaultMembershipRole: string; }; export type UpdateOrgDTO = { @@ -18,6 +19,7 @@ export type UpdateOrgDTO = { authEnforced?: boolean; scimEnabled?: boolean; slug?: string; + defaultMembershipRoleSlug?: string; }; export type BillingDetails = { diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx index b15e0f314..227c15f86 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx @@ -6,6 +6,7 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { + Badge, Button, DeleteActionModal, DropdownMenu, @@ -19,14 +20,30 @@ import { Td, Th, THead, - Tr + Tooltip, + Tr, + UpgradePlanModal } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription +} from "@app/context"; import { usePopUp } from "@app/hooks"; -import { useDeleteOrgRole, useGetOrgRoles } from "@app/hooks/api"; +import { useDeleteOrgRole, useGetOrgRoles, useUpdateOrg } from "@app/hooks/api"; import { TOrgRole } from "@app/hooks/api/roles/types"; import { RoleModal } from "@app/views/Org/RolePage/components"; +enum OrgMembershipRole { + Admin = "admin", + Member = "member", + NoAccess = "no-access" +} + +export const isCustomOrgRole = (slug: string) => + !Object.values(OrgMembershipRole).includes(slug as OrgMembershipRole); + export const OrgRoleTable = () => { const router = useRouter(); const { currentOrg } = useOrganization(); @@ -34,12 +51,14 @@ export const OrgRoleTable = () => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "role", - "deleteRole" + "deleteRole", + "upgradePlan" ] as const); const { data: roles, isLoading: isRolesLoading } = useGetOrgRoles(orgId); - const { mutateAsync: deleteRole } = useDeleteOrgRole(); + const { mutateAsync: updateOrg } = useUpdateOrg(); + const { subscription } = useSubscription(); const handleRoleDelete = async () => { const { id } = popUp?.deleteRole?.data as TOrgRole; @@ -56,6 +75,29 @@ export const OrgRoleTable = () => { } }; + const handleSetRoleAsDefault = async (defaultMembershipRoleSlug: string) => { + const isCustomRole = isCustomOrgRole(defaultMembershipRoleSlug); + + if (isCustomRole && subscription && !subscription?.rbac) { + handlePopUpOpen("upgradePlan", { + description: "You can assign custom roles to members if you upgrade your Infisical plan." + }); + return; + } + + try { + await updateOrg({ + orgId, + defaultMembershipRoleSlug + }); + createNotification({ type: "success", text: "Successfully updated default membership role" }); + handlePopUpClose("deleteRole"); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to update default membership role" }); + } + }; + return (
@@ -90,14 +132,28 @@ export const OrgRoleTable = () => { {roles?.map((role) => { const { id, name, slug } = role; const isNonMutatable = ["owner", "admin", "member", "no-access"].includes(slug); + const isDefaultOrgRole = isCustomOrgRole(slug) + ? id === currentOrg?.defaultMembershipRole + : slug === currentOrg?.defaultMembershipRole; return ( router.push(`/org/${orgId}/roles/${id}`)} > - - {name} + +
+

{name}

+ {isDefaultOrgRole && ( + +
+ + Default + +
+
+ )} +
{slug} @@ -129,29 +185,61 @@ export const OrgRoleTable = () => { )} - {!isNonMutatable && ( + {!isDefaultOrgRole && ( {(isAllowed) => ( { e.stopPropagation(); - handlePopUpOpen("deleteRole", role); + handleSetRoleAsDefault(slug); }} - disabled={!isAllowed} > - Delete Role + Set as Default Role )} )} + {!isNonMutatable && ( + +
+ + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteRole", role); + }} + disabled={!isAllowed || isDefaultOrgRole} + > + Delete Role + + )} + +
+
+ )} @@ -172,6 +260,11 @@ export const OrgRoleTable = () => { onClose={() => handlePopUpClose("deleteRole")} onDeleteApproved={handleRoleDelete} /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + />
); }; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx index eb87d59b5..bed0afb76 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx @@ -1,13 +1,19 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Button, FormControl, Input } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; -import { useUpdateOrg } from "@app/hooks/api"; +import { Button, FormControl, Input, Select, SelectItem, Spinner } from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useOrgPermission +} from "@app/context"; +import { useGetOrgRoles, useUpdateOrg } from "@app/hooks/api"; +import { isCustomOrgRole } from "@app/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable"; const formSchema = yup.object({ name: yup @@ -19,36 +25,55 @@ const formSchema = yup.object({ .string() .matches(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens") .required() - .label("Organization Slug") + .label("Organization Slug"), + defaultMembershipRole: yup.string().required().label("Default Membership Role") }); type FormData = yup.InferType; export const OrgNameChangeSection = (): JSX.Element => { const { currentOrg } = useOrganization(); - + const { permission } = useOrgPermission(); + const { handleSubmit, control, reset } = useForm({ resolver: yupResolver(formSchema) }); const { mutateAsync, isLoading } = useUpdateOrg(); + const canReadOrgRoles = permission.can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + + const { data: roles, isLoading: isRolesLoading } = useGetOrgRoles( + currentOrg?.id!, + canReadOrgRoles + ); + const [isFormInitialized, setIsFormInitialized] = useState(false); + useEffect(() => { if (currentOrg) { reset({ name: currentOrg.name, - slug: currentOrg.slug + slug: currentOrg.slug, + ...(canReadOrgRoles && + roles?.length && { + // will always be present, can't remove role if default + defaultMembershipRole: isCustomOrgRole(currentOrg.defaultMembershipRole) + ? roles?.find((role) => currentOrg.defaultMembershipRole === role.id)?.slug! + : currentOrg.defaultMembershipRole + }) }); + setIsFormInitialized(true); } - }, [currentOrg]); + }, [currentOrg, roles]); - const onFormSubmit = async ({ name, slug }: FormData) => { + const onFormSubmit = async ({ name, slug, defaultMembershipRole }: FormData) => { try { - if (!currentOrg?.id) return; + if (!currentOrg?.id || !roles?.length) return; await mutateAsync({ orgId: currentOrg?.id, name, - slug + slug, + defaultMembershipRoleSlug: defaultMembershipRole }); createNotification({ @@ -64,6 +89,14 @@ export const OrgNameChangeSection = (): JSX.Element => { } }; + if (!isFormInitialized) { + return ( +
+ +
+ ); + } + return (
@@ -92,6 +125,40 @@ export const OrgNameChangeSection = (): JSX.Element => { name="slug" />
+ {canReadOrgRoles && ( +
+

Default Organization Member Role

+

+ ( + + + + )} + /> +

+ )} {(isAllowed) => (