mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: default org membership role
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// 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<void> {
|
||||
// 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof OrganizationsSchema>;
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
56
backend/src/services/org/org-role-fns.ts
Normal file
56
backend/src/services/org/org-role-fns.ts
Normal file
@@ -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 };
|
||||
};
|
||||
@@ -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<typeof orgRoleServiceFactory>;
|
||||
|
||||
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" });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
@@ -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 (
|
||||
<Tr
|
||||
key={`role-list-${id}`}
|
||||
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
onClick={() => router.push(`/org/${orgId}/roles/${id}`)}
|
||||
>
|
||||
<Td className="max-w-md overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{name}
|
||||
<Td className="max-w-md">
|
||||
<div className="flex">
|
||||
<p className="overflow-hidden text-ellipsis whitespace-nowrap">{name}</p>
|
||||
{isDefaultOrgRole && (
|
||||
<Tooltip content="Members joining your organization will be assigned this role unless otherwise specified">
|
||||
<div>
|
||||
<Badge variant="success" className="ml-1">
|
||||
Default
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="max-w-md overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{slug}
|
||||
@@ -129,29 +185,61 @@ export const OrgRoleTable = () => {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
{!isNonMutatable && (
|
||||
{!isDefaultOrgRole && (
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Role}
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Settings}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
disabled={!isAllowed}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteRole", role);
|
||||
handleSetRoleAsDefault(slug);
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Delete Role
|
||||
Set as Default Role
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
{!isNonMutatable && (
|
||||
<Tooltip
|
||||
position="left"
|
||||
content={
|
||||
isDefaultOrgRole
|
||||
? "Cannot delete default organization membership role. Re-assign default to allow deletion."
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Role}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed && !isDefaultOrgRole
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteRole", role);
|
||||
}}
|
||||
disabled={!isAllowed || isDefaultOrgRole}
|
||||
>
|
||||
Delete Role
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
@@ -172,6 +260,11 @@ export const OrgRoleTable = () => {
|
||||
onClose={() => handlePopUpClose("deleteRole")}
|
||||
onDeleteApproved={handleRoleDelete}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<typeof formSchema>;
|
||||
|
||||
export const OrgNameChangeSection = (): JSX.Element => {
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const { permission } = useOrgPermission();
|
||||
|
||||
const { handleSubmit, control, reset } = useForm<FormData>({
|
||||
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 (
|
||||
<div className="flex h-[25.25rem] w-full items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="py-4">
|
||||
<div className="">
|
||||
@@ -92,6 +125,40 @@ export const OrgNameChangeSection = (): JSX.Element => {
|
||||
name="slug"
|
||||
/>
|
||||
</div>
|
||||
{canReadOrgRoles && (
|
||||
<div className="pb-4">
|
||||
<h2 className="text-md mb-2 text-mineshaft-100">Default Organization Member Role</h2>
|
||||
<p className="text-mineshaft-400" />
|
||||
<Controller
|
||||
defaultValue=""
|
||||
control={control}
|
||||
name="defaultMembershipRole"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
helperText="Users joining your org will be assigned this role unless otherwise specified"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="max-w-md"
|
||||
>
|
||||
<Select
|
||||
isDisabled={isRolesLoading}
|
||||
className="w-full capitalize"
|
||||
value={value}
|
||||
onValueChange={!roles?.length ? undefined : onChange}
|
||||
>
|
||||
{roles?.map((role) => {
|
||||
return (
|
||||
<SelectItem key={role.id} value={role.slug}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user