Skip to content

Commit 7095780

Browse files
Non-project repository starting restrictions (#20234)
* 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
1 parent 41f47c8 commit 7095780

File tree

19 files changed

+3396
-816
lines changed

19 files changed

+3396
-816
lines changed

components/dashboard/src/admin/TeamDetail.tsx

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import dayjs from "dayjs";
88
import { useEffect, useState } from "react";
9-
import { Team, TeamMemberInfo, TeamMemberRole } from "@gitpod/gitpod-protocol";
9+
import { Team, TeamMemberInfo, TeamMemberRole, VALID_ORG_MEMBER_ROLES } from "@gitpod/gitpod-protocol";
1010
import { getGitpodService } from "../service/service";
1111
import { Item, ItemField, ItemsList } from "../components/ItemsList";
1212
import DropDown from "../components/DropDown";
@@ -205,20 +205,10 @@ export default function TeamDetail(props: { team: Team }) {
205205
<DropDown
206206
customClasses="w-32"
207207
activeEntry={m.role}
208-
entries={[
209-
{
210-
title: "owner",
211-
onClick: () => setTeamMemberRole(m.userId, "owner"),
212-
},
213-
{
214-
title: "member",
215-
onClick: () => setTeamMemberRole(m.userId, "member"),
216-
},
217-
{
218-
title: "collaborator",
219-
onClick: () => setTeamMemberRole(m.userId, "collaborator"),
220-
},
221-
]}
208+
entries={VALID_ORG_MEMBER_ROLES.map((role) => ({
209+
title: role,
210+
onClick: () => setTeamMemberRole(m.userId, role),
211+
}))}
222212
/>
223213
</span>
224214
</ItemField>
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { useState } from "react";
8+
import { LoadingButton } from "@podkit/buttons/LoadingButton";
9+
import { Button } from "@podkit/buttons/Button";
10+
import { SwitchInputField } from "@podkit/switch/Switch";
11+
import { cn } from "@podkit/lib/cn";
12+
import { UserIcon } from "lucide-react";
13+
import { UseMutationResult } from "@tanstack/react-query";
14+
import { AllowedWorkspaceClass } from "../data/workspaces/workspace-classes-query";
15+
import { useToast } from "./toasts/Toasts";
16+
import Modal, { ModalBaseFooter, ModalBody, ModalHeader } from "./Modal";
17+
import { LoadingState } from "@podkit/loading/LoadingState";
18+
import { VALID_ORG_MEMBER_ROLES } from "@gitpod/gitpod-protocol";
19+
import { OrganizationPermission, RoleRestrictionEntry } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
20+
import { PlainMessage } from "@bufbuild/protobuf";
21+
import { PublicAPIConverter } from "@gitpod/public-api-common/lib/public-api-converter";
22+
23+
const converter = new PublicAPIConverter();
24+
25+
interface WorkspaceClassesOptionsProps {
26+
roleRestrictions: RoleRestrictionEntry[];
27+
defaultClass?: string;
28+
className?: string;
29+
}
30+
31+
export const OrgMemberPermissionRestrictionsOptions = ({
32+
roleRestrictions,
33+
className,
34+
}: WorkspaceClassesOptionsProps) => {
35+
const rolesRestrictingArbitraryRepositories = roleRestrictions.filter((entry) =>
36+
entry.permissions.includes(OrganizationPermission.START_ARBITRARY_REPOS),
37+
);
38+
const rolesAllowedToOpenArbitraryRepositories = VALID_ORG_MEMBER_ROLES.filter(
39+
(role) =>
40+
!rolesRestrictingArbitraryRepositories.some((entry) => entry.role === converter.toOrgMemberRole(role)),
41+
);
42+
43+
if (rolesAllowedToOpenArbitraryRepositories.length === 0) {
44+
return <div>Nobody in the organization can open repositories that are not imported</div>;
45+
}
46+
47+
return (
48+
<div className={cn("space-y-2", className)}>
49+
{rolesAllowedToOpenArbitraryRepositories.map((entry) => (
50+
<div className="flex gap-2 items-center">
51+
<UserIcon size={20} />
52+
<div>
53+
<span className="font-medium text-pk-content-primary capitalize">{entry}</span>
54+
</div>
55+
</div>
56+
))}
57+
</div>
58+
);
59+
};
60+
61+
export type OrganizationRoleRestrictionModalProps = {
62+
isLoading: boolean;
63+
defaultClass?: string;
64+
roleRestrictions: RoleRestrictionEntry[];
65+
showSetDefaultButton: boolean;
66+
showSwitchTitle: boolean;
67+
68+
allowedClasses: AllowedWorkspaceClass[];
69+
updateMutation: UseMutationResult<void, Error, { roleRestrictions: PlainMessage<RoleRestrictionEntry>[] }>;
70+
71+
onClose: () => void;
72+
};
73+
74+
export const OrganizationRoleRestrictionModal = ({
75+
onClose,
76+
updateMutation,
77+
showSetDefaultButton,
78+
showSwitchTitle,
79+
...props
80+
}: OrganizationRoleRestrictionModalProps) => {
81+
const [restrictedRoles, setRestrictedClasses] = useState(
82+
props.roleRestrictions
83+
.filter((entry) => entry.permissions.includes(OrganizationPermission.START_ARBITRARY_REPOS))
84+
.map((entry) => converter.fromOrgMemberRole(entry.role)),
85+
);
86+
87+
const { toast } = useToast();
88+
89+
const handleUpdate = async () => {
90+
updateMutation.mutate(
91+
{
92+
roleRestrictions: restrictedRoles.map((role) => {
93+
return {
94+
role: converter.toOrgMemberRole(role),
95+
permissions: [OrganizationPermission.START_ARBITRARY_REPOS],
96+
};
97+
}),
98+
},
99+
{
100+
onSuccess: () => {
101+
toast({ message: "Role restrictions updated" });
102+
onClose();
103+
},
104+
},
105+
);
106+
};
107+
108+
return (
109+
<Modal visible onClose={onClose} onSubmit={handleUpdate}>
110+
<ModalHeader>Allow roles to start workspaces from non-imported repos</ModalHeader>
111+
<ModalBody>
112+
{props.isLoading ? (
113+
<LoadingState />
114+
) : (
115+
VALID_ORG_MEMBER_ROLES.map((role) => (
116+
<OrganizationRoleRestrictionSwitch
117+
role={role}
118+
checked={!restrictedRoles.includes(role)}
119+
onCheckedChange={(checked) => {
120+
console.log(role, { checked });
121+
if (!checked) {
122+
setRestrictedClasses((prev) => [...prev, role]);
123+
} else {
124+
setRestrictedClasses((prev) => prev.filter((r) => r !== role));
125+
}
126+
}}
127+
/>
128+
))
129+
)}
130+
</ModalBody>
131+
<ModalBaseFooter className="justify-between">
132+
<div className="flex gap-2">
133+
<Button variant="secondary" onClick={onClose}>
134+
Cancel
135+
</Button>
136+
<LoadingButton disabled={props.isLoading} type="submit" loading={updateMutation.isLoading}>
137+
Save
138+
</LoadingButton>
139+
</div>
140+
</ModalBaseFooter>
141+
</Modal>
142+
);
143+
};
144+
145+
interface OrganizationRoleRestrictionSwitchProps {
146+
role: string;
147+
checked: boolean;
148+
onCheckedChange: (checked: boolean) => void;
149+
}
150+
const OrganizationRoleRestrictionSwitch = ({
151+
role,
152+
checked,
153+
onCheckedChange,
154+
}: OrganizationRoleRestrictionSwitchProps) => {
155+
return (
156+
<div className={cn("flex w-full capitalize justify-between items-center mt-2")}>
157+
<SwitchInputField key={role} id={role} label={role} checked={checked} onCheckedChange={onCheckedChange} />
158+
</div>
159+
);
160+
};

components/dashboard/src/data/organizations/members-query.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function useIsOwner(): boolean {
4444
return role === OrganizationRole.OWNER;
4545
}
4646

47-
function useMemberRole(): OrganizationRole {
47+
export function useMemberRole(): OrganizationRole {
4848
const user = useCurrentUser();
4949
const members = useListOrganizationMembers();
5050
return useMemo(

components/dashboard/src/data/organizations/update-org-settings-mutation.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type UpdateOrganizationSettingsArgs = Partial<
2323
| "restrictedEditorNames"
2424
| "defaultRole"
2525
| "timeoutSettings"
26+
| "roleRestrictions"
2627
>
2728
>;
2829

@@ -41,6 +42,7 @@ export const useUpdateOrgSettingsMutation = () => {
4142
restrictedEditorNames,
4243
defaultRole,
4344
timeoutSettings,
45+
roleRestrictions,
4446
}) => {
4547
const settings = await organizationClient.updateOrganizationSettings({
4648
organizationId: teamId,
@@ -53,6 +55,8 @@ export const useUpdateOrgSettingsMutation = () => {
5355
updateRestrictedEditorNames: !!restrictedEditorNames,
5456
defaultRole,
5557
timeoutSettings,
58+
roleRestrictions,
59+
updateRoleRestrictions: !!roleRestrictions,
5660
});
5761
return settings.settings!;
5862
},

components/dashboard/src/service/json-rpc-organization-client.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
import { getGitpodService } from "./service";
4242
import { converter } from "./public-api";
4343
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
44-
import { OrgMemberRole } from "@gitpod/gitpod-protocol";
44+
import { OrgMemberRole, RoleRestrictions } from "@gitpod/gitpod-protocol";
4545

4646
export class JsonRpcOrganizationClient implements PromiseClient<typeof OrganizationService> {
4747
async createOrganization(
@@ -251,13 +251,32 @@ export class JsonRpcOrganizationClient implements PromiseClient<typeof Organizat
251251
"updateRestrictedEditorNames is required to be true to update restrictedEditorNames",
252252
);
253253
}
254+
const roleRestrictions: RoleRestrictions = {};
255+
if (request.updateRoleRestrictions) {
256+
for (const roleRestriction of request?.roleRestrictions ?? []) {
257+
if (!roleRestriction.role) {
258+
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "role is required");
259+
}
260+
const role = converter.fromOrgMemberRole(roleRestriction.role);
261+
const permissions = roleRestriction?.permissions?.map((p) => converter.fromOrganizationPermission(p));
262+
263+
roleRestrictions[role] = permissions;
264+
}
265+
} else if (request.roleRestrictions && Object.keys(request.roleRestrictions).length > 0) {
266+
throw new ApplicationError(
267+
ErrorCodes.BAD_REQUEST,
268+
"updateRoleRestrictions is required to be true to update roleRestrictions",
269+
);
270+
}
271+
254272
await getGitpodService().server.updateOrgSettings(request.organizationId, {
255273
...update,
256274
defaultRole: request.defaultRole as OrgMemberRole,
257275
timeoutSettings: {
258276
inactivity: converter.toDurationString(request.timeoutSettings?.inactivity),
259277
denyUserTimeouts: request.timeoutSettings?.denyUserTimeouts,
260278
},
279+
roleRestrictions,
261280
});
262281
return new UpdateOrganizationSettingsResponse();
263282
}

components/dashboard/src/teams/TeamPolicies.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ import { Link } from "react-router-dom";
3838
import { InputField } from "../components/forms/InputField";
3939
import { TextInput } from "../components/forms/TextInputField";
4040
import { LoadingButton } from "@podkit/buttons/LoadingButton";
41+
import {
42+
OrganizationRoleRestrictionModal,
43+
OrganizationRoleRestrictionModalProps,
44+
OrgMemberPermissionRestrictionsOptions,
45+
} from "../components/OrgMemberPermissionsOptions";
46+
import { LightbulbIcon } from "lucide-react";
4147

4248
export default function TeamPoliciesPage() {
4349
useDocumentTitle("Organization Settings - Policies");
@@ -219,6 +225,12 @@ export default function TeamPoliciesPage() {
219225
settings={settings}
220226
handleUpdateTeamSettings={handleUpdateTeamSettings}
221227
/>
228+
229+
<RolePermissionsRestrictions
230+
settings={settings}
231+
isOwner={isOwner}
232+
handleUpdateTeamSettings={handleUpdateTeamSettings}
233+
/>
222234
</div>
223235
</OrgSettingsPage>
224236
</>
@@ -314,6 +326,71 @@ const OrgWorkspaceClassesOptions = ({
314326
);
315327
};
316328

329+
type RolePermissionsRestrictionsProps = {
330+
settings: OrganizationSettings | undefined;
331+
isOwner: boolean;
332+
handleUpdateTeamSettings: (
333+
newSettings: Partial<PlainMessage<OrganizationSettings>>,
334+
options?: { throwMutateError?: boolean },
335+
) => Promise<void>;
336+
};
337+
338+
const RolePermissionsRestrictions = ({
339+
settings,
340+
isOwner,
341+
handleUpdateTeamSettings,
342+
}: RolePermissionsRestrictionsProps) => {
343+
const [showModal, setShowModal] = useState(false);
344+
345+
const updateMutation: OrganizationRoleRestrictionModalProps["updateMutation"] = useMutation({
346+
mutationFn: async ({ roleRestrictions }) => {
347+
await handleUpdateTeamSettings({ roleRestrictions }, { throwMutateError: true });
348+
},
349+
});
350+
351+
return (
352+
<ConfigurationSettingsField>
353+
<Heading3>Roles allowed to start workspaces from non-imported repos</Heading3>
354+
<Subheading className="mb-2">
355+
Restrict specific roles from initiating workspaces using non-imported repositories. This setting
356+
requires <span className="font-medium">Owner</span> permissions to modify.
357+
<br />
358+
<span className="flex flex-row items-center gap-1 my-2">
359+
<LightbulbIcon size={20} />{" "}
360+
<span>
361+
Tip: Imported repositories are those listed under{" "}
362+
<Link to={"/repositories"} className="gp-link">
363+
Repository settings
364+
</Link>
365+
.
366+
</span>
367+
</span>
368+
</Subheading>
369+
370+
<OrgMemberPermissionRestrictionsOptions roleRestrictions={settings?.roleRestrictions ?? []} />
371+
372+
{isOwner && (
373+
<Button className="mt-6" onClick={() => setShowModal(true)}>
374+
Manage Permissions
375+
</Button>
376+
)}
377+
378+
{showModal && (
379+
<OrganizationRoleRestrictionModal
380+
isLoading={false}
381+
defaultClass={""}
382+
roleRestrictions={settings?.roleRestrictions ?? []}
383+
showSetDefaultButton={false}
384+
showSwitchTitle={false}
385+
allowedClasses={[]}
386+
updateMutation={updateMutation}
387+
onClose={() => setShowModal(false)}
388+
/>
389+
)}
390+
</ConfigurationSettingsField>
391+
);
392+
};
393+
317394
interface EditorOptionsProps {
318395
settings: OrganizationSettings | undefined;
319396
isOwner: boolean;

0 commit comments

Comments
 (0)