Skip to content

[Orgs] Persist slug #16923

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

Merged
merged 4 commits into from
Mar 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { Organization } from "@gitpod/gitpod-protocol";
import { useMutation } from "@tanstack/react-query";
import { getGitpodService } from "../../service/service";
import { useCurrentOrg, useOrganizationsInvalidator } from "./orgs-query";

type UpdateOrgArgs = Pick<Organization, "name" | "slug">;

export const useUpdateOrgMutation = () => {
const org = useCurrentOrg().data;
const invalidateOrgs = useOrganizationsInvalidator();

return useMutation<Organization, Error, UpdateOrgArgs>({
mutationFn: async ({ name, slug }) => {
if (!org) {
throw new Error("No current organization selected");
}

return await getGitpodService().server.updateTeam(org.id, { name, slug });
},
onSuccess(updatedOrg) {
// TODO: Update query cache with new org prior to invalidation so it's reflected immediately
invalidateOrgs();
},
});
};
1 change: 1 addition & 0 deletions components/dashboard/src/service/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function publicApiTeamToProtocol(team: Team): ProtocolTeam {
return {
id: team.id,
name: team.name,
slug: team.slug,
// We do not use the creationTime in the dashboard anywhere, se we keep it empty.
creationTime: "",
};
Expand Down
118 changes: 67 additions & 51 deletions components/dashboard/src/teams/TeamSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@

import React, { useCallback, useState } from "react";
import Alert from "../components/Alert";
import { Button } from "../components/Button";
import ConfirmationModal from "../components/ConfirmationModal";
import { TextInputField } from "../components/forms/TextInputField";
import { Heading2, Subheading } from "../components/typography/headings";
import { 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 { getGitpodService, gitpodHostUrl } from "../service/service";
import { gitpodHostUrl } from "../service/service";
import { useCurrentUser } from "../user-context";
import { OrgSettingsPage } from "./OrgSettingsPage";

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

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

const updateTeamInformation = useCallback(async () => {
if (!org || errorMessage) {
return;
}
try {
await getGitpodService().server.updateTeam(org.id, { name: teamName });
invalidateOrgs();
setUpdated(true);
setTimeout(() => setUpdated(false), 3000);
} catch (error) {
setErrorMessage(`Failed to update organization information: ${error.message}`);
}
}, [org, errorMessage, teamName, invalidateOrgs]);
const teamNameError = useOnBlurError(
teamName.length > 32
? "Organization name must not be longer than 32 characters"
: "Organization name can not be blank",
!!teamName && teamName.length <= 32,
);

const slugError = useOnBlurError(
slug.length > 100
? "Organization slug must not be longer than 100 characters"
: "Organization slug can not be blank.",
!!slug && slug.length <= 100,
);

const orgFormIsValid = teamNameError.isValid && slugError.isValid;

const updateTeamInformation = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();

const onNameChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!org) {
if (!orgFormIsValid) {
return;
}
const newName = event.target.value || "";
setTeamName(newName);
if (newName.trim().length === 0) {
setErrorMessage("Organization name can not be blank.");
return;
} else if (newName.trim().length > 32) {
setErrorMessage("Organization name must not be longer than 32 characters.");
return;
} else {
setErrorMessage(undefined);

try {
await updateOrg.mutateAsync({ name: teamName, slug });
setUpdated(true);
setTimeout(() => setUpdated(false), 3000);
} catch (error) {
console.error(error);
}
},
[org],
[orgFormIsValid, updateOrg, teamName, slug],
);

const deleteTeam = useCallback(async () => {
Expand All @@ -73,37 +79,47 @@ export default function TeamSettings() {
return (
<>
<OrgSettingsPage>
<Heading2>Organization Name</Heading2>
<Subheading className="max-w-2xl">
This is your organization's visible name within Gitpod. For example, the name of your company.
</Subheading>
{errorMessage && (
<Heading2>Organization Details</Heading2>
<Subheading className="max-w-2xl">Details of your organization within Gitpod.</Subheading>

{updateOrg.isError && (
<Alert type="error" closable={true} className="mb-2 max-w-xl rounded-md">
{errorMessage}
<span>Failed to update organization information: </span>
<span>{updateOrg.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.
</Alert>
)}
<div className="flex flex-col lg:flex-row">
<div>
<div className="mt-4 mb-3">
<h4>Name</h4>
<input type="text" value={teamName} onChange={onNameChange} />
</div>
</div>
</div>
<div className="flex flex-row">
<button
className="primary"
disabled={org?.name === teamName || !!errorMessage}
onClick={updateTeamInformation}
<form onSubmit={updateTeamInformation}>
<TextInputField
label="Name"
hint="The name of your company or organization"
value={teamName}
error={teamNameError.message}
onChange={setTeamName}
onBlur={teamNameError.onBlur}
/>

<TextInputField
label="Slug"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Thanks @AlexTugarev for using the new form components! Thanks also @selfcontained for initially adding[1] the htmlFor element.

hint="The slug will be used for easier signin and discovery"
value={slug}
error={slugError.message}
onChange={setSlug}
onBlur={slugError.onBlur}
/>

<Button
className="mt-4"
htmlType="submit"
disabled={(org?.name === teamName && org?.slug === slug) || !orgFormIsValid}
>
Update Organization Name
</button>
</div>
Update Organization
</Button>
</form>

<Heading2 className="pt-12">Delete Organization</Heading2>
<Subheading className="pb-4 max-w-2xl">
Expand Down
1 change: 1 addition & 0 deletions components/gitpod-db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@jmondi/oauth2-server": "^2.6.1",
"mysql": "^2.18.1",
"reflect-metadata": "^0.1.13",
"slugify": "^1.6.5",
"the-big-username-blacklist": "^1.5.2",
"typeorm": "0.2.38"
},
Expand Down
9 changes: 2 additions & 7 deletions components/gitpod-db/src/typeorm/entity/db-team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

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

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

// Deprecated.
@Column({
type: "varchar",
transformer: Transformer.ALWAYS_EMPTY_STRING,
})
slug: string = "";
@Column("varchar")
slug: string;

@Column("varchar")
creationTime: string;
Expand Down
85 changes: 68 additions & 17 deletions components/gitpod-db/src/typeorm/team-db-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import { inject, injectable } from "inversify";
import { TypeORM } from "./typeorm";
import { Repository } from "typeorm";
import { v4 as uuidv4 } from "uuid";
import { randomBytes } from "crypto";
import { TeamDB } from "../team-db";
import { DBTeam } from "./entity/db-team";
import { DBTeamMembership } from "./entity/db-team-membership";
import { DBUser } from "./entity/db-user";
import { DBTeamMembershipInvite } from "./entity/db-team-membership-invite";
import { ResponseError } from "vscode-jsonrpc";
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import slugify from "slugify";

@injectable()
export class TeamDBImpl implements TeamDB {
Expand Down Expand Up @@ -121,18 +123,53 @@ export class TeamDBImpl implements TeamDB {
return soleOwnedTeams;
}

public async updateTeam(teamId: string, team: Pick<Team, "name">): Promise<Team> {
const teamRepo = await this.getTeamRepo();
const existingTeam = await teamRepo.findOne({ id: teamId, deleted: false, markedDeleted: false });
if (!existingTeam) {
throw new ResponseError(ErrorCodes.NOT_FOUND, "Organization not found");
}
public async updateTeam(teamId: string, team: Pick<Team, "name" | "slug">): Promise<Team> {
const name = team.name && team.name.trim();
if (!name || name.length === 0 || name.length > 32) {
throw new ResponseError(ErrorCodes.INVALID_VALUE, "The name must be between 1 and 32 characters long");
const slug = team.slug && team.slug.trim();
if (!name && !slug) {
throw new ResponseError(ErrorCodes.BAD_REQUEST, "No update provided");
}
existingTeam.name = name;
return teamRepo.save(existingTeam);

// Storing entry in a TX to avoid potential slug dupes caused by racing requests.
const em = await this.getEntityManager();
return await em.transaction<DBTeam>(async (em) => {
const teamRepo = em.getRepository<DBTeam>(DBTeam);

const existingTeam = await teamRepo.findOne({ id: teamId, deleted: false, markedDeleted: false });
if (!existingTeam) {
throw new ResponseError(ErrorCodes.NOT_FOUND, "Organization not found");
}

// no changes
if (existingTeam.name === name && existingTeam.slug === slug) {
return existingTeam;
}

if (!!name) {
if (name.length > 32) {
throw new ResponseError(
ErrorCodes.INVALID_VALUE,
"The name must be between 1 and 32 characters long",
);
}
existingTeam.name = name;
}
if (!!slug && existingTeam.slug != slug) {
if (slug.length > 63) {
throw new ResponseError(ErrorCodes.INVALID_VALUE, "Slug must be between 1 and 63 characters long");
}
if (!/^[A-Za-z0-9-]+$/.test(slug)) {
throw new ResponseError(ErrorCodes.BAD_REQUEST, "Slug must contain only letters, or numbers");
}
const anotherTeamWithThatSlug = await teamRepo.findOne({ slug, deleted: false, markedDeleted: false });
if (anotherTeamWithThatSlug) {
throw new ResponseError(ErrorCodes.INVALID_VALUE, "Slug must be unique");
}
existingTeam.slug = slug;
}

return teamRepo.save(existingTeam);
});
}

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

const teamRepo = await this.getTeamRepo();
const team: Team = {
id: uuidv4(),
name,
creationTime: new Date().toISOString(),
};
await teamRepo.save(team);
let slug = slugify(name, { lower: true });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing we've ensured that the resulting slug from this function does actually conform to the regex which we're enforcing when doing the update.


// Storing new entry in a TX to avoid potential dupes caused by racing requests.
const em = await this.getEntityManager();
const team = await em.transaction<DBTeam>(async (em) => {
const teamRepo = em.getRepository<DBTeam>(DBTeam);

const existingTeam = await teamRepo.findOne({ slug, deleted: false, markedDeleted: false });
if (!!existingTeam) {
slug = slug + "-" + randomBytes(4).toString("hex");
}

const team: Team = {
id: uuidv4(),
name,
slug,
creationTime: new Date().toISOString(),
};
return await teamRepo.save(team);
});

const membershipRepo = await this.getMembershipRepo();
await membershipRepo.save({
id: uuidv4(),
Expand Down
1 change: 1 addition & 0 deletions components/gitpod-protocol/go/gitpod-service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2295,6 +2295,7 @@ type UserMessage struct {
type Team struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Slug string `json:"slug,omitempty"`
CreationTime string `json:"creationTime,omitempty"`
}

Expand Down
2 changes: 1 addition & 1 deletion components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,

// Teams
getTeam(teamId: string): Promise<Team>;
updateTeam(teamId: string, team: Pick<Team, "name">): Promise<Team>;
updateTeam(teamId: string, team: Pick<Team, "name" | "slug">): Promise<Team>;
getTeams(): Promise<Team[]>;
getTeamMembers(teamId: string): Promise<TeamMemberInfo[]>;
createTeam(name: string): Promise<Team>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export type Team = Organization;
export interface Organization {
id: string;
name: string;
slug?: string;
creationTime: string;
markedDeleted?: boolean;
/** This is a flag that triggers the HARD DELETION of this entity */
Expand Down
1 change: 1 addition & 0 deletions components/public-api-server/pkg/apiv1/team.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ func teamToAPIResponse(team *protocol.Team, members []*protocol.TeamMemberInfo,
return &v1.Team{
Id: team.ID,
Name: team.Name,
Slug: team.Slug,
Members: teamMembersToAPIResponse(members),
TeamInvitation: teamInviteToAPIResponse(invite),
}
Expand Down
4 changes: 2 additions & 2 deletions components/public-api/gitpod/experimental/v1/teams.proto
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ message Team {
// name is the name of the Team
string name = 2;

// previously used for slug
reserved 3;
// slug is the slug of the Team
string slug = 3;

// members are the team members of this Team
repeated TeamMember members = 4;
Expand Down
Loading