Skip to content

Commit f5acd61

Browse files
Organization timeout defaults (#20099)
* Organization timeout defaults proto * Generated code * Implement TimeoutSettings on the API layer * Add timeout setting UI * Actually change default workspace timeout based on org settings workspace starter fixes * Add alert for free plans * denyUserTimeouts * [dashboard] Fix check for "timeout config eligibility" by using BillingMode * [server] Drop FF "disable_set_timeout" * fix * Move to team policies * don't include `timeoutSettings` if not set * [dashboard] TeamPolicies: Disable "save" if workspace timeouts are disabled --------- Co-authored-by: Gero Posmyk-Leinemann <[email protected]>
1 parent aa1836d commit f5acd61

File tree

22 files changed

+3259
-1653
lines changed

22 files changed

+3259
-1653
lines changed

components/dashboard/src/data/billing-mode/org-billing-mode-query.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@ import { useCurrentOrg } from "../organizations/orgs-query";
1212
type OrgBillingModeQueryResult = BillingMode;
1313

1414
export const useOrgBillingMode = () => {
15-
const team = useCurrentOrg().data;
15+
const organization = useCurrentOrg().data;
1616

1717
return useQuery<OrgBillingModeQueryResult>({
18-
queryKey: getOrgBillingModeQueryKey(team?.id ?? ""),
18+
queryKey: getOrgBillingModeQueryKey(organization?.id ?? ""),
1919
queryFn: async () => {
20-
if (!team) {
20+
if (!organization) {
2121
throw new Error("No current organization selected");
2222
}
23-
return await getGitpodService().server.getBillingModeForTeam(team.id);
23+
return await getGitpodService().server.getBillingModeForTeam(organization.id);
2424
},
25-
enabled: !!team,
25+
enabled: !!organization,
2626
});
2727
};
2828

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,26 @@ import { organizationClient } from "../../service/public-api";
1111
import { OrganizationSettings } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
1212
import { ErrorCode } from "@gitpod/gitpod-protocol/lib/messaging/error";
1313
import { useOrgWorkspaceClassesQueryInvalidator } from "./org-workspace-classes-query";
14+
import { PlainMessage } from "@bufbuild/protobuf";
1415

1516
type UpdateOrganizationSettingsArgs = Partial<
1617
Pick<
17-
OrganizationSettings,
18+
PlainMessage<OrganizationSettings>,
1819
| "workspaceSharingDisabled"
1920
| "defaultWorkspaceImage"
2021
| "allowedWorkspaceClasses"
2122
| "pinnedEditorVersions"
2223
| "restrictedEditorNames"
2324
| "defaultRole"
25+
| "timeoutSettings"
2426
>
2527
>;
2628

2729
export const useUpdateOrgSettingsMutation = () => {
2830
const org = useCurrentOrg().data;
2931
const invalidateOrgSettings = useOrgSettingsQueryInvalidator();
3032
const invalidateWorkspaceClasses = useOrgWorkspaceClassesQueryInvalidator();
31-
const teamId = org?.id || "";
33+
const teamId = org?.id ?? "";
3234

3335
return useMutation<OrganizationSettings, Error, UpdateOrganizationSettingsArgs>({
3436
mutationFn: async ({
@@ -38,6 +40,7 @@ export const useUpdateOrgSettingsMutation = () => {
3840
pinnedEditorVersions,
3941
restrictedEditorNames,
4042
defaultRole,
43+
timeoutSettings,
4144
}) => {
4245
const settings = await organizationClient.updateOrganizationSettings({
4346
organizationId: teamId,
@@ -49,6 +52,7 @@ export const useUpdateOrgSettingsMutation = () => {
4952
restrictedEditorNames,
5053
updateRestrictedEditorNames: !!restrictedEditorNames,
5154
defaultRole,
55+
timeoutSettings,
5256
});
5357
return settings.settings!;
5458
},

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,10 @@ export class JsonRpcOrganizationClient implements PromiseClient<typeof Organizat
254254
await getGitpodService().server.updateOrgSettings(request.organizationId, {
255255
...update,
256256
defaultRole: request.defaultRole as OrgMemberRole,
257+
timeoutSettings: {
258+
inactivity: converter.toDurationString(request.timeoutSettings?.inactivity),
259+
denyUserTimeouts: request.timeoutSettings?.denyUserTimeouts,
260+
},
257261
});
258262
return new UpdateOrganizationSettingsResponse();
259263
}

components/dashboard/src/teams/TeamPolicies.tsx

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

77
import { isGitpodIo } from "../utils";
88
import { OrganizationSettings } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
9-
import React, { useCallback, useMemo, useState } from "react";
9+
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
1010
import Alert from "../components/Alert";
1111
import { CheckboxInputField } from "../components/forms/CheckboxInputField";
1212
import { Heading2, Heading3, Subheading } from "../components/typography/headings";
@@ -29,17 +29,32 @@ import { IdeOptions, IdeOptionsModifyModal, IdeOptionsModifyModalProps } from ".
2929
import { useDocumentTitle } from "../hooks/use-document-title";
3030
import { LinkButton } from "@podkit/buttons/LinkButton";
3131
import PillLabel from "../components/PillLabel";
32+
import { useOrgBillingMode } from "../data/billing-mode/org-billing-mode-query";
33+
import { converter } from "../service/public-api";
34+
import { useToast } from "../components/toasts/Toasts";
35+
import type { PlainMessage } from "@bufbuild/protobuf";
36+
import { WorkspaceTimeoutDuration } from "@gitpod/gitpod-protocol";
37+
import { Link } from "react-router-dom";
38+
import { InputField } from "../components/forms/InputField";
39+
import { TextInput } from "../components/forms/TextInputField";
40+
import { LoadingButton } from "@podkit/buttons/LoadingButton";
3241

3342
export default function TeamPoliciesPage() {
3443
useDocumentTitle("Organization Settings - Policies");
44+
const { toast } = useToast();
3545
const org = useCurrentOrg().data;
3646
const isOwner = useIsOwner();
3747

3848
const { data: settings, isLoading } = useOrgSettingsQuery();
3949
const updateTeamSettings = useUpdateOrgSettingsMutation();
4050

51+
const billingMode = useOrgBillingMode();
52+
const [workspaceTimeout, setWorkspaceTimeout] = useState<string | undefined>(undefined);
53+
const [allowTimeoutChangeByMembers, setAllowTimeoutChangeByMembers] = useState<boolean | undefined>(undefined);
54+
const [workspaceTimeoutSettingError, setWorkspaceTimeoutSettingError] = useState<string | undefined>(undefined);
55+
4156
const handleUpdateTeamSettings = useCallback(
42-
async (newSettings: Partial<OrganizationSettings>, options?: { throwMutateError?: boolean }) => {
57+
async (newSettings: Partial<PlainMessage<OrganizationSettings>>, options?: { throwMutateError?: boolean }) => {
4358
if (!org?.id) {
4459
throw new Error("no organization selected");
4560
}
@@ -51,16 +66,58 @@ export default function TeamPoliciesPage() {
5166
...settings,
5267
...newSettings,
5368
});
69+
setWorkspaceTimeoutSettingError(undefined);
70+
toast("Organization settings updated");
5471
} catch (error) {
5572
if (options?.throwMutateError) {
5673
throw error;
5774
}
75+
toast(`Failed to update organization settings: ${error.message}`);
5876
console.error(error);
5977
}
6078
},
61-
[updateTeamSettings, org?.id, isOwner, settings],
79+
[updateTeamSettings, org?.id, isOwner, settings, toast],
80+
);
81+
82+
useEffect(() => {
83+
setWorkspaceTimeout(
84+
settings?.timeoutSettings?.inactivity
85+
? converter.toDurationString(settings.timeoutSettings.inactivity)
86+
: undefined,
87+
);
88+
setAllowTimeoutChangeByMembers(!settings?.timeoutSettings?.denyUserTimeouts);
89+
}, [settings?.timeoutSettings]);
90+
91+
const handleUpdateOrganizationTimeoutSettings = useCallback(
92+
(e: FormEvent<HTMLFormElement>) => {
93+
e.preventDefault();
94+
try {
95+
if (workspaceTimeout) {
96+
WorkspaceTimeoutDuration.validate(workspaceTimeout);
97+
}
98+
} catch (error) {
99+
setWorkspaceTimeoutSettingError(error.message);
100+
return;
101+
}
102+
103+
// Nothing has changed
104+
if (workspaceTimeout === undefined && allowTimeoutChangeByMembers === undefined) {
105+
return;
106+
}
107+
108+
handleUpdateTeamSettings({
109+
timeoutSettings: {
110+
inactivity: workspaceTimeout ? converter.toDuration(workspaceTimeout) : undefined,
111+
denyUserTimeouts: !allowTimeoutChangeByMembers,
112+
},
113+
});
114+
},
115+
[workspaceTimeout, allowTimeoutChangeByMembers, handleUpdateTeamSettings],
62116
);
63117

118+
const billingModeAllowsWorkspaceTimeouts =
119+
billingMode.data?.mode === "none" || (billingMode.data?.mode === "usage-based" && billingMode.data?.paid);
120+
64121
return (
65122
<>
66123
<OrgSettingsPage>
@@ -91,6 +148,64 @@ export default function TeamPoliciesPage() {
91148
/>
92149
</ConfigurationSettingsField>
93150

151+
<ConfigurationSettingsField>
152+
<Heading3>Workspace timeouts</Heading3>
153+
{!billingModeAllowsWorkspaceTimeouts && (
154+
<Alert type="info" className="my-3">
155+
Setting Workspace timeouts is only available for organizations on a paid plan. Visit{" "}
156+
<Link to={"/billing"} className="gp-link">
157+
Billing
158+
</Link>{" "}
159+
to upgrade your plan.
160+
</Alert>
161+
)}
162+
<form onSubmit={handleUpdateOrganizationTimeoutSettings}>
163+
<InputField
164+
label="Default workspace timeout"
165+
error={workspaceTimeoutSettingError}
166+
hint={
167+
<span>
168+
Use minutes or hours, like <span className="font-semibold">30m</span> or{" "}
169+
<span className="font-semibold">2h</span>
170+
</span>
171+
}
172+
>
173+
<TextInput
174+
value={workspaceTimeout ?? ""}
175+
placeholder="e.g. 30m"
176+
onChange={setWorkspaceTimeout}
177+
disabled={
178+
updateTeamSettings.isLoading || !isOwner || !billingModeAllowsWorkspaceTimeouts
179+
}
180+
/>
181+
</InputField>
182+
<CheckboxInputField
183+
label="Allow members to change workspace timeouts"
184+
hint="Allow users to change the timeout duration for their workspaces as well as setting a default one in their user settings."
185+
checked={!!allowTimeoutChangeByMembers}
186+
containerClassName="my-4"
187+
onChange={setAllowTimeoutChangeByMembers}
188+
disabled={
189+
updateTeamSettings.isLoading || !isOwner || !billingModeAllowsWorkspaceTimeouts
190+
}
191+
/>
192+
<LoadingButton
193+
type="submit"
194+
loading={updateTeamSettings.isLoading}
195+
disabled={
196+
!isOwner ||
197+
!billingModeAllowsWorkspaceTimeouts ||
198+
((workspaceTimeout ===
199+
converter.toDurationString(settings?.timeoutSettings?.inactivity) ??
200+
"") &&
201+
allowTimeoutChangeByMembers === !settings?.timeoutSettings?.denyUserTimeouts)
202+
}
203+
>
204+
Save
205+
</LoadingButton>
206+
</form>
207+
</ConfigurationSettingsField>
208+
94209
<OrgWorkspaceClassesOptions
95210
isOwner={isOwner}
96211
settings={settings}

components/dashboard/src/teams/TeamSettings.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,22 @@ import { useInstallationDefaultWorkspaceImageQuery } from "../data/installation/
3030
import { ConfigurationSettingsField } from "../repositories/detail/ConfigurationSettingsField";
3131
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@podkit/select/Select";
3232
import { useDocumentTitle } from "../hooks/use-document-title";
33+
import { PlainMessage } from "@bufbuild/protobuf";
34+
import { useToast } from "../components/toasts/Toasts";
3335

3436
export default function TeamSettingsPage() {
3537
useDocumentTitle("Organization Settings - General");
38+
const { toast } = useToast();
3639
const user = useCurrentUser();
3740
const org = useCurrentOrg().data;
3841
const isOwner = useIsOwner();
3942
const invalidateOrgs = useOrganizationsInvalidator();
43+
4044
const [modal, setModal] = useState(false);
4145
const [teamNameToDelete, setTeamNameToDelete] = useState("");
4246
const [teamName, setTeamName] = useState(org?.name || "");
4347
const [updated, setUpdated] = useState(false);
48+
4449
const updateOrg = useUpdateOrgMutation();
4550

4651
const close = () => setModal(false);
@@ -93,7 +98,7 @@ export default function TeamSettingsPage() {
9398
const [showImageEditModal, setShowImageEditModal] = useState(false);
9499

95100
const handleUpdateTeamSettings = useCallback(
96-
async (newSettings: Partial<OrganizationSettings>, options?: { throwMutateError?: boolean }) => {
101+
async (newSettings: Partial<PlainMessage<OrganizationSettings>>, options?: { throwMutateError?: boolean }) => {
97102
if (!org?.id) {
98103
throw new Error("no organization selected");
99104
}
@@ -105,14 +110,16 @@ export default function TeamSettingsPage() {
105110
...settings,
106111
...newSettings,
107112
});
113+
toast("Organization settings updated");
108114
} catch (error) {
109115
if (options?.throwMutateError) {
110116
throw error;
111117
}
118+
toast(`Failed to update organization settings: ${error.message}`);
112119
console.error(error);
113120
}
114121
},
115-
[updateTeamSettings, org?.id, isOwner, settings],
122+
[updateTeamSettings, org?.id, isOwner, settings, toast],
116123
);
117124

118125
return (

components/dashboard/src/user-settings/Preferences.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
import { useOrgBillingMode } from "../data/billing-mode/org-billing-mode-query";
2424
import { converter, userClient } from "../service/public-api";
2525
import { LoadingButton } from "@podkit/buttons/LoadingButton";
26+
import { useOrgSettingsQuery } from "../data/organizations/org-settings-query";
27+
import Alert from "../components/Alert";
2628

2729
export type IDEChangedTrackLocation = "workspace_list" | "workspace_start" | "preferences";
2830

@@ -32,6 +34,7 @@ export default function Preferences() {
3234
const updateUser = useUpdateCurrentUserMutation();
3335
const billingMode = useOrgBillingMode();
3436
const updateDotfileRepo = useUpdateCurrentUserDotfileRepoMutation();
37+
const { data: settings } = useOrgSettingsQuery();
3538

3639
const [dotfileRepo, setDotfileRepo] = useState<string>(user?.dotfileRepo || "");
3740

@@ -166,6 +169,14 @@ export default function Preferences() {
166169
<Subheading>Workspaces will stop after a period of inactivity without any user input.</Subheading>
167170

168171
<div className="mt-4 max-w-xl">
172+
{!!settings?.timeoutSettings?.denyUserTimeouts && (
173+
<Alert type="warning" className="mb-4">
174+
The currently selected organization does not allow members to set custom workspace timeouts,
175+
so for workspaces created in it, its default timeout of{" "}
176+
{converter.toDurationString(settings?.timeoutSettings?.inactivity)} will be used.
177+
</Alert>
178+
)}
179+
169180
<form onSubmit={saveWorkspaceTimeout}>
170181
<InputField
171182
label="Default Workspace Timeout"
@@ -198,7 +209,7 @@ export default function Preferences() {
198209
Save
199210
</LoadingButton>
200211
</div>
201-
{!!creationError && (
212+
{creationError && (
202213
<p className="text-gitpod-red w-full max-w-lg">
203214
Cannot set custom workspace timeout: {creationError.message}
204215
</p>

components/gitpod-db/src/typeorm/entity/db-team-settings.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { OrgMemberRole, OrganizationSettings } from "@gitpod/gitpod-protocol";
7+
import { OrgMemberRole, OrganizationSettings, TimeoutSettings } from "@gitpod/gitpod-protocol";
88
import { Entity, Column, PrimaryColumn } from "typeorm";
99
import { TypeORM } from "../typeorm";
1010

@@ -33,6 +33,9 @@ export class DBOrgSettings implements OrganizationSettings {
3333
@Column("varchar", { nullable: true })
3434
defaultRole?: OrgMemberRole | undefined;
3535

36+
@Column("json", { nullable: true })
37+
timeoutSettings?: TimeoutSettings | undefined;
38+
3639
@Column()
3740
deleted: boolean;
3841
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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 { MigrationInterface, QueryRunner } from "typeorm";
8+
import { columnExists } from "./helper/helper";
9+
10+
const table = "d_b_org_settings";
11+
const newColumn = "timeoutSettings";
12+
13+
export class AddOrgTimeoutSettings1723148483807 implements MigrationInterface {
14+
public async up(queryRunner: QueryRunner): Promise<void> {
15+
if (!(await columnExists(queryRunner, table, newColumn))) {
16+
await queryRunner.query(`ALTER TABLE ${table} ADD COLUMN ${newColumn} JSON NULL`);
17+
}
18+
}
19+
20+
public async down(queryRunner: QueryRunner): Promise<void> {
21+
if (await columnExists(queryRunner, table, newColumn)) {
22+
await queryRunner.query(`ALTER TABLE ${table} DROP COLUMN ${newColumn}`);
23+
}
24+
}
25+
}

components/gitpod-db/src/typeorm/team-db-impl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ export class TeamDBImpl extends TransactionalDBImpl<TeamDB> implements TeamDB {
368368
"pinnedEditorVersions",
369369
"restrictedEditorNames",
370370
"defaultRole",
371+
"timeoutSettings",
371372
],
372373
});
373374
}

0 commit comments

Comments
 (0)