@@ -136,10 +150,7 @@ export default function Preferences() {
Save
@@ -173,7 +184,12 @@ export default function Preferences() {
Save
diff --git a/components/dashboard/src/user-settings/SelectIDE.tsx b/components/dashboard/src/user-settings/SelectIDE.tsx
index e87ac85890ca7d..3b15ca0493d31e 100644
--- a/components/dashboard/src/user-settings/SelectIDE.tsx
+++ b/components/dashboard/src/user-settings/SelectIDE.tsx
@@ -4,13 +4,13 @@
* See License.AGPL.txt in the project root for license information.
*/
-import { useCallback, useContext, useEffect, useState } from "react";
+import { useCallback, useContext, useState } from "react";
import { UserContext } from "../user-context";
import { CheckboxInputField } from "../components/forms/CheckboxInputField";
-import { User } from "@gitpod/gitpod-protocol";
import SelectIDEComponent from "../components/SelectIDEComponent";
import PillLabel from "../components/PillLabel";
import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation";
+import { converter } from "../service/public-api";
export type IDEChangedTrackLocation = "workspace_list" | "workspace_start" | "preferences";
interface SelectIDEProps {
@@ -21,54 +21,35 @@ export default function SelectIDE(props: SelectIDEProps) {
const { user, setUser } = useContext(UserContext);
const updateUser = useUpdateCurrentUserMutation();
- // Only exec once when we access this component
- useEffect(() => {
- user && User.migrationIDESettings(user);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const [defaultIde, setDefaultIde] = useState
(user?.additionalData?.ideSettings?.defaultIde || "code");
- const [useLatestVersion, setUseLatestVersion] = useState(
- user?.additionalData?.ideSettings?.useLatestVersion ?? false,
- );
+ const [defaultIde, setDefaultIde] = useState(user?.editorSettings?.name || "code");
+ const [useLatestVersion, setUseLatestVersion] = useState(user?.editorSettings?.version === "latest");
const actualUpdateUserIDEInfo = useCallback(
async (selectedIde: string, useLatestVersion: boolean) => {
- const additionalData = user?.additionalData || {};
- const ideSettings = additionalData.ideSettings || {};
-
// update stored autostart options to match useLatestVersion value set here
- const workspaceAutostartOptions = additionalData?.workspaceAutostartOptions?.map((option) => {
+ const workspaceAutostartOptions = user?.workspaceAutostartOptions?.map((o) => {
+ const option = converter.fromWorkspaceAutostartOption(o);
+
if (option.ideSettings) {
- const newOption = {
- ...option,
- ideSettings: {
- ...option.ideSettings,
- useLatestVersion,
- },
- };
- return newOption;
+ option.ideSettings.useLatestVersion = useLatestVersion;
}
return option;
});
- const updates = {
+ const updatedUser = await updateUser.mutateAsync({
additionalData: {
- ...additionalData,
workspaceAutostartOptions,
ideSettings: {
- ...ideSettings,
settingVersion: "2.0",
defaultIde: selectedIde,
useLatestVersion: useLatestVersion,
},
},
- };
- const newUserData = await updateUser.mutateAsync(updates);
- setUser(newUserData);
+ });
+ setUser(updatedUser);
},
- [setUser, updateUser, user?.additionalData],
+ [setUser, updateUser, user?.workspaceAutostartOptions],
);
const actuallySetDefaultIde = useCallback(
diff --git a/components/dashboard/src/whatsnew/MigrationPage.tsx b/components/dashboard/src/whatsnew/MigrationPage.tsx
deleted file mode 100644
index bbddd125d091ad..00000000000000
--- a/components/dashboard/src/whatsnew/MigrationPage.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * 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 { AdditionalUserData, User } from "@gitpod/gitpod-protocol";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useContext } from "react";
-import { Separator } from "../components/Separator";
-import { Heading3 } from "../components/typography/headings";
-import gitpodIcon from "../icons/gitpod.svg";
-import { OnboardingStep } from "../onboarding/OnboardingStep";
-import { getGitpodService } from "../service/service";
-import { UserContext, useCurrentUser } from "../user-context";
-
-namespace SkipMigration {
- const key = "skip-migration";
- interface SkipInfo {
- validUntil: string;
- timesSkipped: number;
- }
-
- function useGetSkipInfo() {
- return useQuery([key], () => {
- const skippedSerialized = window.localStorage.getItem(key);
- const skipped = skippedSerialized ? (JSON.parse(skippedSerialized) as SkipInfo) : undefined;
- return skipped || null;
- });
- }
-
- export function useIsSkipped(): boolean {
- const skipped = useGetSkipInfo();
- return !!skipped.data && skipped.data.validUntil > new Date().toISOString();
- }
-
- export function useCanSkip(): boolean {
- const skipped = useGetSkipInfo();
- return !skipped.data || skipped.data.timesSkipped < 3;
- }
-
- export function clearSkipInfo() {
- window.localStorage.removeItem(key);
- }
-
- export function useMarkSkipped() {
- const queryClient = useQueryClient();
- const currentSkip = useGetSkipInfo();
- return useMutation({
- mutationFn: async () => {
- const tomorrow = new Date();
- tomorrow.setDate(tomorrow.getDate() + 1);
- const info: SkipInfo = {
- validUntil: tomorrow.toISOString(),
- timesSkipped: currentSkip.data ? currentSkip.data.timesSkipped + 1 : 1,
- };
- window.localStorage.setItem(key, JSON.stringify(info));
- return info;
- },
- onSuccess: (info) => {
- queryClient.invalidateQueries({ queryKey: [key] });
- },
- });
- }
-}
-
-export function useShouldSeeMigrationPage(): boolean {
- const user = useCurrentUser();
- const isSkipped = SkipMigration.useIsSkipped();
- return !!user && !!user.additionalData?.shouldSeeMigrationMessage && !isSkipped;
-}
-
-export function MigrationPage() {
- const markRead = useMarkMessageReadMutation();
- const user = useCurrentUser();
- const canSkip = SkipMigration.useCanSkip();
- const markSkipped = SkipMigration.useMarkSkipped();
- const skipForNow = canSkip ? markSkipped.mutate : undefined;
-
- return (
-
-
-
-
-
-
-
-
- What's different?
-
- Your personal account ({user?.fullName || user?.name} ) was converted to an
- organization. As part of this any of your personal workspaces, projects, and configurations
- have moved to that organization. Additionally, usage cost is now always attributed to the
- currently selected organization, allowing for better cost control.{" "}
-
- Learn more →
-
-
- Who has access to this organization?
-
- Just you. You are the only member of this organization. You can invite members to join your
- org or continue working by yourself.
-
- What do I need to do?
-
- Nothing. There are no changes to your resources or monthly cost. You can manage organization
- settings, billing, or invite others to your organization at any time.
-
-
-
-
-
- );
-}
-
-function useMarkMessageReadMutation() {
- const { user, setUser } = useContext(UserContext);
-
- return useMutation({
- mutationFn: async () => {
- if (!user) {
- throw new Error("No user");
- }
- let updatedUser = AdditionalUserData.set(user, { shouldSeeMigrationMessage: false });
- updatedUser = await getGitpodService().server.updateLoggedInUser(updatedUser);
- SkipMigration.clearSkipInfo();
- setUser(updatedUser);
- return updatedUser;
- },
- });
-}
diff --git a/components/dashboard/src/whatsnew/WhatsNew-2021-04.tsx b/components/dashboard/src/whatsnew/WhatsNew-2021-04.tsx
deleted file mode 100644
index 29b4b2af23ef0d..00000000000000
--- a/components/dashboard/src/whatsnew/WhatsNew-2021-04.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * Copyright (c) 2021 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 { User } from "@gitpod/gitpod-protocol";
-import { getGitpodService } from "../service/service";
-import { WhatsNewEntry } from "./WhatsNew";
-
-export const switchToVSCodeAction = async (user: User) => {
- const additionalData = (user.additionalData = user.additionalData || {});
- // make sure code is set as the editor preference
- const ideSettings = (additionalData.ideSettings = additionalData.ideSettings || {});
- ideSettings.defaultIde = "code";
- user = await getGitpodService().server.updateLoggedInUser({
- additionalData,
- });
- return user;
-};
-
-export const WhatsNewEntry202104: WhatsNewEntry = {
- newsKey: "April-2021",
- maxUserCreationDate: "2021-04-08",
- children: () => (
- <>
-
-
New Dashboard
-
- We have made some layout changes on the dashboard to improve the overall user experience of Gitpod.
-
-
-
-
VS Code
-
- We are changing the default IDE to VS Code.
-
-
-
-
-
- We're preserving most user settings and extensions .
-
-
- Extensions you have manually uploaded are not transferred. You'll need to search and
- install those extensions through the extension panel in VS Code.
-
-
-
-
-
-
- We've reduced the number of pre-installed extensions .
-
-
- The Theia-based editor included pre-installed extensions for the most popular
- programming languages which was convenient for starters but added additional bloat. You
- can now install any extensions you need and leave out those you don't.
-
-
-
-
-
-
- You can still switch the IDE back to Theia.
-
-
- In case you run into trouble with VS Code, you can go to the settings and switch back to
- the Theia.
-
-
-
-
-
- >
- ),
- actionAfterSeen: switchToVSCodeAction,
-};
diff --git a/components/dashboard/src/whatsnew/WhatsNew-2021-06.tsx b/components/dashboard/src/whatsnew/WhatsNew-2021-06.tsx
deleted file mode 100644
index 15cab8a0f368c5..00000000000000
--- a/components/dashboard/src/whatsnew/WhatsNew-2021-06.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Copyright (c) 2021 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 { User } from "@gitpod/gitpod-protocol";
-import { WhatsNewEntry } from "./WhatsNew";
-import { switchToVSCodeAction } from "./WhatsNew-2021-04";
-import PillLabel from "../components/PillLabel";
-
-export const WhatsNewEntry202106: WhatsNewEntry = {
- children: (user: User, setUser: React.Dispatch) => {
- return (
- <>
-
-
- Exposing Ports Configuration Update
-
-
- We've changed the default behavior of exposed ports to improve the security of your dev
- environments.
-
-
- Exposing ports are now private by default. You can still change port visibility through the
- editor or even configure this with the visibility property in{" "}
- .gitpod.yml.
-
-
- {user.additionalData?.ideSettings?.defaultIde !== "code" && (
-
-
- New Editor Deprecation Warning
-
-
- We're deprecating the Theia editor. You can still switch back to Theia for the next few
- weeks but the preference will be removed by the end of August 2021.
-
-
- )}
- >
- );
- },
- newsKey: "June-2021",
- maxUserCreationDate: "2021-07-01",
- actionAfterSeen: switchToVSCodeAction,
-};
diff --git a/components/dashboard/src/whatsnew/WhatsNew.tsx b/components/dashboard/src/whatsnew/WhatsNew.tsx
deleted file mode 100644
index c11dae9cc92d73..00000000000000
--- a/components/dashboard/src/whatsnew/WhatsNew.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * Copyright (c) 2021 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 { User } from "@gitpod/gitpod-protocol";
-import Modal, { ModalHeader } from "../components/Modal";
-import { WhatsNewEntry202104 } from "./WhatsNew-2021-04";
-import { WhatsNewEntry202106 } from "./WhatsNew-2021-06";
-import { UserContext } from "../user-context";
-import { useContext, useState } from "react";
-import { getGitpodService } from "../service/service";
-import { Button } from "@podkit/buttons/Button";
-
-const allEntries: WhatsNewEntry[] = [WhatsNewEntry202106, WhatsNewEntry202104];
-
-export const shouldSeeWhatsNew = (
- user: User,
- news: { newsKey: string; maxUserCreationDate: string }[] = allEntries,
-) => {
- const whatsNewSeen = user?.additionalData?.whatsNewSeen;
- return news.some((x) => user.creationDate <= x.maxUserCreationDate && (!whatsNewSeen || !whatsNewSeen[x.newsKey]));
-};
-
-export function WhatsNew(props: { onClose: () => void }) {
- const { user, setUser } = useContext(UserContext);
-
- const _unseenEntries = allEntries.filter((x) => user && shouldSeeWhatsNew(user, [x])) || [];
- const [visibleEntry, setVisibleEntry] = useState(_unseenEntries.pop());
- const [unseenEntries, setUnseenEntries] = useState(_unseenEntries);
-
- const markAsSeen = async (user?: User, ...news: (WhatsNewEntry | undefined)[]) => {
- if (!user) {
- return;
- }
-
- for (const n of news.filter((x) => x && x.actionAfterSeen)) {
- user = await n!.actionAfterSeen!(user);
- }
-
- const additionalData = (user.additionalData = user.additionalData || {});
- additionalData.whatsNewSeen = additionalData.whatsNewSeen || {};
- const now = new Date().toISOString();
- for (const newsKey of (news.filter((x) => x !== undefined) as WhatsNewEntry[]).map((x) => x.newsKey)) {
- additionalData.whatsNewSeen[newsKey] = now;
- }
- user = await getGitpodService().server.updateLoggedInUser({
- additionalData,
- });
- setUser(user);
- };
-
- const internalClose = async () => {
- await markAsSeen(user, ...unseenEntries, visibleEntry);
- props.onClose();
- };
-
- const hasNext = () => unseenEntries.length > 0;
-
- const next = async () => {
- if (unseenEntries.length === 0) {
- return;
- }
- visibleEntry && (await markAsSeen(user, visibleEntry));
- const _unseenEntries = unseenEntries;
- setVisibleEntry(_unseenEntries.pop());
- setUnseenEntries(_unseenEntries);
- };
-
- return (
- // TODO: Use title and buttons props
-
- What's New 🎁
- <>{visibleEntry && user ? visibleEntry.children(user, setUser) : <>>}>
- {hasNext() ? (
-
-
- {unseenEntries.length} more update{unseenEntries.length > 1 ? "s" : ""}
-
-
- Dismiss All
-
-
- Next
-
-
- ) : (
-
- Continue
-
- )}
-
- );
-}
-
-export interface WhatsNewEntry {
- newsKey: string;
- maxUserCreationDate: string;
- children: (user: User, setUser: React.Dispatch) => React.ReactChild[] | React.ReactChild;
- actionAfterSeen?: (user: User) => Promise;
-}
diff --git a/components/dashboard/src/workspaces/CreateWorkspacePage.tsx b/components/dashboard/src/workspaces/CreateWorkspacePage.tsx
index 51cc2bccb25062..273c1b0f8ad06c 100644
--- a/components/dashboard/src/workspaces/CreateWorkspacePage.tsx
+++ b/components/dashboard/src/workspaces/CreateWorkspacePage.tsx
@@ -4,7 +4,7 @@
* See License.AGPL.txt in the project root for license information.
*/
-import { AdditionalUserData, CommitContext, SuggestedRepository, WithReferrerContext } from "@gitpod/gitpod-protocol";
+import { CommitContext, SuggestedRepository, WithReferrerContext } from "@gitpod/gitpod-protocol";
import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth";
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";
@@ -28,7 +28,7 @@ import { useListWorkspacesQuery } from "../data/workspaces/list-workspaces-query
import { useWorkspaceContext } from "../data/workspaces/resolve-context-query";
import { useDirtyState } from "../hooks/use-dirty-state";
import { openAuthorizeWindow } from "../provider-utils";
-import { getGitpodService, gitpodHostUrl } from "../service/service";
+import { gitpodHostUrl } from "../service/service";
import { StartWorkspaceError } from "../start/StartPage";
import { VerifyModal } from "../start/VerifyModal";
import { StartWorkspaceOptions } from "../start/start-workspace-options";
@@ -45,9 +45,14 @@ import { Button } from "@podkit/buttons/Button";
import { LoadingButton } from "@podkit/buttons/LoadingButton";
import { CreateAndStartWorkspaceRequest } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
import { PartialMessage } from "@bufbuild/protobuf";
+import { User_WorkspaceAutostartOption } from "@gitpod/public-api/lib/gitpod/v1/user_pb";
+import { EditorReference } from "@gitpod/public-api/lib/gitpod/v1/editor_pb";
+import { converter } from "../service/public-api";
+import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation";
export function CreateWorkspacePage() {
const { user, setUser } = useContext(UserContext);
+ const updateUser = useUpdateCurrentUserMutation();
const currentOrg = useCurrentOrg().data;
const projects = useListAllProjectsQuery();
const workspaces = useListWorkspacesQuery({ limit: 50 });
@@ -60,12 +65,10 @@ export function CreateWorkspacePage() {
const defaultLatestIde =
props.ideSettings?.useLatestVersion !== undefined
? props.ideSettings.useLatestVersion
- : !!user?.additionalData?.ideSettings?.useLatestVersion;
+ : user?.editorSettings?.version === "latest";
const [useLatestIde, setUseLatestIde] = useState(defaultLatestIde);
const defaultIde =
- props.ideSettings?.defaultIde !== undefined
- ? props.ideSettings.defaultIde
- : user?.additionalData?.ideSettings?.defaultIde;
+ props.ideSettings?.defaultIde !== undefined ? props.ideSettings.defaultIde : user?.editorSettings?.name;
const [selectedIde, setSelectedIde, selectedIdeIsDirty] = useDirtyState(defaultIde);
const defaultWorkspaceClass = props.workspaceClass;
const [selectedWsClass, setSelectedWsClass, selectedWsClassIsDirty] = useDirtyState(defaultWorkspaceClass);
@@ -89,29 +92,34 @@ export function CreateWorkspacePage() {
if (!cloneURL) {
return;
}
- let workspaceAutoStartOptions = (user.additionalData?.workspaceAutostartOptions || []).filter(
- (e) => !(e.cloneURL === cloneURL && e.organizationId === currentOrg.id),
+ let workspaceAutoStartOptions = (user.workspaceAutostartOptions || []).filter(
+ (e) => !(e.cloneUrl === cloneURL && e.organizationId === currentOrg.id),
);
// we only keep the last 40 options
workspaceAutoStartOptions = workspaceAutoStartOptions.slice(-40);
// remember options
- workspaceAutoStartOptions.push({
- cloneURL,
- organizationId: currentOrg.id,
- ideSettings: {
- defaultIde: selectedIde,
- useLatestVersion: useLatestIde,
+ workspaceAutoStartOptions.push(
+ new User_WorkspaceAutostartOption({
+ cloneUrl: cloneURL,
+ organizationId: currentOrg.id,
+ workspaceClass: selectedWsClass,
+ editorSettings: new EditorReference({
+ name: selectedIde,
+ version: useLatestIde ? "latest" : "stable",
+ }),
+ }),
+ );
+ const updatedUser = await updateUser.mutateAsync({
+ additionalData: {
+ workspaceAutostartOptions: workspaceAutoStartOptions.map((o) =>
+ converter.fromWorkspaceAutostartOption(o),
+ ),
},
- workspaceClass: selectedWsClass,
- });
- AdditionalUserData.set(user, {
- workspaceAutostartOptions: workspaceAutoStartOptions,
});
- setUser(user);
- await getGitpodService().server.updateLoggedInUser(user);
- }, [currentOrg, selectedIde, selectedWsClass, setUser, useLatestIde, user, workspaceContext.data]);
+ setUser(updatedUser);
+ }, [updateUser, currentOrg, selectedIde, selectedWsClass, setUser, useLatestIde, user, workspaceContext.data]);
// see if we have a matching project based on context url and project's repo url
const project = useMemo(() => {
@@ -279,13 +287,13 @@ export function CreateWorkspacePage() {
if (!cloneURL) {
return undefined;
}
- const rememberedOptions = (user?.additionalData?.workspaceAutostartOptions || []).find(
- (e) => e.cloneURL === cloneURL && e.organizationId === currentOrg?.id,
+ const rememberedOptions = (user?.workspaceAutostartOptions || []).find(
+ (e) => e.cloneUrl === cloneURL && e.organizationId === currentOrg?.id,
);
if (rememberedOptions) {
if (!selectedIdeIsDirty) {
- setSelectedIde(rememberedOptions.ideSettings?.defaultIde, false);
- setUseLatestIde(!!rememberedOptions.ideSettings?.useLatestVersion);
+ setSelectedIde(rememberedOptions.editorSettings?.name, false);
+ setUseLatestIde(rememberedOptions.editorSettings?.version === "latest");
}
if (!selectedWsClassIsDirty) {
diff --git a/components/gitpod-db/src/typeorm/team-db-impl.ts b/components/gitpod-db/src/typeorm/team-db-impl.ts
index cb695bb05e77a0..9fdcac7585408b 100644
--- a/components/gitpod-db/src/typeorm/team-db-impl.ts
+++ b/components/gitpod-db/src/typeorm/team-db-impl.ts
@@ -10,7 +10,6 @@ import {
TeamMemberInfo,
TeamMemberRole,
TeamMembershipInvite,
- User,
} from "@gitpod/gitpod-protocol";
import { ErrorCodes, ApplicationError } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { randomBytes } from "crypto";
@@ -101,7 +100,6 @@ export class TeamDBImpl extends TransactionalDBImpl implements TeamDB {
return {
userId: u.id,
fullName: u.fullName || u.name,
- primaryEmail: User.getPrimaryEmail(u),
avatarUrl: u.avatarUrl,
role: m.role,
memberSince: m.creationTime,
diff --git a/components/gitpod-protocol/src/experiments/configcat.ts b/components/gitpod-protocol/src/experiments/configcat.ts
index b0a56f0b09f391..ff707930167021 100644
--- a/components/gitpod-protocol/src/experiments/configcat.ts
+++ b/components/gitpod-protocol/src/experiments/configcat.ts
@@ -7,7 +7,6 @@
import { Attributes, Client } from "./types";
import { User as ConfigCatUser } from "configcat-common/lib/RolloutEvaluator";
import { IConfigCatClient } from "configcat-common/lib/ConfigCatClient";
-import { User } from "../protocol";
export const USER_ID_ATTRIBUTE = "user_id";
export const PROJECT_ID_ATTRIBUTE = "project_id";
@@ -37,7 +36,7 @@ export class ConfigCatClient implements Client {
export function attributesToUser(attributes: Attributes): ConfigCatUser {
const userId = attributes.user?.id || "";
- const email = User.is(attributes.user) ? User.getPrimaryEmail(attributes.user) : attributes.user?.email || "";
+ const email = attributes.user?.email || "";
const custom: { [key: string]: string } = {};
if (userId) {
diff --git a/components/gitpod-protocol/src/experiments/types.ts b/components/gitpod-protocol/src/experiments/types.ts
index d804c99acdac31..ae0566c59023f0 100644
--- a/components/gitpod-protocol/src/experiments/types.ts
+++ b/components/gitpod-protocol/src/experiments/types.ts
@@ -4,7 +4,7 @@
* See License.AGPL.txt in the project root for license information.
*/
-import { BillingTier, User } from "../protocol";
+import { BillingTier } from "../protocol";
export const Client = Symbol("Client");
@@ -12,7 +12,7 @@ export const Client = Symbol("Client");
// Set the attributes which you want to use to group audiences into.
export interface Attributes {
// user.id is mapped to ConfigCat's "identifier" + "custom.user_id"
- user?: User | { id: string; email?: string };
+ user?: { id: string; email?: string };
// The BillingTier of this particular user
billingTier?: BillingTier;
diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts
index 10bf9114dc0e40..0877e769e8ca81 100644
--- a/components/gitpod-protocol/src/protocol.ts
+++ b/components/gitpod-protocol/src/protocol.ts
@@ -71,77 +71,6 @@ export namespace User {
return user.identities.find((id) => id.authProviderId === authProviderId);
}
- /**
- * Returns a primary email address of a user.
- *
- * For accounts owned by an organization, it returns the email of the most recently used SSO identity.
- *
- * For personal accounts, first it looks for a email stored by the user, and falls back to any of the Git provider identities.
- *
- * @param user
- * @returns A primaryEmail, or undefined.
- */
- export function getPrimaryEmail(user: User): string | undefined {
- // If the accounts is owned by an organization, use the email of the most recently
- // used SSO identity.
- if (User.isOrganizationOwned(user)) {
- const compareTime = (a?: string, b?: string) => (a || "").localeCompare(b || "");
- const recentlyUsedSSOIdentity = user.identities
- .sort((a, b) => compareTime(a.lastSigninTime, b.lastSigninTime))
- // optimistically pick the most recent one
- .reverse()[0];
- return recentlyUsedSSOIdentity?.primaryEmail;
- }
-
- // In case of a personal account, check for the email stored by the user.
- if (!isOrganizationOwned(user) && user.additionalData?.profile?.emailAddress) {
- return user.additionalData?.profile?.emailAddress;
- }
-
- // Otherwise pick any
- // FIXME(at) this is still not correct, as it doesn't distinguish between
- // sign-in providers and additional Git hosters.
- const identities = user.identities.filter((i) => !!i.primaryEmail);
- if (identities.length <= 0) {
- return undefined;
- }
-
- return identities[0].primaryEmail || undefined;
- }
-
- export function getName(user: User): string | undefined {
- const name = user.fullName || user.name;
- if (name) {
- return name;
- }
-
- for (const id of user.identities) {
- if (id.authName !== "") {
- return id.authName;
- }
- }
- return undefined;
- }
-
- export function hasPreferredIde(user: User) {
- return (
- typeof user?.additionalData?.ideSettings?.defaultIde !== "undefined" ||
- typeof user?.additionalData?.ideSettings?.useLatestVersion !== "undefined"
- );
- }
-
- export function isOnboardingUser(user: User) {
- if (isOrganizationOwned(user)) {
- return false;
- }
- // If a user has already been onboarded
- // Also, used to rule out "admin-user"
- if (!!user.additionalData?.profile?.onboardedTimestamp) {
- return false;
- }
- return !hasPreferredIde(user);
- }
-
export function isOrganizationOwned(user: User) {
return !!user.organizationId;
}
@@ -175,74 +104,6 @@ export namespace User {
}
user.additionalData.ideSettings = newIDESettings;
}
-
- // TODO: make it more explicit that these field names are relied for our tracking purposes
- // and decouple frontend from relying on them - instead use user.additionalData.profile object directly in FE
- export function getProfile(user: User): Profile {
- return {
- name: User.getName(user!) || "",
- email: User.getPrimaryEmail(user!) || "",
- company: user?.additionalData?.profile?.companyName,
- avatarURL: user?.avatarUrl,
- jobRole: user?.additionalData?.profile?.jobRole,
- jobRoleOther: user?.additionalData?.profile?.jobRoleOther,
- explorationReasons: user?.additionalData?.profile?.explorationReasons,
- signupGoals: user?.additionalData?.profile?.signupGoals,
- signupGoalsOther: user?.additionalData?.profile?.signupGoalsOther,
- companySize: user?.additionalData?.profile?.companySize,
- onboardedTimestamp: user?.additionalData?.profile?.onboardedTimestamp,
- };
- }
-
- export function setProfile(user: User, profile: Profile): User {
- user.fullName = profile.name;
- user.avatarUrl = profile.avatarURL;
-
- if (!user.additionalData) {
- user.additionalData = {};
- }
- if (!user.additionalData.profile) {
- user.additionalData.profile = {};
- }
- user.additionalData.profile.emailAddress = profile.email;
- user.additionalData.profile.companyName = profile.company;
- user.additionalData.profile.lastUpdatedDetailsNudge = new Date().toISOString();
-
- return user;
- }
-
- // TODO: refactor where this is referenced so it's more clearly tied to just analytics-tracking
- // Let other places rely on the ProfileDetails type since that's what we store
- // This is the profile data we send to our Segment analytics tracking pipeline
- export interface Profile {
- name: string;
- email: string;
- company?: string;
- avatarURL?: string;
- jobRole?: string;
- jobRoleOther?: string;
- explorationReasons?: string[];
- signupGoals?: string[];
- signupGoalsOther?: string;
- onboardedTimestamp?: string;
- companySize?: string;
- }
- export namespace Profile {
- export function hasChanges(before: Profile, after: Profile) {
- return (
- before.name !== after.name ||
- before.email !== after.email ||
- before.company !== after.company ||
- before.avatarURL !== after.avatarURL ||
- before.jobRole !== after.jobRole ||
- before.jobRoleOther !== after.jobRoleOther ||
- // not checking explorationReasons or signupGoals atm as it's an array - need to check deep equality
- before.signupGoalsOther !== after.signupGoalsOther ||
- before.onboardedTimestamp !== after.onboardedTimestamp ||
- before.companySize !== after.companySize
- );
- }
- }
}
export interface WorkspaceTimeoutSetting {
@@ -278,7 +139,7 @@ export interface AdditionalUserData extends Partial {
workspaceAutostartOptions?: WorkspaceAutostartOption[];
}
-interface WorkspaceAutostartOption {
+export interface WorkspaceAutostartOption {
cloneURL: string;
organizationId: string;
workspaceClass?: string;
@@ -1450,12 +1311,6 @@ export interface CommitInfo {
authorDate?: string;
}
-export namespace Repository {
- export function fullRepoName(repo: Repository): string {
- return `${repo.host}/${repo.owner}/${repo.name}`;
- }
-}
-
export interface WorkspaceInstancePortsChangedEvent {
type: "PortsChanged";
instanceID: string;
@@ -1500,17 +1355,6 @@ export namespace WorkspaceCreationResult {
}
}
-export interface UserMessage {
- readonly id: string;
- readonly title?: string;
- /**
- * date from where on this message should be shown
- */
- readonly from?: string;
- readonly content?: string;
- readonly url?: string;
-}
-
export interface AuthProviderInfo {
readonly authProviderId: string;
readonly authProviderType: string;
diff --git a/components/public-api/typescript-common/fixtures/toUser_1.golden b/components/public-api/typescript-common/fixtures/toUser_1.golden
new file mode 100644
index 00000000000000..a687a822efefbb
--- /dev/null
+++ b/components/public-api/typescript-common/fixtures/toUser_1.golden
@@ -0,0 +1,75 @@
+{
+ "result": {
+ "id": "007a807f-f2a7-436b-a77f-66ed11ee7828",
+ "organizationId": "6f5b2707-c83f-4e04-a37c-23d1b1d385ae",
+ "name": "Gitpod Tester",
+ "avatarUrl": "https://avatars.githubusercontent.com/u/37021919?v=4",
+ "createdAt": "2023-12-04T08:54:31.686Z",
+ "identities": [
+ {
+ "authProviderId": "Public-GitHub",
+ "authId": "37021919",
+ "authName": "GitpodTester",
+ "primaryEmail": "tester@gitpod.io"
+ }
+ ],
+ "blocked": false,
+ "lastVerificationTime": "2023-12-04T08:54:31.700Z",
+ "verificationPhoneNumber": "+49150-111111111",
+ "workspaceTimeoutSettings": {
+ "inactivity": "3600s",
+ "disabledDisconnected": true
+ },
+ "emailNotificationSettings": {
+ "allowsChangelogMail": true,
+ "allowsDevxMail": true,
+ "allowsOnboardingMail": true
+ },
+ "editorSettings": {
+ "name": "code",
+ "version": "latest"
+ },
+ "dotfileRepo": "https://github.com/gitpod-samples/demo-dotfiles-with-gitpod",
+ "workspaceClass": "XXXL",
+ "profile": {
+ "lastUpdatedDetailsNudge": "2023-12-04T08:54:34.831Z",
+ "acceptedPrivacyPolicyDate": "2023-12-04T08:54:31.700Z",
+ "companyName": "",
+ "emailAddress": "tester@gitpod.io",
+ "jobRole": "other",
+ "jobRoleOther": "",
+ "explorationReasons": [
+ "replace-remote-dev",
+ "replace-remote-dev"
+ ],
+ "signupGoals": [
+ "other",
+ "other"
+ ],
+ "signupGoalsOther": "",
+ "onboardedTimestamp": "2023-12-04T08:54:41.326Z",
+ "companySize": ""
+ },
+ "workspaceAutostartOptions": [
+ {
+ "cloneUrl": "https://github.com/gitpod-io/gitpod",
+ "organizationId": "6f5b2707-c83f-4e04-a37c-23d1b1d385ae",
+ "workspaceClass": "XXXL",
+ "editorSettings": {
+ "name": "code",
+ "version": "stable"
+ },
+ "region": ""
+ }
+ ],
+ "usageAttributionId": "",
+ "workspaceFeatureFlags": [
+ "USER_FEATURE_FLAG_FULL_WORKSPACE_BACKUP"
+ ],
+ "rolesOrPermissions": [
+ "ROLE_OR_PERMISSION_ADMIN",
+ "ROLE_OR_PERMISSION_DEVELOPER"
+ ]
+ },
+ "err": ""
+}
diff --git a/components/public-api/typescript-common/fixtures/toUser_1.json b/components/public-api/typescript-common/fixtures/toUser_1.json
new file mode 100644
index 00000000000000..82abbd2646f889
--- /dev/null
+++ b/components/public-api/typescript-common/fixtures/toUser_1.json
@@ -0,0 +1,84 @@
+{
+ "id": "007a807f-f2a7-436b-a77f-66ed11ee7828",
+ "creationDate": "2023-12-04T08:54:31.686Z",
+ "avatarUrl": "https://avatars.githubusercontent.com/u/37021919?v=4",
+ "name": "GitpodTester",
+ "fullName": "Gitpod Tester",
+ "organizationId": "6f5b2707-c83f-4e04-a37c-23d1b1d385ae",
+ "verificationPhoneNumber": "+49150-111111111",
+ "blocked": false,
+ "featureFlags": {
+ "permanentWSFeatureFlags": [
+ "full_workspace_backup"
+ ]
+ },
+ "rolesOrPermissions": [
+ "admin",
+ "developer"
+ ],
+ "markedDeleted": false,
+ "noReleasePeriod": false,
+ "additionalData": {
+ "emailNotificationSettings": {
+ "allowsChangelogMail": true,
+ "allowsDevXMail": true,
+ "allowsOnboardingMail": true
+ },
+ "profile": {
+ "acceptedPrivacyPolicyDate": "2023-12-04T08:54:31.700Z",
+ "emailAddress": "tester@gitpod.io",
+ "lastUpdatedDetailsNudge": "2023-12-04T08:54:34.831Z",
+ "jobRole": "other",
+ "jobRoleOther": "",
+ "explorationReasons": [
+ "replace-remote-dev",
+ "replace-remote-dev"
+ ],
+ "signupGoals": [
+ "other",
+ "other"
+ ],
+ "signupGoalsOther": "",
+ "companySize": "",
+ "onboardedTimestamp": "2023-12-04T08:54:41.326Z",
+ "name": "Gitpod Tester",
+ "email": "tester@gitpod.io",
+ "company": "",
+ "avatarURL": "https://avatars.githubusercontent.com/u/37021919?v=4"
+ },
+ "shouldSeeMigrationMessage": false,
+ "ideSettings": {
+ "settingVersion": "2.0",
+ "defaultIde": "code",
+ "useLatestVersion": true
+ },
+ "workspaceAutostartOptions": [
+ {
+ "cloneURL": "https://github.com/gitpod-io/gitpod",
+ "organizationId": "6f5b2707-c83f-4e04-a37c-23d1b1d385ae",
+ "ideSettings": {
+ "defaultIde": "code"
+ },
+ "workspaceClass": "XXXL"
+ }
+ ],
+ "disabledClosedTimeout": true,
+ "workspaceTimeout": "1h",
+ "dotfileRepo": "https://github.com/gitpod-samples/demo-dotfiles-with-gitpod",
+ "workspaceClasses": {
+ "regular": "XXXL"
+ }
+ },
+ "lastVerificationTime": "2023-12-04T08:54:31.700Z",
+ "fgaRelationshipsVersion": 5,
+ "identities": [
+ {
+ "authProviderId": "Public-GitHub",
+ "authId": "37021919",
+ "authName": "GitpodTester",
+ "primaryEmail": "tester@gitpod.io",
+ "deleted": false,
+ "readonly": false
+ }
+ ]
+}
diff --git a/components/public-api/typescript-common/package.json b/components/public-api/typescript-common/package.json
index 650bc23f23843a..cc0e6654d88f80 100644
--- a/components/public-api/typescript-common/package.json
+++ b/components/public-api/typescript-common/package.json
@@ -19,7 +19,8 @@
"test": "mocha './**/*.spec.js' --exclude './node_modules/**' --exclude './lib/esm/**' --exit",
"test:forceUpdate": "mocha './**/*.spec.js' --exclude './node_modules/**' --exclude './lib/esm/**' --exit -force -update && yarn format:fixtures",
"test:leeway": "yarn build && yarn test",
- "format:fixtures": "git ls-files -- 'fixtures/*' | xargs pre-commit run end-of-file-fixer --files > /dev/null || exit 0"
+ "format:fixtures": "git ls-files -- 'fixtures/*' | xargs pre-commit run end-of-file-fixer --files > /dev/null || exit 0",
+ "watch": "leeway exec --package .:lib --transitive-dependencies --filter-type yarn --components --parallel -- tsc -w --preserveWatchOutput"
},
"dependencies": {
"@bufbuild/protobuf": "^1.3.3",
diff --git a/components/public-api/typescript-common/src/public-api-converter.spec.ts b/components/public-api/typescript-common/src/public-api-converter.spec.ts
index f4699c4553c7da..37bc947b124880 100644
--- a/components/public-api/typescript-common/src/public-api-converter.spec.ts
+++ b/components/public-api/typescript-common/src/public-api-converter.spec.ts
@@ -35,6 +35,10 @@ describe("PublicAPIConverter", () => {
const converter = new PublicAPIConverter();
describe("golden tests", () => {
+ it("toUser", async () => {
+ await startFixtureTest("../fixtures/toUser_*.json", async (input) => converter.toUser(input));
+ });
+
it("toOrganization", async () => {
await startFixtureTest("../fixtures/toOrganization_*.json", async (input) =>
converter.toOrganization(input),
@@ -219,10 +223,10 @@ describe("PublicAPIConverter", () => {
});
describe("toDurationString", () => {
- it("should convert with 0", () => {
- expect(converter.toDurationString(new Duration())).to.equal("0");
- expect(converter.toDurationString(new Duration({ seconds: BigInt(0) }))).to.equal("0");
- expect(converter.toDurationString(new Duration({ nanos: 0 }))).to.equal("0");
+ it("should convert with empty string", () => {
+ expect(converter.toDurationString(new Duration())).to.equal("");
+ expect(converter.toDurationString(new Duration({ seconds: BigInt(0) }))).to.equal("");
+ expect(converter.toDurationString(new Duration({ nanos: 0 }))).to.equal("");
});
it("should convert with hours", () => {
expect(converter.toDurationString(new Duration({ seconds: BigInt(3600) }))).to.equal("1h");
diff --git a/components/public-api/typescript-common/src/public-api-converter.ts b/components/public-api/typescript-common/src/public-api-converter.ts
index 5fcc2a9665fcc0..05ebf4521c2710 100644
--- a/components/public-api/typescript-common/src/public-api-converter.ts
+++ b/components/public-api/typescript-common/src/public-api-converter.ts
@@ -4,6 +4,8 @@
* See License.AGPL.txt in the project root for license information.
*/
+import "reflect-metadata";
+
import { Timestamp, toPlainMessage, PartialMessage, Duration } from "@bufbuild/protobuf";
import { Code, ConnectError } from "@connectrpc/connect";
import {
@@ -25,6 +27,15 @@ import {
AuthProviderType,
OAuth2Config,
} from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb";
+import {
+ Identity,
+ User,
+ User_EmailNotificationSettings,
+ User_RoleOrPermission,
+ User_UserFeatureFlag,
+ User_WorkspaceAutostartOption,
+ User_WorkspaceTimeoutSettings,
+} from "@gitpod/public-api/lib/gitpod/v1/user_pb";
import {
BranchMatchingStrategy,
Configuration,
@@ -81,6 +92,8 @@ import {
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { InvalidGitpodYMLError, RepositoryNotFoundError, UnauthorizedRepositoryAccessError } from "./public-api-errors";
import {
+ User as UserProtocol,
+ Identity as IdentityProtocol,
AuthProviderEntry as AuthProviderProtocol,
AuthProviderInfo,
CommitContext,
@@ -99,6 +112,9 @@ import {
UserSSHPublicKeyValue,
SnapshotContext,
EmailDomainFilterEntry,
+ NamedWorkspaceFeatureFlag,
+ WorkspaceAutostartOption,
+ IDESettings,
} from "@gitpod/gitpod-protocol/lib/protocol";
import {
OrgMemberInfo,
@@ -116,11 +132,14 @@ import {
WorkspaceInstance,
WorkspaceInstanceConditions,
WorkspaceInstancePort,
-} from "@gitpod/gitpod-protocol/lib//workspace-instance";
+} from "@gitpod/gitpod-protocol/lib/workspace-instance";
import { Author, Commit } from "@gitpod/public-api/lib/gitpod/v1/scm_pb";
import type { DeepPartial } from "@gitpod/gitpod-protocol/lib/util/deep-partial";
import { BlockedRepository as ProtocolBlockedRepository } from "@gitpod/gitpod-protocol/lib/blocked-repositories-protocol";
import { SupportedWorkspaceClass } from "@gitpod/gitpod-protocol/lib/workspace-class";
+import { RoleOrPermission } from "@gitpod/gitpod-protocol/lib/permission";
+import { parseGoDurationToMs } from "@gitpod/gitpod-protocol/lib/util/timeutil";
+import { isWorkspaceRegion } from "@gitpod/gitpod-protocol/lib/workspace-cluster";
export type PartialConfiguration = DeepPartial & Pick;
@@ -1052,10 +1071,10 @@ export class PublicAPIConverter {
* `Duration.nanos` is ignored
* @returns a string like "1h2m3s", valid time units are `s`, `m`, `h`
*/
- toDurationString(duration: PartialMessage): string {
- const seconds = duration.seconds || 0;
+ toDurationString(duration?: PartialMessage): string {
+ const seconds = duration?.seconds || 0;
if (seconds === 0) {
- return "0";
+ return "";
}
const totalMilliseconds = Number(seconds) * 1000;
@@ -1070,30 +1089,76 @@ export class PublicAPIConverter {
}`;
}
+ toUser(from: UserProtocol): User {
+ const {
+ id,
+ name,
+ fullName,
+ creationDate,
+ identities,
+ additionalData,
+ avatarUrl,
+ featureFlags,
+ organizationId,
+ rolesOrPermissions,
+ usageAttributionId,
+ blocked,
+ lastVerificationTime,
+ verificationPhoneNumber,
+ } = from;
+ const {
+ disabledClosedTimeout,
+ dotfileRepo,
+ emailNotificationSettings,
+ ideSettings,
+ profile,
+ workspaceAutostartOptions,
+ workspaceClasses,
+ workspaceTimeout,
+ } = additionalData || {};
+
+ return new User({
+ id,
+ name: fullName || name,
+ createdAt: this.toTimestamp(creationDate),
+ avatarUrl,
+ organizationId,
+ usageAttributionId,
+ blocked,
+ identities: identities?.map((i) => this.toIdentity(i)),
+ rolesOrPermissions: rolesOrPermissions?.map((rp) => this.toRoleOrPermission(rp)),
+ workspaceFeatureFlags: featureFlags?.permanentWSFeatureFlags?.map((ff) => this.toUserFeatureFlags(ff)),
+ workspaceTimeoutSettings: new User_WorkspaceTimeoutSettings({
+ inactivity: !!workspaceTimeout ? this.toDuration(workspaceTimeout) : undefined,
+ disabledDisconnected: disabledClosedTimeout,
+ }),
+ dotfileRepo,
+ emailNotificationSettings: new User_EmailNotificationSettings({
+ allowsChangelogMail: emailNotificationSettings?.allowsChangelogMail,
+ allowsDevxMail: emailNotificationSettings?.allowsDevXMail,
+ allowsOnboardingMail: emailNotificationSettings?.allowsOnboardingMail,
+ }),
+ editorSettings: this.toEditorReference(ideSettings),
+ lastVerificationTime: this.toTimestamp(lastVerificationTime),
+ verificationPhoneNumber,
+ workspaceClass: workspaceClasses?.regular,
+ workspaceAutostartOptions: workspaceAutostartOptions?.map((o) => this.toWorkspaceAutostartOption(o)),
+ profile,
+ });
+ }
+
/**
* Converts a duration string like "1h2m3s" to a Duration
*
* @param durationString "1h2m3s" valid time units are `s`, `m`, `h`
*/
- toDuration(durationString: string): Duration {
- const units = new Map([
- ["h", 3600],
- ["m", 60],
- ["s", 1],
- ]);
- const regex = /(\d+(?:\.\d+)?)([hmsµµs]+)/g;
- let totalSeconds = 0;
- let match: RegExpExecArray | null;
-
- while ((match = regex.exec(durationString)) !== null) {
- const value = parseFloat(match[1]);
- const unit = match[2];
- totalSeconds += value * (units.get(unit) || 0);
- }
-
+ toDuration(from: string): Duration {
+ const millis = parseGoDurationToMs(from);
+ const seconds = BigInt(Math.floor(millis / 1000));
+ const nanos = (millis % 1000) * 1000000;
return new Duration({
- seconds: BigInt(Math.floor(totalSeconds)),
- nanos: (totalSeconds - Math.floor(totalSeconds)) * 1e9,
+ seconds,
+ nanos,
});
}
@@ -1105,4 +1170,102 @@ export class PublicAPIConverter {
isDefault: cls.isDefault,
});
}
+
+ toTimestamp(from?: string | undefined): Timestamp | undefined {
+ return from ? Timestamp.fromDate(new Date(from)) : undefined;
+ }
+
+ toIdentity(from: IdentityProtocol): Identity {
+ const { authId, authName, authProviderId, lastSigninTime, primaryEmail } = from;
+ return new Identity({
+ authProviderId,
+ authId,
+ authName,
+ lastSigninTime: this.toTimestamp(lastSigninTime),
+ primaryEmail,
+ });
+ }
+
+ toRoleOrPermission(from: RoleOrPermission): User_RoleOrPermission {
+ switch (from) {
+ case "admin":
+ return User_RoleOrPermission.ADMIN;
+ case "devops":
+ return User_RoleOrPermission.DEVOPS;
+ case "viewer":
+ return User_RoleOrPermission.VIEWER;
+ case "developer":
+ return User_RoleOrPermission.DEVELOPER;
+ case "registry-access":
+ return User_RoleOrPermission.REGISTRY_ACCESS;
+ case "admin-permissions":
+ return User_RoleOrPermission.ADMIN_PERMISSIONS;
+ case "admin-users":
+ return User_RoleOrPermission.ADMIN_USERS;
+ case "admin-workspace-content":
+ return User_RoleOrPermission.ADMIN_WORKSPACE_CONTENT;
+ case "admin-workspaces":
+ return User_RoleOrPermission.ADMIN_WORKSPACES;
+ case "admin-projects":
+ return User_RoleOrPermission.ADMIN_PROJECTS;
+ case "new-workspace-cluster":
+ return User_RoleOrPermission.NEW_WORKSPACE_CLUSTER;
+ }
+ return User_RoleOrPermission.UNSPECIFIED;
+ }
+
+ toUserFeatureFlags(from: NamedWorkspaceFeatureFlag): User_UserFeatureFlag {
+ switch (from) {
+ case "full_workspace_backup":
+ return User_UserFeatureFlag.FULL_WORKSPACE_BACKUP;
+ case "workspace_class_limiting":
+ return User_UserFeatureFlag.WORKSPACE_CLASS_LIMITING;
+ case "workspace_connection_limiting":
+ return User_UserFeatureFlag.WORKSPACE_CONNECTION_LIMITING;
+ case "workspace_psi":
+ return User_UserFeatureFlag.WORKSPACE_PSI;
+ }
+ return User_UserFeatureFlag.UNSPECIFIED;
+ }
+
+ toEditorReference(from?: IDESettings): EditorReference | undefined {
+ if (!from) {
+ return undefined;
+ }
+ return new EditorReference({
+ name: from.defaultIde,
+ version: from.useLatestVersion ? "latest" : "stable",
+ });
+ }
+
+ fromEditorReference(e?: EditorReference): IDESettings | undefined {
+ if (!e) {
+ return undefined;
+ }
+ return {
+ defaultIde: e.name,
+ useLatestVersion: e.version === "latest",
+ };
+ }
+
+ toWorkspaceAutostartOption(from: WorkspaceAutostartOption): User_WorkspaceAutostartOption {
+ return new User_WorkspaceAutostartOption({
+ cloneUrl: from.cloneURL,
+ editorSettings: this.toEditorReference(from.ideSettings),
+ organizationId: from.organizationId,
+ region: from.region,
+ workspaceClass: from.workspaceClass,
+ });
+ }
+
+ fromWorkspaceAutostartOption(o: User_WorkspaceAutostartOption): WorkspaceAutostartOption {
+ const region = isWorkspaceRegion(o.region) ? o.region : "";
+ return {
+ cloneURL: o.cloneUrl,
+ ideSettings: this.fromEditorReference(o.editorSettings),
+ organizationId: o.organizationId,
+ region,
+ workspaceClass: o.workspaceClass,
+ };
+ }
}
diff --git a/components/public-api/typescript-common/src/user-utils.spec.ts b/components/public-api/typescript-common/src/user-utils.spec.ts
new file mode 100644
index 00000000000000..9cdd3dcdb0e47d
--- /dev/null
+++ b/components/public-api/typescript-common/src/user-utils.spec.ts
@@ -0,0 +1,43 @@
+/**
+ * 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 { Timestamp } from "@bufbuild/protobuf";
+import { Identity, User, User_ProfileDetails } from "@gitpod/public-api/lib/gitpod/v1/user_pb";
+import * as chai from "chai";
+import { getPrimaryEmail } from "./user-utils";
+
+const expect = chai.expect;
+
+describe("getPrimaryEmail", function () {
+ const user = new User({
+ organizationId: undefined,
+ profile: new User_ProfileDetails({
+ emailAddress: "personal@email.com",
+ }),
+ identities: [
+ new Identity({
+ primaryEmail: "git-email@provider.com",
+ }),
+ ],
+ });
+ it(`should return email from profile exists`, () => {
+ const email = getPrimaryEmail(user);
+ expect(email).to.equal(user.profile!.emailAddress);
+ });
+ it(`should return email from SSO provider for org-owned accounts`, () => {
+ const ssoEmail = "sso-email@provider.com";
+ user.identities.unshift(
+ new Identity({
+ primaryEmail: ssoEmail,
+ // SSO identities have `lastSigninTime` set
+ lastSigninTime: Timestamp.fromDate(new Date()),
+ }),
+ );
+ user.organizationId = "any";
+ const email = getPrimaryEmail(user);
+ expect(email).to.equal(ssoEmail);
+ });
+});
diff --git a/components/public-api/typescript-common/src/user-utils.ts b/components/public-api/typescript-common/src/user-utils.ts
new file mode 100644
index 00000000000000..5027e6442c6aa7
--- /dev/null
+++ b/components/public-api/typescript-common/src/user-utils.ts
@@ -0,0 +1,75 @@
+/**
+ * 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 { User } from "@gitpod/public-api/lib/gitpod/v1/user_pb";
+import { User as UserProtocol } from "@gitpod/gitpod-protocol/lib/protocol";
+import { Timestamp } from "@bufbuild/protobuf";
+
+/**
+ * Returns a primary email address of a user.
+ *
+ * For accounts owned by an organization, it returns the email of the most recently used SSO identity.
+ *
+ * For personal accounts, first it looks for a email stored by the user, and falls back to any of the Git provider identities.
+ *
+ * @param user
+ * @returns A primaryEmail, or undefined.
+ */
+export function getPrimaryEmail(user: User | UserProtocol): string | undefined {
+ // If the accounts is owned by an organization, use the email of the most recently
+ // used SSO identity.
+ if (isOrganizationOwned(user)) {
+ const timestampToString = (a?: string | Timestamp) =>
+ a instanceof Timestamp ? a?.toDate()?.toISOString() : a || "";
+ const compareTime = (a?: string | Timestamp, b?: string | Timestamp) => {
+ return timestampToString(a).localeCompare(timestampToString(b));
+ };
+ const recentlyUsedSSOIdentity = user.identities
+ .sort((a, b) => compareTime(a.lastSigninTime, b.lastSigninTime))
+ // optimistically pick the most recent one
+ .reverse()[0];
+ return recentlyUsedSSOIdentity?.primaryEmail;
+ }
+
+ // In case of a personal account, check for the email stored by the user.
+ if (!isOrganizationOwned(user)) {
+ const emailAddress =
+ user instanceof User //
+ ? user.profile?.emailAddress
+ : user.additionalData?.profile?.emailAddress;
+ if (emailAddress) {
+ return emailAddress;
+ }
+ }
+
+ // Otherwise pick any
+ // FIXME(at) this is still not correct, as it doesn't distinguish between
+ // sign-in providers and additional Git hosters.
+ const primaryEmails: string[] = user.identities.map((i) => i.primaryEmail || "").filter((e) => !!e);
+ if (primaryEmails.length <= 0) {
+ return undefined;
+ }
+
+ return primaryEmails[0] || undefined;
+}
+
+export function getName(user: User | UserProtocol): string | undefined {
+ const name = user.name;
+ if (name) {
+ return name;
+ }
+
+ for (const id of user.identities) {
+ if (id.authName !== "") {
+ return id.authName;
+ }
+ }
+ return undefined;
+}
+
+export function isOrganizationOwned(user: User | UserProtocol) {
+ return !!user.organizationId;
+}
diff --git a/components/server/src/analytics.ts b/components/server/src/analytics.ts
index 3eba4951e07c59..129e2dd4710ff1 100644
--- a/components/server/src/analytics.ts
+++ b/components/server/src/analytics.ts
@@ -9,6 +9,7 @@ import { IAnalyticsWriter, IdentifyMessage, PageMessage, TrackMessage } from "@g
import * as crypto from "crypto";
import { clientIp } from "./express-util";
import { ctxTrySubjectId } from "./util/request-context";
+import { getPrimaryEmail } from "@gitpod/public-api-common/lib/user-utils";
export async function trackLogin(user: User, request: Request, authHost: string, analytics: IAnalyticsWriter) {
// make new complete identify call for each login
@@ -85,7 +86,7 @@ async function fullIdentify(user: User, request: Request, analytics: IAnalyticsW
},
traits: {
...resolveIdentities(user),
- email: User.getPrimaryEmail(user) || "",
+ email: getPrimaryEmail(user) || "",
full_name: user.fullName,
created_at: user.creationDate,
unsubscribed_onboarding: user.additionalData?.emailNotificationSettings?.allowsOnboardingMail === false,
diff --git a/components/server/src/api/verification-service-api.ts b/components/server/src/api/verification-service-api.ts
index 1f88b6044d56ce..02eaaa057d0df1 100644
--- a/components/server/src/api/verification-service-api.ts
+++ b/components/server/src/api/verification-service-api.ts
@@ -20,6 +20,7 @@ import { ctxUserId } from "../util/request-context";
import { UserService } from "../user/user-service";
import { formatPhoneNumber } from "../user/phone-numbers";
import { validate as uuidValidate } from "uuid";
+import { getPrimaryEmail } from "@gitpod/public-api-common/lib/user-utils";
@injectable()
export class VerificationServiceAPI implements ServiceImpl {
@@ -42,7 +43,10 @@ export class VerificationServiceAPI implements ServiceImpl;
@@ -134,7 +135,10 @@ export class VerificationService {
"isPhoneVerificationEnabled",
false,
{
- user,
+ user: {
+ id: user.id,
+ email: getPrimaryEmail(user),
+ },
},
);
return isPhoneVerificationEnabled;
diff --git a/components/server/src/bitbucket-server/bitbucket-server-repository-provider.ts b/components/server/src/bitbucket-server/bitbucket-server-repository-provider.ts
index 62087fdabd4f20..2edb495d60fa65 100644
--- a/components/server/src/bitbucket-server/bitbucket-server-repository-provider.ts
+++ b/components/server/src/bitbucket-server/bitbucket-server-repository-provider.ts
@@ -11,6 +11,7 @@ import { RepositoryProvider } from "../repohost/repository-provider";
import { BitbucketServerApi } from "./bitbucket-server-api";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
+import { getPrimaryEmail } from "@gitpod/public-api-common/lib/user-utils";
@injectable()
export class BitbucketServerRepositoryProvider implements RepositoryProvider {
@@ -150,7 +151,10 @@ export class BitbucketServerRepositoryProvider implements RepositoryProvider {
"repositoryFinderSearch",
false,
{
- user,
+ user: {
+ id: user.id,
+ email: getPrimaryEmail(user),
+ },
},
);
diff --git a/components/server/src/ide-service.ts b/components/server/src/ide-service.ts
index 5e2f2bcc91bf4d..9f860b225a9c2f 100644
--- a/components/server/src/ide-service.ts
+++ b/components/server/src/ide-service.ts
@@ -12,6 +12,7 @@ import {
IDEServiceDefinition,
ResolveWorkspaceConfigResponse,
} from "@gitpod/ide-service-api/lib/ide.pb";
+import { getPrimaryEmail } from "@gitpod/public-api-common/lib/user-utils";
import { inject, injectable } from "inversify";
import { AuthorizationService } from "./user/authorization-service";
@@ -87,7 +88,7 @@ export class IDEService {
workspaceConfig: JSON.stringify(workspace.config),
user: {
id: user.id,
- email: User.getPrimaryEmail(user),
+ email: getPrimaryEmail(user),
},
};
for (let attempt = 0; attempt < 15; attempt++) {
diff --git a/components/server/src/orgs/organization-service.ts b/components/server/src/orgs/organization-service.ts
index be774f80dbe659..ffa98eeef435d1 100644
--- a/components/server/src/orgs/organization-service.ts
+++ b/components/server/src/orgs/organization-service.ts
@@ -20,6 +20,7 @@ import { Authorizer } from "../authorization/authorizer";
import { ProjectsService } from "../projects/projects-service";
import { TransactionalContext } from "@gitpod/gitpod-db/lib/typeorm/transactional-db-impl";
import { DefaultWorkspaceImageValidator } from "./default-workspace-image-validator";
+import { getPrimaryEmail } from "@gitpod/public-api-common/lib/user-utils";
@injectable()
export class OrganizationService {
@@ -197,7 +198,17 @@ export class OrganizationService {
public async listMembers(userId: string, orgId: string): Promise {
await this.auth.checkPermissionOnOrganization(userId, "read_members", orgId);
- return this.teamDB.findMembersByTeam(orgId);
+ const members = await this.teamDB.findMembersByTeam(orgId);
+
+ // TODO(at) remove this workaround once email addresses are persisted under `User.emails`.
+ // For now we're avoiding adding `getPrimaryEmail` as dependency to `gitpod-db` module.
+ for (const member of members) {
+ const user = await this.userDB.findUserById(member.userId);
+ if (user) {
+ member.primaryEmail = getPrimaryEmail(user);
+ }
+ }
+ return members;
}
public async getOrCreateInvite(userId: string, orgId: string): Promise {
diff --git a/components/server/src/user/user-service.ts b/components/server/src/user/user-service.ts
index d83c917854c719..aaaa3983c090e6 100644
--- a/components/server/src/user/user-service.ts
+++ b/components/server/src/user/user-service.ts
@@ -24,6 +24,7 @@ import { CreateUserParams } from "./user-authentication";
import { IAnalyticsWriter } from "@gitpod/gitpod-protocol/lib/analytics";
import { TransactionalContext } from "@gitpod/gitpod-db/lib/typeorm/transactional-db-impl";
import { RelationshipUpdater } from "../authorization/relationship-updater";
+import { getName, getPrimaryEmail } from "@gitpod/public-api-common/lib/user-utils";
@injectable()
export class UserService {
@@ -105,7 +106,7 @@ export class UserService {
await this.authorizer.checkPermissionOnUser(userId, "write_info", user.id);
//hang on to user profile before it's overwritten for analytics below
- const oldProfile = User.getProfile(user);
+ const oldProfile = Profile.getProfile(user);
const allowedFields: (keyof User)[] = ["fullName", "additionalData"];
for (const p of allowedFields) {
@@ -117,8 +118,8 @@ export class UserService {
await this.userDb.updateUserPartial(user);
//track event and user profile if profile of partialUser changed
- const newProfile = User.getProfile(user);
- if (User.Profile.hasChanges(oldProfile, newProfile)) {
+ const newProfile = Profile.getProfile(user);
+ if (Profile.hasChanges(oldProfile, newProfile)) {
this.analytics.track({
userId: user.id,
event: "profile_changed",
@@ -312,3 +313,53 @@ export class UserService {
log.info("User verified", { userId: user.id });
}
}
+
+// TODO: refactor where this is referenced so it's more clearly tied to just analytics-tracking
+// Let other places rely on the ProfileDetails type since that's what we store
+// This is the profile data we send to our Segment analytics tracking pipeline
+interface Profile {
+ name: string;
+ email: string;
+ company?: string;
+ avatarURL?: string;
+ jobRole?: string;
+ jobRoleOther?: string;
+ explorationReasons?: string[];
+ signupGoals?: string[];
+ signupGoalsOther?: string;
+ onboardedTimestamp?: string;
+ companySize?: string;
+}
+namespace Profile {
+ export function hasChanges(before: Profile, after: Profile) {
+ return (
+ before.name !== after.name ||
+ before.email !== after.email ||
+ before.company !== after.company ||
+ before.avatarURL !== after.avatarURL ||
+ before.jobRole !== after.jobRole ||
+ before.jobRoleOther !== after.jobRoleOther ||
+ // not checking explorationReasons or signupGoals atm as it's an array - need to check deep equality
+ before.signupGoalsOther !== after.signupGoalsOther ||
+ before.onboardedTimestamp !== after.onboardedTimestamp ||
+ before.companySize !== after.companySize
+ );
+ }
+
+ export function getProfile(user: User): Profile {
+ const profile = user.additionalData?.profile;
+ return {
+ name: getName(user) || "",
+ email: getPrimaryEmail(user) || "",
+ company: profile?.companyName,
+ avatarURL: user?.avatarUrl,
+ jobRole: profile?.jobRole,
+ jobRoleOther: profile?.jobRoleOther,
+ explorationReasons: profile?.explorationReasons,
+ signupGoals: profile?.signupGoals,
+ signupGoalsOther: profile?.signupGoalsOther,
+ companySize: profile?.companySize,
+ onboardedTimestamp: profile?.onboardedTimestamp,
+ };
+ }
+}
diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts
index fef95da86f8fcc..5a4f6bcd99bcab 100644
--- a/components/server/src/workspace/gitpod-server-impl.ts
+++ b/components/server/src/workspace/gitpod-server-impl.ts
@@ -144,6 +144,7 @@ import { ScmService } from "../scm/scm-service";
import { ContextService } from "./context-service";
import { runWithRequestContext, runWithSubjectId } from "../util/request-context";
import { SubjectId } from "../auth/subject-id";
+import { getPrimaryEmail } from "@gitpod/public-api-common/lib/user-utils";
// shortcut
export const traceWI = (ctx: TraceContext, wi: Omit) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager
@@ -460,7 +461,10 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
"phoneVerificationByCall",
false,
{
- user,
+ user: {
+ id: user.id,
+ email: getPrimaryEmail(user),
+ },
},
);
@@ -2413,7 +2417,10 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
private async guardWithFeatureFlag(flagName: string, user: User, teamId: string) {
// Guard method w/ a feature flag check
const isEnabled = await getExperimentsClientForBackend().getValueAsync(flagName, false, {
- user: user,
+ user: {
+ id: user.id,
+ email: getPrimaryEmail(user),
+ },
teamId,
});
if (!isEnabled) {
@@ -2498,7 +2505,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
async getIDEOptions(ctx: TraceContext): Promise {
const user = await this.checkUser("identifyUser");
- const email = User.getPrimaryEmail(user);
+ const email = getPrimaryEmail(user);
const ideConfig = await this.ideService.getIDEConfig({ user: { id: user.id, email } });
return ideConfig.ideOptions;
}
@@ -2620,7 +2627,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
await this.auth.checkPermissionOnOrganization(user.id, "write_billing", attrId.teamId);
//TODO billing email should be editable within the org
- const billingEmail = User.getPrimaryEmail(user);
+ const billingEmail = getPrimaryEmail(user);
const billingName = org.name;
let customer: StripeCustomer | undefined;
@@ -2821,7 +2828,10 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
throw new ApplicationError(ErrorCodes.NOT_FOUND, "Organization not found.");
}
const isMemberUsageEnabled = await getExperimentsClientForBackend().getValueAsync("member_usage", false, {
- user: user,
+ user: {
+ id: user.id,
+ email: getPrimaryEmail(user),
+ },
teamId: attributionId.teamId,
});
if (isMemberUsageEnabled && member.role !== "owner") {