Skip to content

Commit

Permalink
Non-project repository starting restrictions (#20234)
Browse files Browse the repository at this point in the history
* add proto

* codegen

* impl

* WIP UI

* make it work

* Make it work

* Empty state

* Update copies (thx Fernando!)

* Fix tip flexbox

* fix newline for role restriction empty state

* When arbitrary repos are restricted, don't suggest them
  • Loading branch information
filiptronicek authored Sep 27, 2024
1 parent 41f47c8 commit 7095780
Show file tree
Hide file tree
Showing 19 changed files with 3,396 additions and 816 deletions.
20 changes: 5 additions & 15 deletions components/dashboard/src/admin/TeamDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { Team, TeamMemberInfo, TeamMemberRole } from "@gitpod/gitpod-protocol";
import { Team, TeamMemberInfo, TeamMemberRole, VALID_ORG_MEMBER_ROLES } from "@gitpod/gitpod-protocol";
import { getGitpodService } from "../service/service";
import { Item, ItemField, ItemsList } from "../components/ItemsList";
import DropDown from "../components/DropDown";
Expand Down Expand Up @@ -205,20 +205,10 @@ export default function TeamDetail(props: { team: Team }) {
<DropDown
customClasses="w-32"
activeEntry={m.role}
entries={[
{
title: "owner",
onClick: () => setTeamMemberRole(m.userId, "owner"),
},
{
title: "member",
onClick: () => setTeamMemberRole(m.userId, "member"),
},
{
title: "collaborator",
onClick: () => setTeamMemberRole(m.userId, "collaborator"),
},
]}
entries={VALID_ORG_MEMBER_ROLES.map((role) => ({
title: role,
onClick: () => setTeamMemberRole(m.userId, role),
}))}
/>
</span>
</ItemField>
Expand Down
160 changes: 160 additions & 0 deletions components/dashboard/src/components/OrgMemberPermissionsOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { useState } from "react";
import { LoadingButton } from "@podkit/buttons/LoadingButton";
import { Button } from "@podkit/buttons/Button";
import { SwitchInputField } from "@podkit/switch/Switch";
import { cn } from "@podkit/lib/cn";
import { UserIcon } from "lucide-react";
import { UseMutationResult } from "@tanstack/react-query";
import { AllowedWorkspaceClass } from "../data/workspaces/workspace-classes-query";
import { useToast } from "./toasts/Toasts";
import Modal, { ModalBaseFooter, ModalBody, ModalHeader } from "./Modal";
import { LoadingState } from "@podkit/loading/LoadingState";
import { VALID_ORG_MEMBER_ROLES } from "@gitpod/gitpod-protocol";
import { OrganizationPermission, RoleRestrictionEntry } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
import { PlainMessage } from "@bufbuild/protobuf";
import { PublicAPIConverter } from "@gitpod/public-api-common/lib/public-api-converter";

const converter = new PublicAPIConverter();

interface WorkspaceClassesOptionsProps {
roleRestrictions: RoleRestrictionEntry[];
defaultClass?: string;
className?: string;
}

export const OrgMemberPermissionRestrictionsOptions = ({
roleRestrictions,
className,
}: WorkspaceClassesOptionsProps) => {
const rolesRestrictingArbitraryRepositories = roleRestrictions.filter((entry) =>
entry.permissions.includes(OrganizationPermission.START_ARBITRARY_REPOS),
);
const rolesAllowedToOpenArbitraryRepositories = VALID_ORG_MEMBER_ROLES.filter(
(role) =>
!rolesRestrictingArbitraryRepositories.some((entry) => entry.role === converter.toOrgMemberRole(role)),
);

if (rolesAllowedToOpenArbitraryRepositories.length === 0) {
return <div>Nobody in the organization can open repositories that are not imported</div>;
}

return (
<div className={cn("space-y-2", className)}>
{rolesAllowedToOpenArbitraryRepositories.map((entry) => (
<div className="flex gap-2 items-center">
<UserIcon size={20} />
<div>
<span className="font-medium text-pk-content-primary capitalize">{entry}</span>
</div>
</div>
))}
</div>
);
};

export type OrganizationRoleRestrictionModalProps = {
isLoading: boolean;
defaultClass?: string;
roleRestrictions: RoleRestrictionEntry[];
showSetDefaultButton: boolean;
showSwitchTitle: boolean;

allowedClasses: AllowedWorkspaceClass[];
updateMutation: UseMutationResult<void, Error, { roleRestrictions: PlainMessage<RoleRestrictionEntry>[] }>;

onClose: () => void;
};

export const OrganizationRoleRestrictionModal = ({
onClose,
updateMutation,
showSetDefaultButton,
showSwitchTitle,
...props
}: OrganizationRoleRestrictionModalProps) => {
const [restrictedRoles, setRestrictedClasses] = useState(
props.roleRestrictions
.filter((entry) => entry.permissions.includes(OrganizationPermission.START_ARBITRARY_REPOS))
.map((entry) => converter.fromOrgMemberRole(entry.role)),
);

const { toast } = useToast();

const handleUpdate = async () => {
updateMutation.mutate(
{
roleRestrictions: restrictedRoles.map((role) => {
return {
role: converter.toOrgMemberRole(role),
permissions: [OrganizationPermission.START_ARBITRARY_REPOS],
};
}),
},
{
onSuccess: () => {
toast({ message: "Role restrictions updated" });
onClose();
},
},
);
};

return (
<Modal visible onClose={onClose} onSubmit={handleUpdate}>
<ModalHeader>Allow roles to start workspaces from non-imported repos</ModalHeader>
<ModalBody>
{props.isLoading ? (
<LoadingState />
) : (
VALID_ORG_MEMBER_ROLES.map((role) => (
<OrganizationRoleRestrictionSwitch
role={role}
checked={!restrictedRoles.includes(role)}
onCheckedChange={(checked) => {
console.log(role, { checked });
if (!checked) {
setRestrictedClasses((prev) => [...prev, role]);
} else {
setRestrictedClasses((prev) => prev.filter((r) => r !== role));
}
}}
/>
))
)}
</ModalBody>
<ModalBaseFooter className="justify-between">
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<LoadingButton disabled={props.isLoading} type="submit" loading={updateMutation.isLoading}>
Save
</LoadingButton>
</div>
</ModalBaseFooter>
</Modal>
);
};

interface OrganizationRoleRestrictionSwitchProps {
role: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}
const OrganizationRoleRestrictionSwitch = ({
role,
checked,
onCheckedChange,
}: OrganizationRoleRestrictionSwitchProps) => {
return (
<div className={cn("flex w-full capitalize justify-between items-center mt-2")}>
<SwitchInputField key={role} id={role} label={role} checked={checked} onCheckedChange={onCheckedChange} />
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function useIsOwner(): boolean {
return role === OrganizationRole.OWNER;
}

function useMemberRole(): OrganizationRole {
export function useMemberRole(): OrganizationRole {
const user = useCurrentUser();
const members = useListOrganizationMembers();
return useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type UpdateOrganizationSettingsArgs = Partial<
| "restrictedEditorNames"
| "defaultRole"
| "timeoutSettings"
| "roleRestrictions"
>
>;

Expand All @@ -41,6 +42,7 @@ export const useUpdateOrgSettingsMutation = () => {
restrictedEditorNames,
defaultRole,
timeoutSettings,
roleRestrictions,
}) => {
const settings = await organizationClient.updateOrganizationSettings({
organizationId: teamId,
Expand All @@ -53,6 +55,8 @@ export const useUpdateOrgSettingsMutation = () => {
updateRestrictedEditorNames: !!restrictedEditorNames,
defaultRole,
timeoutSettings,
roleRestrictions,
updateRoleRestrictions: !!roleRestrictions,
});
return settings.settings!;
},
Expand Down
21 changes: 20 additions & 1 deletion components/dashboard/src/service/json-rpc-organization-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
import { getGitpodService } from "./service";
import { converter } from "./public-api";
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { OrgMemberRole } from "@gitpod/gitpod-protocol";
import { OrgMemberRole, RoleRestrictions } from "@gitpod/gitpod-protocol";

export class JsonRpcOrganizationClient implements PromiseClient<typeof OrganizationService> {
async createOrganization(
Expand Down Expand Up @@ -251,13 +251,32 @@ export class JsonRpcOrganizationClient implements PromiseClient<typeof Organizat
"updateRestrictedEditorNames is required to be true to update restrictedEditorNames",
);
}
const roleRestrictions: RoleRestrictions = {};
if (request.updateRoleRestrictions) {
for (const roleRestriction of request?.roleRestrictions ?? []) {
if (!roleRestriction.role) {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "role is required");
}
const role = converter.fromOrgMemberRole(roleRestriction.role);
const permissions = roleRestriction?.permissions?.map((p) => converter.fromOrganizationPermission(p));

roleRestrictions[role] = permissions;
}
} else if (request.roleRestrictions && Object.keys(request.roleRestrictions).length > 0) {
throw new ApplicationError(
ErrorCodes.BAD_REQUEST,
"updateRoleRestrictions is required to be true to update roleRestrictions",
);
}

await getGitpodService().server.updateOrgSettings(request.organizationId, {
...update,
defaultRole: request.defaultRole as OrgMemberRole,
timeoutSettings: {
inactivity: converter.toDurationString(request.timeoutSettings?.inactivity),
denyUserTimeouts: request.timeoutSettings?.denyUserTimeouts,
},
roleRestrictions,
});
return new UpdateOrganizationSettingsResponse();
}
Expand Down
77 changes: 77 additions & 0 deletions components/dashboard/src/teams/TeamPolicies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ import { Link } from "react-router-dom";
import { InputField } from "../components/forms/InputField";
import { TextInput } from "../components/forms/TextInputField";
import { LoadingButton } from "@podkit/buttons/LoadingButton";
import {
OrganizationRoleRestrictionModal,
OrganizationRoleRestrictionModalProps,
OrgMemberPermissionRestrictionsOptions,
} from "../components/OrgMemberPermissionsOptions";
import { LightbulbIcon } from "lucide-react";

export default function TeamPoliciesPage() {
useDocumentTitle("Organization Settings - Policies");
Expand Down Expand Up @@ -219,6 +225,12 @@ export default function TeamPoliciesPage() {
settings={settings}
handleUpdateTeamSettings={handleUpdateTeamSettings}
/>

<RolePermissionsRestrictions
settings={settings}
isOwner={isOwner}
handleUpdateTeamSettings={handleUpdateTeamSettings}
/>
</div>
</OrgSettingsPage>
</>
Expand Down Expand Up @@ -314,6 +326,71 @@ const OrgWorkspaceClassesOptions = ({
);
};

type RolePermissionsRestrictionsProps = {
settings: OrganizationSettings | undefined;
isOwner: boolean;
handleUpdateTeamSettings: (
newSettings: Partial<PlainMessage<OrganizationSettings>>,
options?: { throwMutateError?: boolean },
) => Promise<void>;
};

const RolePermissionsRestrictions = ({
settings,
isOwner,
handleUpdateTeamSettings,
}: RolePermissionsRestrictionsProps) => {
const [showModal, setShowModal] = useState(false);

const updateMutation: OrganizationRoleRestrictionModalProps["updateMutation"] = useMutation({
mutationFn: async ({ roleRestrictions }) => {
await handleUpdateTeamSettings({ roleRestrictions }, { throwMutateError: true });
},
});

return (
<ConfigurationSettingsField>
<Heading3>Roles allowed to start workspaces from non-imported repos</Heading3>
<Subheading className="mb-2">
Restrict specific roles from initiating workspaces using non-imported repositories. This setting
requires <span className="font-medium">Owner</span> permissions to modify.
<br />
<span className="flex flex-row items-center gap-1 my-2">
<LightbulbIcon size={20} />{" "}
<span>
Tip: Imported repositories are those listed under{" "}
<Link to={"/repositories"} className="gp-link">
Repository settings
</Link>
.
</span>
</span>
</Subheading>

<OrgMemberPermissionRestrictionsOptions roleRestrictions={settings?.roleRestrictions ?? []} />

{isOwner && (
<Button className="mt-6" onClick={() => setShowModal(true)}>
Manage Permissions
</Button>
)}

{showModal && (
<OrganizationRoleRestrictionModal
isLoading={false}
defaultClass={""}
roleRestrictions={settings?.roleRestrictions ?? []}
showSetDefaultButton={false}
showSwitchTitle={false}
allowedClasses={[]}
updateMutation={updateMutation}
onClose={() => setShowModal(false)}
/>
)}
</ConfigurationSettingsField>
);
};

interface EditorOptionsProps {
settings: OrganizationSettings | undefined;
isOwner: boolean;
Expand Down
Loading

0 comments on commit 7095780

Please sign in to comment.