-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Add default workspace image to org setting #18723
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 20 commits
b7dd2e6
5cb3118
90a851e
2c6c5d1
03262da
88e8909
bc074ba
5080271
7652208
d22e969
7e6e29c
f347e3b
0a7b1ff
dea9564
ae32c3a
2771ff4
e2a7849
f875e63
2756caf
c8ccb52
8dd6695
06cfd08
1941376
c2df290
4987594
5f0daea
97ae60f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,7 +5,7 @@ | |
*/ | ||
|
||
import { OrganizationSettings } from "@gitpod/gitpod-protocol"; | ||
import React, { useCallback, useState } from "react"; | ||
import React, { useCallback, useState, useEffect } from "react"; | ||
import Alert from "../components/Alert"; | ||
import { Button } from "../components/Button"; | ||
import { CheckboxInputField } from "../components/forms/CheckboxInputField"; | ||
|
@@ -14,13 +14,14 @@ import { TextInputField } from "../components/forms/TextInputField"; | |
import { Heading2, Subheading } from "../components/typography/headings"; | ||
import { useUpdateOrgSettingsMutation } from "../data/organizations/update-org-settings-mutation"; | ||
import { useOrgSettingsQuery } from "../data/organizations/org-settings-query"; | ||
import { useCurrentOrg, useOrganizationsInvalidator } from "../data/organizations/orgs-query"; | ||
import { OrganizationInfo, useCurrentOrg, useOrganizationsInvalidator } from "../data/organizations/orgs-query"; | ||
import { useUpdateOrgMutation } from "../data/organizations/update-org-mutation"; | ||
import { useOnBlurError } from "../hooks/use-onblur-error"; | ||
import { teamsService } from "../service/public-api"; | ||
import { gitpodHostUrl } from "../service/service"; | ||
import { useCurrentUser } from "../user-context"; | ||
import { OrgSettingsPage } from "./OrgSettingsPage"; | ||
import { useToast } from "../components/toasts/Toasts"; | ||
|
||
export default function TeamSettingsPage() { | ||
const user = useCurrentUser(); | ||
|
@@ -31,21 +32,6 @@ export default function TeamSettingsPage() { | |
const [teamName, setTeamName] = useState(org?.name || ""); | ||
const [updated, setUpdated] = useState(false); | ||
const updateOrg = useUpdateOrgMutation(); | ||
const { data: settings, isLoading } = useOrgSettingsQuery(); | ||
const updateTeamSettings = useUpdateOrgSettingsMutation(); | ||
|
||
const handleUpdateTeamSettings = useCallback( | ||
(newSettings: Partial<OrganizationSettings>) => { | ||
if (!org?.id) { | ||
throw new Error("no organization selected"); | ||
} | ||
updateTeamSettings.mutate({ | ||
...settings, | ||
...newSettings, | ||
}); | ||
}, | ||
[updateTeamSettings, org?.id, settings], | ||
); | ||
|
||
const close = () => setModal(false); | ||
|
||
|
@@ -60,6 +46,9 @@ export default function TeamSettingsPage() { | |
|
||
const updateTeamInformation = useCallback( | ||
async (e: React.FormEvent) => { | ||
if (!org?.isOwner) { | ||
return; | ||
} | ||
e.preventDefault(); | ||
|
||
if (!orgFormIsValid) { | ||
|
@@ -74,7 +63,7 @@ export default function TeamSettingsPage() { | |
console.error(error); | ||
} | ||
}, | ||
[orgFormIsValid, updateOrg, teamName], | ||
[orgFormIsValid, updateOrg, teamName, org], | ||
); | ||
|
||
const deleteTeam = useCallback(async () => { | ||
|
@@ -99,12 +88,6 @@ export default function TeamSettingsPage() { | |
<span>{updateOrg.error.message || "unknown error"}</span> | ||
</Alert> | ||
)} | ||
{updateTeamSettings.isError && ( | ||
<Alert type="error" closable={true} className="mb-2 max-w-xl rounded-md"> | ||
<span>Failed to update organization settings: </span> | ||
<span>{updateTeamSettings.error.message || "unknown error"}</span> | ||
</Alert> | ||
)} | ||
{updated && ( | ||
<Alert type="message" closable={true} className="mb-2 max-w-xl rounded-md"> | ||
Organization name has been updated. | ||
|
@@ -117,30 +100,27 @@ export default function TeamSettingsPage() { | |
value={teamName} | ||
error={teamNameError.message} | ||
onChange={setTeamName} | ||
disabled={!org?.isOwner} | ||
onBlur={teamNameError.onBlur} | ||
/> | ||
|
||
<Button className="mt-4" htmlType="submit" disabled={org?.name === teamName || !orgFormIsValid}> | ||
Update Organization | ||
</Button> | ||
|
||
<Heading2 className="pt-12">Collaboration & Sharing</Heading2> | ||
<CheckboxInputField | ||
label="Workspace Sharing" | ||
hint="Allow workspaces created within an Organization to share the workspace with any authenticated user." | ||
checked={!settings?.workspaceSharingDisabled} | ||
onChange={(checked) => handleUpdateTeamSettings({ workspaceSharingDisabled: !checked })} | ||
disabled={isLoading} | ||
/> | ||
{org?.isOwner && ( | ||
<Button className="mt-4" htmlType="submit" disabled={org?.name === teamName || !orgFormIsValid}> | ||
Update Organization | ||
</Button> | ||
)} | ||
</form> | ||
|
||
{user?.organizationId !== org?.id && ( | ||
<OrgSettingsForm org={org} /> | ||
|
||
{user?.organizationId !== org?.id && org?.isOwner && ( | ||
<> | ||
<Heading2 className="pt-12">Delete Organization</Heading2> | ||
<Subheading className="pb-4 max-w-2xl"> | ||
Deleting this organization will also remove all associated data, including projects and | ||
workspaces. Deleted organizations cannot be restored! | ||
</Subheading> | ||
|
||
<button className="danger secondary" onClick={() => setModal(true)}> | ||
Delete Organization | ||
</button> | ||
|
@@ -185,3 +165,91 @@ export default function TeamSettingsPage() { | |
</> | ||
); | ||
} | ||
|
||
function OrgSettingsForm(props: { org?: OrganizationInfo }) { | ||
const { org } = props; | ||
const { data: settings, isLoading } = useOrgSettingsQuery(); | ||
const updateTeamSettings = useUpdateOrgSettingsMutation(); | ||
const [defaultWorkspaceImage, setDefaultWorkspaceImage] = useState(settings?.defaultWorkspaceImage ?? ""); | ||
const { toast } = useToast(); | ||
|
||
useEffect(() => { | ||
if (!settings) { | ||
return; | ||
} | ||
setDefaultWorkspaceImage(settings.defaultWorkspaceImage ?? ""); | ||
}, [settings]); | ||
|
||
const handleUpdateTeamSettings = useCallback( | ||
async (newSettings: Partial<OrganizationSettings>) => { | ||
if (!org?.id) { | ||
throw new Error("no organization selected"); | ||
} | ||
if (!org.isOwner) { | ||
throw new Error("no organization settings change permission"); | ||
} | ||
try { | ||
await updateTeamSettings.mutateAsync({ | ||
...settings, | ||
...newSettings, | ||
}); | ||
if (newSettings.defaultWorkspaceImage) { | ||
toast("Default workspace image has been updated."); | ||
} | ||
} catch (error) { | ||
console.error(error); | ||
toast( | ||
error.message | ||
? "Failed to update organization settings: " + error.message | ||
: "Oh no, there was a problem with our service.", | ||
); | ||
} | ||
}, | ||
[updateTeamSettings, org?.id, org?.isOwner, settings, toast], | ||
); | ||
|
||
return ( | ||
<form | ||
onSubmit={(e) => { | ||
e.preventDefault(); | ||
handleUpdateTeamSettings({ defaultWorkspaceImage }); | ||
}} | ||
> | ||
<Heading2 className="pt-12">Collaboration & Sharing</Heading2> | ||
<Subheading className="max-w-2xl"> | ||
Choose which workspace images you want to use for your workspaces. | ||
</Subheading> | ||
|
||
{updateTeamSettings.isError && ( | ||
<Alert type="error" closable={true} className="mb-2 max-w-xl rounded-md"> | ||
<span>Failed to update organization settings: </span> | ||
<span>{updateTeamSettings.error.message || "unknown error"}</span> | ||
</Alert> | ||
)} | ||
|
||
<CheckboxInputField | ||
label="Workspace Sharing" | ||
hint="Allow workspaces created within an Organization to share the workspace with any authenticated user." | ||
checked={!settings?.workspaceSharingDisabled} | ||
onChange={(checked) => handleUpdateTeamSettings({ workspaceSharingDisabled: !checked })} | ||
disabled={isLoading || !org?.isOwner} | ||
/> | ||
|
||
<Heading2 className="pt-12">Workspace Settings</Heading2> | ||
<TextInputField | ||
label="Default Image" | ||
// TODO: Provide document links | ||
hint="Use any official Gitpod Docker image, or Docker image reference" | ||
value={defaultWorkspaceImage} | ||
onChange={setDefaultWorkspaceImage} | ||
disabled={isLoading || !org?.isOwner} | ||
/> | ||
Comment on lines
+240
to
+247
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue(non-blocking): Minor UX issue, but could we use a placeholder for now when someone is deleting the text input_ here? Feels a bit unintuitive when you delete all text and click save that there's a default value overriding the empty text input. Could be improved later. |
||
|
||
{org?.isOwner && ( | ||
<Button htmlType="submit" className="mt-4" disabled={!org.isOwner}> | ||
Save | ||
mustard-mh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
</Button> | ||
)} | ||
</form> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -5,6 +5,7 @@ | |||||||
package cmd | ||||||||
|
||||||||
import ( | ||||||||
"context" | ||||||||
"errors" | ||||||||
"fmt" | ||||||||
"os" | ||||||||
|
@@ -31,9 +32,10 @@ var initCmd = &cobra.Command{ | |||||||
Create a Gitpod configuration for this project. | ||||||||
`, | ||||||||
RunE: func(cmd *cobra.Command, args []string) error { | ||||||||
ctx := cmd.Context() | ||||||||
cfg := gitpodlib.GitpodFile{} | ||||||||
if interactive { | ||||||||
if err := askForDockerImage(&cfg); err != nil { | ||||||||
if err := askForDockerImage(ctx, &cfg); err != nil { | ||||||||
return err | ||||||||
} | ||||||||
if err := askForPorts(&cfg); err != nil { | ||||||||
|
@@ -52,7 +54,16 @@ Create a Gitpod configuration for this project. | |||||||
return err | ||||||||
} | ||||||||
if !interactive { | ||||||||
d = []byte(`# List the start up tasks. Learn more: https://www.gitpod.io/docs/configure/workspaces/tasks | ||||||||
defaultImage, err := getDefaultWorkspaceImage(ctx) | ||||||||
if err != nil { | ||||||||
fmt.Printf("failed to get organization default workspace image: %v\n", err) | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: Not the best line to comment on this, but org users can easily run into non-functional workspace start flow if image is inaccessible, tags are missing, etc. See relevant discussion (internal). Cc @akosyakov
|
||||||||
fmt.Println("fallback to gitpod default") | ||||||||
defaultImage = "gitpod/workspace-full" | ||||||||
} | ||||||||
yml := fmt.Sprintf(`# Image of workspace. Learn more: https://www.gitpod.io/docs/configure/workspaces/workspace-image | ||||||||
image: %s | ||||||||
|
||||||||
# List the start up tasks. Learn more: https://www.gitpod.io/docs/configure/workspaces/tasks | ||||||||
tasks: | ||||||||
- name: Script Task | ||||||||
init: echo 'init script' # runs during prebuild => https://www.gitpod.io/docs/configure/projects/prebuilds | ||||||||
|
@@ -66,7 +77,8 @@ ports: | |||||||
onOpen: open-preview | ||||||||
|
||||||||
# Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart | ||||||||
`) | ||||||||
`, defaultImage) | ||||||||
d = []byte(yml) | ||||||||
} else { | ||||||||
fmt.Printf("\n\n---\n%s", d) | ||||||||
} | ||||||||
|
@@ -132,7 +144,7 @@ func ask(lbl string, def string, validator promptui.ValidateFunc) (string, error | |||||||
return prompt.Run() | ||||||||
} | ||||||||
|
||||||||
func askForDockerImage(cfg *gitpodlib.GitpodFile) error { | ||||||||
func askForDockerImage(ctx context.Context, cfg *gitpodlib.GitpodFile) error { | ||||||||
prompt := promptui.Select{ | ||||||||
Label: "Workspace Docker image", | ||||||||
Items: []string{"default", "custom image", "docker file"}, | ||||||||
|
@@ -146,6 +158,11 @@ func askForDockerImage(cfg *gitpodlib.GitpodFile) error { | |||||||
} | ||||||||
|
||||||||
if chce == 0 { | ||||||||
defaultImage, err := getDefaultWorkspaceImage(ctx) | ||||||||
if err != nil { | ||||||||
return fmt.Errorf("failed to get organization default workspace image: %w", err) | ||||||||
} | ||||||||
cfg.SetImageName(defaultImage) | ||||||||
return nil | ||||||||
} | ||||||||
if chce == 1 { | ||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We are effectively reimplementing the permissions here in an implicit way. Instead of assuming that owners have access we should rely on FGA to figure that out. Not necessarily as part of this PR, though because it involves more discussion and work.
cc @akosyakov @geropl