Skip to content

Commit b8cdd34

Browse files
committed
WIP UI
1 parent 6c439d1 commit b8cdd34

File tree

5 files changed

+263
-15
lines changed

5 files changed

+263
-15
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: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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 { CpuIcon } 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+
interface WorkspaceClassesOptionsProps {
24+
roleRestrictions: RoleRestrictionEntry[];
25+
defaultClass?: string;
26+
className?: string;
27+
emptyState?: React.ReactNode;
28+
}
29+
30+
export const OrgMemberPermissionRestrictionsOptions = ({
31+
roleRestrictions,
32+
emptyState,
33+
className,
34+
}: WorkspaceClassesOptionsProps) => {
35+
const rolesRestrictingArbitraryRepositories = roleRestrictions.filter((entry) =>
36+
entry.permissions.includes(OrganizationPermission.START_ARBITRARY_REPOS),
37+
);
38+
if (rolesRestrictingArbitraryRepositories.length === 0) {
39+
return <>{emptyState}</>;
40+
}
41+
42+
return (
43+
<div className={cn("space-y-2", className)}>
44+
{rolesRestrictingArbitraryRepositories.map((entry) => (
45+
<div className="flex gap-2 items-center">
46+
<CpuIcon size={20} />
47+
<div>
48+
<span className="font-medium text-pk-content-primary capitalize">{entry.role}</span>
49+
</div>
50+
</div>
51+
))}
52+
</div>
53+
);
54+
};
55+
56+
export type OrganizationRoleRestrictionModalProps = {
57+
isLoading: boolean;
58+
defaultClass?: string;
59+
roleRestrictions: RoleRestrictionEntry[];
60+
showSetDefaultButton: boolean;
61+
showSwitchTitle: boolean;
62+
63+
allowedClasses: AllowedWorkspaceClass[];
64+
updateMutation: UseMutationResult<void, Error, { roleRestrictions: PlainMessage<RoleRestrictionEntry>[] }>;
65+
66+
onClose: () => void;
67+
};
68+
69+
const converter = new PublicAPIConverter();
70+
71+
export const OrganizationRoleRestrictionModal = ({
72+
onClose,
73+
updateMutation,
74+
showSetDefaultButton,
75+
showSwitchTitle,
76+
...props
77+
}: OrganizationRoleRestrictionModalProps) => {
78+
const [restrictedRoles, setRestrictedClasses] = useState(
79+
props.roleRestrictions
80+
.filter((entry) => entry.permissions.includes(OrganizationPermission.START_ARBITRARY_REPOS))
81+
.map((entry) => converter.fromOrgMemberRole(entry.role)),
82+
);
83+
84+
const { toast } = useToast();
85+
86+
const handleUpdate = async () => {
87+
updateMutation.mutate(
88+
{
89+
roleRestrictions: restrictedRoles.map((role) => {
90+
return {
91+
role: converter.toOrgMemberRole(role),
92+
permissions: [OrganizationPermission.START_ARBITRARY_REPOS],
93+
};
94+
}),
95+
},
96+
{
97+
onSuccess: () => {
98+
toast({ message: "Role restrictions updated" });
99+
onClose();
100+
},
101+
},
102+
);
103+
};
104+
105+
return (
106+
<Modal visible onClose={onClose} onSubmit={handleUpdate}>
107+
<ModalHeader>Restricted organization roles from opening non-imported repositories</ModalHeader>
108+
<ModalBody>
109+
{props.isLoading ? (
110+
<LoadingState />
111+
) : (
112+
VALID_ORG_MEMBER_ROLES.map((role) => (
113+
<OrganizationRoleRestrictionSwitch
114+
role={role}
115+
checked={!restrictedRoles.includes(role)}
116+
onCheckedChange={(checked) => {
117+
console.log(role, { checked });
118+
if (!checked) {
119+
setRestrictedClasses((prev) => [...prev, role]);
120+
} else {
121+
setRestrictedClasses((prev) => prev.filter((r) => r !== role));
122+
}
123+
}}
124+
/>
125+
))
126+
)}
127+
</ModalBody>
128+
<ModalBaseFooter className="justify-between">
129+
<div className="flex gap-2">
130+
<Button variant="secondary" onClick={onClose}>
131+
Cancel
132+
</Button>
133+
<LoadingButton disabled={props.isLoading} type="submit" loading={updateMutation.isLoading}>
134+
Save
135+
</LoadingButton>
136+
</div>
137+
</ModalBaseFooter>
138+
</Modal>
139+
);
140+
};
141+
142+
interface OrganizationRoleRestrictionSwitchProps {
143+
role: string;
144+
checked: boolean;
145+
onCheckedChange: (checked: boolean) => void;
146+
}
147+
const OrganizationRoleRestrictionSwitch = ({
148+
role,
149+
checked,
150+
onCheckedChange,
151+
}: OrganizationRoleRestrictionSwitchProps) => {
152+
return (
153+
<div className={cn("flex w-full capitalize justify-between items-center mt-2")}>
154+
<SwitchInputField key={role} id={role} label={role} checked={checked} onCheckedChange={onCheckedChange} />
155+
</div>
156+
);
157+
};

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/teams/TeamPolicies.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ 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";
4146

4247
export default function TeamPoliciesPage() {
4348
useDocumentTitle("Organization Settings - Policies");
@@ -219,6 +224,12 @@ export default function TeamPoliciesPage() {
219224
settings={settings}
220225
handleUpdateTeamSettings={handleUpdateTeamSettings}
221226
/>
227+
228+
<RolePermissionsRestrictions
229+
settings={settings}
230+
isOwner={isOwner}
231+
handleUpdateTeamSettings={handleUpdateTeamSettings}
232+
/>
222233
</div>
223234
</OrgSettingsPage>
224235
</>
@@ -314,6 +325,60 @@ const OrgWorkspaceClassesOptions = ({
314325
);
315326
};
316327

328+
type RolePermissionsRestrictionsProps = {
329+
settings: OrganizationSettings | undefined;
330+
isOwner: boolean;
331+
handleUpdateTeamSettings: (
332+
newSettings: Partial<PlainMessage<OrganizationSettings>>,
333+
options?: { throwMutateError?: boolean },
334+
) => Promise<void>;
335+
};
336+
337+
const RolePermissionsRestrictions = ({
338+
settings,
339+
isOwner,
340+
handleUpdateTeamSettings,
341+
}: RolePermissionsRestrictionsProps) => {
342+
const [showModal, setShowModal] = useState(false);
343+
344+
const updateMutation: OrganizationRoleRestrictionModalProps["updateMutation"] = useMutation({
345+
mutationFn: async ({ roleRestrictions }) => {
346+
await handleUpdateTeamSettings({ roleRestrictions }, { throwMutateError: true });
347+
},
348+
});
349+
350+
return (
351+
<ConfigurationSettingsField>
352+
<Heading3>Role permissions restrictions</Heading3>
353+
<Subheading>
354+
Limit the permissions of certain roles in your organization. Requires{" "}
355+
<span className="font-medium">Owner</span> permissions to change.
356+
</Subheading>
357+
358+
<OrgMemberPermissionRestrictionsOptions roleRestrictions={settings?.roleRestrictions ?? []} />
359+
360+
{isOwner && (
361+
<Button className="mt-6" onClick={() => setShowModal(true)}>
362+
Manage Permissions
363+
</Button>
364+
)}
365+
366+
{showModal && (
367+
<OrganizationRoleRestrictionModal
368+
isLoading={false}
369+
defaultClass={""}
370+
roleRestrictions={settings?.roleRestrictions ?? []}
371+
showSetDefaultButton={false}
372+
showSwitchTitle={false}
373+
allowedClasses={[]}
374+
updateMutation={updateMutation}
375+
onClose={() => setShowModal(false)}
376+
/>
377+
)}
378+
</ConfigurationSettingsField>
379+
);
380+
};
381+
317382
interface EditorOptionsProps {
318383
settings: OrganizationSettings | undefined;
319384
isOwner: boolean;

components/server/src/workspace/workspace-starter.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
DBWithTracing,
2020
ProjectDB,
2121
RedisPublisher,
22+
TeamDB,
2223
TracedUserDB,
2324
TracedWorkspaceDB,
2425
UserDB,
@@ -228,6 +229,7 @@ export class WorkspaceStarter {
228229
@inject(IAnalyticsWriter) private readonly analytics: IAnalyticsWriter,
229230
@inject(OneTimeSecretServer) private readonly otsServer: OneTimeSecretServer,
230231
@inject(ProjectDB) private readonly projectDB: ProjectDB,
232+
@inject(TeamDB) private readonly orgDB: TeamDB,
231233
@inject(BlockedRepositoryDB) private readonly blockedRepositoryDB: BlockedRepositoryDB,
232234
@inject(EntitlementService) private readonly entitlementService: EntitlementService,
233235
@inject(RedisMutex) private readonly redisMutex: RedisMutex,
@@ -257,6 +259,7 @@ export class WorkspaceStarter {
257259

258260
let instanceId: string | undefined = undefined;
259261
try {
262+
await this.checkStartPermission(user, workspace, project);
260263
await this.checkBlockedRepository(user, workspace);
261264

262265
// Some workspaces do not have an image source.
@@ -543,6 +546,35 @@ export class WorkspaceStarter {
543546
}
544547
}
545548

549+
private async checkStartPermission(user: User, workspace: Workspace, project?: Project) {
550+
// explicit project
551+
if (project) {
552+
return;
553+
}
554+
555+
const { organizationId, contextURL } = workspace;
556+
557+
const membership = await this.orgDB.findTeamMembership(user.id, organizationId);
558+
if (!membership) {
559+
return;
560+
}
561+
562+
// check if user's role is restricted from starting arbitrary repositories
563+
const organizationSettings = await this.orgService.getSettings(user.id, organizationId);
564+
if (!organizationSettings?.roleRestrictions?.[membership.role]?.includes("start_arbitrary_repositories")) {
565+
return;
566+
}
567+
568+
// implicit project (existing on the same clone URL)
569+
const projects = await this.projectService.findProjectsByCloneUrl(user.id, contextURL, organizationId);
570+
if (projects.length === 0) {
571+
throw new ApplicationError(
572+
ErrorCodes.PRECONDITION_FAILED,
573+
"You don't have permission to start this workspace.",
574+
);
575+
}
576+
}
577+
546578
// Note: this function does not expect to be awaited for by its caller. This means that it takes care of error handling itself.
547579
private async actuallyStartWorkspace(
548580
ctx: TraceContext,

0 commit comments

Comments
 (0)