diff --git a/components/dashboard/src/AppNotifications.tsx b/components/dashboard/src/AppNotifications.tsx index bf0b1fbae9f9cd..242cf52ae4eac6 100644 --- a/components/dashboard/src/AppNotifications.tsx +++ b/components/dashboard/src/AppNotifications.tsx @@ -6,7 +6,7 @@ import { useEffect, useState } from "react"; import Alert from "./components/Alert"; -import { getGitpodService, gitpodHostUrl } from "./service/service"; +import { getGitpodService } from "./service/service"; const KEY_APP_NOTIFICATIONS = "gitpod-app-notifications"; @@ -49,9 +49,9 @@ export function AppNotifications() { const getManageBilling = () => { let href; if (notifications.length === 1) { - href = `${gitpodHostUrl}billing`; + href = "/user/billing"; } else if (notifications.length === 2) { - href = `${gitpodHostUrl}/org-billing`; + href = `/billing`; } return ( diff --git a/components/dashboard/src/Menu.tsx b/components/dashboard/src/Menu.tsx deleted file mode 100644 index 2be09b07b800a1..00000000000000 --- a/components/dashboard/src/Menu.tsx +++ /dev/null @@ -1,527 +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, TeamMemberInfo, Project } from "@gitpod/gitpod-protocol"; -import { useContext, useEffect, useState } from "react"; -import { Link } from "react-router-dom"; -import { useLocation, useRouteMatch } from "react-router"; -import { Location } from "history"; -import { countries } from "countries-list"; -import gitpodIcon from "./icons/gitpod.svg"; -import { getGitpodService, gitpodHostUrl } from "./service/service"; -import { UserContext } from "./user-context"; -import { useCurrentTeam, useTeams } from "./teams/teams-context"; -import { getAdminMenu } from "./admin/admin-menu"; -import ContextMenu, { ContextMenuEntry } from "./components/ContextMenu"; -import Separator from "./components/Separator"; -import PillMenuItem from "./components/PillMenuItem"; -import TabMenuItem from "./components/TabMenuItem"; -import { getTeamSettingsMenu } from "./teams/TeamSettings"; -import { getProjectSettingsMenu } from "./projects/ProjectSettings"; -import { ProjectContext } from "./projects/project-context"; -import { PaymentContext } from "./payment-context"; -import FeedbackFormModal from "./feedback-form/FeedbackModal"; -import { inResource, isGitpodIo } from "./utils"; -import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; -import { FeatureFlagContext } from "./contexts/FeatureFlagContext"; -import { publicApiTeamMembersToProtocol, teamsService } from "./service/public-api"; -import { listAllProjects } from "./service/public-api"; - -interface Entry { - title: string; - link: string; - alternatives?: string[]; -} - -export default function Menu() { - const { user } = useContext(UserContext); - const { showUsageView, oidcServiceEnabled } = useContext(FeatureFlagContext); - const teams = useTeams(); - const location = useLocation(); - const team = useCurrentTeam(); - const { setCurrency, setIsStudent, setIsChargebeeCustomer } = useContext(PaymentContext); - const [teamBillingMode, setTeamBillingMode] = useState(undefined); - const [userBillingMode, setUserBillingMode] = useState(undefined); - const { project, setProject } = useContext(ProjectContext); - const [isFeedbackFormVisible, setFeedbackFormVisible] = useState(false); - - const [hasIndividualProjects, setHasIndividualProjects] = useState(false); - - useEffect(() => { - getGitpodService() - .server.getUserProjects() - .then((projects) => setHasIndividualProjects(projects.length > 0)); - getGitpodService().server.getBillingModeForUser().then(setUserBillingMode); - }, []); - - const projectsRouteMatch = useRouteMatch<{ segment1?: string; segment2?: string }>( - "/projects/:segment1?/:segment2?", - ); - - const projectSlug = (() => { - const resource = projectsRouteMatch?.params.segment1; - if ( - resource && - ![ - // team sub-pages - "projects", - "members", - "settings", - "billing", - "sso", - "usage", - // admin sub-pages - "users", - "workspaces", - "teams", - "orgs", - ].includes(resource) - ) { - return resource; - } - })(); - const prebuildId = (() => { - const resource = projectSlug && projectsRouteMatch?.params.segment2; - if ( - resource && - ![ - // project sub-pages - "prebuilds", - "settings", - "variables", - ].includes(resource) - ) { - return resource; - } - })(); - - function isSelected(entry: Entry, location: Location) { - const all = [entry.link, ...(entry.alternatives || [])].map((l) => l.toLowerCase()); - const path = location.pathname.toLowerCase(); - return all.some((n) => n === path || n + "/" === path); - } - - // Hide most of the top menu when in a full-page form. - const isMinimalUI = inResource(location.pathname, ["new", "orgs/new", "open"]); - const isWorkspacesUI = inResource(location.pathname, ["workspaces"]); - const isPersonalSettingsUI = inResource(location.pathname, [ - "account", - "notifications", - "billing", - "plans", - "teams", - "orgs", - "variables", - "keys", - "integrations", - "preferences", - "tokens", - ]); - const isAdminUI = inResource(window.location.pathname, ["admin"]); - - const [teamMembers, setTeamMembers] = useState>({}); - - useEffect(() => { - if (!teams) { - return; - } - (async () => { - const members: Record = {}; - await Promise.all( - teams.map(async (team) => { - try { - members[team.id] = publicApiTeamMembersToProtocol( - (await teamsService.getTeam({ teamId: team!.id })).team?.members || [], - ); - } catch (error) { - console.error("Could not get members of team", team, error); - } - }), - ); - setTeamMembers(members); - })(); - }, [teams]); - - useEffect(() => { - if (!teams || !projectSlug) { - return; - } - (async () => { - let projects: Project[]; - if (!!team) { - projects = await listAllProjects({ teamId: team.id }); - } else { - projects = await listAllProjects({ userId: user?.id }); - } - - // Find project matching with slug, otherwise with name - const project = projectSlug && projects.find((p) => Project.slug(p) === projectSlug); - if (!project) { - return; - } - setProject(project); - })(); - }, [projectSlug, setProject, team, teams]); - - useEffect(() => { - const { server } = getGitpodService(); - Promise.all([ - server.getClientRegion().then((v) => () => { - // @ts-ignore - setCurrency(countries[v]?.currency === "EUR" ? "EUR" : "USD"); - }), - server.isStudent().then((v) => () => setIsStudent(v)), - server.isChargebeeCustomer().then((v) => () => setIsChargebeeCustomer(v)), - ]).then((setters) => setters.forEach((s) => s())); - }, []); - - useEffect(() => { - if (team) { - getGitpodService().server.getBillingModeForTeam(team.id).then(setTeamBillingMode); - } - }, [team]); - - const secondLevelMenu: Entry[] = (() => { - // Project menu - if (projectSlug) { - return [ - { - title: "Branches", - link: `/projects/${projectSlug}`, - }, - { - title: "Prebuilds", - link: `/projects/${projectSlug}/prebuilds`, - }, - { - title: "Settings", - link: `/projects/${projectSlug}/settings`, - alternatives: getProjectSettingsMenu({ slug: projectSlug } as Project, team).flatMap((e) => e.link), - }, - ]; - } - // Team menu - if (!team) { - return [ - { - title: "Projects", - link: `/projects`, - alternatives: [] as string[], - }, - ...(BillingMode.showUsageBasedBilling(userBillingMode) && - !user?.additionalData?.isMigratedToTeamOnlyAttribution - ? [ - { - title: "Usage", - link: "/usage", - }, - ] - : []), - ]; - } - const currentUserInTeam = (teamMembers[team.id] || []).find((m) => m.userId === user?.id); - - const teamSettingsList = [ - { - title: "Projects", - link: `/projects`, - alternatives: [] as string[], - }, - { - title: "Members", - link: `/members`, - }, - ]; - if ( - currentUserInTeam?.role === "owner" && - (showUsageView || (teamBillingMode && teamBillingMode.mode === "usage-based")) - ) { - teamSettingsList.push({ - title: "Usage", - link: `/usage`, - }); - } - if (currentUserInTeam?.role === "owner") { - teamSettingsList.push({ - title: "Settings", - link: `/org-settings`, - alternatives: getTeamSettingsMenu({ - team, - billingMode: teamBillingMode, - ssoEnabled: oidcServiceEnabled, - }).flatMap((e) => e.link), - }); - } - - return teamSettingsList; - })(); - const leftMenu: Entry[] = [ - { - title: "Workspaces", - link: "/workspaces", - alternatives: ["/"], - }, - ]; - const rightMenu: Entry[] = [ - ...(user?.rolesOrPermissions?.includes("admin") - ? [ - { - title: "Admin", - link: "/admin", - alternatives: getAdminMenu().flatMap((e) => e.link), - }, - ] - : []), - ]; - - const handleFeedbackFormClick = () => { - setFeedbackFormVisible(true); - }; - - const onFeedbackFormClose = () => { - setFeedbackFormVisible(false); - }; - const isTeamLevelActive = !projectSlug && !isWorkspacesUI && !isPersonalSettingsUI && !isAdminUI; - const renderTeamMenu = () => { - if (!hasIndividualProjects && (!teams || teams.length === 0)) { - return ( -
- -
- ); - } - const userFullName = user?.fullName || user?.name || "..."; - const entries: ContextMenuEntry[] = [ - ...(!user?.additionalData?.isMigratedToTeamOnlyAttribution - ? [ - { - title: userFullName, - customContent: ( -
- - {userFullName} - - Personal Account -
- ), - active: team === undefined, - separator: true, - link: `/projects/?org=0`, - }, - ] - : []), - ...(teams || []) - .map((t) => ({ - title: t.name, - customContent: ( -
- {t.name} - - {!!teamMembers[t.id] - ? `${teamMembers[t.id].length} member${teamMembers[t.id].length === 1 ? "" : "s"}` - : "..."} - -
- ), - active: team?.id === t.id, - separator: true, - link: `/projects/?org=${t.id}`, - })) - .sort((a, b) => (a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1)), - { - title: "Create a new organization", - customContent: ( -
- New Organization - - - -
- ), - link: "/orgs/new", - }, - ]; - const classes = - "flex h-full text-base py-0 " + - (isTeamLevelActive - ? "text-gray-50 bg-gray-800 dark:bg-gray-50 dark:text-gray-900 border-gray-700 dark:border-gray-200" - : "text-gray-500 bg-gray-50 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 dark:border-gray-700"); - const selectedEntry = entries.find((e) => e.active) || entries[0]; - return ( -
- - - {selectedEntry.title!} - - -
- -
- - - Toggle organization selection menu - -
-
-
- {projectSlug && !prebuildId && !isAdminUI && ( - - - {project?.name} - - - )} - {prebuildId && ( - - - {project?.name} - - - )} - {prebuildId && ( -
-
- - - -
- - - {prebuildId.substring(0, 8).trimEnd()} - - -
- )} -
- ); - }; - - return ( - <> -
-
-
- - Gitpod's logo - - {!isMinimalUI && ( - <> -
- {leftMenu.map((entry) => ( -
- -
- ))} -
- {renderTeamMenu()} - - )} -
- - {isFeedbackFormVisible && } -
- {!isMinimalUI && !prebuildId && !isWorkspacesUI && !isPersonalSettingsUI && !isAdminUI && ( - - )} -
- - - ); -} diff --git a/components/dashboard/src/app/AppRoutes.tsx b/components/dashboard/src/app/AppRoutes.tsx index edac971b2d3cbf..8e3cc3e7b9ae99 100644 --- a/components/dashboard/src/app/AppRoutes.tsx +++ b/components/dashboard/src/app/AppRoutes.tsx @@ -11,7 +11,7 @@ import { StartPage, StartPhase } from "../start/StartPage"; import { getURLHash, isGitpodIo, isLocalPreview } from "../utils"; import { shouldSeeWhatsNew, WhatsNew } from "../whatsnew/WhatsNew"; import { Redirect, Route, Switch } from "react-router"; -import Menu from "../Menu"; +import Menu from "../menu/Menu"; import { parseProps } from "../start/StartWorkspace"; import { AppNotifications } from "../AppNotifications"; import { AdminRoute } from "./AdminRoute"; @@ -224,8 +224,8 @@ export const AppRoutes: FunctionComponent = ({ user, teams }) => - - + + diff --git a/components/dashboard/src/components/Header.tsx b/components/dashboard/src/components/Header.tsx index 9b392324ff0c38..33817f250f3d10 100644 --- a/components/dashboard/src/components/Header.tsx +++ b/components/dashboard/src/components/Header.tsx @@ -5,20 +5,30 @@ */ import { useEffect } from "react"; +import { useLocation } from "react-router"; import Separator from "./Separator"; +import TabMenuItem from "./TabMenuItem"; export interface HeaderProps { title: string | React.ReactElement; subtitle: string | React.ReactElement; + tabs?: TabEntry[]; +} + +export interface TabEntry { + title: string; + link: string; + alternatives?: string[]; } export default function Header(p: HeaderProps) { + const location = useLocation(); useEffect(() => { if (typeof p.title !== "string") { return; } document.title = `${p.title} — Gitpod`; - }, []); + }, [p.title]); return (
@@ -27,6 +37,18 @@ export default function Header(p: HeaderProps) { {typeof p.subtitle === "string" ?

{p.subtitle}

: p.subtitle}
+ ); diff --git a/components/dashboard/src/components/PageWithSubMenu.tsx b/components/dashboard/src/components/PageWithSubMenu.tsx index 49fb9f56ff0a12..a366232ddcd42a 100644 --- a/components/dashboard/src/components/PageWithSubMenu.tsx +++ b/components/dashboard/src/components/PageWithSubMenu.tsx @@ -6,7 +6,7 @@ import { useLocation } from "react-router"; import { Link } from "react-router-dom"; -import Header from "../components/Header"; +import Header, { TabEntry } from "../components/Header"; export interface PageWithSubMenuProps { title: string; @@ -15,6 +15,7 @@ export interface PageWithSubMenuProps { title: string; link: string[]; }[]; + tabs?: TabEntry[]; children: React.ReactNode; } @@ -22,7 +23,7 @@ export function PageWithSubMenu(p: PageWithSubMenuProps) { const location = useLocation(); return (
-
+
    diff --git a/components/dashboard/src/components/UsageBasedBillingConfig.tsx b/components/dashboard/src/components/UsageBasedBillingConfig.tsx index 77837521942c9f..902df1da36412f 100644 --- a/components/dashboard/src/components/UsageBasedBillingConfig.tsx +++ b/components/dashboard/src/components/UsageBasedBillingConfig.tsx @@ -32,6 +32,7 @@ interface Props { export default function UsageBasedBillingConfig({ attributionId }: Props) { const location = useLocation(); + const attrId = attributionId ? AttributionId.parse(attributionId) : undefined; const [showUpdateLimitModal, setShowUpdateLimitModal] = useState(false); const [showBillingSetupModal, setShowBillingSetupModal] = useState(false); const [stripeSubscriptionId, setStripeSubscriptionId] = useState(); @@ -102,7 +103,6 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) { // Pick a good initial value for the Stripe usage limit (base_limit * team_size) // FIXME: Should we ask the customer to confirm or edit this default limit? let limit = BASE_USAGE_LIMIT_FOR_STRIPE_USERS; - const attrId = AttributionId.parse(attributionId); if (attrId?.kind === "team") { const members = publicApiTeamMembersToProtocol( (await teamsService.getTeam({ teamId: attrId.teamId })).team?.members || [], @@ -227,9 +227,9 @@ export default function UsageBasedBillingConfig({ attributionId }: Props) {
diff --git a/components/dashboard/src/components/UsageLimitReachedModal.tsx b/components/dashboard/src/components/UsageLimitReachedModal.tsx index 671a2bb5a5f886..10c79522618961 100644 --- a/components/dashboard/src/components/UsageLimitReachedModal.tsx +++ b/components/dashboard/src/components/UsageLimitReachedModal.tsx @@ -7,7 +7,7 @@ import { Team } from "@gitpod/gitpod-protocol"; import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; import { useEffect, useState } from "react"; -import { gitpodHostUrl } from "../service/service"; +import { settingsPathBilling } from "../settings/settings.routes"; import { useTeams } from "../teams/teams-context"; import Alert from "./Alert"; import Modal from "./Modal"; @@ -29,6 +29,7 @@ export function UsageLimitReachedModal(p: { hints: any }) { }, []); const attributedTeamName = attributedTeam?.name; + const billingLink = attributedTeam ? "/billing" : settingsPathBilling; return ( {}}>

@@ -45,11 +46,11 @@ export function UsageLimitReachedModal(p: { hints: any }) { of {attributedTeamName} )} - to increase the usage limit, or change your billing settings. + to increase the usage limit, or change your billing settings.

diff --git a/components/dashboard/src/data/workspaces/list-workspaces-query.ts b/components/dashboard/src/data/workspaces/list-workspaces-query.ts index 9613343cee5ec7..d561b5d00cf4ef 100644 --- a/components/dashboard/src/data/workspaces/list-workspaces-query.ts +++ b/components/dashboard/src/data/workspaces/list-workspaces-query.ts @@ -12,6 +12,7 @@ export type ListWorkspacesQueryResult = WorkspaceInfo[]; type UseListWorkspacesQueryArgs = { limit: number; + orgId?: string; }; export const useListWorkspacesQuery = ({ limit }: UseListWorkspacesQueryArgs) => { diff --git a/components/dashboard/src/menu/Menu.tsx b/components/dashboard/src/menu/Menu.tsx new file mode 100644 index 00000000000000..71c43971e4b2c4 --- /dev/null +++ b/components/dashboard/src/menu/Menu.tsx @@ -0,0 +1,221 @@ +/** + * 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 { useContext, useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { useLocation } from "react-router"; +import { Location } from "history"; +import { countries } from "countries-list"; +import gitpodIcon from "../icons/gitpod.svg"; +import { getGitpodService, gitpodHostUrl } from "../service/service"; +import { useCurrentUser } from "../user-context"; +import { useBillingModeForCurrentTeam, useCurrentTeam, useTeamMemberInfos } from "../teams/teams-context"; +import ContextMenu from "../components/ContextMenu"; +import Separator from "../components/Separator"; +import PillMenuItem from "../components/PillMenuItem"; +import { getTeamSettingsMenu } from "../teams/TeamSettings"; +import { PaymentContext } from "../payment-context"; +import FeedbackFormModal from "../feedback-form/FeedbackModal"; +import { inResource, isGitpodIo } from "../utils"; +import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; +import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; +import OrganizationSelector from "./OrganizationSelector"; + +interface Entry { + title: string; + link: string; + alternatives?: string[]; +} + +export default function Menu() { + const user = useCurrentUser(); + const team = useCurrentTeam(); + const location = useLocation(); + const teamBillingMode = useBillingModeForCurrentTeam(); + const { showUsageView, oidcServiceEnabled } = useContext(FeatureFlagContext); + const { setCurrency, setIsStudent, setIsChargebeeCustomer } = useContext(PaymentContext); + const [userBillingMode, setUserBillingMode] = useState(undefined); + const [isFeedbackFormVisible, setFeedbackFormVisible] = useState(false); + const teamMembers = useTeamMemberInfos(); + + useEffect(() => { + getGitpodService().server.getBillingModeForUser().then(setUserBillingMode); + }, []); + + function isSelected(entry: Entry, location: Location) { + const all = [entry.link, ...(entry.alternatives || [])].map((l) => l.toLowerCase()); + const path = location.pathname.toLowerCase(); + return all.some((n) => n === path || n + "/" === path); + } + + // Hide most of the top menu when in a full-page form. + const isMinimalUI = inResource(location.pathname, ["new", "orgs/new", "open"]); + + useEffect(() => { + const { server } = getGitpodService(); + Promise.all([ + server.getClientRegion().then((v) => () => { + // @ts-ignore + setCurrency(countries[v]?.currency === "EUR" ? "EUR" : "USD"); + }), + server.isStudent().then((v) => () => setIsStudent(v)), + server.isChargebeeCustomer().then((v) => () => setIsChargebeeCustomer(v)), + ]).then((setters) => setters.forEach((s) => s())); + }, [setCurrency, setIsChargebeeCustomer, setIsStudent]); + + const leftMenu = useMemo(() => { + const leftMenu: Entry[] = [ + { + title: "Workspaces", + link: "/workspaces", + alternatives: ["/"], + }, + { + title: "Projects", + link: `/projects`, + alternatives: [] as string[], + }, + ]; + + if ( + !team && + BillingMode.showUsageBasedBilling(userBillingMode) && + !user?.additionalData?.isMigratedToTeamOnlyAttribution + ) { + leftMenu.push({ + title: "Usage", + link: "/usage", + }); + } + if (team) { + leftMenu.push({ + title: "Members", + link: `/members`, + }); + const currentUserInTeam = (teamMembers[team.id] || []).find((m) => m.userId === user?.id); + if ( + currentUserInTeam?.role === "owner" && + (showUsageView || (teamBillingMode && teamBillingMode.mode === "usage-based")) + ) { + leftMenu.push({ + title: "Usage", + link: `/usage`, + }); + } + if (currentUserInTeam?.role === "owner") { + leftMenu.push({ + title: "Settings", + link: `/settings`, + alternatives: getTeamSettingsMenu({ + team, + billingMode: teamBillingMode, + ssoEnabled: oidcServiceEnabled, + }).flatMap((e) => e.link), + }); + } + } + return leftMenu; + }, [oidcServiceEnabled, showUsageView, team, teamBillingMode, teamMembers, user, userBillingMode]); + + const handleFeedbackFormClick = () => { + setFeedbackFormVisible(true); + }; + + const onFeedbackFormClose = () => { + setFeedbackFormVisible(false); + }; + + return ( + <> +
+
+
+ + Gitpod's logo + + + {!isMinimalUI && ( + <> +
+ {leftMenu.map((entry) => ( +
+ +
+ ))} +
+ + )} +
+ + {isFeedbackFormVisible && } +
+
+ + + ); +} diff --git a/components/dashboard/src/menu/OrganizationSelector.tsx b/components/dashboard/src/menu/OrganizationSelector.tsx new file mode 100644 index 00000000000000..4738cb5ec388b4 --- /dev/null +++ b/components/dashboard/src/menu/OrganizationSelector.tsx @@ -0,0 +1,109 @@ +/** + * 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 { useMemo } from "react"; +import { useLocation } from "react-router-dom"; +import ContextMenu, { ContextMenuEntry } from "../components/ContextMenu"; +import { useCurrentTeam, useTeamMemberInfos, useTeams } from "../teams/teams-context"; +import { useCurrentUser } from "../user-context"; + +export interface OrganizationSelectorProps {} + +export default function OrganizationSelector(p: OrganizationSelectorProps) { + const user = useCurrentUser(); + const teams = useTeams(); + const team = useCurrentTeam(); + const teamMembers = useTeamMemberInfos(); + const location = useLocation(); + + const userFullName = user?.fullName || user?.name || "..."; + const entries: ContextMenuEntry[] = useMemo( + () => [ + ...(!user?.additionalData?.isMigratedToTeamOnlyAttribution + ? [ + { + title: userFullName, + customContent: ( +
+ + {userFullName} + + Personal Account +
+ ), + active: team === undefined, + separator: true, + link: `${location.pathname}?org=0`, + }, + ] + : []), + ...(teams || []) + .map((t) => ({ + title: t.name, + customContent: ( +
+ {t.name} + + {!!teamMembers[t.id] + ? `${teamMembers[t.id].length} member${teamMembers[t.id].length === 1 ? "" : "s"}` + : "..."} + +
+ ), + active: team?.id === t.id, + separator: true, + link: `${location.pathname}?org=${t.id}`, + })) + .sort((a, b) => (a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1)), + { + title: "Create a new organization", + customContent: ( +
+ New Organization + + + +
+ ), + link: "/orgs/new", + }, + ], + [ + user?.additionalData?.isMigratedToTeamOnlyAttribution, + userFullName, + team, + location.pathname, + teams, + teamMembers, + ], + ); + const selectedEntry = entries.find((e) => e.active) || entries[0]; + const classes = + "flex h-full text-base py-0 text-gray-500 bg-gray-50 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 dark:border-gray-700"; + return ( + +
+
{selectedEntry.title!}
+
+ + + Toggle organization selection menu + +
+
+
+ ); +} diff --git a/components/dashboard/src/projects/Prebuilds.tsx b/components/dashboard/src/projects/Prebuilds.tsx index feaa061e39f75b..2dab4f38b1412b 100644 --- a/components/dashboard/src/projects/Prebuilds.tsx +++ b/components/dashboard/src/projects/Prebuilds.tsx @@ -21,6 +21,7 @@ import { shortCommitMessage } from "./render-utils"; import { Link } from "react-router-dom"; import { Disposable } from "vscode-jsonrpc"; import { useCurrentProject } from "./project-context"; +import { getProjectTabs } from "./projects.routes"; export default function (props: { project?: Project; isAdminDashboard?: boolean }) { const currentProject = useCurrentProject(); @@ -130,7 +131,11 @@ export default function (props: { project?: Project; isAdminDashboard?: boolean return ( <> {!props.isAdminDashboard && ( -
+
)}
diff --git a/components/dashboard/src/projects/Project.tsx b/components/dashboard/src/projects/Project.tsx index 626c6a035f604d..3993d08e49f723 100644 --- a/components/dashboard/src/projects/Project.tsx +++ b/components/dashboard/src/projects/Project.tsx @@ -19,6 +19,7 @@ import { getGitpodService, gitpodHostUrl } from "../service/service"; import { StartWorkspaceModalContext } from "../workspaces/start-workspace-modal-context"; import { prebuildStatusIcon, prebuildStatusLabel } from "./Prebuilds"; import { useCurrentProject } from "./project-context"; +import { getProjectTabs } from "./projects.routes"; import { shortCommitMessage, toRemoteURL } from "./render-utils"; export default function () { @@ -179,7 +180,7 @@ export default function () { return ( <>
View recent active branches for{" "} @@ -189,6 +190,7 @@ export default function () { . } + tabs={getProjectTabs(project)} />
{showAuthBanner ? ( @@ -266,13 +268,12 @@ export default function () { )} {!isResuming && ( - resumePrebuilds()} > Resume prebuilds - + )} )} @@ -333,7 +334,7 @@ export default function () { href={ prebuild ? `/projects/${Project.slug(project!)}/${prebuild.info.id}` - : "javascript:void(0)" + : "" } > {prebuild ? ( diff --git a/components/dashboard/src/projects/ProjectSettings.tsx b/components/dashboard/src/projects/ProjectSettings.tsx index 3b7893f89d5889..23b7013e90ac98 100644 --- a/components/dashboard/src/projects/ProjectSettings.tsx +++ b/components/dashboard/src/projects/ProjectSettings.tsx @@ -6,7 +6,7 @@ import { useCallback, useContext, useEffect, useState } from "react"; import { useHistory } from "react-router"; -import { Project, ProjectSettings, Team } from "@gitpod/gitpod-protocol"; +import { Project, ProjectSettings } from "@gitpod/gitpod-protocol"; import CheckBox from "../components/CheckBox"; import { getGitpodService } from "../service/service"; import { useCurrentTeam } from "../teams/teams-context"; @@ -18,28 +18,15 @@ import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; import Alert from "../components/Alert"; import { Link } from "react-router-dom"; import { RemoveProjectModal } from "./RemoveProjectModal"; - -export function getProjectSettingsMenu(project?: Project, team?: Team) { - return [ - { - title: "General", - link: [`/projects/${Project.slug(project!)}/settings`], - }, - { - title: "Variables", - link: [`/projects/${Project.slug(project!)}/variables`], - }, - ]; -} +import { getProjectSettingsMenu, getProjectTabs } from "./projects.routes"; export function ProjectSettingsPage(props: { project?: Project; children?: React.ReactNode }) { - const team = useCurrentTeam(); - return ( {props.children} @@ -131,7 +118,7 @@ export default function () { , first cancel your existing plan. - +
diff --git a/components/dashboard/src/projects/project-context.tsx b/components/dashboard/src/projects/project-context.tsx index de2734ae9d2003..9ab80c97967a68 100644 --- a/components/dashboard/src/projects/project-context.tsx +++ b/components/dashboard/src/projects/project-context.tsx @@ -5,7 +5,12 @@ */ import { Project } from "@gitpod/gitpod-protocol"; -import React, { createContext, useContext, useState } from "react"; +import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { useRouteMatch } from "react-router"; +import { validate as uuidValidate } from "uuid"; +import { listAllProjects } from "../service/public-api"; +import { useCurrentTeam } from "../teams/teams-context"; +import { useCurrentUser } from "../user-context"; export const ProjectContext = createContext<{ project?: Project; @@ -19,7 +24,53 @@ export const ProjectContextProvider: React.FC = ({ children }) => { return {children}; }; +export function useProjectSlugs(): { projectSlug?: string; prebuildId?: string } { + const projectsRouteMatch = useRouteMatch<{ projectSlug?: string; prebuildId?: string }>( + "/projects/:projectSlug?/:prebuildId?", + ); + + return useMemo(() => { + const projectSlug = projectsRouteMatch?.params.projectSlug; + const result: { projectSlug?: string; prebuildId?: string } = {}; + const reservedProjectSlugs = ["new"]; + if (!projectSlug || reservedProjectSlugs.includes(projectSlug)) { + return result; + } + result.projectSlug = projectSlug; + const prebuildId = projectsRouteMatch?.params.prebuildId; + if (prebuildId && uuidValidate(prebuildId)) { + result.prebuildId = projectsRouteMatch?.params.prebuildId; + } + return result; + }, [projectsRouteMatch?.params.projectSlug, projectsRouteMatch?.params.prebuildId]); +} + export function useCurrentProject(): Project | undefined { - const { project } = useContext(ProjectContext); + const { project, setProject } = useContext(ProjectContext); + const user = useCurrentUser(); + const team = useCurrentTeam(); + const slugs = useProjectSlugs(); + + useEffect(() => { + if (!user || !slugs.projectSlug) { + return; + } + (async () => { + let projects: Project[]; + if (!!team) { + projects = await listAllProjects({ teamId: team.id }); + } else { + projects = await listAllProjects({ userId: user?.id }); + } + + // Find project matching with slug, otherwise with name + const project = slugs.projectSlug && projects.find((p) => Project.slug(p) === slugs.projectSlug); + if (!project) { + return; + } + setProject(project); + })(); + }, [slugs.projectSlug, setProject, team, user]); + return project; } diff --git a/components/dashboard/src/projects/projects.routes.ts b/components/dashboard/src/projects/projects.routes.ts index 3880af51bb8e9b..43adfa8ea0ca3e 100644 --- a/components/dashboard/src/projects/projects.routes.ts +++ b/components/dashboard/src/projects/projects.routes.ts @@ -4,8 +4,47 @@ * See License.AGPL.txt in the project root for license information. */ +import { Project } from "@gitpod/gitpod-protocol"; +import { TabEntry } from "../components/Header"; + export const projectsPathMain = "/projects"; export const projectsPathMainWithParams = [projectsPathMain, ":projectName", ":resourceOrPrebuild?"].join("/"); export const projectsPathInstallGitHubApp = "/install-github-app"; export const projectsPathNew = "/new"; + +export function getProjectTabs(project: Project | undefined): TabEntry[] { + if (!project) { + return []; + } + const projectSlug = Project.slug(project); + return [ + { + title: "Branches", + link: `/projects/${projectSlug}`, + }, + { + title: "Prebuilds", + link: `/projects/${projectSlug}/prebuilds`, + }, + { + title: "Settings", + link: `/projects/${projectSlug}/settings`, + alternatives: getProjectSettingsMenu(project).flatMap((e) => e.link), + }, + ]; +} + +export function getProjectSettingsMenu(project?: Project) { + const slug = project ? Project.slug(project) : "unknown"; + return [ + { + title: "General", + link: [`/projects/${slug}/settings`], + }, + { + title: "Variables", + link: [`/projects/${slug}/variables`], + }, + ]; +} diff --git a/components/dashboard/src/settings/settings.routes.ts b/components/dashboard/src/settings/settings.routes.ts index 26da302aef8fb1..b114f4affa2d2c 100644 --- a/components/dashboard/src/settings/settings.routes.ts +++ b/components/dashboard/src/settings/settings.routes.ts @@ -4,18 +4,18 @@ * See License.AGPL.txt in the project root for license information. */ -export const settingsPathMain = "/settings"; +export const settingsPathMain = "/user/settings"; export const usagePathMain = "/usage"; -export const settingsPathAccount = "/account"; -export const settingsPathIntegrations = "/integrations"; -export const settingsPathNotifications = "/notifications"; -export const settingsPathBilling = "/billing"; -export const settingsPathPlans = "/plans"; -export const settingsPathPreferences = "/preferences"; -export const settingsPathVariables = "/variables"; -export const settingsPathPersonalAccessTokens = "/tokens"; -export const settingsPathPersonalAccessTokenCreate = "/tokens/create"; -export const settingsPathPersonalAccessTokenEdit = "/tokens/edit"; +export const settingsPathAccount = "/user/account"; +export const settingsPathIntegrations = "/user/integrations"; +export const settingsPathNotifications = "/user/notifications"; +export const settingsPathBilling = "/user/billing"; +export const settingsPathPlans = "/user/plans"; +export const settingsPathPreferences = "/user/preferences"; +export const settingsPathVariables = "/user/variables"; +export const settingsPathPersonalAccessTokens = "/user/tokens"; +export const settingsPathPersonalAccessTokenCreate = "/user/tokens/create"; +export const settingsPathPersonalAccessTokenEdit = "/user/tokens/edit"; -export const settingsPathSSHKeys = "/keys"; +export const settingsPathSSHKeys = "/user/keys"; diff --git a/components/dashboard/src/teams/TeamSettings.tsx b/components/dashboard/src/teams/TeamSettings.tsx index fda947f20b541c..eb5e08394480a5 100644 --- a/components/dashboard/src/teams/TeamSettings.tsx +++ b/components/dashboard/src/teams/TeamSettings.tsx @@ -22,7 +22,7 @@ export function getTeamSettingsMenu(params: { team?: Team; billingMode?: Billing const result = [ { title: "General", - link: [`/org-settings`], + link: [`/settings`], }, ]; if (ssoEnabled) { @@ -35,7 +35,7 @@ export function getTeamSettingsMenu(params: { team?: Team; billingMode?: Billing // The Billing page contains both chargebee and usage-based components, so: always show them! result.push({ title: "Billing", - link: [`/org-billing`], + link: ["/billing"], }); } return result; diff --git a/components/dashboard/src/teams/teams-context.tsx b/components/dashboard/src/teams/teams-context.tsx index 5ef2c04569cb94..4c1b49ba533b04 100644 --- a/components/dashboard/src/teams/teams-context.tsx +++ b/components/dashboard/src/teams/teams-context.tsx @@ -4,9 +4,12 @@ * See License.AGPL.txt in the project root for license information. */ -import { Team } from "@gitpod/gitpod-protocol"; -import React, { createContext, useContext, useState } from "react"; +import { Team, TeamMemberInfo } from "@gitpod/gitpod-protocol"; +import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; +import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; import { useLocation } from "react-router"; +import { publicApiTeamMembersToProtocol, teamsService } from "../service/public-api"; +import { getGitpodService } from "../service/service"; import { useCurrentUser } from "../user-context"; export const TeamsContext = createContext<{ @@ -52,3 +55,39 @@ export function useTeams(): Team[] | undefined { const { teams } = useContext(TeamsContext); return teams; } + +export function useBillingModeForCurrentTeam(): BillingMode | undefined { + const team = useCurrentTeam(); + const [billingMode, setBillingMode] = useState(); + useCallback(() => { + if (!!team) { + getGitpodService().server.getBillingModeForTeam(team.id).then(setBillingMode); + } + }, [team]); + return billingMode; +} + +export function useTeamMemberInfos(): Record { + const [teamMembers, setTeamMembers] = useState>({}); + const teams = useTeams(); + + useEffect(() => { + if (!teams) { + return; + } + (async () => { + const members: Record = {}; + for (const team of teams) { + try { + members[team.id] = publicApiTeamMembersToProtocol( + (await teamsService.getTeam({ teamId: team!.id })).team?.members || [], + ); + } catch (error) { + console.error("Could not get members of team", team, error); + } + } + setTeamMembers(members); + })(); + }, [teams]); + return teamMembers; +} diff --git a/components/gitpod-protocol/src/util/gitpod-host-url.ts b/components/gitpod-protocol/src/util/gitpod-host-url.ts index 97d8df30d77ae8..142c0d1181ed9b 100644 --- a/components/gitpod-protocol/src/util/gitpod-host-url.ts +++ b/components/gitpod-protocol/src/util/gitpod-host-url.ts @@ -100,28 +100,16 @@ export class GitpodHostUrl { return this.with((url) => ({ pathname: "/" })); } - asBilling(): GitpodHostUrl { - return this.with((url) => ({ pathname: "/billing" })); - } - asLogin(): GitpodHostUrl { return this.with((url) => ({ pathname: "/login" })); } - asUpgradeSubscription(): GitpodHostUrl { - return this.with((url) => ({ pathname: "/plans" })); - } - asAccessControl(): GitpodHostUrl { return this.with((url) => ({ pathname: "/integrations" })); } - asSettings(): GitpodHostUrl { - return this.with((url) => ({ pathname: "/settings" })); - } - asPreferences(): GitpodHostUrl { - return this.with((url) => ({ pathname: "/preferences" })); + return this.with((url) => ({ pathname: "/user/preferences" })); } asStart(workspaceId = this.workspaceId): GitpodHostUrl { diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 9dc3093a7a18c8..5695f55d6c333f 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -2253,15 +2253,15 @@ export class GitpodServerEEImpl extends GitpodServerImpl { const user = this.checkAndBlockUser("getStripePortalUrl"); - let returnUrl = this.config.hostUrl.with(() => ({ pathname: `/billing` })).toString(); + let returnUrl = this.config.hostUrl + .with(() => ({ pathname: `/billing`, search: `org=${attrId.kind === "team" ? attrId.teamId : "0"}` })) + .toString(); if (attrId.kind === "user") { await this.ensureStripeApiIsAllowed({ user }); + returnUrl = this.config.hostUrl.with(() => ({ pathname: `/user/billing`, search: `org=0` })).toString(); } else if (attrId.kind === "team") { const team = await this.guardTeamOperation(attrId.teamId, "update"); await this.ensureStripeApiIsAllowed({ team }); - returnUrl = this.config.hostUrl - .with(() => ({ pathname: `/org-billing`, search: `org=${team.id}` })) - .toString(); } let url: string; try {