From e4ccbf0b4d224714ffd16719a3b5c50630d6edbc Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Wed, 6 Dec 2023 16:42:14 +0100 Subject: [PATCH] GetAuthenticatedUser in Dashboard (#19142) * [dashboard] remove unused service mock * [dashboard] use `GetAuthenticatedUser` instead of `getLoggedInUser` * fixup: override workspaceAutostartOptions also fix toDurationString call * fixup: move `isOnboardingUser` to dashboard * fixup: move getProfile from protocol to common * fixup fromWorkspaceAutostartOption * move getPrimaryEmail to common and clean up * rm getProfile from protocol, use ProfileDetails * fixup missing leeway dependencies * fix getPrimaryEmail * fix resetting workspace timeout * [gitpod-db] remove dependency to `@gitpod/public-api-common` * cleanup BUILD.yaml --- components/dashboard/package.json | 1 + components/dashboard/src/AppNotifications.tsx | 78 +++--- components/dashboard/src/Login.tsx | 9 +- components/dashboard/src/admin/UserSearch.tsx | 3 +- components/dashboard/src/app/AdminRoute.tsx | 3 +- .../dashboard/src/app/AppBlockingFlows.tsx | 7 - components/dashboard/src/app/AppRoutes.tsx | 10 +- .../dashboard/src/components/AuthorizeGit.tsx | 15 +- .../current-user/authenticated-user-query.ts | 23 ++ .../src/data/current-user/update-mutation.ts | 30 ++- .../dashboard/src/data/featureflag-query.ts | 6 +- .../src/data/organizations/orgs-query.ts | 2 +- components/dashboard/src/data/setup.tsx | 4 +- .../src/dedicated-setup/DedicatedSetup.tsx | 9 +- .../src/hooks/use-analytics-tracking.ts | 2 +- .../dashboard/src/hooks/use-user-loader.ts | 8 +- components/dashboard/src/menu/Menu.tsx | 9 +- .../src/menu/OrganizationSelector.tsx | 6 +- .../dashboard/src/onboarding/StepOrgInfo.tsx | 26 +- .../src/onboarding/StepPersonalize.tsx | 6 +- .../dashboard/src/onboarding/StepUserInfo.tsx | 15 +- .../src/onboarding/UserOnboarding.tsx | 11 +- .../onboarding/use-show-user-onboarding.ts | 23 +- .../src/service/json-rpc-user-client.ts | 42 +++ .../dashboard/src/service/public-api.ts | 13 +- .../dashboard/src/service/service-mock.ts | 252 ------------------ components/dashboard/src/service/service.tsx | 10 +- .../git-integrations/GitIntegrationModal.tsx | 10 +- components/dashboard/src/user-context.tsx | 7 +- .../dashboard/src/user-settings/Account.tsx | 51 ++-- .../src/user-settings/Integrations.tsx | 14 +- .../src/user-settings/Notifications.tsx | 43 ++- .../src/user-settings/Preferences.tsx | 50 ++-- .../dashboard/src/user-settings/SelectIDE.tsx | 43 +-- .../dashboard/src/whatsnew/MigrationPage.tsx | 139 ---------- .../src/whatsnew/WhatsNew-2021-04.tsx | 78 ------ .../src/whatsnew/WhatsNew-2021-06.tsx | 47 ---- .../dashboard/src/whatsnew/WhatsNew.tsx | 102 ------- .../src/workspaces/CreateWorkspacePage.tsx | 58 ++-- .../gitpod-db/src/typeorm/team-db-impl.ts | 2 - .../src/experiments/configcat.ts | 3 +- .../gitpod-protocol/src/experiments/types.ts | 4 +- components/gitpod-protocol/src/protocol.ts | 158 +---------- .../fixtures/toUser_1.golden | 75 ++++++ .../typescript-common/fixtures/toUser_1.json | 84 ++++++ .../public-api/typescript-common/package.json | 3 +- .../src/public-api-converter.spec.ts | 12 +- .../src/public-api-converter.ts | 207 ++++++++++++-- .../typescript-common/src/user-utils.spec.ts | 43 +++ .../typescript-common/src/user-utils.ts | 75 ++++++ components/server/src/analytics.ts | 3 +- .../src/api/verification-service-api.ts | 6 +- .../server/src/auth/verification-service.ts | 6 +- .../bitbucket-server-repository-provider.ts | 6 +- components/server/src/ide-service.ts | 3 +- .../server/src/orgs/organization-service.ts | 13 +- components/server/src/user/user-service.ts | 57 +++- .../src/workspace/gitpod-server-impl.ts | 20 +- 58 files changed, 954 insertions(+), 1091 deletions(-) create mode 100644 components/dashboard/src/data/current-user/authenticated-user-query.ts create mode 100644 components/dashboard/src/service/json-rpc-user-client.ts delete mode 100644 components/dashboard/src/service/service-mock.ts delete mode 100644 components/dashboard/src/whatsnew/MigrationPage.tsx delete mode 100644 components/dashboard/src/whatsnew/WhatsNew-2021-04.tsx delete mode 100644 components/dashboard/src/whatsnew/WhatsNew-2021-06.tsx delete mode 100644 components/dashboard/src/whatsnew/WhatsNew.tsx create mode 100644 components/public-api/typescript-common/fixtures/toUser_1.golden create mode 100644 components/public-api/typescript-common/fixtures/toUser_1.json create mode 100644 components/public-api/typescript-common/src/user-utils.spec.ts create mode 100644 components/public-api/typescript-common/src/user-utils.ts diff --git a/components/dashboard/package.json b/components/dashboard/package.json index 1534d33b5a40af..4d3d5497481f46 100644 --- a/components/dashboard/package.json +++ b/components/dashboard/package.json @@ -30,6 +30,7 @@ "countries-list": "^2.6.1", "crypto-browserify": "3.12.0", "dayjs": "^1.11.5", + "deepmerge": "^4.2.2", "file-saver": "^2.0.5", "idb-keyval": "^6.2.0", "js-cookie": "^3.0.1", diff --git a/components/dashboard/src/AppNotifications.tsx b/components/dashboard/src/AppNotifications.tsx index ce6e588eaef39a..d24f2e7754c0ab 100644 --- a/components/dashboard/src/AppNotifications.tsx +++ b/components/dashboard/src/AppNotifications.tsx @@ -5,13 +5,14 @@ */ import dayjs from "dayjs"; -import deepMerge from "deepmerge"; import { useCallback, useEffect, useState } from "react"; import Alert, { AlertType } from "./components/Alert"; import { useUserLoader } from "./hooks/use-user-loader"; -import { getGitpodService } from "./service/service"; import { isGitpodIo } from "./utils"; import { trackEvent } from "./Analytics"; +import { useUpdateCurrentUserMutation } from "./data/current-user/update-mutation"; +import { User as UserProtocol } from "@gitpod/gitpod-protocol"; +import { User } from "@gitpod/public-api/lib/gitpod/v1/user_pb"; const KEY_APP_DISMISSED_NOTIFICATIONS = "gitpod-app-notifications-dismissed"; const PRIVACY_POLICY_LAST_UPDATED = "2023-10-17"; @@ -24,59 +25,60 @@ interface Notification { onClose?: () => void; } -const UPDATED_PRIVACY_POLICY: Notification = { - id: "privacy-policy-update", - type: "info", - preventDismiss: true, - onClose: async () => { - let dismissSuccess = false; - try { - const userUpdates = { additionalData: { profile: { acceptedPrivacyPolicyDate: dayjs().toISOString() } } }; - const previousUser = await getGitpodService().server.getLoggedInUser(); - const updatedUser = await getGitpodService().server.updateLoggedInUser( - deepMerge(previousUser, userUpdates), - ); - dismissSuccess = !!updatedUser; - } catch (err) { - console.error("Failed to update user's privacy policy acceptance date", err); - dismissSuccess = false; - } finally { - trackEvent("privacy_policy_update_accepted", { - path: window.location.pathname, - success: dismissSuccess, - }); - } - }, - message: ( - - We've updated our Privacy Policy. You can review it{" "} - - here - - . - - ), +const UPDATED_PRIVACY_POLICY = (updateUser: (user: Partial) => Promise) => { + return { + id: "privacy-policy-update", + type: "info", + preventDismiss: true, + onClose: async () => { + let dismissSuccess = false; + try { + const updatedUser = await updateUser({ + additionalData: { profile: { acceptedPrivacyPolicyDate: dayjs().toISOString() } }, + }); + dismissSuccess = !!updatedUser; + } catch (err) { + console.error("Failed to update user's privacy policy acceptance date", err); + dismissSuccess = false; + } finally { + trackEvent("privacy_policy_update_accepted", { + path: window.location.pathname, + success: dismissSuccess, + }); + } + }, + message: ( + + We've updated our Privacy Policy. You can review it{" "} + + here + + . + + ), + } as Notification; }; export function AppNotifications() { const [topNotification, setTopNotification] = useState(undefined); const { user, loading } = useUserLoader(); + const updateUser = useUpdateCurrentUserMutation(); useEffect(() => { const notifications = []; if (!loading && isGitpodIo()) { if ( - !user?.additionalData?.profile?.acceptedPrivacyPolicyDate || - new Date(PRIVACY_POLICY_LAST_UPDATED) > new Date(user.additionalData.profile.acceptedPrivacyPolicyDate) + !user?.profile?.acceptedPrivacyPolicyDate || + new Date(PRIVACY_POLICY_LAST_UPDATED) > new Date(user.profile.acceptedPrivacyPolicyDate) ) { - notifications.push(UPDATED_PRIVACY_POLICY); + notifications.push(UPDATED_PRIVACY_POLICY((u: Partial) => updateUser.mutateAsync(u))); } } const dismissedNotifications = getDismissedNotifications(); const topNotification = notifications.find((n) => !dismissedNotifications.includes(n.id)); setTopNotification(topNotification); - }, [loading, setTopNotification, user]); + }, [loading, updateUser, setTopNotification, user]); const dismissNotification = useCallback(() => { if (!topNotification) { diff --git a/components/dashboard/src/Login.tsx b/components/dashboard/src/Login.tsx index f8eaa99b135d30..f0ea3be4c25937 100644 --- a/components/dashboard/src/Login.tsx +++ b/components/dashboard/src/Login.tsx @@ -23,6 +23,7 @@ import { useNeedsSetup } from "./dedicated-setup/use-needs-setup"; import { AuthProviderDescription } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb"; import { Button, ButtonProps } from "@podkit/buttons/Button"; import { cn } from "@podkit/lib/cn"; +import { userClient } from "./service/public-api"; export function markLoggedIn() { document.cookie = GitpodCookie.generateCookie(window.location.hostname); @@ -67,9 +68,11 @@ export const Login: FC = ({ onLoggedIn }) => { const updateUser = useCallback(async () => { await getGitpodService().reconnect(); - const user = await getGitpodService().server.getLoggedInUser(); - setUser(user); - markLoggedIn(); + const { user } = await userClient.getAuthenticatedUser({}); + if (user) { + setUser(user); + markLoggedIn(); + } }, [setUser]); const authorizeSuccessful = useCallback(async () => { diff --git a/components/dashboard/src/admin/UserSearch.tsx b/components/dashboard/src/admin/UserSearch.tsx index 5b8886b0d25dc7..86021483733dee 100644 --- a/components/dashboard/src/admin/UserSearch.tsx +++ b/components/dashboard/src/admin/UserSearch.tsx @@ -16,6 +16,7 @@ import { AdminPageHeader } from "./AdminPageHeader"; import UserDetail from "./UserDetail"; import searchIcon from "../icons/search.svg"; import Tooltip from "../components/Tooltip"; +import { getPrimaryEmail } from "@gitpod/public-api-common/lib/user-utils"; export default function UserSearch() { const location = useLocation(); @@ -129,7 +130,7 @@ function UserEntry(p: { user: User }) { if (!p) { return <>; } - const email = User.getPrimaryEmail(p.user) || "---"; + const email = getPrimaryEmail(p.user) || "---"; return (
diff --git a/components/dashboard/src/app/AdminRoute.tsx b/components/dashboard/src/app/AdminRoute.tsx index 8ee6143ef2a68b..611f4e1360f3c9 100644 --- a/components/dashboard/src/app/AdminRoute.tsx +++ b/components/dashboard/src/app/AdminRoute.tsx @@ -7,6 +7,7 @@ import { useContext } from "react"; import { Redirect, Route } from "react-router"; import { UserContext } from "../user-context"; +import { User_RoleOrPermission } from "@gitpod/public-api/lib/gitpod/v1/user_pb"; // A wrapper for that redirects to the workspaces screen if the user isn't a admin. // This wrapper only accepts the component property @@ -15,7 +16,7 @@ export function AdminRoute({ component }: any) { return ( - user?.rolesOrPermissions?.includes("admin") ? ( + user?.rolesOrPermissions?.includes(User_RoleOrPermission.ADMIN) ? ( ) : ( { const history = useHistory(); const user = useCurrentUser(); const org = useCurrentOrg(); - const shouldSeeMigrationPage = useShouldSeeMigrationPage(); const showDedicatedSetup = useShowDedicatedSetup(); const showUserOnboarding = useShowUserOnboarding(); @@ -31,11 +29,6 @@ export const AppBlockingFlows: FC = ({ children }) => { return <>; } - // If orgOnlyAttribution is enabled and the user hasn't been migrated, yet, we need to show the migration page - if (shouldSeeMigrationPage) { - return ; - } - // Handle dedicated setup if necessary if (showDedicatedSetup.showSetup) { return ( diff --git a/components/dashboard/src/app/AppRoutes.tsx b/components/dashboard/src/app/AppRoutes.tsx index 726f071bb65470..033228b6f4027f 100644 --- a/components/dashboard/src/app/AppRoutes.tsx +++ b/components/dashboard/src/app/AppRoutes.tsx @@ -4,7 +4,7 @@ * See License.AGPL.txt in the project root for license information. */ -import React, { useState } from "react"; +import React from "react"; import { Redirect, Route, Switch, useLocation } from "react-router"; import OAuthClientApproval from "../OauthClientApproval"; import Menu from "../menu/Menu"; @@ -25,7 +25,6 @@ import { usagePathMain, } from "../user-settings/settings.routes"; import { getURLHash, isGitpodIo } from "../utils"; -import { WhatsNew, shouldSeeWhatsNew } from "../whatsnew/WhatsNew"; import { workspacesPathMain } from "../workspaces/workspaces.routes"; import { AdminRoute } from "./AdminRoute"; import { Blocked } from "./Blocked"; @@ -33,7 +32,6 @@ import { Blocked } from "./Blocked"; // TODO: Can we bundle-split/lazy load these like other pages? import { BlockedRepositories } from "../admin/BlockedRepositories"; import { Heading1, Subheading } from "../components/typography/headings"; -import { useCurrentUser } from "../user-context"; import PersonalAccessTokenCreateView from "../user-settings/PersonalAccessTokensCreateView"; import { CreateWorkspacePage } from "../workspaces/CreateWorkspacePage"; import { WebsocketClients } from "./WebsocketClients"; @@ -84,8 +82,6 @@ const ConfigurationDetailPage = React.lazy( export const AppRoutes = () => { const hash = getURLHash(); - const user = useCurrentUser(); - const [isWhatsNewShown, setWhatsNewShown] = useState(user && shouldSeeWhatsNew(user)); const location = useLocation(); const repoConfigListAndDetail = useFeatureFlag("repoConfigListAndDetail"); @@ -99,10 +95,6 @@ export const AppRoutes = () => { return ; } - if (isWhatsNewShown) { - return setWhatsNewShown(false)} />; - } - // TODO: Try and encapsulate this in a route for "/" (check for hash in route component, render or redirect accordingly) const isCreation = location.pathname === "/" && hash !== ""; if (isCreation) { diff --git a/components/dashboard/src/components/AuthorizeGit.tsx b/components/dashboard/src/components/AuthorizeGit.tsx index aa8ad0c0cd1640..f807a7720740e1 100644 --- a/components/dashboard/src/components/AuthorizeGit.tsx +++ b/components/dashboard/src/components/AuthorizeGit.tsx @@ -8,7 +8,7 @@ import { FC, useCallback, useContext } from "react"; import { Link } from "react-router-dom"; import { useAuthProviderDescriptions } from "../data/auth-providers/auth-provider-descriptions-query"; import { openAuthorizeWindow } from "../provider-utils"; -import { getGitpodService } from "../service/service"; +import { userClient } from "../service/public-api"; import { UserContext, useCurrentUser } from "../user-context"; import { Button } from "./Button"; import { Heading2, Heading3, Subheading } from "./typography/headings"; @@ -18,20 +18,23 @@ import { useIsOwner } from "../data/organizations/members-query"; import { AuthProviderDescription } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb"; export function useNeedsGitAuthorization() { - const authProviders = useAuthProviderDescriptions(); + const { data: authProviders } = useAuthProviderDescriptions(); const user = useCurrentUser(); - if (!user || !authProviders.data) { + if (!user || !authProviders) { return false; } - return !authProviders.data.some((ap) => user.identities.some((i) => ap.id === i.authProviderId)); + return !authProviders.some((ap) => user.identities.some((i) => ap.id === i.authProviderId)); } export const AuthorizeGit: FC<{ className?: string }> = ({ className }) => { const { setUser } = useContext(UserContext); const owner = useIsOwner(); const { data: authProviders } = useAuthProviderDescriptions(); - const updateUser = useCallback(() => { - getGitpodService().server.getLoggedInUser().then(setUser); + const updateUser = useCallback(async () => { + const response = await userClient.getAuthenticatedUser({}); + if (response.user) { + setUser(response.user); + } }, [setUser]); const connect = useCallback( diff --git a/components/dashboard/src/data/current-user/authenticated-user-query.ts b/components/dashboard/src/data/current-user/authenticated-user-query.ts new file mode 100644 index 00000000000000..f9fde8b45e28fe --- /dev/null +++ b/components/dashboard/src/data/current-user/authenticated-user-query.ts @@ -0,0 +1,23 @@ +/** + * 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 { useQuery } from "@tanstack/react-query"; +import { userClient } from "../../service/public-api"; +import { GetAuthenticatedUserRequest, User } from "@gitpod/public-api/lib/gitpod/v1/user_pb"; + +export const useAuthenticatedUser = () => { + const query = useQuery({ + queryKey: getAuthenticatedUserQueryKey(), + queryFn: async () => { + const params = new GetAuthenticatedUserRequest(); + const response = await userClient.getAuthenticatedUser(params); + return response.user!; + }, + }); + return query; +}; + +export const getAuthenticatedUserQueryKey = () => ["authenticated-user", {}]; diff --git a/components/dashboard/src/data/current-user/update-mutation.ts b/components/dashboard/src/data/current-user/update-mutation.ts index ec84db5d72b0db..29ceeb849b1fed 100644 --- a/components/dashboard/src/data/current-user/update-mutation.ts +++ b/components/dashboard/src/data/current-user/update-mutation.ts @@ -4,18 +4,35 @@ * See License.AGPL.txt in the project root for license information. */ -import { User } from "@gitpod/gitpod-protocol"; +import { AdditionalUserData, User as UserProtocol } from "@gitpod/gitpod-protocol"; import { useMutation } from "@tanstack/react-query"; import { trackEvent } from "../../Analytics"; import { getGitpodService } from "../../service/service"; import { useCurrentUser } from "../../user-context"; +import { converter } from "../../service/public-api"; +import deepmerge from "deepmerge"; -type UpdateCurrentUserArgs = Partial; +type UpdateCurrentUserArgs = Partial; export const useUpdateCurrentUserMutation = () => { return useMutation({ mutationFn: async (partialUser: UpdateCurrentUserArgs) => { - return await getGitpodService().server.updateLoggedInUser(partialUser); + const current = await getGitpodService().server.getLoggedInUser(); + const currentAdditionalData = { ...current.additionalData }; + // workspaceAutostartOptions needs to be overriden + if (partialUser.additionalData?.workspaceAutostartOptions) { + currentAdditionalData.workspaceAutostartOptions = []; + } + const update: UpdateCurrentUserArgs = { + id: current.id, + fullName: partialUser.fullName || current.fullName, + additionalData: deepmerge( + currentAdditionalData || {}, + partialUser.additionalData || {}, + ), + }; + const user = await getGitpodService().server.updateLoggedInUser(update); + return converter.toUser(user); }, }); }; @@ -31,7 +48,6 @@ export const useUpdateCurrentUserDotfileRepoMutation = () => { } const additionalData = { - ...(user.additionalData || {}), dotfileRepo, }; const updatedUser = await updateUser.mutateAsync({ additionalData }); @@ -40,14 +56,14 @@ export const useUpdateCurrentUserDotfileRepoMutation = () => { }, onMutate: async () => { return { - previousDotfileRepo: user?.additionalData?.dotfileRepo || "", + previousDotfileRepo: user?.dotfileRepo || "", }; }, onSuccess: (updatedUser, _, context) => { - if (updatedUser?.additionalData?.dotfileRepo !== context?.previousDotfileRepo) { + if (updatedUser?.dotfileRepo !== context?.previousDotfileRepo) { trackEvent("dotfile_repo_changed", { previous: context?.previousDotfileRepo ?? "", - current: updatedUser?.additionalData?.dotfileRepo ?? "", + current: updatedUser?.dotfileRepo ?? "", }); } }, diff --git a/components/dashboard/src/data/featureflag-query.ts b/components/dashboard/src/data/featureflag-query.ts index c70c4d540cf792..26efc0e04f08cc 100644 --- a/components/dashboard/src/data/featureflag-query.ts +++ b/components/dashboard/src/data/featureflag-query.ts @@ -4,6 +4,7 @@ * See License.AGPL.txt in the project root for license information. */ +import { getPrimaryEmail } from "@gitpod/public-api-common/lib/user-utils"; import { useQuery } from "@tanstack/react-query"; import { getExperimentsClient } from "../experiments/client"; import { useCurrentProject } from "../projects/project-context"; @@ -40,7 +41,10 @@ export const useFeatureFlag = (featureFlag: K): Fe const query = useQuery(queryKey, async () => { const flagValue = await getExperimentsClient().getValueAsync(featureFlag, featureFlags[featureFlag], { - user, + user: user && { + id: user.id, + email: getPrimaryEmail(user), + }, projectId: project?.id, teamId: org?.id, teamName: org?.name, diff --git a/components/dashboard/src/data/organizations/orgs-query.ts b/components/dashboard/src/data/organizations/orgs-query.ts index 45bb77d85c3d98..7784e7a9fbd055 100644 --- a/components/dashboard/src/data/organizations/orgs-query.ts +++ b/components/dashboard/src/data/organizations/orgs-query.ts @@ -4,7 +4,7 @@ * See License.AGPL.txt in the project root for license information. */ -import { User } from "@gitpod/gitpod-protocol"; +import { User } from "@gitpod/public-api/lib/gitpod/v1/user_pb"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import { useLocation } from "react-router"; diff --git a/components/dashboard/src/data/setup.tsx b/components/dashboard/src/data/setup.tsx index 2ab1b5ac4296b7..a9b8f06a28132c 100644 --- a/components/dashboard/src/data/setup.tsx +++ b/components/dashboard/src/data/setup.tsx @@ -28,11 +28,12 @@ import * as VerificationClasses from "@gitpod/public-api/lib/gitpod/v1/verificat import * as InstallationClasses from "@gitpod/public-api/lib/gitpod/v1/installation_pb"; import * as SCMClasses from "@gitpod/public-api/lib/gitpod/v1/scm_pb"; import * as SSHClasses from "@gitpod/public-api/lib/gitpod/v1/ssh_pb"; +import * as UserClasses from "@gitpod/public-api/lib/gitpod/v1/user_pb"; // This is used to version the cache // If data we cache changes in a non-backwards compatible way, increment this version // That will bust any previous cache versions a client may have stored -const CACHE_VERSION = "16"; +const CACHE_VERSION = "17"; export function noPersistence(queryKey: QueryKey): QueryKey { return [...queryKey, "no-persistence"]; @@ -158,6 +159,7 @@ function initializeMessages() { ...Object.values(InstallationClasses), ...Object.values(SCMClasses), ...Object.values(SSHClasses), + ...Object.values(UserClasses), ]; for (const c of constr) { if ((c as any).prototype instanceof Message) { diff --git a/components/dashboard/src/dedicated-setup/DedicatedSetup.tsx b/components/dashboard/src/dedicated-setup/DedicatedSetup.tsx index 38d9c8aa646273..44fdd8f4694afd 100644 --- a/components/dashboard/src/dedicated-setup/DedicatedSetup.tsx +++ b/components/dashboard/src/dedicated-setup/DedicatedSetup.tsx @@ -21,6 +21,7 @@ import { useDocumentTitle } from "../hooks/use-document-title"; import { forceDedicatedSetupParam } from "./use-show-dedicated-setup"; import { Organization } from "@gitpod/public-api/lib/gitpod/v1/organization_pb"; import { Delayed } from "@podkit/loading/Delayed"; +import { userClient } from "../service/public-api"; type Props = { onComplete: () => void; @@ -96,9 +97,13 @@ const DedicatedSetupSteps: FC = ({ org, ssoConfig, onC }, [dropConfetti]); const updateUser = useCallback(async () => { + // TODO(at) this is still required if the FE shim is used per FF await getGitpodService().reconnect(); - const user = await getGitpodService().server.getLoggedInUser(); - setUser(user); + + const response = await userClient.getAuthenticatedUser({}); + if (response.user) { + setUser(response.user); + } }, [setUser]); const handleEndSetup = useCallback(async () => { diff --git a/components/dashboard/src/hooks/use-analytics-tracking.ts b/components/dashboard/src/hooks/use-analytics-tracking.ts index 20d0cfd5abafc5..f0ae2a917c3c81 100644 --- a/components/dashboard/src/hooks/use-analytics-tracking.ts +++ b/components/dashboard/src/hooks/use-analytics-tracking.ts @@ -59,7 +59,7 @@ export const useAnalyticsTracking = () => { }, []); useEffect(() => { - if (!user || !user.additionalData?.profile?.onboardedTimestamp || !isOrbitalLoaded) { + if (!user || !user.profile?.onboardedTimestamp || !isOrbitalLoaded) { return; } diff --git a/components/dashboard/src/hooks/use-user-loader.ts b/components/dashboard/src/hooks/use-user-loader.ts index a6ad82e91bb7ce..a6cb6c83eb7bc6 100644 --- a/components/dashboard/src/hooks/use-user-loader.ts +++ b/components/dashboard/src/hooks/use-user-loader.ts @@ -6,12 +6,12 @@ import { useContext } from "react"; import { UserContext } from "../user-context"; -import { getGitpodService } from "../service/service"; import { trackLocation } from "../Analytics"; import { useQuery } from "@tanstack/react-query"; import { noPersistence } from "../data/setup"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { useFeatureFlag } from "../data/featureflag-query"; +import { userClient } from "../service/public-api"; export const useUserLoader = () => { const { user, setUser } = useContext(UserContext); @@ -22,7 +22,7 @@ export const useUserLoader = () => { const { isLoading } = useQuery({ queryKey: noPersistence(["current-user"]), queryFn: async () => { - const user = await getGitpodService().server.getLoggedInUser(); + const user = (await userClient.getAuthenticatedUser({})).user; return user || null; }, @@ -41,7 +41,9 @@ export const useUserLoader = () => { cacheTime: 1000 * 60 * 60 * 1, // 1 hour staleTime: 1000 * 60 * 60 * 1, // 1 hour onSuccess: (loadedUser) => { - setUser(loadedUser); + if (loadedUser) { + setUser(loadedUser); + } }, onSettled: (loadedUser) => { trackLocation(!!loadedUser); diff --git a/components/dashboard/src/menu/Menu.tsx b/components/dashboard/src/menu/Menu.tsx index ca391170b21ddf..73e9b9df0e8bb1 100644 --- a/components/dashboard/src/menu/Menu.tsx +++ b/components/dashboard/src/menu/Menu.tsx @@ -4,7 +4,6 @@ * See License.AGPL.txt in the project root for license information. */ -import { User } from "@gitpod/gitpod-protocol"; import { FC, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useLocation } from "react-router"; import { Location } from "history"; @@ -20,6 +19,8 @@ import { isGitpodIo } from "../utils"; import OrganizationSelector from "./OrganizationSelector"; import { getAdminTabs } from "../admin/admin.routes"; import classNames from "classnames"; +import { User, User_RoleOrPermission } from "@gitpod/public-api/lib/gitpod/v1/user_pb"; +import { getPrimaryEmail } from "@gitpod/public-api-common/lib/user-utils"; interface Entry { title: string; @@ -82,7 +83,7 @@ export default function Menu() {
diff --git a/components/dashboard/src/user-settings/Integrations.tsx b/components/dashboard/src/user-settings/Integrations.tsx index 2443e7fec442d2..f0113f339203b5 100644 --- a/components/dashboard/src/user-settings/Integrations.tsx +++ b/components/dashboard/src/user-settings/Integrations.tsx @@ -4,7 +4,6 @@ * See License.AGPL.txt in the project root for license information. */ -import { User } from "@gitpod/gitpod-protocol"; import { getScopesForAuthProviderType } from "@gitpod/public-api-common/lib/auth-providers"; import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth"; import { useQuery } from "@tanstack/react-query"; @@ -21,7 +20,7 @@ import { Heading2, Subheading } from "../components/typography/headings"; import copy from "../images/copy.svg"; import exclamation from "../images/exclamation.svg"; import { openAuthorizeWindow, toAuthProviderLabel } from "../provider-utils"; -import { getGitpodService, gitpodHostUrl } from "../service/service"; +import { gitpodHostUrl } from "../service/service"; import { UserContext } from "../user-context"; import { AuthEntryItem } from "./AuthEntryItem"; import { IntegrationEntryItem } from "./IntegrationItemEntry"; @@ -36,11 +35,12 @@ import { AuthProviderDescription, AuthProviderType, } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb"; -import { authProviderClient, scmClient } from "../service/public-api"; +import { authProviderClient, scmClient, userClient } from "../service/public-api"; import { useCreateUserAuthProviderMutation } from "../data/auth-providers/create-user-auth-provider-mutation"; import { useUpdateUserAuthProviderMutation } from "../data/auth-providers/update-user-auth-provider-mutation"; import { useDeleteUserAuthProviderMutation } from "../data/auth-providers/delete-user-auth-provider-mutation"; import { Button } from "@podkit/buttons/Button"; +import { isOrganizationOwned } from "@gitpod/public-api-common/lib/user-utils"; export default function Integrations() { return ( @@ -124,7 +124,7 @@ function GitProviders() { }); } const canDisconnect = - (user && User.isOrganizationOwned(user)) || + (user && isOrganizationOwned(user)) || authProviders.data?.some((p) => p.id !== provider.id && isConnected(p.id)); if (canDisconnect) { result.push({ @@ -190,8 +190,10 @@ function GitProviders() { }; const updateUser = async () => { - const user = await getGitpodService().server.getLoggedInUser(); - setUser(user); + const { user } = await userClient.getAuthenticatedUser({}); + if (user) { + setUser(user); + } }; const doAuthorize = async (host: string, scopes?: string[]) => { diff --git a/components/dashboard/src/user-settings/Notifications.tsx b/components/dashboard/src/user-settings/Notifications.tsx index e3be5a507c7911..743c300f8f7146 100644 --- a/components/dashboard/src/user-settings/Notifications.tsx +++ b/components/dashboard/src/user-settings/Notifications.tsx @@ -5,76 +5,67 @@ */ import { useContext, useState } from "react"; -import { getGitpodService } from "../service/service"; import { UserContext } from "../user-context"; import { CheckboxInputField } from "../components/forms/CheckboxInputField"; import { identifyUser } from "../Analytics"; import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu"; import { Heading2 } from "../components/typography/headings"; +import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation"; export default function Notifications() { const { user, setUser } = useContext(UserContext); - const [isOnboardingMail, setOnboardingMail] = useState( - !!user?.additionalData?.emailNotificationSettings?.allowsOnboardingMail, - ); - const [isChangelogMail, setChangelogMail] = useState( - !!user?.additionalData?.emailNotificationSettings?.allowsChangelogMail, - ); - const [isDevXMail, setDevXMail] = useState(!!user?.additionalData?.emailNotificationSettings?.allowsDevXMail); + const [isOnboardingMail, setOnboardingMail] = useState(!!user?.emailNotificationSettings?.allowsOnboardingMail); + const [isChangelogMail, setChangelogMail] = useState(!!user?.emailNotificationSettings?.allowsChangelogMail); + const [isDevXMail, setDevXMail] = useState(!!user?.emailNotificationSettings?.allowsDevxMail); + const updateUser = useUpdateCurrentUserMutation(); const toggleOnboardingMail = async () => { - if (user && user.additionalData && user.additionalData.emailNotificationSettings) { + if (user && user.emailNotificationSettings) { const newIsOnboardingMail = !isOnboardingMail; - user.additionalData.emailNotificationSettings.allowsOnboardingMail = newIsOnboardingMail; - await getGitpodService().server.updateLoggedInUser({ + user.emailNotificationSettings.allowsOnboardingMail = newIsOnboardingMail; + const updatedUser = await updateUser.mutateAsync({ additionalData: { - ...user.additionalData, emailNotificationSettings: { - ...user.additionalData.emailNotificationSettings, allowsOnboardingMail: newIsOnboardingMail, }, }, }); identifyUser({ unsubscribed_onboarding: !newIsOnboardingMail }); - setUser(user); + setUser(updatedUser); setOnboardingMail(newIsOnboardingMail); } }; const toggleChangelogMail = async () => { - if (user && user.additionalData && user.additionalData.emailNotificationSettings) { + if (user && user.emailNotificationSettings) { const newIsChangelogMail = !isChangelogMail; - user.additionalData.emailNotificationSettings.allowsChangelogMail = newIsChangelogMail; - await getGitpodService().server.updateLoggedInUser({ + user.emailNotificationSettings.allowsChangelogMail = newIsChangelogMail; + const updatedUser = await updateUser.mutateAsync({ additionalData: { - ...user.additionalData, emailNotificationSettings: { - ...user.additionalData.emailNotificationSettings, allowsChangelogMail: newIsChangelogMail, }, }, }); identifyUser({ unsubscribed_changelog: !newIsChangelogMail }); - setUser(user); + setUser(updatedUser); setChangelogMail(newIsChangelogMail); } }; const toggleDevXMail = async () => { - if (user && user.additionalData && user.additionalData.emailNotificationSettings) { + if (user && user.emailNotificationSettings) { const newIsDevXMail = !isDevXMail; - user.additionalData.emailNotificationSettings.allowsDevXMail = newIsDevXMail; - await getGitpodService().server.updateLoggedInUser({ + user.emailNotificationSettings.allowsDevxMail = newIsDevXMail; + const updatedUser = await updateUser.mutateAsync({ additionalData: { - ...user.additionalData, emailNotificationSettings: { - ...user.additionalData.emailNotificationSettings, allowsDevXMail: newIsDevXMail, }, }, }); identifyUser({ unsubscribed_devx: !newIsDevXMail }); - setUser(user); + setUser(updatedUser); setDevXMail(newIsDevXMail); } }; diff --git a/components/dashboard/src/user-settings/Preferences.tsx b/components/dashboard/src/user-settings/Preferences.tsx index 8c90e574ba30e7..f70e74d233d737 100644 --- a/components/dashboard/src/user-settings/Preferences.tsx +++ b/components/dashboard/src/user-settings/Preferences.tsx @@ -16,20 +16,27 @@ import SelectIDE from "./SelectIDE"; import { InputField } from "../components/forms/InputField"; import { TextInput } from "../components/forms/TextInputField"; import { useToast } from "../components/toasts/Toasts"; -import { useUpdateCurrentUserDotfileRepoMutation } from "../data/current-user/update-mutation"; -import { AdditionalUserData } from "@gitpod/gitpod-protocol"; +import { + useUpdateCurrentUserDotfileRepoMutation, + useUpdateCurrentUserMutation, +} from "../data/current-user/update-mutation"; import { useOrgBillingMode } from "../data/billing-mode/org-billing-mode-query"; +import { converter, userClient } from "../service/public-api"; export type IDEChangedTrackLocation = "workspace_list" | "workspace_start" | "preferences"; export default function Preferences() { const { toast } = useToast(); const { user, setUser } = useContext(UserContext); + const updateUser = useUpdateCurrentUserMutation(); const billingMode = useOrgBillingMode(); const updateDotfileRepo = useUpdateCurrentUserDotfileRepoMutation(); - const [dotfileRepo, setDotfileRepo] = useState(user?.additionalData?.dotfileRepo || ""); - const [workspaceTimeout, setWorkspaceTimeout] = useState(user?.additionalData?.workspaceTimeout ?? ""); + const [dotfileRepo, setDotfileRepo] = useState(user?.dotfileRepo || ""); + + const [workspaceTimeout, setWorkspaceTimeout] = useState( + converter.toDurationString(user?.workspaceTimeoutSettings?.inactivity), + ); const [timeoutUpdating, setTimeoutUpdating] = useState(false); const [creationError, setCreationError] = useState(); @@ -37,8 +44,10 @@ export default function Preferences() { async (e) => { e.preventDefault(); - const updatedUser = await updateDotfileRepo.mutateAsync(dotfileRepo); - setUser(updatedUser); + const user = await updateDotfileRepo.mutateAsync(dotfileRepo); + if (user) { + setUser(user); + } toast("Your dotfiles repository was updated."); }, [updateDotfileRepo, dotfileRepo, setUser, toast], @@ -54,8 +63,10 @@ export default function Preferences() { await getGitpodService().server.updateWorkspaceTimeoutSetting({ workspaceTimeout: workspaceTimeout }); // TODO: Once current user is in react-query, we can instead invalidate the query vs. refetching here - const updatedUser = await getGitpodService().server.getLoggedInUser(); - setUser(updatedUser); + const { user } = await userClient.getAuthenticatedUser({}); + if (user) { + setUser(user); + } let toastMessage = <>Default workspace timeout was updated.; if (billingMode.data?.mode === "usage-based") { @@ -87,11 +98,14 @@ export default function Preferences() { if (!user) { return; } - AdditionalUserData.set(user, { workspaceAutostartOptions: [] }); - setUser(user); - await getGitpodService().server.updateLoggedInUser(user); + const updatedUser = await updateUser.mutateAsync({ + additionalData: { + workspaceAutostartOptions: [], + }, + }); + setUser(updatedUser); toast("Workspace options have been cleared."); - }, [setUser, toast, user]); + }, [updateUser, setUser, toast, user]); return (
@@ -136,10 +150,7 @@ export default function Preferences() { @@ -173,7 +184,12 @@ export default function Preferences() { 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 ( -
-
-
- Gitpod's logo -
- -
- - 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. -

-
    -
  1. -
    -

    - 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. -

    -
    -
  2. -
  3. -
    -

    - 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. -

    -
    -
  4. -
  5. -
    -

    - 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. -

    -
    -
  6. -
-
- - ), - 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" : ""} -
- - -
- ) : ( -
- -
- )} -
- ); -} - -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") {