Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
Loading