Skip to content

Commit 275e782

Browse files
[Orgs] Persist slug (#16923)
* [orgs] Persist `slug` * [Orgs] Make `slug` changeable on Settings page * update to use input/button components and mutation * [papi] Re-add Team.slug --------- Co-authored-by: Brad Harris <[email protected]>
1 parent e5db6cd commit 275e782

File tree

15 files changed

+353
-232
lines changed

15 files changed

+353
-232
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Copyright (c) 2023 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 { Organization } from "@gitpod/gitpod-protocol";
8+
import { useMutation } from "@tanstack/react-query";
9+
import { getGitpodService } from "../../service/service";
10+
import { useCurrentOrg, useOrganizationsInvalidator } from "./orgs-query";
11+
12+
type UpdateOrgArgs = Pick<Organization, "name" | "slug">;
13+
14+
export const useUpdateOrgMutation = () => {
15+
const org = useCurrentOrg().data;
16+
const invalidateOrgs = useOrganizationsInvalidator();
17+
18+
return useMutation<Organization, Error, UpdateOrgArgs>({
19+
mutationFn: async ({ name, slug }) => {
20+
if (!org) {
21+
throw new Error("No current organization selected");
22+
}
23+
24+
return await getGitpodService().server.updateTeam(org.id, { name, slug });
25+
},
26+
onSuccess(updatedOrg) {
27+
// TODO: Update query cache with new org prior to invalidation so it's reflected immediately
28+
invalidateOrgs();
29+
},
30+
});
31+
};

components/dashboard/src/service/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function publicApiTeamToProtocol(team: Team): ProtocolTeam {
3030
return {
3131
id: team.id,
3232
name: team.name,
33+
slug: team.slug,
3334
// We do not use the creationTime in the dashboard anywhere, se we keep it empty.
3435
creationTime: "",
3536
};

components/dashboard/src/teams/TeamSettings.tsx

Lines changed: 67 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66

77
import React, { useCallback, useState } from "react";
88
import Alert from "../components/Alert";
9+
import { Button } from "../components/Button";
910
import ConfirmationModal from "../components/ConfirmationModal";
11+
import { TextInputField } from "../components/forms/TextInputField";
1012
import { Heading2, Subheading } from "../components/typography/headings";
1113
import { useCurrentOrg, useOrganizationsInvalidator } from "../data/organizations/orgs-query";
14+
import { useUpdateOrgMutation } from "../data/organizations/update-org-mutation";
15+
import { useOnBlurError } from "../hooks/use-onblur-error";
1216
import { teamsService } from "../service/public-api";
13-
import { getGitpodService, gitpodHostUrl } from "../service/service";
17+
import { gitpodHostUrl } from "../service/service";
1418
import { useCurrentUser } from "../user-context";
1519
import { OrgSettingsPage } from "./OrgSettingsPage";
1620

@@ -21,43 +25,45 @@ export default function TeamSettings() {
2125
const [modal, setModal] = useState(false);
2226
const [teamNameToDelete, setTeamNameToDelete] = useState("");
2327
const [teamName, setTeamName] = useState(org?.name || "");
24-
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
28+
const [slug, setSlug] = useState(org?.slug || "");
2529
const [updated, setUpdated] = useState(false);
30+
const updateOrg = useUpdateOrgMutation();
2631

2732
const close = () => setModal(false);
2833

29-
const updateTeamInformation = useCallback(async () => {
30-
if (!org || errorMessage) {
31-
return;
32-
}
33-
try {
34-
await getGitpodService().server.updateTeam(org.id, { name: teamName });
35-
invalidateOrgs();
36-
setUpdated(true);
37-
setTimeout(() => setUpdated(false), 3000);
38-
} catch (error) {
39-
setErrorMessage(`Failed to update organization information: ${error.message}`);
40-
}
41-
}, [org, errorMessage, teamName, invalidateOrgs]);
34+
const teamNameError = useOnBlurError(
35+
teamName.length > 32
36+
? "Organization name must not be longer than 32 characters"
37+
: "Organization name can not be blank",
38+
!!teamName && teamName.length <= 32,
39+
);
40+
41+
const slugError = useOnBlurError(
42+
slug.length > 100
43+
? "Organization slug must not be longer than 100 characters"
44+
: "Organization slug can not be blank.",
45+
!!slug && slug.length <= 100,
46+
);
47+
48+
const orgFormIsValid = teamNameError.isValid && slugError.isValid;
49+
50+
const updateTeamInformation = useCallback(
51+
async (e: React.FormEvent) => {
52+
e.preventDefault();
4253

43-
const onNameChange = useCallback(
44-
async (event: React.ChangeEvent<HTMLInputElement>) => {
45-
if (!org) {
54+
if (!orgFormIsValid) {
4655
return;
4756
}
48-
const newName = event.target.value || "";
49-
setTeamName(newName);
50-
if (newName.trim().length === 0) {
51-
setErrorMessage("Organization name can not be blank.");
52-
return;
53-
} else if (newName.trim().length > 32) {
54-
setErrorMessage("Organization name must not be longer than 32 characters.");
55-
return;
56-
} else {
57-
setErrorMessage(undefined);
57+
58+
try {
59+
await updateOrg.mutateAsync({ name: teamName, slug });
60+
setUpdated(true);
61+
setTimeout(() => setUpdated(false), 3000);
62+
} catch (error) {
63+
console.error(error);
5864
}
5965
},
60-
[org],
66+
[orgFormIsValid, updateOrg, teamName, slug],
6167
);
6268

6369
const deleteTeam = useCallback(async () => {
@@ -73,37 +79,47 @@ export default function TeamSettings() {
7379
return (
7480
<>
7581
<OrgSettingsPage>
76-
<Heading2>Organization Name</Heading2>
77-
<Subheading className="max-w-2xl">
78-
This is your organization's visible name within Gitpod. For example, the name of your company.
79-
</Subheading>
80-
{errorMessage && (
82+
<Heading2>Organization Details</Heading2>
83+
<Subheading className="max-w-2xl">Details of your organization within Gitpod.</Subheading>
84+
85+
{updateOrg.isError && (
8186
<Alert type="error" closable={true} className="mb-2 max-w-xl rounded-md">
82-
{errorMessage}
87+
<span>Failed to update organization information: </span>
88+
<span>{updateOrg.error.message || "unknown error"}</span>
8389
</Alert>
8490
)}
8591
{updated && (
8692
<Alert type="message" closable={true} className="mb-2 max-w-xl rounded-md">
8793
Organization name has been updated.
8894
</Alert>
8995
)}
90-
<div className="flex flex-col lg:flex-row">
91-
<div>
92-
<div className="mt-4 mb-3">
93-
<h4>Name</h4>
94-
<input type="text" value={teamName} onChange={onNameChange} />
95-
</div>
96-
</div>
97-
</div>
98-
<div className="flex flex-row">
99-
<button
100-
className="primary"
101-
disabled={org?.name === teamName || !!errorMessage}
102-
onClick={updateTeamInformation}
96+
<form onSubmit={updateTeamInformation}>
97+
<TextInputField
98+
label="Name"
99+
hint="The name of your company or organization"
100+
value={teamName}
101+
error={teamNameError.message}
102+
onChange={setTeamName}
103+
onBlur={teamNameError.onBlur}
104+
/>
105+
106+
<TextInputField
107+
label="Slug"
108+
hint="The slug will be used for easier signin and discovery"
109+
value={slug}
110+
error={slugError.message}
111+
onChange={setSlug}
112+
onBlur={slugError.onBlur}
113+
/>
114+
115+
<Button
116+
className="mt-4"
117+
htmlType="submit"
118+
disabled={(org?.name === teamName && org?.slug === slug) || !orgFormIsValid}
103119
>
104-
Update Organization Name
105-
</button>
106-
</div>
120+
Update Organization
121+
</Button>
122+
</form>
107123

108124
<Heading2 className="pt-12">Delete Organization</Heading2>
109125
<Subheading className="pb-4 max-w-2xl">

components/gitpod-db/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@jmondi/oauth2-server": "^2.6.1",
2828
"mysql": "^2.18.1",
2929
"reflect-metadata": "^0.1.13",
30+
"slugify": "^1.6.5",
3031
"the-big-username-blacklist": "^1.5.2",
3132
"typeorm": "0.2.38"
3233
},

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

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

77
import { Team } from "@gitpod/gitpod-protocol";
88
import { Entity, Column, PrimaryColumn } from "typeorm";
9-
import { Transformer } from "../transformer";
109
import { TypeORM } from "../typeorm";
1110

1211
@Entity()
@@ -18,12 +17,8 @@ export class DBTeam implements Team {
1817
@Column("varchar")
1918
name: string;
2019

21-
// Deprecated.
22-
@Column({
23-
type: "varchar",
24-
transformer: Transformer.ALWAYS_EMPTY_STRING,
25-
})
26-
slug: string = "";
20+
@Column("varchar")
21+
slug: string;
2722

2823
@Column("varchar")
2924
creationTime: string;

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

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import { inject, injectable } from "inversify";
99
import { TypeORM } from "./typeorm";
1010
import { Repository } from "typeorm";
1111
import { v4 as uuidv4 } from "uuid";
12+
import { randomBytes } from "crypto";
1213
import { TeamDB } from "../team-db";
1314
import { DBTeam } from "./entity/db-team";
1415
import { DBTeamMembership } from "./entity/db-team-membership";
1516
import { DBUser } from "./entity/db-user";
1617
import { DBTeamMembershipInvite } from "./entity/db-team-membership-invite";
1718
import { ResponseError } from "vscode-jsonrpc";
1819
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
20+
import slugify from "slugify";
1921

2022
@injectable()
2123
export class TeamDBImpl implements TeamDB {
@@ -121,18 +123,53 @@ export class TeamDBImpl implements TeamDB {
121123
return soleOwnedTeams;
122124
}
123125

124-
public async updateTeam(teamId: string, team: Pick<Team, "name">): Promise<Team> {
125-
const teamRepo = await this.getTeamRepo();
126-
const existingTeam = await teamRepo.findOne({ id: teamId, deleted: false, markedDeleted: false });
127-
if (!existingTeam) {
128-
throw new ResponseError(ErrorCodes.NOT_FOUND, "Organization not found");
129-
}
126+
public async updateTeam(teamId: string, team: Pick<Team, "name" | "slug">): Promise<Team> {
130127
const name = team.name && team.name.trim();
131-
if (!name || name.length === 0 || name.length > 32) {
132-
throw new ResponseError(ErrorCodes.INVALID_VALUE, "The name must be between 1 and 32 characters long");
128+
const slug = team.slug && team.slug.trim();
129+
if (!name && !slug) {
130+
throw new ResponseError(ErrorCodes.BAD_REQUEST, "No update provided");
133131
}
134-
existingTeam.name = name;
135-
return teamRepo.save(existingTeam);
132+
133+
// Storing entry in a TX to avoid potential slug dupes caused by racing requests.
134+
const em = await this.getEntityManager();
135+
return await em.transaction<DBTeam>(async (em) => {
136+
const teamRepo = em.getRepository<DBTeam>(DBTeam);
137+
138+
const existingTeam = await teamRepo.findOne({ id: teamId, deleted: false, markedDeleted: false });
139+
if (!existingTeam) {
140+
throw new ResponseError(ErrorCodes.NOT_FOUND, "Organization not found");
141+
}
142+
143+
// no changes
144+
if (existingTeam.name === name && existingTeam.slug === slug) {
145+
return existingTeam;
146+
}
147+
148+
if (!!name) {
149+
if (name.length > 32) {
150+
throw new ResponseError(
151+
ErrorCodes.INVALID_VALUE,
152+
"The name must be between 1 and 32 characters long",
153+
);
154+
}
155+
existingTeam.name = name;
156+
}
157+
if (!!slug && existingTeam.slug != slug) {
158+
if (slug.length > 63) {
159+
throw new ResponseError(ErrorCodes.INVALID_VALUE, "Slug must be between 1 and 63 characters long");
160+
}
161+
if (!/^[A-Za-z0-9-]+$/.test(slug)) {
162+
throw new ResponseError(ErrorCodes.BAD_REQUEST, "Slug must contain only letters, or numbers");
163+
}
164+
const anotherTeamWithThatSlug = await teamRepo.findOne({ slug, deleted: false, markedDeleted: false });
165+
if (anotherTeamWithThatSlug) {
166+
throw new ResponseError(ErrorCodes.INVALID_VALUE, "Slug must be unique");
167+
}
168+
existingTeam.slug = slug;
169+
}
170+
171+
return teamRepo.save(existingTeam);
172+
});
136173
}
137174

138175
public async createTeam(userId: string, name: string): Promise<Team> {
@@ -146,13 +183,27 @@ export class TeamDBImpl implements TeamDB {
146183
);
147184
}
148185

149-
const teamRepo = await this.getTeamRepo();
150-
const team: Team = {
151-
id: uuidv4(),
152-
name,
153-
creationTime: new Date().toISOString(),
154-
};
155-
await teamRepo.save(team);
186+
let slug = slugify(name, { lower: true });
187+
188+
// Storing new entry in a TX to avoid potential dupes caused by racing requests.
189+
const em = await this.getEntityManager();
190+
const team = await em.transaction<DBTeam>(async (em) => {
191+
const teamRepo = em.getRepository<DBTeam>(DBTeam);
192+
193+
const existingTeam = await teamRepo.findOne({ slug, deleted: false, markedDeleted: false });
194+
if (!!existingTeam) {
195+
slug = slug + "-" + randomBytes(4).toString("hex");
196+
}
197+
198+
const team: Team = {
199+
id: uuidv4(),
200+
name,
201+
slug,
202+
creationTime: new Date().toISOString(),
203+
};
204+
return await teamRepo.save(team);
205+
});
206+
156207
const membershipRepo = await this.getMembershipRepo();
157208
await membershipRepo.save({
158209
id: uuidv4(),

components/gitpod-protocol/go/gitpod-service.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2295,6 +2295,7 @@ type UserMessage struct {
22952295
type Team struct {
22962296
ID string `json:"id,omitempty"`
22972297
Name string `json:"name,omitempty"`
2298+
Slug string `json:"slug,omitempty"`
22982299
CreationTime string `json:"creationTime,omitempty"`
22992300
}
23002301

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
174174

175175
// Teams
176176
getTeam(teamId: string): Promise<Team>;
177-
updateTeam(teamId: string, team: Pick<Team, "name">): Promise<Team>;
177+
updateTeam(teamId: string, team: Pick<Team, "name" | "slug">): Promise<Team>;
178178
getTeams(): Promise<Team[]>;
179179
getTeamMembers(teamId: string): Promise<TeamMemberInfo[]>;
180180
createTeam(name: string): Promise<Team>;

components/gitpod-protocol/src/teams-projects-protocol.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export type Team = Organization;
135135
export interface Organization {
136136
id: string;
137137
name: string;
138+
slug?: string;
138139
creationTime: string;
139140
markedDeleted?: boolean;
140141
/** This is a flag that triggers the HARD DELETION of this entity */

components/public-api-server/pkg/apiv1/team.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ func teamToAPIResponse(team *protocol.Team, members []*protocol.TeamMemberInfo,
290290
return &v1.Team{
291291
Id: team.ID,
292292
Name: team.Name,
293+
Slug: team.Slug,
293294
Members: teamMembersToAPIResponse(members),
294295
TeamInvitation: teamInviteToAPIResponse(invite),
295296
}

components/public-api/gitpod/experimental/v1/teams.proto

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ message Team {
1313
// name is the name of the Team
1414
string name = 2;
1515

16-
// previously used for slug
17-
reserved 3;
16+
// slug is the slug of the Team
17+
string slug = 3;
1818

1919
// members are the team members of this Team
2020
repeated TeamMember members = 4;

0 commit comments

Comments
 (0)