Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce UI for Custom Roles #2512

Merged
merged 44 commits into from
May 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
fac854a
Add policies layer in between roles and permissions
jdorn May 8, 2024
5c4e782
Define policies, role->policies map, and policy->permissions map
jdorn May 8, 2024
b57773b
Hook up new policy-based roles to existing permissions flow
jdorn May 9, 2024
b2e6d92
Add new fields to OrganizationModel
jdorn May 9, 2024
818657e
Remove test suite (part of different PR)
jdorn May 9, 2024
1788cb1
Add isRoleValid checks throughout back-end
jdorn May 9, 2024
7450d68
Merges in main and resolves conflict.
mknowlton89 May 10, 2024
2d4950f
Fixes broken import paths
mknowlton89 May 10, 2024
e430fd9
Merge remote-tracking branch 'origin/main' into permission-policies
jdorn May 13, 2024
7895224
Fix tests, add metadata about policies
jdorn May 13, 2024
4b40c2f
Move isRoleValid and areProjectRolesValid to shared
jdorn May 13, 2024
5f133b4
Helper function to get default role
jdorn May 13, 2024
9f950ac
Move permission constants to new file, add displayName to policies
jdorn May 13, 2024
4a23b7f
Remove old permission-constants file that's not being used
jdorn May 13, 2024
4e29865
Change getRoles logic to match design mocks
jdorn May 13, 2024
db8b73a
Use getDefaultRole helper throughout codebase
jdorn May 13, 2024
f9e31a7
Fix type export
jdorn May 13, 2024
974af56
Add new permission, commercial feature, and back-end routes for manag…
jdorn May 13, 2024
160fcca
Merges in main and resolves conflicts.
mknowlton89 May 13, 2024
9bebefb
Fixes lint issues and updates policies.
mknowlton89 May 13, 2024
b8d0fed
Adds missing 'manageSDKWebhooks' permission to SDKConnectionsFullAcce…
mknowlton89 May 13, 2024
b641a41
Introduce UI for Custom Roles
mknowlton89 May 13, 2024
a505a17
Merge branch 'permission-policies' into mk/custom-roles-ui
mknowlton89 May 13, 2024
b40720c
Adds basic logic for creating, updating, duplicating, and deleting roles
mknowlton89 May 14, 2024
a99c467
Gets basic flow working a bit better and formats the role dropdown wh…
mknowlton89 May 14, 2024
be07356
Merges in main and resolves import conflict.
mknowlton89 May 14, 2024
8a0d605
Removes FactFiltersFullAccess policy
mknowlton89 May 14, 2024
bedc5b8
Merge branch 'permission-policies' into mk/custom-roles-ui
mknowlton89 May 14, 2024
603f088
Changes to improve the duplicate role flow.
mknowlton89 May 14, 2024
2787e19
Improves UI around role selector, adds wrapper to reduce copy/pasted …
mknowlton89 May 14, 2024
bf34a4c
Cleans up RoleList a bit
mknowlton89 May 15, 2024
2d05fa2
Optimizations to improve readability
mknowlton89 May 15, 2024
4a5db5e
Small UI tweaks to match the mocks more closely
mknowlton89 May 15, 2024
eb4d9e1
Hides the Create Custom Role link if the org doesn't have the feature…
mknowlton89 May 15, 2024
eb54191
Adds logic to revert back to collaborator role if org has custom role…
mknowlton89 May 15, 2024
d58484e
Merge branch 'main' into permission-policies
mknowlton89 May 15, 2024
0afe3be
Merge branch 'permission-policies' into mk/custom-roles-ui
mknowlton89 May 15, 2024
ba8c875
Increases role.description limit to 100 characters and ensures we're …
mknowlton89 May 15, 2024
3053445
Addresses Jeremys comments
mknowlton89 May 16, 2024
28ce4b7
merging in main and resolving conflicts
mknowlton89 May 16, 2024
fd08e68
Merging in one last conflict
mknowlton89 May 16, 2024
094fae8
Removes duplicate commercial feature on enterprise plan
mknowlton89 May 16, 2024
0d3131a
Updates the default role selector to match the single role selector.
mknowlton89 May 16, 2024
40d019f
Updates the permission check to actually call the function.
mknowlton89 May 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/back-end/src/models/OrganizationModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,12 @@ export async function removeCustomRole(
teams: TeamInterface[],
id: string
) {
// Make sure the id isn't the org's default
if (org.settings?.defaultRole?.role === id) {
throw new Error(
"Cannot delete role. This role is set as the organization's default role."
);
}
// Make sure no members, invites, pending members, or teams are using the role
if (org.members.some((m) => usingRole(m, id))) {
throw new Error("Role is currently being used by at least one member");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,7 @@ export async function getOrganization(req: AuthRequest, res: Response) {
freeTrialDate: org.freeTrialDate,
discountCode: org.discountCode || "",
slackTeam: connections?.slack?.team,
customRoles: org.customRoles,
settings: {
...settings,
attributeSchema: filteredAttributes,
Expand Down
16 changes: 15 additions & 1 deletion packages/back-end/src/util/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
DEFAULT_SEQUENTIAL_TESTING_TUNING_PARAMETER,
DEFAULT_STATS_ENGINE,
} from "shared/constants";
import { getDefaultRole } from "shared/permissions";
import { RESERVED_ROLE_IDS, getDefaultRole } from "shared/permissions";
import { accountFeatures, getAccountPlan } from "enterprise";
import { LegacyReportInterface, ReportInterface } from "@back-end/types/report";
import { SdkWebHookLogDocument } from "../models/SdkWebhookLogModel";
import { LegacyMetricInterface, MetricInterface } from "../../types/metric";
Expand Down Expand Up @@ -388,6 +389,7 @@ export function upgradeOrganizationDoc(
doc: OrganizationInterface
): OrganizationInterface {
const org = cloneDeep(doc);
const commercialFeatures = [...accountFeatures[getAccountPlan(org)]];

// Add settings from config.json
const configSettings = getConfigOrganizationSettings();
Expand All @@ -409,6 +411,18 @@ export function upgradeOrganizationDoc(
// Add a default role if one doesn't exist
if (!org.settings.defaultRole) {
org.settings.defaultRole = getDefaultRole(org);
} else {
// if the defaultRole is a custom role and the org no longer has that feature, default to collaborator
if (
!RESERVED_ROLE_IDS.includes(org.settings.defaultRole.role) &&
!commercialFeatures.includes("custom-roles")
) {
org.settings.defaultRole = {
role: "collaborator",
environments: [],
limitAccessByEnvironment: false,
};
}
}

// Default attribute schema for backwards compatibility
Expand Down
4 changes: 3 additions & 1 deletion packages/front-end/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface Props
children: ReactNode;
loading?: boolean;
stopPropagation?: boolean;
loadingCta?: string;
}

const Button: FC<Props> = ({
Expand All @@ -31,6 +32,7 @@ const Button: FC<Props> = ({
disabled,
loading: _externalLoading,
stopPropagation,
loadingCta = "Loading",
...otherProps
}) => {
const [_internalLoading, setLoading] = useState(false);
Expand Down Expand Up @@ -64,7 +66,7 @@ const Button: FC<Props> = ({
>
{loading ? (
<>
<LoadingSpinner /> Loading
<LoadingSpinner /> {loadingCta}
</>
) : (
children
Expand Down
2 changes: 1 addition & 1 deletion packages/front-end/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ const navlinks: SidebarLinkProps[] = [
permissionsUtils.canManageOrgSettings(),
},
{
name: "Team",
name: "Members",
href: "/settings/team",
path: /^settings\/team/,
filter: ({ permissionsUtils }) => permissionsUtils.canManageTeam(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ const InviteModal: FC<{ mutate: () => void; close: () => void }> = ({
header="Invite Member"
open={true}
cta="Invite"
size="lg"
closeCta={
successfulInvites.length || failedInvites.length ? "Close" : "Cancel"
}
Expand Down
54 changes: 40 additions & 14 deletions packages/front-end/components/Settings/Team/SingleRoleSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ReactNode, useMemo } from "react";
import { MemberRoleInfo } from "back-end/types/organization";
import uniqid from "uniqid";
import { roleSupportsEnvLimit } from "shared/permissions";
import { RESERVED_ROLE_IDS, roleSupportsEnvLimit } from "shared/permissions";
import { useUser } from "@/services/UserContext";
import { useEnvironments } from "@/services/features";
import MultiSelectField from "@/components/Forms/MultiSelectField";
Expand All @@ -24,6 +24,7 @@ export default function SingleRoleSelector({
}) {
const { roles, hasCommercialFeature, organization } = useUser();
const hasFeature = hasCommercialFeature("advanced-permissions");
const hasCustomRolesFeature = hasCommercialFeature("custom-roles");

const isNoAccessRoleEnabled = hasCommercialFeature("no-access-role");

Expand All @@ -33,10 +34,42 @@ export default function SingleRoleSelector({
roleOptions = roles.filter((r) => r.id !== "noaccess");
}

if (!includeAdminRole) {
roleOptions = roleOptions.filter((r) => r.id !== "admin");
}

const standardOptions: { label: string; value: string }[] = [];
const customOptions: { label: string; value: string }[] = [];

roleOptions.forEach((r) => {
if (RESERVED_ROLE_IDS.includes(r.id)) {
standardOptions.push({ label: r.id, value: r.id });
} else {
if (hasCustomRolesFeature) {
customOptions.push({ label: r.id, value: r.id });
}
}
});

const groupedOptions = [
{
label: "Standard",
options: standardOptions,
},
{
label: "Custom",
options: customOptions,
},
];

const availableEnvs = useEnvironments();

const id = useMemo(() => uniqid(), []);

const formatGroupLabel = (data) => (
<div className={data.label === "Custom" ? "border-bottom my-3" : ""}></div>
);

return (
<div>
<SelectField
Expand All @@ -48,23 +81,16 @@ export default function SingleRoleSelector({
role,
});
}}
options={roleOptions
.filter((r) => includeAdminRole || r.id !== "admin")
.map((r) => ({
label: r.id,
value: r.id,
}))}
options={groupedOptions}
sort={false}
formatGroupLabel={formatGroupLabel}
formatOptionLabel={(value) => {
const r = roles.find((r) => r.id === value.label);
if (!r) return <strong>{value.label}</strong>;
return (
<div className="d-flex align-items-center">
{/* @ts-expect-error TS(2532) If you come across this, please fix it!: Object is possibly 'undefined'. */}
<strong style={{ width: 110 }}>{r.id}</strong>
<small className="ml-2">
{/* @ts-expect-error TS(2532) If you come across this, please fix it!: Object is possibly 'undefined'. */}
<em>{r.description}</em>
</small>
<div>
<strong className="pr-2">{r.id}.</strong>
{r.description}
</div>
);
}}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { getDefaultRole } from "shared/permissions";
import { RESERVED_ROLE_IDS, getDefaultRole } from "shared/permissions";
import Button from "@/components/Button";
import SelectField from "@/components/Forms/SelectField";
import { useUser } from "@/services/UserContext";
Expand All @@ -11,6 +11,29 @@ export default function UpdateDefaultRoleForm() {
const [defaultRoleError, setDefaultRoleError] = useState<string | null>(null);

const { apiCall } = useAuth();
const roleOptions = [...roles];

const standardOptions: { label: string; value: string }[] = [];
const customOptions: { label: string; value: string }[] = [];

roleOptions.forEach((r) => {
if (RESERVED_ROLE_IDS.includes(r.id)) {
standardOptions.push({ label: r.id, value: r.id });
} else {
customOptions.push({ label: r.id, value: r.id });
}
});

const groupedOptions = [
{
label: "Standard",
options: standardOptions,
},
{
label: "Custom",
options: customOptions,
},
];

const form = useForm({
defaultValues: {
Expand All @@ -36,6 +59,11 @@ export default function UpdateDefaultRoleForm() {
setDefaultRoleError(e.message);
}
});

const formatGroupLabel = (data) => (
<div className={data.label === "Custom" ? "border-bottom my-3" : ""}></div>
);

return (
<div className="bg-white p-3 border mt-5 mb-5">
<div className="row">
Expand All @@ -50,20 +78,16 @@ export default function UpdateDefaultRoleForm() {
onChange={async (role: string) => {
form.setValue("defaultRole", role);
}}
options={roles.map((r) => ({
label: r.id,
value: r.id,
}))}
options={groupedOptions}
sort={false}
formatGroupLabel={formatGroupLabel}
formatOptionLabel={(value) => {
const r = roles.find((r) => r.id === value.value);
if (!r) return value.label;
const r = roles.find((r) => r.id === value.label);
if (!r) return <strong>{value.label}</strong>;
return (
<div className="d-flex align-items-center">
<strong style={{ width: 110 }}>{r.id}</strong>
<small className="ml-2">
<em>{r.description}</em>
</small>
<div>
<strong className="pr-2">{r.id}.</strong>
{r.description}
</div>
);
}}
Expand Down