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/Setup.tsx b/components/dashboard/src/Setup.tsx index b633e4082a0276..9da8704c73959d 100644 --- a/components/dashboard/src/Setup.tsx +++ b/components/dashboard/src/Setup.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from "react"; import Modal from "./components/Modal"; import { getGitpodService, gitpodHostUrl } from "./service/service"; -import { GitIntegrationModal } from "./settings/Integrations"; +import { GitIntegrationModal } from "./user-settings/Integrations"; export default function Setup() { const [showModal, setShowModal] = useState(false); diff --git a/components/dashboard/src/app/AppRoutes.tsx b/components/dashboard/src/app/AppRoutes.tsx index edac971b2d3cbf..303f9ec4e9bce3 100644 --- a/components/dashboard/src/app/AppRoutes.tsx +++ b/components/dashboard/src/app/AppRoutes.tsx @@ -6,12 +6,12 @@ import React, { FunctionComponent, useContext, useState } from "react"; import { ContextURL, User, Team } from "@gitpod/gitpod-protocol"; -import SelectIDEModal from "../settings/SelectIDEModal"; +import SelectIDEModal from "../user-settings/SelectIDEModal"; 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"; @@ -30,7 +30,7 @@ import { settingsPathPersonalAccessTokens, settingsPathPersonalAccessTokenCreate, settingsPathPersonalAccessTokenEdit, -} from "../settings/settings.routes"; +} from "../user-settings/settings.routes"; import { projectsPathInstallGitHubApp, projectsPathNew } from "../projects/projects.routes"; import { workspacesPathMain } from "../workspaces/workspaces.routes"; import { LocalPreviewAlert } from "./LocalPreviewAlert"; @@ -39,23 +39,27 @@ import { Blocked } from "./Blocked"; // TODO: Can we bundle-split/lazy load these like other pages? import { BlockedRepositories } from "../admin/BlockedRepositories"; -import PersonalAccessTokenCreateView from "../settings/PersonalAccessTokensCreateView"; +import PersonalAccessTokenCreateView from "../user-settings/PersonalAccessTokensCreateView"; import { StartWorkspaceModalContext } from "../workspaces/start-workspace-modal-context"; import { StartWorkspaceOptions } from "../start/start-workspace-options"; import { WebsocketClients } from "./WebsocketClients"; const Setup = React.lazy(() => import(/* webpackPrefetch: true */ "../Setup")); const WorkspacesNew = React.lazy(() => import(/* webpackPrefetch: true */ "../workspaces/WorkspacesNew")); -const Account = React.lazy(() => import(/* webpackPrefetch: true */ "../settings/Account")); -const Notifications = React.lazy(() => import(/* webpackPrefetch: true */ "../settings/Notifications")); -const Billing = React.lazy(() => import(/* webpackPrefetch: true */ "../settings/Billing")); -const Plans = React.lazy(() => import(/* webpackPrefetch: true */ "../settings/Plans")); -const ChargebeeTeams = React.lazy(() => import(/* webpackPrefetch: true */ "../settings/ChargebeeTeams")); -const EnvironmentVariables = React.lazy(() => import(/* webpackPrefetch: true */ "../settings/EnvironmentVariables")); -const SSHKeys = React.lazy(() => import(/* webpackPrefetch: true */ "../settings/SSHKeys")); -const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ "../settings/Integrations")); -const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ "../settings/Preferences")); -const PersonalAccessTokens = React.lazy(() => import(/* webpackPrefetch: true */ "../settings/PersonalAccessTokens")); +const Account = React.lazy(() => import(/* webpackPrefetch: true */ "../user-settings/Account")); +const Notifications = React.lazy(() => import(/* webpackPrefetch: true */ "../user-settings/Notifications")); +const Billing = React.lazy(() => import(/* webpackPrefetch: true */ "../user-settings/Billing")); +const Plans = React.lazy(() => import(/* webpackPrefetch: true */ "../user-settings/Plans")); +const ChargebeeTeams = React.lazy(() => import(/* webpackPrefetch: true */ "../user-settings/ChargebeeTeams")); +const EnvironmentVariables = React.lazy( + () => import(/* webpackPrefetch: true */ "../user-settings/EnvironmentVariables"), +); +const SSHKeys = React.lazy(() => import(/* webpackPrefetch: true */ "../user-settings/SSHKeys")); +const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ "../user-settings/Integrations")); +const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ "../user-settings/Preferences")); +const PersonalAccessTokens = React.lazy( + () => import(/* webpackPrefetch: true */ "../user-settings/PersonalAccessTokens"), +); const Open = React.lazy(() => import(/* webpackPrefetch: true */ "../start/Open")); const StartWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ "../start/StartWorkspace")); const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ "../start/CreateWorkspace")); @@ -224,8 +228,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..2f00e26b4eaec2 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 "../user-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..c1485c213d7c41 --- /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..83ef3a0e3ba7d1 100644 --- a/components/dashboard/src/projects/Project.tsx +++ b/components/dashboard/src/projects/Project.tsx @@ -7,7 +7,7 @@ import { PrebuildWithStatus, Project } from "@gitpod/gitpod-protocol"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import dayjs from "dayjs"; -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { useHistory } from "react-router"; import Alert from "../components/Alert"; import Header from "../components/Header"; @@ -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 () { @@ -32,30 +33,26 @@ export default function () { const [isConsideredInactive, setIsConsideredInactive] = useState(false); const [isResuming, setIsResuming] = useState(false); const [prebuilds, setPrebuilds] = useState>(new Map()); - const [prebuildLoaders] = useState>(new Set()); + const [prebuildLoaders, setPrebuildLoaders] = useState>(new Set()); const [searchFilter, setSearchFilter] = useState(); const [showAuthBanner, setShowAuthBanner] = useState<{ host: string } | undefined>(undefined); useEffect(() => { - if (!project) { - return; - } - (async () => { - try { - await updateBranches(); - } catch (error) { - if (error && error.code === ErrorCodes.NOT_AUTHENTICATED) { - setShowAuthBanner({ host: new URL(project.cloneUrl).hostname }); - } else { - console.error("Getting branches failed", error); - } - } - })(); + // project changed, reset state + setBranches([]); + setIsLoading(false); + setIsLoadingBranches(false); + setIsConsideredInactive(false); + setIsResuming(false); + setPrebuilds(new Map()); + setPrebuildLoaders(new Set()); + setSearchFilter(undefined); + setShowAuthBanner(undefined); }, [project]); - const updateBranches = async () => { + const updateBranches = useCallback(async () => { if (!project) { return; } @@ -71,7 +68,17 @@ export default function () { } finally { setIsLoadingBranches(false); } - }; + }, [project]); + + useEffect(() => { + updateBranches().catch((error) => { + if (project && error && error.code === ErrorCodes.NOT_AUTHENTICATED) { + setShowAuthBanner({ host: new URL(project.cloneUrl).hostname }); + } else { + console.error("Getting branches failed", error); + } + }); + }, [project, updateBranches]); const tryAuthorize = async (host: string, onSuccess: () => void) => { try { @@ -179,7 +186,7 @@ export default function () { return ( <>
View recent active branches for{" "} @@ -189,6 +196,7 @@ export default function () { . } + tabs={getProjectTabs(project)} />
{showAuthBanner ? ( @@ -266,13 +274,12 @@ export default function () { )} {!isResuming && ( - resumePrebuilds()} > Resume prebuilds - + )} )} @@ -282,111 +289,118 @@ export default function () { Fetching repository branches...
)} - {branches - .filter(filter) - .slice(0, 10) - .map((branch, index) => { - let prebuild = matchingPrebuild(branch); // this might lazily trigger fetching of prebuild details - if (prebuild && prebuild.info.changeHash !== branch.changeHash) { - prebuild = undefined; - } - const avatar = branch.changeAuthorAvatar && ( - {branch.changeAuthor} - ); - const statusIcon = prebuildStatusIcon(prebuild); - const status = prebuildStatusLabel(prebuild); + {project && + branches + .filter(filter) + .slice(0, 10) + .map((branch, index) => { + let prebuild = matchingPrebuild(branch); // this might lazily trigger fetching of prebuild details + if (prebuild && prebuild.info.changeHash !== branch.changeHash) { + prebuild = undefined; + } + const avatar = branch.changeAuthorAvatar && ( + {branch.changeAuthor} + ); + const statusIcon = prebuildStatusIcon(prebuild); + const status = prebuildStatusLabel(prebuild); - return ( - - -
- -
- {branch.name} - {branch.isDefault && ( - - DEFAULT - - )} + return ( + + + + + +
+
+ {shortCommitMessage(branch.changeTitle)}
- -
-
- -
-
- {shortCommitMessage(branch.changeTitle)} +

+ {avatar}Authored {formatDate(branch.changeDate)} ·{" "} + {branch.changeHash?.substring(0, 8)} +

-

- {avatar}Authored {formatDate(branch.changeDate)} ·{" "} - {branch.changeHash?.substring(0, 8)} -

-
-
- - - {prebuild ? ( - <> -
- {statusIcon} -
- {status} - - ) : ( - - )} -
- - - - - - setStartWorkspaceModalProps({ - contextUrl: branch.url, - allowContextUrlChange: true, - }), - separator: true, - }, - prebuild?.status === "queued" || prebuild?.status === "building" - ? { - title: "Cancel Prebuild", - customFontStyle: - "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300", - onClick: () => - prebuild && cancelPrebuild(prebuild.info.id), - } - : { - title: `${prebuild ? "Rerun" : "Run"} Prebuild (${ - branch.name - })`, - onClick: () => triggerPrebuild(branch), - }, - ]} - /> -
- - ); - })} + {prebuild ? ( + <> +
+ {statusIcon} +
+ {status} + + ) : ( + + )} + + + + + + + setStartWorkspaceModalProps({ + contextUrl: branch.url, + allowContextUrlChange: true, + }), + separator: true, + }, + prebuild?.status === "queued" || + prebuild?.status === "building" + ? { + title: "Cancel Prebuild", + customFontStyle: + "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300", + onClick: () => + prebuild && cancelPrebuild(prebuild.info.id), + } + : { + title: `${prebuild ? "Rerun" : "Run"} Prebuild (${ + branch.name + })`, + onClick: () => triggerPrebuild(branch), + }, + ]} + /> + + + ); + })} )} diff --git a/components/dashboard/src/projects/ProjectSettings.tsx b/components/dashboard/src/projects/ProjectSettings.tsx index 3b7893f89d5889..ef112f5438bbd5 100644 --- a/components/dashboard/src/projects/ProjectSettings.tsx +++ b/components/dashboard/src/projects/ProjectSettings.tsx @@ -6,40 +6,27 @@ 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"; import { PageWithSubMenu } from "../components/PageWithSubMenu"; import PillLabel from "../components/PillLabel"; import { ProjectContext } from "./project-context"; -import SelectWorkspaceClass from "../settings/selectClass"; +import SelectWorkspaceClass from "../user-settings/selectClass"; 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..afa46fe327e8f9 100644 --- a/components/dashboard/src/projects/project-context.tsx +++ b/components/dashboard/src/projects/project-context.tsx @@ -5,11 +5,16 @@ */ 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; - setProject: React.Dispatch; + setProject: React.Dispatch; }>({ setProject: () => null, }); @@ -19,7 +24,51 @@ 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) { + setProject(undefined); + 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 = projects.find((p) => Project.slug(p) === slugs.projectSlug); + 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 deleted file mode 100644 index 26da302aef8fb1..00000000000000 --- a/components/dashboard/src/settings/settings.routes.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) 2022 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. - */ - -export const settingsPathMain = "/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 settingsPathSSHKeys = "/keys"; diff --git a/components/dashboard/src/start/CreateWorkspace.tsx b/components/dashboard/src/start/CreateWorkspace.tsx index b612093b9a55b4..6d57f83902dcce 100644 --- a/components/dashboard/src/start/CreateWorkspace.tsx +++ b/components/dashboard/src/start/CreateWorkspace.tsx @@ -20,7 +20,7 @@ import { StartPage, StartPhase, StartWorkspaceError } from "./StartPage"; import StartWorkspace, { parseProps } from "./StartWorkspace"; import { openAuthorizeWindow } from "../provider-utils"; import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth"; -import { SelectAccountModal } from "../settings/SelectAccountModal"; +import { SelectAccountModal } from "../user-settings/SelectAccountModal"; import PrebuildLogs from "../components/PrebuildLogs"; import FeedbackComponent from "../feedback-form/FeedbackComponent"; import { isGitpodIo } from "../utils"; diff --git a/components/dashboard/src/teams/OrgSettingsPage.tsx b/components/dashboard/src/teams/OrgSettingsPage.tsx new file mode 100644 index 00000000000000..c3719e0b7a916b --- /dev/null +++ b/components/dashboard/src/teams/OrgSettingsPage.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2022 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 { useContext } from "react"; +import { PageWithSubMenu } from "../components/PageWithSubMenu"; +import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; +import { useCurrentTeam, useBillingModeForCurrentTeam } from "./teams-context"; +import { getTeamSettingsMenu } from "./TeamSettings"; + +export interface OrgSettingsPageProps { + children: React.ReactNode; +} + +export function OrgSettingsPage({ children }: OrgSettingsPageProps) { + const team = useCurrentTeam(); + const teamBillingMode = useBillingModeForCurrentTeam(); + const { oidcServiceEnabled } = useContext(FeatureFlagContext); + const menu = getTeamSettingsMenu({ team, billingMode: teamBillingMode, ssoEnabled: oidcServiceEnabled }); + + return ( + + {children} + + ); +} diff --git a/components/dashboard/src/teams/SSO.tsx b/components/dashboard/src/teams/SSO.tsx index b3aebec9d242d6..db83f74c2d8c0e 100644 --- a/components/dashboard/src/teams/SSO.tsx +++ b/components/dashboard/src/teams/SSO.tsx @@ -7,30 +7,25 @@ import { useContext, useEffect, useState } from "react"; import { Redirect } from "react-router"; import { TeamMemberInfo } from "@gitpod/gitpod-protocol"; -import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; -import { PageWithSubMenu } from "../components/PageWithSubMenu"; import { ReactComponent as Spinner } from "../icons/Spinner.svg"; import { useCurrentTeam } from "./teams-context"; -import { getTeamSettingsMenu } from "./TeamSettings"; import { UserContext } from "../user-context"; import { oidcService, publicApiTeamMembersToProtocol, teamsService } from "../service/public-api"; -import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; import { OIDCClientConfig } from "@gitpod/public-api/lib/gitpod/experimental/v1/oidc_pb"; -import { getGitpodService, gitpodHostUrl } from "../service/service"; +import { gitpodHostUrl } from "../service/service"; import { Item, ItemField, ItemFieldContextMenu, ItemFieldIcon, ItemsList } from "../components/ItemsList"; import { ContextMenuEntry } from "../components/ContextMenu"; import Modal from "../components/Modal"; import copy from "../images/copy.svg"; import exclamation from "../images/exclamation.svg"; +import { OrgSettingsPage } from "./OrgSettingsPage"; export default function SSO() { const { user } = useContext(UserContext); const team = useCurrentTeam(); - const [teamBillingMode, setTeamBillingMode] = useState(undefined); const [isUserOwner, setIsUserOwner] = useState(true); const [isLoading, setIsLoading] = useState(true); - const { oidcServiceEnabled } = useContext(FeatureFlagContext); useEffect(() => { if (!team) { @@ -40,32 +35,27 @@ export default function SSO() { const memberInfos = await teamsService.getTeam({ teamId: team!.id }).then((resp) => { return publicApiTeamMembersToProtocol(resp.team?.members || []); }); - getGitpodService().server.getBillingModeForTeam(team.id).then(setTeamBillingMode).catch(console.error); const currentUserInTeam = memberInfos.find((member: TeamMemberInfo) => member.userId === user?.id); const isUserOwner = currentUserInTeam?.role === "owner"; setIsUserOwner(isUserOwner); setIsLoading(false); })(); - }, [team]); + }, [team, user?.id]); if (!isUserOwner) { return ; } return ( - + {isLoading && (
)} {!isLoading && team && isUserOwner && } -
+ ); } diff --git a/components/dashboard/src/teams/TeamBilling.tsx b/components/dashboard/src/teams/TeamBilling.tsx index 657cfd5bf43980..fa2c31f4d530e3 100644 --- a/components/dashboard/src/teams/TeamBilling.tsx +++ b/components/dashboard/src/teams/TeamBilling.tsx @@ -4,7 +4,6 @@ * See License.AGPL.txt in the project root for license information. */ -import { TeamMemberInfo } from "@gitpod/gitpod-protocol"; import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; import { Currency, Plan, Plans, PlanType } from "@gitpod/gitpod-protocol/lib/plans"; import { TeamSubscription2 } from "@gitpod/gitpod-protocol/lib/team-subscription-protocol"; @@ -14,19 +13,21 @@ import { ChargebeeClient } from "../chargebee/chargebee-client"; import Alert from "../components/Alert"; import Card from "../components/Card"; import DropDown from "../components/DropDown"; -import { PageWithSubMenu } from "../components/PageWithSubMenu"; import PillLabel from "../components/PillLabel"; import SolidCard from "../components/SolidCard"; -import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; import { getExperimentsClient } from "../experiments/client"; import { ReactComponent as Spinner } from "../icons/Spinner.svg"; import { ReactComponent as CheckSvg } from "../images/check.svg"; import { PaymentContext } from "../payment-context"; -import { publicApiTeamMembersToProtocol, teamsService } from "../service/public-api"; import { getGitpodService } from "../service/service"; import { UserContext } from "../user-context"; -import { useCurrentTeam } from "./teams-context"; -import { getTeamSettingsMenu } from "./TeamSettings"; +import { OrgSettingsPage } from "./OrgSettingsPage"; +import { + useBillingModeForCurrentTeam, + useCurrentTeam, + useIsOwnerOfCurrentTeam, + useTeamMemberInfos, +} from "./teams-context"; import TeamUsageBasedBilling from "./TeamUsageBasedBilling"; type PendingPlan = Plan & { pendingSince: number }; @@ -34,33 +35,22 @@ type PendingPlan = Plan & { pendingSince: number }; export default function TeamBilling() { const { user } = useContext(UserContext); const team = useCurrentTeam(); - const [members, setMembers] = useState([]); - const [isUserOwner, setIsUserOwner] = useState(true); + const members = useTeamMemberInfos(); + const isUserOwner = useIsOwnerOfCurrentTeam(); + const teamBillingMode = useBillingModeForCurrentTeam(); const [teamSubscription, setTeamSubscription] = useState(); const { currency, setCurrency } = useContext(PaymentContext); const [isUsageBasedBillingEnabled, setIsUsageBasedBillingEnabled] = useState(false); - const [teamBillingMode, setTeamBillingMode] = useState(undefined); const [pendingTeamPlan, setPendingTeamPlan] = useState(); const [pollTeamSubscriptionTimeout, setPollTeamSubscriptionTimeout] = useState(); - const { oidcServiceEnabled } = useContext(FeatureFlagContext); useEffect(() => { if (!team) { return; } (async () => { - const [memberInfos, subscription, teamBillingMode] = await Promise.all([ - teamsService.getTeam({ teamId: team!.id }).then((resp) => { - return publicApiTeamMembersToProtocol(resp.team?.members || []); - }), - getGitpodService().server.getTeamSubscription(team.id), - getGitpodService().server.getBillingModeForTeam(team.id), - ]); - setMembers(memberInfos); - const currentUserInTeam = memberInfos.find((member: TeamMemberInfo) => member.userId === user?.id); - setIsUserOwner(currentUserInTeam?.role === "owner"); + const subscription = await getGitpodService().server.getTeamSubscription(team.id); setTeamSubscription(subscription); - setTeamBillingMode(teamBillingMode); })(); }, [team]); @@ -130,7 +120,7 @@ export default function TeamBilling() { const availableTeamPlans = Plans.getAvailableTeamPlans(currency || "USD").filter((p) => p.type !== "student"); const checkout = async (plan: Plan) => { - if (!team || members.length < 1) { + if (!team || !members[team.id]) { return; } const chargebeeClient = await ChargebeeClient.getOrCreate(team.id); @@ -148,7 +138,7 @@ export default function TeamBilling() { window.localStorage.setItem(`pendingPlanForTeam${team.id}`, JSON.stringify(pending)); }; - const isLoading = members.length === 0; + const isLoading = !team || !members[team.id]; const teamPlan = pendingTeamPlan || Plans.getById(teamSubscription?.planId); const featuresByPlanType: { [type in PlanType]?: Array } = { @@ -244,9 +234,10 @@ export default function TeamBilling() {
- {members.length} x {Currency.getSymbol(tp.currency)} + {team && members[team.id]?.length} x{" "} + {Currency.getSymbol(tp.currency)} {tp.pricePerMonth} = {Currency.getSymbol(tp.currency)} - {members.length * tp.pricePerMonth} per month + {team && members[team.id]?.length * tp.pricePerMonth} per month
Includes:
@@ -344,11 +335,7 @@ export default function TeamBilling() { const showUBP = BillingMode.showUsageBasedBilling(teamBillingMode); return ( - + {teamBillingMode === undefined ? (
@@ -359,7 +346,7 @@ export default function TeamBilling() { {!showUBP && renderTeamBilling()} )} - + ); } diff --git a/components/dashboard/src/teams/TeamSettings.tsx b/components/dashboard/src/teams/TeamSettings.tsx index fda947f20b541c..53719838e6d02a 100644 --- a/components/dashboard/src/teams/TeamSettings.tsx +++ b/components/dashboard/src/teams/TeamSettings.tsx @@ -6,23 +6,22 @@ import { Team } from "@gitpod/gitpod-protocol"; import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; -import React, { useCallback, useContext, useEffect, useState } from "react"; +import React, { useCallback, useContext, useState } from "react"; import { Redirect } from "react-router"; import Alert from "../components/Alert"; import ConfirmationModal from "../components/ConfirmationModal"; -import { PageWithSubMenu } from "../components/PageWithSubMenu"; -import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; -import { publicApiTeamMembersToProtocol, teamsService } from "../service/public-api"; +import { teamsService } from "../service/public-api"; import { getGitpodService, gitpodHostUrl } from "../service/service"; import { useCurrentUser } from "../user-context"; -import { TeamsContext, useCurrentTeam } from "./teams-context"; +import { OrgSettingsPage } from "./OrgSettingsPage"; +import { TeamsContext, useCurrentTeam, useIsOwnerOfCurrentTeam } from "./teams-context"; export function getTeamSettingsMenu(params: { team?: Team; billingMode?: BillingMode; ssoEnabled?: boolean }) { const { billingMode, ssoEnabled } = params; const result = [ { title: "General", - link: [`/org-settings`], + link: [`/settings`], }, ]; if (ssoEnabled) { @@ -35,7 +34,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; @@ -49,29 +48,11 @@ export default function TeamSettings() { const [teamNameToDelete, setTeamNameToDelete] = useState(""); const [teamName, setTeamName] = useState(team?.name || ""); const [errorMessage, setErrorMessage] = useState(undefined); - const [isUserOwner, setIsUserOwner] = useState(true); - const [billingMode, setBillingMode] = useState(undefined); + const isUserOwner = useIsOwnerOfCurrentTeam(); const [updated, setUpdated] = useState(false); - const { oidcServiceEnabled } = useContext(FeatureFlagContext); const close = () => setModal(false); - useEffect(() => { - (async () => { - if (!team) return; - const members = publicApiTeamMembersToProtocol( - (await teamsService.getTeam({ teamId: team!.id })).team?.members || [], - ); - - const currentUserInTeam = members.find((member) => member.userId === user?.id); - setIsUserOwner(currentUserInTeam?.role === "owner"); - - // TODO(gpl) Maybe we should have TeamContext here instead of repeating ourselves... - const billingMode = await getGitpodService().server.getBillingModeForTeam(team.id); - setBillingMode(billingMode); - })(); - }, [team, user]); - const updateTeamInformation = useCallback(async () => { if (!team || errorMessage || !teams) { return; @@ -124,11 +105,7 @@ export default function TeamSettings() { return ( <> - +

Organization Name

This is your organization's visible name within Gitpod. For example, the name of your company. @@ -169,7 +146,7 @@ export default function TeamSettings() { - + (); + useEffect(() => { + 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; +} + +export function useIsOwnerOfCurrentTeam(): boolean { + const team = useCurrentTeam(); + const teamMemberInfos = useTeamMemberInfos(); + + if (!team || !teamMemberInfos[team.id]) { + return true; + } + return teamMemberInfos[team.id]?.some((tmi) => tmi.role === "owner") || false; +} diff --git a/components/dashboard/src/settings/Account.tsx b/components/dashboard/src/user-settings/Account.tsx similarity index 97% rename from components/dashboard/src/settings/Account.tsx rename to components/dashboard/src/user-settings/Account.tsx index e93b45798d8c88..75a70c4c0060ce 100644 --- a/components/dashboard/src/settings/Account.tsx +++ b/components/dashboard/src/user-settings/Account.tsx @@ -66,7 +66,7 @@ export default function Account() { setTypedEmail(e.target.value)}> - +

Profile

{ diff --git a/components/dashboard/src/settings/Billing.tsx b/components/dashboard/src/user-settings/Billing.tsx similarity index 91% rename from components/dashboard/src/settings/Billing.tsx rename to components/dashboard/src/user-settings/Billing.tsx index 51533b421e8603..275579bb90b746 100644 --- a/components/dashboard/src/settings/Billing.tsx +++ b/components/dashboard/src/user-settings/Billing.tsx @@ -15,7 +15,7 @@ export default function Billing() { const { user } = useContext(UserContext); return ( - +

Default Billing Account

diff --git a/components/dashboard/src/settings/ChargebeeTeams.tsx b/components/dashboard/src/user-settings/ChargebeeTeams.tsx similarity index 99% rename from components/dashboard/src/settings/ChargebeeTeams.tsx rename to components/dashboard/src/user-settings/ChargebeeTeams.tsx index 3b9d84fd13a5cd..45b699bf77e88d 100644 --- a/components/dashboard/src/settings/ChargebeeTeams.tsx +++ b/components/dashboard/src/user-settings/ChargebeeTeams.tsx @@ -28,10 +28,7 @@ import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; export default function ChargebeeTeams() { return (
- +
diff --git a/components/dashboard/src/settings/EnvironmentVariables.tsx b/components/dashboard/src/user-settings/EnvironmentVariables.tsx similarity index 99% rename from components/dashboard/src/settings/EnvironmentVariables.tsx rename to components/dashboard/src/user-settings/EnvironmentVariables.tsx index 7957c723b40cb3..2b73b5729e04a1 100644 --- a/components/dashboard/src/settings/EnvironmentVariables.tsx +++ b/components/dashboard/src/user-settings/EnvironmentVariables.tsx @@ -203,7 +203,7 @@ export default function EnvVars() { }; return ( - + {isAddEnvVarModalVisible && ( - +
diff --git a/components/dashboard/src/settings/Notifications.tsx b/components/dashboard/src/user-settings/Notifications.tsx similarity index 97% rename from components/dashboard/src/settings/Notifications.tsx rename to components/dashboard/src/user-settings/Notifications.tsx index c79b89f9fcdaea..dae1fcbd2c9dd1 100644 --- a/components/dashboard/src/settings/Notifications.tsx +++ b/components/dashboard/src/user-settings/Notifications.tsx @@ -80,7 +80,7 @@ export default function Notifications() { return (
- +

Email Notification Preferences

{children} diff --git a/components/dashboard/src/settings/PersonalAccessTokens.tsx b/components/dashboard/src/user-settings/PersonalAccessTokens.tsx similarity index 99% rename from components/dashboard/src/settings/PersonalAccessTokens.tsx rename to components/dashboard/src/user-settings/PersonalAccessTokens.tsx index a67376f9f93f4d..66d2c35be6e84f 100644 --- a/components/dashboard/src/settings/PersonalAccessTokens.tsx +++ b/components/dashboard/src/user-settings/PersonalAccessTokens.tsx @@ -32,7 +32,7 @@ export default function PersonalAccessTokens() { return (
- +
diff --git a/components/dashboard/src/settings/PersonalAccessTokensCreateView.tsx b/components/dashboard/src/user-settings/PersonalAccessTokensCreateView.tsx similarity index 99% rename from components/dashboard/src/settings/PersonalAccessTokensCreateView.tsx rename to components/dashboard/src/user-settings/PersonalAccessTokensCreateView.tsx index a71a5e1d2aa52e..67b7bf8a72ff11 100644 --- a/components/dashboard/src/settings/PersonalAccessTokensCreateView.tsx +++ b/components/dashboard/src/user-settings/PersonalAccessTokensCreateView.tsx @@ -148,7 +148,7 @@ function PersonalAccessTokenCreateView() { return (
- +