Path: blob/main/components/dashboard/src/menu/OrganizationSelector.tsx
2500 views
/**1* Copyright (c) 2023 Gitpod GmbH. All rights reserved.2* Licensed under the GNU Affero General Public License (AGPL).3* See License.AGPL.txt in the project root for license information.4*/56import { FunctionComponent, useCallback } from "react";7import ContextMenu, { ContextMenuEntry } from "../components/ContextMenu";8import { OrgIcon, OrgIconProps } from "../components/org-icon/OrgIcon";9import { useCurrentUser } from "../user-context";10import { useCurrentOrg, useOrganizations } from "../data/organizations/orgs-query";11import { useLocation } from "react-router";12import { useOrgBillingMode } from "../data/billing-mode/org-billing-mode-query";13import { useIsOwner, useListOrganizationMembers, useHasRolePermission } from "../data/organizations/members-query";14import { isAllowedToCreateOrganization } from "@gitpod/public-api-common/lib/user-utils";15import { OrganizationRole } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";16import { useFeatureFlag } from "../data/featureflag-query";17import { PlusIcon } from "lucide-react";18import { useInstallationConfiguration } from "../data/installation/installation-config-query";1920export default function OrganizationSelector() {21const user = useCurrentUser();22const orgs = useOrganizations();23const currentOrg = useCurrentOrg();24const members = useListOrganizationMembers().data ?? [];25const isOwner = useIsOwner();26const hasMemberPermission = useHasRolePermission(OrganizationRole.MEMBER);27const { data: billingMode } = useOrgBillingMode();28const getOrgURL = useGetOrgURL();29const { data: installationConfig } = useInstallationConfiguration();30const isDedicated = !!installationConfig?.isDedicatedInstallation;31const isMultiOrgEnabled = useFeatureFlag("enable_multi_org");3233// we should have an API to ask for permissions, until then we duplicate the logic here34const canCreateOrgs = user && isAllowedToCreateOrganization(user, isDedicated, isMultiOrgEnabled);3536const userFullName = user?.name || "...";3738const activeOrgEntry = !currentOrg.data39? {40title: userFullName,41customContent: <CurrentOrgEntry title={userFullName} subtitle="Personal Account" />,42active: false,43separator: false,44tight: true,45}46: {47title: currentOrg.data.name,48customContent: (49<CurrentOrgEntry50title={currentOrg.data.name}51subtitle={hasMemberPermission ? `${members.length} member${members.length === 1 ? "" : "s"}` : ""}52/>53),54active: false,55separator: false,56tight: true,57};5859const linkEntries: ContextMenuEntry[] = [];6061// Show members if we have an org selected62if (currentOrg.data) {63// collaborator can't access projects, members, usage and billing64if (hasMemberPermission) {65linkEntries.push({66title: "Prebuilds",67customContent: <LinkEntry>Prebuilds</LinkEntry>,68active: false,69separator: false,70link: "/prebuilds",71});72linkEntries.push({73title: "Members",74customContent: <LinkEntry>Members</LinkEntry>,75active: false,76separator: true,77link: "/members",78});79if (isDedicated) {80if (isOwner) {81linkEntries.push({82title: "Insights",83customContent: <LinkEntry>Insights</LinkEntry>,84active: false,85separator: false,86link: "/insights",87});88}89} else {90linkEntries.push({91title: "Usage",92customContent: <LinkEntry>Usage</LinkEntry>,93active: false,94separator: false,95link: "/usage",96});97}98// Show billing if user is an owner of current org99if (isOwner) {100if (billingMode?.mode === "usage-based") {101linkEntries.push({102title: "Billing",103customContent: <LinkEntry>Billing</LinkEntry>,104active: false,105separator: false,106link: "/billing",107});108}109}110111linkEntries.push({112title: "Repository Settings",113customContent: <LinkEntry>Repository Settings</LinkEntry>,114active: false,115separator: false,116link: "/repositories",117});118119// Org settings is available for all members, but only owner can change them120// collaborator can read org setting via API so that other feature like restrict org workspace classes could work121// we only hide the menu from dashboard122linkEntries.push({123title: "Organization Settings",124customContent: <LinkEntry>Organization Settings</LinkEntry>,125active: false,126separator: false,127link: "/settings",128});129130if (isOwner && isDedicated) {131// Add Admin link for owners132linkEntries.push({133title: "Organization Administration",134customContent: <LinkEntry>Organization Administration</LinkEntry>,135active: false,136separator: false,137link: "/org-admin",138});139}140}141}142143// Ensure only last link entry has a separator144linkEntries.forEach((e, idx) => {145e.separator = idx === linkEntries.length - 1;146});147148const otherOrgEntries = (orgs.data || [])149.filter((org) => org.id !== currentOrg.data?.id)150.sort((a, b) => a.name.localeCompare(b.name))151.map((org) => ({152title: org.name,153customContent: <OrgEntry id={org.id} title={org.name} subtitle={""} />,154// marking as active for styles155active: true,156separator: true,157link: getOrgURL(org.id),158}));159160const entries = [161activeOrgEntry,162...linkEntries,163...otherOrgEntries,164...(canCreateOrgs165? [166{167title: "Create a new organization",168customContent: (169<div className="w-full text-pk-content-secondary flex items-center">170<span className="flex-1">New Organization</span>171<PlusIcon size={20} className="size-3.5" />172</div>173),174link: "/orgs/new",175// marking as active for styles176active: true,177},178]179: []),180];181182const selectedTitle = currentOrg?.data ? currentOrg.data.name : userFullName;183const classes =184"flex h-full text-base py-0 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700";185return (186<ContextMenu customClasses="w-64 left-0 text-left" menuEntries={entries}>187<div className={`${classes} rounded-2xl pl-1`}>188<div className="py-1 pr-1 flex font-medium max-w-xs truncate">189<OrgIcon190id={currentOrg?.data?.id || user?.id || "empty"}191name={selectedTitle}192size="small"193className="mr-2"194/>195{selectedTitle}196</div>197<div className="flex h-full pl-0 pr-1 py-1.5 text-gray-50">198<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg">199<path200fillRule="evenodd"201clipRule="evenodd"202d="M5.293 7.293a1 1 0 0 1 1.414 0L10 10.586l3.293-3.293a1 1 0 1 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414Z"203fill="#78716C"204/>205<title>Toggle organization selection menu</title>206</svg>207</div>208</div>209</ContextMenu>210);211}212213const LinkEntry: FunctionComponent = ({ children }) => {214return (215<div className="w-full text-sm text-gray-500 dark:text-gray-400">216<span>{children}</span>217</div>218);219};220221type OrgEntryProps = {222id: string;223title: string;224subtitle: string;225iconSize?: OrgIconProps["size"];226};227export const OrgEntry: FunctionComponent<OrgEntryProps> = ({ id, title, subtitle, iconSize }) => {228return (229<div className="w-full text-gray-400 flex items-center">230<OrgIcon id={id} name={title} className="mr-4" size={iconSize} />231<div className="flex flex-col">232<span className="text-gray-800 dark:text-gray-300 text-base font-semibold truncate w-40">{title}</span>233<span>{subtitle}</span>234</div>235</div>236);237};238239type CurrentOrgEntryProps = {240title: string;241subtitle: string;242};243const CurrentOrgEntry: FunctionComponent<CurrentOrgEntryProps> = ({ title, subtitle }) => {244return (245<div className="w-full text-gray-400 flex items-center justify-between">246<div className="flex flex-col">247<span className="text-gray-800 dark:text-gray-300 text-base font-semibold truncate w-40">{title}</span>248<span>{subtitle}</span>249</div>250251<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" className="dark:hidden" fill="none">252<path253fill="#78716C"254fillRule="evenodd"255d="M18.2348 5.8867 7.88699 16.2345l-2.12132-2.1213L16.1135 3.76538l2.1213 2.12132Z"256clipRule="evenodd"257/>258<path259fill="#78716C"260fillRule="evenodd"261d="m3.88695 8.06069 5.00004 5.00001-2.12132 2.1214-5.00005-5.0001 2.12133-2.12131Z"262clipRule="evenodd"263/>264</svg>265266<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" className="hidden dark:block" fill="none">267<path268fill="#E7E5E4"269fillRule="evenodd"270d="M18.2348 5.8867 7.88699 16.2345l-2.12132-2.1213L16.1135 3.76538l2.1213 2.12132Z"271clipRule="evenodd"272/>273<path274fill="#E7E5E4"275fillRule="evenodd"276d="m3.88695 8.06069 5.00004 5.00001-2.12132 2.1214-5.00005-5.0001 2.12133-2.12131Z"277clipRule="evenodd"278/>279</svg>280</div>281);282};283284// Determine url to use when switching orgs285// Maintains the current location & context url (hash) when on the new workspace page286const useGetOrgURL = () => {287const location = useLocation();288289return useCallback(290(orgID: string) => {291// Default to root path when switching orgs292let path = "/";293let hash = "";294const search = new URLSearchParams();295search.append("org", orgID);296297// If we're on the new workspace page, try to maintain the location and context url298if (/^\/new(\/$)?$/.test(location.pathname)) {299path = `/new`;300hash = location.hash;301search.append("autostart", "false");302}303304return `${path}?${search.toString()}${hash}`;305},306[location.hash, location.pathname],307);308};309310311