From 7faacc22d591ac272e9a0ffb53f7275ea76a55ef Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Sat, 6 Jun 2026 12:58:15 -0400 Subject: [PATCH 01/21] move help and user menus, create org menu, remove prefix selector from home and admin pages --- .../admin/Billing/TenantOptions.tsx | 16 -- src/components/admin/Billing/index.tsx | 10 +- .../admin/Billing/useTenantChangeReset.ts | 24 +++ src/components/admin/Settings/index.tsx | 16 +- src/components/home/dashboard/index.tsx | 12 +- src/components/inputs/PrefixedName/index.tsx | 32 ++-- .../inputs/PrefixedName/useValidatePrefix.ts | 17 +- src/components/menus/HelpMenu.tsx | 29 +++- src/components/menus/OrgMenu.tsx | 154 ++++++++++++++++++ src/components/menus/UserMenu.tsx | 153 ++++++++++------- src/components/menus/shared.ts | 13 ++ src/components/navigation/ModeSwitch.tsx | 52 ------ .../navigation/NavTriggerButton.tsx | 44 +++++ src/components/navigation/Navigation.tsx | 52 +++++- src/components/navigation/TopBar.tsx | 6 - src/lang/en-US/Navigation.ts | 20 +-- 16 files changed, 433 insertions(+), 217 deletions(-) delete mode 100644 src/components/admin/Billing/TenantOptions.tsx create mode 100644 src/components/admin/Billing/useTenantChangeReset.ts create mode 100644 src/components/menus/OrgMenu.tsx create mode 100644 src/components/menus/shared.ts delete mode 100644 src/components/navigation/ModeSwitch.tsx create mode 100644 src/components/navigation/NavTriggerButton.tsx diff --git a/src/components/admin/Billing/TenantOptions.tsx b/src/components/admin/Billing/TenantOptions.tsx deleted file mode 100644 index 17466aaa7a..0000000000 --- a/src/components/admin/Billing/TenantOptions.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useCallback } from 'react'; - -import TenantSelector from 'src/components/shared/TenantSelector'; -import { useBillingStore } from 'src/stores/Billing'; - -function TenantOptions() { - const resetBillingState = useBillingStore((state) => state.resetState); - - const updateStoreState = useCallback(() => { - resetBillingState(); - }, [resetBillingState]); - - return ; -} - -export default TenantOptions; diff --git a/src/components/admin/Billing/index.tsx b/src/components/admin/Billing/index.tsx index 4e2892fc40..fac8da302f 100644 --- a/src/components/admin/Billing/index.tsx +++ b/src/components/admin/Billing/index.tsx @@ -19,7 +19,7 @@ import BillingLoadError from 'src/components/admin/Billing/LoadError'; import PaymentMethods from 'src/components/admin/Billing/PaymentMethods'; import PricingTierDetails from 'src/components/admin/Billing/PricingTierDetails'; import { INVOICE_ROW_HEIGHT } from 'src/components/admin/Billing/shared'; -import TenantOptions from 'src/components/admin/Billing/TenantOptions'; +import useTenantChangeReset from 'src/components/admin/Billing/useTenantChangeReset'; import AdminTabs from 'src/components/admin/Tabs'; import GraphLoadingState from 'src/components/graphs/states/Loading'; import GraphStateWrapper from 'src/components/graphs/states/Wrapper'; @@ -50,6 +50,8 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { headerLink: 'https://www.estuary.dev/pricing/', }); + useTenantChangeReset(); + const intl = useIntl(); const selectedTenant = useTenantStore((state) => state.selectedTenant); @@ -139,12 +141,6 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { - - - diff --git a/src/components/admin/Billing/useTenantChangeReset.ts b/src/components/admin/Billing/useTenantChangeReset.ts new file mode 100644 index 0000000000..45dcffa40a --- /dev/null +++ b/src/components/admin/Billing/useTenantChangeReset.ts @@ -0,0 +1,24 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { useBillingStore } from 'src/stores/Billing'; +import { useTenantStore } from 'src/stores/Tenant'; + +function useTenantChangeReset() { + const selectedTenant = useTenantStore((state) => state.selectedTenant); + + const resetBillingState = useBillingStore((state) => state.resetState); + + const resetStores = useCallback(() => { + resetBillingState(); + }, [resetBillingState]); + + const previousTenant = useRef(selectedTenant); + useEffect(() => { + if (previousTenant.current !== selectedTenant) { + previousTenant.current = selectedTenant; + resetStores(); + } + }, [selectedTenant, resetStores]); +} + +export default useTenantChangeReset; diff --git a/src/components/admin/Settings/index.tsx b/src/components/admin/Settings/index.tsx index e731f94c79..10054fd37d 100644 --- a/src/components/admin/Settings/index.tsx +++ b/src/components/admin/Settings/index.tsx @@ -1,11 +1,10 @@ -import { Divider, Grid, Stack } from '@mui/material'; +import { Divider, Stack } from '@mui/material'; import { authenticatedRoutes } from 'src/app/routes'; import DataPlanes from 'src/components/admin/Settings/DataPlanes'; import PrefixAlerts from 'src/components/admin/Settings/PrefixAlerts'; import { StorageMappings } from 'src/components/admin/Settings/StorageMappings'; import AdminTabs from 'src/components/admin/Tabs'; -import TenantSelector from 'src/components/shared/TenantSelector'; import usePageTitle from 'src/hooks/usePageTitle'; function Settings() { @@ -17,19 +16,6 @@ function Settings() { <> - - - - - - diff --git a/src/components/home/dashboard/index.tsx b/src/components/home/dashboard/index.tsx index ed59efb0a2..6112494686 100644 --- a/src/components/home/dashboard/index.tsx +++ b/src/components/home/dashboard/index.tsx @@ -1,20 +1,10 @@ -import { Box, Grid } from '@mui/material'; +import { Grid } from '@mui/material'; import EntityStatOverview from 'src/components/home/dashboard/EntityStatOverview'; -import TenantSelector from 'src/components/shared/TenantSelector'; export default function Dashboard() { return ( - - - - - - ); diff --git a/src/components/inputs/PrefixedName/index.tsx b/src/components/inputs/PrefixedName/index.tsx index b9193ba518..e06169b2b5 100644 --- a/src/components/inputs/PrefixedName/index.tsx +++ b/src/components/inputs/PrefixedName/index.tsx @@ -169,16 +169,28 @@ function PrefixedName({ }, }} > - handlers.setPrefix(newValue)} - options={objectRoles} - value={prefix} - variantString={variantString} - /> + {singleOption ? ( + + ) : ( + handlers.setPrefix(newValue)} + options={objectRoles} + value={prefix} + variantString={variantString} + /> + )} [ - // state.selectedTenant, - // state.setSelectedTenant, - // ])); + const selectedTenant = useTenantStore((state) => state.selectedTenant); + const singleOption = objectRoles.length === 1 || hasLength(selectedTenant); // Local State for editing const [errors, setErrors] = useState(null); const [name, setName] = useState(''); const [nameError, setNameError] = useState(null); const [prefix, setPrefix] = useState( - singleOption || defaultPrefix ? objectRoles[0] : '' //selectedTenant + hasLength(selectedTenant) + ? selectedTenant + : singleOption || defaultPrefix + ? objectRoles[0] + : '' ); const [prefixError, setPrefixError] = useState(null); @@ -111,7 +111,6 @@ function useValidatePrefix({ ); setPrefix(prefixValue); - // setSelectedTenant(prefixValue); if (onPrefixChange) { onPrefixChange(prefixValue, errorString, { diff --git a/src/components/menus/HelpMenu.tsx b/src/components/menus/HelpMenu.tsx index c1855ce0a8..842c247e0f 100644 --- a/src/components/menus/HelpMenu.tsx +++ b/src/components/menus/HelpMenu.tsx @@ -1,18 +1,29 @@ -import { HelpCircle } from 'iconoir-react'; +import { Menu } from '@mui/material'; + import { FormattedMessage, useIntl } from 'react-intl'; -import IconMenu from 'src/components/menus/IconMenu'; +import { + sideNavMenuAnchorOrigin, + sideNavMenuTransformOrigin, +} from 'src/components/menus/shared'; import ExternalLinkMenuItem from 'src/components/shared/ExternalLinkMenuItem'; -function HelpMenu() { +interface HelpMenuProps { + anchorEl: HTMLElement | null; + onClose: () => void; +} + +export function HelpMenu({ anchorEl, onClose }: HelpMenuProps) { const intl = useIntl(); return ( - } - identifier="help-menu" - tooltip={intl.formatMessage({ id: 'helpMenu.tooltip' })} + - + ); } diff --git a/src/components/menus/OrgMenu.tsx b/src/components/menus/OrgMenu.tsx new file mode 100644 index 0000000000..c25bc55923 --- /dev/null +++ b/src/components/menus/OrgMenu.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; + +import { + Dialog, + DialogContent, + DialogTitle, + MenuItem, + Popover, + Typography, +} from '@mui/material'; + +import { Building, Check } from 'iconoir-react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import PrefixSelector from 'src/components/inputs/PrefixedName/PrefixSelector'; +import { + sideNavMenuAnchorOrigin, + sideNavMenuTransformOrigin, +} from 'src/components/menus/shared'; +import NavTriggerButton from 'src/components/navigation/NavTriggerButton'; +import { useUserInfoSummaryStore } from 'src/context/UserInfoSummary/useUserInfoSummaryStore'; +import { + useEntitiesStore_capabilities_adminable, + useEntitiesStore_tenantsWithAdmin, +} from 'src/stores/Entities/hooks'; +import { useTenantStore } from 'src/stores/Tenant'; + +interface OrgMenuProps { + // Whether the side nav is expanded; controls the trigger label visibility. + open: boolean; +} + +const OrgMenu = ({ open }: OrgMenuProps) => { + const intl = useIntl(); + const selectedTenant = useTenantStore((state) => state.selectedTenant); + const setSelectedTenant = useTenantStore( + (state) => state.setSelectedTenant + ); + const tenantNames = useEntitiesStore_tenantsWithAdmin(); + const hasSupportAccess = useUserInfoSummaryStore( + (state) => state.hasSupportAccess + ); + const allPrefixes = useEntitiesStore_capabilities_adminable(false); + + const [orgAnchor, setOrgAnchor] = useState(null); + const [orgDialogOpen, setOrgDialogOpen] = useState(false); + + const tenantLabel = selectedTenant + ? selectedTenant.replace(/\/$/, '') + : null; + + return ( + <> + + hasSupportAccess + ? setOrgDialogOpen(true) + : setOrgAnchor(e.currentTarget) + } + icon={} + label={tenantLabel} + /> + + setOrgAnchor(null)} + anchorOrigin={sideNavMenuAnchorOrigin} + transformOrigin={sideNavMenuTransformOrigin} + slotProps={{ + paper: { + sx: { + width: 240, + p: 1, + borderRadius: 2, + }, + }, + }} + > + + + + + {tenantNames.map((tenant) => { + const label = tenant.replace(/\/$/, ''); + const isSelected = tenant === selectedTenant; + + return ( + { + setSelectedTenant(tenant); + setOrgAnchor(null); + }} + sx={{ + borderRadius: 1, + fontSize: 13, + py: 0.75, + justifyContent: 'space-between', + }} + > + {label} + + {isSelected ? : null} + + ); + })} + + + setOrgDialogOpen(false)} + fullWidth + maxWidth="xs" + > + + + + + { + setSelectedTenant(newValue); + setOrgDialogOpen(false); + }} + options={allPrefixes} + value={selectedTenant} + variantString="outlined" + /> + + + + ); +}; + +export default OrgMenu; diff --git a/src/components/menus/UserMenu.tsx b/src/components/menus/UserMenu.tsx index 7bf6a3ccd4..00c7966ab6 100644 --- a/src/components/menus/UserMenu.tsx +++ b/src/components/menus/UserMenu.tsx @@ -1,97 +1,130 @@ -/* eslint-disable @typescript-eslint/no-unnecessary-condition */ -import type { SxProps } from '@mui/material'; - -import { Stack, Typography } from '@mui/material'; -import Divider from '@mui/material/Divider'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import MenuItem from '@mui/material/MenuItem'; +import { useState } from 'react'; + +import { + Divider, + ListItemIcon, + Menu, + MenuItem, + Stack, + Typography, + useTheme, +} from '@mui/material'; import { useShallow } from 'zustand/react/shallow'; -import { LogOut, Mail, ProfileCircle } from 'iconoir-react'; -import { FormattedMessage, useIntl } from 'react-intl'; +import { HalfMoon, LogOut, SunLight } from 'iconoir-react'; +import { FormattedMessage } from 'react-intl'; -import IconMenu from 'src/components/menus/IconMenu'; +import { + sideNavMenuAnchorOrigin, + sideNavMenuTransformOrigin, +} from 'src/components/menus/shared'; +import NavTriggerButton from 'src/components/navigation/NavTriggerButton'; import UserAvatar from 'src/components/shared/UserAvatar'; import { supabaseClient } from 'src/context/GlobalProviders'; +import { useColorMode } from 'src/context/Theme'; import { useUserStore } from 'src/context/User/useUserContextStore'; -interface Props { - iconColor: string; +interface UserMenuProps { + // Whether the side nav is expanded; controls the trigger label visibility. + open: boolean; } -const nonInteractiveMenuStyling: SxProps = { - '&:hover': { - cursor: 'revert', - }, -}; - -const UserMenu = ({ iconColor }: Props) => { - const intl = useIntl(); +const UserMenu = ({ open }: UserMenuProps) => { + const theme = useTheme(); + const colorMode = useColorMode(); const userDetails = useUserStore(useShallow((state) => state.userDetails)); - const handlers = { - logout: async () => { - await supabaseClient.auth.signOut(); - }, - }; + const [menuAnchor, setMenuAnchor] = useState(null); + + if (!userDetails) { + return null; + } - if (userDetails) { - const { avatar, email, emailVerified, userName } = userDetails; - return ( - + setMenuAnchor(e.currentTarget)} icon={ } - identifier="account-menu" - tooltip={intl.formatMessage({ id: 'accountMenu.tooltip' })} + label={userDetails.userName ?? userDetails.email} + /> + + setMenuAnchor(null)} + onClick={() => setMenuAnchor(null)} + anchorOrigin={sideNavMenuAnchorOrigin} + transformOrigin={sideNavMenuTransformOrigin} > - - - - - {userName} + + + + {userDetails.userName ?? userDetails.email} + + + {userDetails.email} + + - + + + { + e.stopPropagation(); + colorMode.toggleColorMode(); + }} + > - + {theme.palette.mode === 'dark' ? ( + + ) : ( + + )} - - - {email} - - {emailVerified ? ( - - - - ) : null} - + { - void handlers.logout(); + void supabaseClient.auth.signOut(); }} > - + - - - ); - } else { - return null; - } + + + ); }; export default UserMenu; diff --git a/src/components/menus/shared.ts b/src/components/menus/shared.ts new file mode 100644 index 0000000000..8dfa7709b8 --- /dev/null +++ b/src/components/menus/shared.ts @@ -0,0 +1,13 @@ +import type { PopoverOrigin } from '@mui/material'; + +// Side-nav menus anchor to the top-left of their trigger and grow upward, so +// the menu's bottom-left corner lines up with the trigger's top-left corner. +export const sideNavMenuAnchorOrigin: PopoverOrigin = { + horizontal: 'left', + vertical: 'top', +}; + +export const sideNavMenuTransformOrigin: PopoverOrigin = { + horizontal: 'left', + vertical: 'bottom', +}; diff --git a/src/components/navigation/ModeSwitch.tsx b/src/components/navigation/ModeSwitch.tsx deleted file mode 100644 index 8328dd40f0..0000000000 --- a/src/components/navigation/ModeSwitch.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { - ListItemButton, - ListItemIcon, - ListItemText, - Tooltip, - useTheme, -} from '@mui/material'; - -import { HalfMoon, SunLight } from 'iconoir-react'; -import { FormattedMessage, useIntl } from 'react-intl'; - -import { useColorMode } from 'src/context/Theme'; - -function ModeSwitch() { - const intl = useIntl(); - const theme = useTheme(); - const colorMode = useColorMode(); - - return ( - - - - {theme.palette.mode === 'dark' ? ( - - ) : ( - - )} - - - - - - - - ); -} - -export default ModeSwitch; diff --git a/src/components/navigation/NavTriggerButton.tsx b/src/components/navigation/NavTriggerButton.tsx new file mode 100644 index 0000000000..9411212834 --- /dev/null +++ b/src/components/navigation/NavTriggerButton.tsx @@ -0,0 +1,44 @@ +import type { MouseEvent, ReactNode } from 'react'; + +import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; + +interface NavTriggerButtonProps { + icon: ReactNode; + label: ReactNode; + // Whether the side nav is expanded; hides the label when collapsed. + open: boolean; + onClick: (event: MouseEvent) => void; +} + +// A side-nav list item that opens a menu/popover anchored to itself, rather +// than navigating. Shared by the user and org switcher triggers. +const NavTriggerButton = ({ + icon, + label, + open, + onClick, +}: NavTriggerButtonProps) => ( + + {icon} + + + +); + +export default NavTriggerButton; diff --git a/src/components/navigation/Navigation.tsx b/src/components/navigation/Navigation.tsx index 82ef0714bd..8c808b357d 100644 --- a/src/components/navigation/Navigation.tsx +++ b/src/components/navigation/Navigation.tsx @@ -1,4 +1,7 @@ -//TODO (UI / UX) - These icons are not final +import type { MouseEvent } from 'react'; + +import { useState } from 'react'; + import { Box, List, @@ -12,20 +15,26 @@ import { } from '@mui/material'; import MuiDrawer, { drawerClasses } from '@mui/material/Drawer'; +import { useShallow } from 'zustand/react/shallow'; + import { CloudDownload, CloudUpload, DatabaseScript, FastArrowLeft, + HelpCircle, HomeSimple, Settings, } from 'iconoir-react'; import { useIntl } from 'react-intl'; import { authenticatedRoutes } from 'src/app/routes'; +import { HelpMenu } from 'src/components/menus/HelpMenu'; +import OrgMenu from 'src/components/menus/OrgMenu'; +import UserMenu from 'src/components/menus/UserMenu'; import ListItemLink from 'src/components/navigation/ListItemLink'; -import ModeSwitch from 'src/components/navigation/ModeSwitch'; import { paperBackground } from 'src/context/Theme'; +import { useUserStore } from 'src/context/User/useUserContextStore'; interface NavigationProps { open: boolean; @@ -36,6 +45,9 @@ interface NavigationProps { const Navigation = ({ open, width, onNavigationToggle }: NavigationProps) => { const intl = useIntl(); const theme = useTheme(); + const userDetails = useUserStore(useShallow((state) => state.userDetails)); + + const [helpAnchor, setHelpAnchor] = useState(null); const openNavigation = () => { onNavigationToggle(true); @@ -78,7 +90,7 @@ const Navigation = ({ open, width, onNavigationToggle }: NavigationProps) => { { - - @@ -157,6 +171,26 @@ const Navigation = ({ open, width, onNavigationToggle }: NavigationProps) => { /> + } + title="helpMenu.tooltip" + link={(e: MouseEvent) => + setHelpAnchor(e.currentTarget) + } + isOpen={open} + /> + setHelpAnchor(null)} + /> + + {userDetails ? ( + <> + + + + + ) : null} diff --git a/src/components/navigation/TopBar.tsx b/src/components/navigation/TopBar.tsx index 5d35e1a122..6b2de160a8 100644 --- a/src/components/navigation/TopBar.tsx +++ b/src/components/navigation/TopBar.tsx @@ -4,8 +4,6 @@ import { useTheme } from '@mui/material/styles'; import { HeaderPill } from 'src/components/AgentSkills/HeaderPill'; import CompanyLogo from 'src/components/graphics/CompanyLogo'; -import HelpMenu from 'src/components/menus/HelpMenu'; -import UserMenu from 'src/components/menus/UserMenu'; import PageTitle from 'src/components/navigation/PageTitle'; import SidePanelDocsOpenButton from 'src/components/sidePanelDocs/OpenButton'; import { UpdateAlert } from 'src/components/UpdateAlert'; @@ -45,10 +43,6 @@ const Topbar = () => { - - - - diff --git a/src/lang/en-US/Navigation.ts b/src/lang/en-US/Navigation.ts index 304f759a81..f29a1d5afd 100644 --- a/src/lang/en-US/Navigation.ts +++ b/src/lang/en-US/Navigation.ts @@ -2,15 +2,11 @@ import { CommonMessages } from 'src/lang/en-US/CommonMessages'; import { CTAs } from 'src/lang/en-US/CTAs'; export const Navigation: Record = { - 'navigation.toggle.ariaLabel': `Toggle Navigation`, - 'navigation.expand': `Expand Navigation`, - 'navigation.collapse': `Collapse Navigation`, + 'navigation.ariaLabel': `Main Navigation`, + 'navigation.tooltip.expand': `Expand Navigation`, + 'navigation.collapse': `Collapse`, - // Header - 'mainMenu.tooltip': `Open Main Menu`, - - 'helpMenu.ariaLabel': `Open Help Menu`, - 'helpMenu.tooltip': `Helpful Links`, + 'helpMenu.tooltip': `Help`, 'helpMenu.docs': `Docs`, 'helpMenu.docs.link': `https://docs.estuary.dev/`, 'helpMenu.slack': `Estuary Slack`, @@ -19,15 +15,13 @@ export const Navigation: Record = { 'helpMenu.support.link': `${CommonMessages['support.email']}`, 'helpMenu.contact': `${CTAs['cta.contactUs']}`, 'helpMenu.contact.link': `https://estuary.dev/contact-us`, - 'helpMenu.about': `About ${CommonMessages.productName}`, 'helpMenu.status': `Status`, 'helpMenu.status.link': `https://status.estuary.dev/`, - 'accountMenu.ariaLabel': `Open Account Menu`, - 'accountMenu.tooltip': `My Account`, - 'accountMenu.emailVerified': `verified`, + 'modeSwitch.label.light': `Light mode`, + 'modeSwitch.label.dark': `Dark mode`, - 'modeSwitch.label': `Toggle Color Mode`, + 'tenant.organization': `Organization`, 'updateAlert.cta': `Update`, 'updateAlert.title': `Dashboard Updated`, From a712916c09a26b0271b7ff38863fb836088037d9 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 12:47:53 -0400 Subject: [PATCH 02/21] OrgMenu: default to the first available tenant on render The org menu replaced the per-page TenantSelector (removed from the home and admin pages), which was what previously defaulted the selected tenant. Since OrgMenu is always mounted in the nav, default selectedTenant to the first admin-capable tenant when none is validly selected, while preserving a still-valid persisted selection. --- src/components/menus/OrgMenu.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/menus/OrgMenu.tsx b/src/components/menus/OrgMenu.tsx index c25bc55923..1b07762477 100644 --- a/src/components/menus/OrgMenu.tsx +++ b/src/components/menus/OrgMenu.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Dialog, @@ -24,6 +24,7 @@ import { useEntitiesStore_tenantsWithAdmin, } from 'src/stores/Entities/hooks'; import { useTenantStore } from 'src/stores/Tenant'; +import { hasLength } from 'src/utils/misc-utils'; interface OrgMenuProps { // Whether the side nav is expanded; controls the trigger label visibility. @@ -42,6 +43,18 @@ const OrgMenu = ({ open }: OrgMenuProps) => { ); const allPrefixes = useEntitiesStore_capabilities_adminable(false); + // The org menu is always mounted in the nav, so it owns defaulting the + // selected tenant: keep a still-valid selection (e.g. one persisted from a + // prior session), otherwise fall back to the first available tenant. + useEffect(() => { + if ( + hasLength(tenantNames) && + !(selectedTenant && tenantNames.includes(selectedTenant)) + ) { + setSelectedTenant(tenantNames[0]); + } + }, [selectedTenant, setSelectedTenant, tenantNames]); + const [orgAnchor, setOrgAnchor] = useState(null); const [orgDialogOpen, setOrgDialogOpen] = useState(false); From 62d6a9d497ced4953e4d7d40032692b6adb0f271 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 13:03:14 -0400 Subject: [PATCH 03/21] Fix Prettier formatting in Billing index --- src/components/admin/Billing/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/admin/Billing/index.tsx b/src/components/admin/Billing/index.tsx index fac8da302f..aca30473a0 100644 --- a/src/components/admin/Billing/index.tsx +++ b/src/components/admin/Billing/index.tsx @@ -140,7 +140,6 @@ function AdminBilling({ showAddPayment }: AdminBillingProps) { - From faaf8be020c59958830aae2878dfb82a76746552 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 14:01:27 -0400 Subject: [PATCH 04/21] OrgMenu: honor ?prefix= deep-link and use one org list for all users Brings the org menu to parity with the removed per-page TenantSelector: - Honor a ?prefix= URL param (e.g. the billing add-payment-method CTA) the first time it appears, then keep a manual selection. A ref tracks the applied param so a stale value lingering in the URL doesn't override later org switches. - Drop the support-only dialog that listed the full prefix set (allPrefixes); support users now see the same top-level org list (tenantsWithAdmin) as everyone else, matching what the old selector showed. --- src/components/menus/OrgMenu.tsx | 96 ++++++++++++-------------------- 1 file changed, 35 insertions(+), 61 deletions(-) diff --git a/src/components/menus/OrgMenu.tsx b/src/components/menus/OrgMenu.tsx index 1b07762477..0db330aece 100644 --- a/src/components/menus/OrgMenu.tsx +++ b/src/components/menus/OrgMenu.tsx @@ -1,28 +1,19 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; -import { - Dialog, - DialogContent, - DialogTitle, - MenuItem, - Popover, - Typography, -} from '@mui/material'; +import { MenuItem, Popover, Typography } from '@mui/material'; import { Building, Check } from 'iconoir-react'; -import { FormattedMessage, useIntl } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; -import PrefixSelector from 'src/components/inputs/PrefixedName/PrefixSelector'; import { sideNavMenuAnchorOrigin, sideNavMenuTransformOrigin, } from 'src/components/menus/shared'; import NavTriggerButton from 'src/components/navigation/NavTriggerButton'; -import { useUserInfoSummaryStore } from 'src/context/UserInfoSummary/useUserInfoSummaryStore'; -import { - useEntitiesStore_capabilities_adminable, - useEntitiesStore_tenantsWithAdmin, -} from 'src/stores/Entities/hooks'; +import useGlobalSearchParams, { + GlobalSearchParams, +} from 'src/hooks/searchParams/useGlobalSearchParams'; +import { useEntitiesStore_tenantsWithAdmin } from 'src/stores/Entities/hooks'; import { useTenantStore } from 'src/stores/Tenant'; import { hasLength } from 'src/utils/misc-utils'; @@ -32,31 +23,46 @@ interface OrgMenuProps { } const OrgMenu = ({ open }: OrgMenuProps) => { - const intl = useIntl(); const selectedTenant = useTenantStore((state) => state.selectedTenant); const setSelectedTenant = useTenantStore( (state) => state.setSelectedTenant ); const tenantNames = useEntitiesStore_tenantsWithAdmin(); - const hasSupportAccess = useUserInfoSummaryStore( - (state) => state.hasSupportAccess - ); - const allPrefixes = useEntitiesStore_capabilities_adminable(false); - // The org menu is always mounted in the nav, so it owns defaulting the - // selected tenant: keep a still-valid selection (e.g. one persisted from a - // prior session), otherwise fall back to the first available tenant. + const prefixParam = useGlobalSearchParams(GlobalSearchParams.PREFIX); + const appliedPrefixParam = useRef(null); + + // The org menu is always mounted, so it owns selecting a tenant. Once the + // tenant list loads: honor a `?prefix=` deep link (e.g. the billing "add + // payment method" CTA) the first time it appears, then keep a still-valid + // selection, otherwise fall back to the first available tenant. Tracking the + // applied param lets a manual switch stick instead of losing to a stale + // param lingering in the URL. useEffect(() => { + if (!hasLength(tenantNames)) { + return; + } + if ( - hasLength(tenantNames) && - !(selectedTenant && tenantNames.includes(selectedTenant)) + hasLength(prefixParam) && + tenantNames.includes(prefixParam) && + prefixParam !== appliedPrefixParam.current ) { + appliedPrefixParam.current = prefixParam; + + if (prefixParam !== selectedTenant) { + setSelectedTenant(prefixParam); + } + + return; + } + + if (!(selectedTenant && tenantNames.includes(selectedTenant))) { setSelectedTenant(tenantNames[0]); } - }, [selectedTenant, setSelectedTenant, tenantNames]); + }, [prefixParam, selectedTenant, setSelectedTenant, tenantNames]); const [orgAnchor, setOrgAnchor] = useState(null); - const [orgDialogOpen, setOrgDialogOpen] = useState(false); const tenantLabel = selectedTenant ? selectedTenant.replace(/\/$/, '') @@ -66,11 +72,7 @@ const OrgMenu = ({ open }: OrgMenuProps) => { <> - hasSupportAccess - ? setOrgDialogOpen(true) - : setOrgAnchor(e.currentTarget) - } + onClick={(e) => setOrgAnchor(e.currentTarget)} icon={} label={tenantLabel} /> @@ -132,34 +134,6 @@ const OrgMenu = ({ open }: OrgMenuProps) => { ); })} - - setOrgDialogOpen(false)} - fullWidth - maxWidth="xs" - > - - - - - { - setSelectedTenant(newValue); - setOrgDialogOpen(false); - }} - options={allPrefixes} - value={selectedTenant} - variantString="outlined" - /> - - ); }; From 6cc847b25e00c60bffd5bda27d0ea96bb9dc0918 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 14:09:19 -0400 Subject: [PATCH 05/21] OrgMenu: keep the support autocomplete dialog, validate per-user list Reverts the dialog removal from the previous commit. estuary-support users admin a large number of tenants and need a searchable list, so they keep the autocomplete dialog over the full admin prefix set; regular users keep the simple top-level org menu. Also fixes the defaulting effect to validate the current selection against the list each user actually sees (allPrefixes for support, tenantsWithAdmin otherwise) instead of always against the top-level orgs, so a support user's selection isn't reset to the first top-level org. --- src/components/menus/OrgMenu.tsx | 79 +++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/src/components/menus/OrgMenu.tsx b/src/components/menus/OrgMenu.tsx index 0db330aece..4b7854f30f 100644 --- a/src/components/menus/OrgMenu.tsx +++ b/src/components/menus/OrgMenu.tsx @@ -1,19 +1,31 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; -import { MenuItem, Popover, Typography } from '@mui/material'; +import { + Dialog, + DialogContent, + DialogTitle, + MenuItem, + Popover, + Typography, +} from '@mui/material'; import { Building, Check } from 'iconoir-react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; +import PrefixSelector from 'src/components/inputs/PrefixedName/PrefixSelector'; import { sideNavMenuAnchorOrigin, sideNavMenuTransformOrigin, } from 'src/components/menus/shared'; import NavTriggerButton from 'src/components/navigation/NavTriggerButton'; +import { useUserInfoSummaryStore } from 'src/context/UserInfoSummary/useUserInfoSummaryStore'; import useGlobalSearchParams, { GlobalSearchParams, } from 'src/hooks/searchParams/useGlobalSearchParams'; -import { useEntitiesStore_tenantsWithAdmin } from 'src/stores/Entities/hooks'; +import { + useEntitiesStore_capabilities_adminable, + useEntitiesStore_tenantsWithAdmin, +} from 'src/stores/Entities/hooks'; import { useTenantStore } from 'src/stores/Tenant'; import { hasLength } from 'src/utils/misc-utils'; @@ -23,29 +35,41 @@ interface OrgMenuProps { } const OrgMenu = ({ open }: OrgMenuProps) => { + const intl = useIntl(); const selectedTenant = useTenantStore((state) => state.selectedTenant); const setSelectedTenant = useTenantStore( (state) => state.setSelectedTenant ); const tenantNames = useEntitiesStore_tenantsWithAdmin(); + const hasSupportAccess = useUserInfoSummaryStore( + (state) => state.hasSupportAccess + ); + const allPrefixes = useEntitiesStore_capabilities_adminable(false); + + // Support users pick from every admin prefix (a potentially huge list, hence + // the autocomplete dialog); everyone else picks from their top-level orgs. + const availableTenants = useMemo( + () => (hasSupportAccess ? allPrefixes : tenantNames), + [allPrefixes, hasSupportAccess, tenantNames] + ); const prefixParam = useGlobalSearchParams(GlobalSearchParams.PREFIX); const appliedPrefixParam = useRef(null); // The org menu is always mounted, so it owns selecting a tenant. Once the - // tenant list loads: honor a `?prefix=` deep link (e.g. the billing "add + // available list loads: honor a `?prefix=` deep link (e.g. the billing "add // payment method" CTA) the first time it appears, then keep a still-valid // selection, otherwise fall back to the first available tenant. Tracking the // applied param lets a manual switch stick instead of losing to a stale // param lingering in the URL. useEffect(() => { - if (!hasLength(tenantNames)) { + if (!hasLength(availableTenants)) { return; } if ( hasLength(prefixParam) && - tenantNames.includes(prefixParam) && + availableTenants.includes(prefixParam) && prefixParam !== appliedPrefixParam.current ) { appliedPrefixParam.current = prefixParam; @@ -57,12 +81,13 @@ const OrgMenu = ({ open }: OrgMenuProps) => { return; } - if (!(selectedTenant && tenantNames.includes(selectedTenant))) { - setSelectedTenant(tenantNames[0]); + if (!(selectedTenant && availableTenants.includes(selectedTenant))) { + setSelectedTenant(availableTenants[0]); } - }, [prefixParam, selectedTenant, setSelectedTenant, tenantNames]); + }, [availableTenants, prefixParam, selectedTenant, setSelectedTenant]); const [orgAnchor, setOrgAnchor] = useState(null); + const [orgDialogOpen, setOrgDialogOpen] = useState(false); const tenantLabel = selectedTenant ? selectedTenant.replace(/\/$/, '') @@ -72,7 +97,11 @@ const OrgMenu = ({ open }: OrgMenuProps) => { <> setOrgAnchor(e.currentTarget)} + onClick={(e) => + hasSupportAccess + ? setOrgDialogOpen(true) + : setOrgAnchor(e.currentTarget) + } icon={} label={tenantLabel} /> @@ -134,6 +163,34 @@ const OrgMenu = ({ open }: OrgMenuProps) => { ); })} + + setOrgDialogOpen(false)} + fullWidth + maxWidth="xs" + > + + + + + { + setSelectedTenant(newValue); + setOrgDialogOpen(false); + }} + options={allPrefixes} + value={selectedTenant} + variantString="outlined" + /> + + ); }; From d0675b296254a790aaf373f4cf07f30dfdde5d6e Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 14:18:18 -0400 Subject: [PATCH 06/21] OrgMenu: support dialog lists the same tenants as the old selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The support dialog was listing allPrefixes (useEntitiesStore_capabilities_adminable) — the full admin prefix set incl. sub-prefixes, which no tenant selector showed before this PR. Switch it to useEntitiesStore_tenantsWithAdmin, the exact list the old TenantSelector showed every user (regular and support), so support users get the identical top-level org list they had pre-change, just in the searchable dialog. Both selectors now share one list, so the defaulting effect no longer needs a per-user list. --- src/components/menus/OrgMenu.tsx | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/components/menus/OrgMenu.tsx b/src/components/menus/OrgMenu.tsx index 4b7854f30f..3122ea5ec3 100644 --- a/src/components/menus/OrgMenu.tsx +++ b/src/components/menus/OrgMenu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Dialog, @@ -22,10 +22,7 @@ import { useUserInfoSummaryStore } from 'src/context/UserInfoSummary/useUserInfo import useGlobalSearchParams, { GlobalSearchParams, } from 'src/hooks/searchParams/useGlobalSearchParams'; -import { - useEntitiesStore_capabilities_adminable, - useEntitiesStore_tenantsWithAdmin, -} from 'src/stores/Entities/hooks'; +import { useEntitiesStore_tenantsWithAdmin } from 'src/stores/Entities/hooks'; import { useTenantStore } from 'src/stores/Tenant'; import { hasLength } from 'src/utils/misc-utils'; @@ -40,36 +37,29 @@ const OrgMenu = ({ open }: OrgMenuProps) => { const setSelectedTenant = useTenantStore( (state) => state.setSelectedTenant ); + // Same tenant list the old TenantSelector showed everyone (incl. support). const tenantNames = useEntitiesStore_tenantsWithAdmin(); const hasSupportAccess = useUserInfoSummaryStore( (state) => state.hasSupportAccess ); - const allPrefixes = useEntitiesStore_capabilities_adminable(false); - - // Support users pick from every admin prefix (a potentially huge list, hence - // the autocomplete dialog); everyone else picks from their top-level orgs. - const availableTenants = useMemo( - () => (hasSupportAccess ? allPrefixes : tenantNames), - [allPrefixes, hasSupportAccess, tenantNames] - ); const prefixParam = useGlobalSearchParams(GlobalSearchParams.PREFIX); const appliedPrefixParam = useRef(null); // The org menu is always mounted, so it owns selecting a tenant. Once the - // available list loads: honor a `?prefix=` deep link (e.g. the billing "add + // tenant list loads: honor a `?prefix=` deep link (e.g. the billing "add // payment method" CTA) the first time it appears, then keep a still-valid // selection, otherwise fall back to the first available tenant. Tracking the // applied param lets a manual switch stick instead of losing to a stale // param lingering in the URL. useEffect(() => { - if (!hasLength(availableTenants)) { + if (!hasLength(tenantNames)) { return; } if ( hasLength(prefixParam) && - availableTenants.includes(prefixParam) && + tenantNames.includes(prefixParam) && prefixParam !== appliedPrefixParam.current ) { appliedPrefixParam.current = prefixParam; @@ -81,10 +71,10 @@ const OrgMenu = ({ open }: OrgMenuProps) => { return; } - if (!(selectedTenant && availableTenants.includes(selectedTenant))) { - setSelectedTenant(availableTenants[0]); + if (!(selectedTenant && tenantNames.includes(selectedTenant))) { + setSelectedTenant(tenantNames[0]); } - }, [availableTenants, prefixParam, selectedTenant, setSelectedTenant]); + }, [prefixParam, selectedTenant, setSelectedTenant, tenantNames]); const [orgAnchor, setOrgAnchor] = useState(null); const [orgDialogOpen, setOrgDialogOpen] = useState(false); @@ -185,7 +175,7 @@ const OrgMenu = ({ open }: OrgMenuProps) => { setSelectedTenant(newValue); setOrgDialogOpen(false); }} - options={allPrefixes} + options={tenantNames} value={selectedTenant} variantString="outlined" /> From 341f36afb1cbf2da4e0a88c8f8f14048bee8b6d7 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 14:40:27 -0400 Subject: [PATCH 07/21] Add a logout escape hatch to the legal-check error screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the click-to-accept (ToS/Privacy) check fails to fetch, LegalGuard renders a full-page error in place of the children — before the layout, and the now-relocated logout button in the nav, can render. That left a stuck user no way to sign out (logging out and back in clears a stale check). Add an optional actions slot to FullPageError and pass a Log out button (supabaseClient.auth.signOut()) from LegalGuard's error branch. The ToS accept screen already had this via the Actions cancel button; this fills the gap on the error screen. --- src/app/guards/LegalGuard.tsx | 17 ++++++++++++++++- src/components/fullPage/Error.tsx | 5 ++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/app/guards/LegalGuard.tsx b/src/app/guards/LegalGuard.tsx index 6b088d1476..27c9bcc6c9 100644 --- a/src/app/guards/LegalGuard.tsx +++ b/src/app/guards/LegalGuard.tsx @@ -1,12 +1,13 @@ import type { BaseComponentProps } from 'src/types'; -import { Box } from '@mui/material'; +import { Box, Button } from '@mui/material'; import { FormattedMessage } from 'react-intl'; import FullPageWrapper from 'src/app/FullPageWrapper'; import useDirectiveGuard from 'src/app/guards/hooks'; import FullPageError from 'src/components/fullPage/Error'; +import { supabaseClient } from 'src/context/GlobalProviders'; import ClickToAccept from 'src/directives/ClickToAccept'; const SELECTED_DIRECTIVE = 'clickToAccept'; @@ -34,6 +35,20 @@ function LegalGuard({ children }: BaseComponentProps) { }} /> } + actions={ + // The check failed before the layout (and its logout) could + // render, so offer a way out — signing out and back in + // clears a stale ToS-acceptance check. + + } /> ); } diff --git a/src/components/fullPage/Error.tsx b/src/components/fullPage/Error.tsx index 7fa4957e34..d43601102e 100644 --- a/src/components/fullPage/Error.tsx +++ b/src/components/fullPage/Error.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import type { ErrorDetails } from 'src/components/shared/Error/types'; import { useMemo } from 'react'; @@ -16,11 +16,13 @@ import { CustomEvents } from 'src/services/types'; interface Props { error: ErrorDetails; + actions?: ReactNode; disableMessageWrapping?: boolean; message?: ReactElement; title?: ReactElement | string; } function FullPageError({ + actions, disableMessageWrapping, error, message, @@ -56,6 +58,7 @@ function FullPageError({ + {actions} From a179417d5730ede8279b3ff3e48d2f023950cc36 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 14:47:57 -0400 Subject: [PATCH 08/21] Always offer logout on the full-page error, not per call site Every FullPageError call site is behind auth (LegalGuard, UserInfoSummary, TenantBillingDetails, and the entities/storage-mappings hydrators) and is a dead-end a stuck user may need to escape. Move the Log out button into FullPageError itself and drop the opt-in actions prop / LegalGuard wiring added in the previous commit, so all full-page errors get the escape hatch consistently. --- src/app/guards/LegalGuard.tsx | 17 +---------------- src/components/fullPage/Error.tsx | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/app/guards/LegalGuard.tsx b/src/app/guards/LegalGuard.tsx index 27c9bcc6c9..6b088d1476 100644 --- a/src/app/guards/LegalGuard.tsx +++ b/src/app/guards/LegalGuard.tsx @@ -1,13 +1,12 @@ import type { BaseComponentProps } from 'src/types'; -import { Box, Button } from '@mui/material'; +import { Box } from '@mui/material'; import { FormattedMessage } from 'react-intl'; import FullPageWrapper from 'src/app/FullPageWrapper'; import useDirectiveGuard from 'src/app/guards/hooks'; import FullPageError from 'src/components/fullPage/Error'; -import { supabaseClient } from 'src/context/GlobalProviders'; import ClickToAccept from 'src/directives/ClickToAccept'; const SELECTED_DIRECTIVE = 'clickToAccept'; @@ -35,20 +34,6 @@ function LegalGuard({ children }: BaseComponentProps) { }} /> } - actions={ - // The check failed before the layout (and its logout) could - // render, so offer a way out — signing out and back in - // clears a stale ToS-acceptance check. - - } /> ); } diff --git a/src/components/fullPage/Error.tsx b/src/components/fullPage/Error.tsx index d43601102e..6bd941d9d6 100644 --- a/src/components/fullPage/Error.tsx +++ b/src/components/fullPage/Error.tsx @@ -1,9 +1,9 @@ -import type { ReactElement, ReactNode } from 'react'; +import type { ReactElement } from 'react'; import type { ErrorDetails } from 'src/components/shared/Error/types'; import { useMemo } from 'react'; -import { Divider, Stack, Typography } from '@mui/material'; +import { Button, Divider, Stack, Typography } from '@mui/material'; import { FormattedMessage } from 'react-intl'; import { useMount } from 'react-use'; @@ -11,18 +11,17 @@ import { useMount } from 'react-use'; import FullPageWrapper from 'src/app/FullPageWrapper'; import MessageWithLink from 'src/components/content/MessageWithLink'; import Error from 'src/components/shared/Error'; +import { supabaseClient } from 'src/context/GlobalProviders'; import { logRocketEvent } from 'src/services/shared'; import { CustomEvents } from 'src/services/types'; interface Props { error: ErrorDetails; - actions?: ReactNode; disableMessageWrapping?: boolean; message?: ReactElement; title?: ReactElement | string; } function FullPageError({ - actions, disableMessageWrapping, error, message, @@ -58,7 +57,21 @@ function FullPageError({ - {actions} + + {/* A full-page error replaces the layout — including the nav's + logout button. Every caller is behind auth, so always offer + logout as an escape; signing out and back in clears stale + check/hydration failures. */} + + From 496597c11f1fef1921783bfc9a0e7561428e95bd Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 15:47:19 -0400 Subject: [PATCH 09/21] Add a Reload button beside Logout on the full-page error Most full-page errors (failed data hydration or fetches) are transient, so offer a page reload as the primary recovery action, with the logout escape hatch as the secondary. --- src/components/fullPage/Error.tsx | 36 ++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/components/fullPage/Error.tsx b/src/components/fullPage/Error.tsx index 6bd941d9d6..b893ca75f6 100644 --- a/src/components/fullPage/Error.tsx +++ b/src/components/fullPage/Error.tsx @@ -58,19 +58,29 @@ function FullPageError({ - {/* A full-page error replaces the layout — including the nav's - logout button. Every caller is behind auth, so always offer - logout as an escape; signing out and back in clears stale - check/hydration failures. */} - + {/* A full-page error replaces the whole layout, including the + nav. Most of these failures are transient, so offer a reload; + and since every caller is behind auth, a logout escape hatch + too (signing out and back in clears stale checks). */} + + + + + From ad7450a8a4b03f50531c02b6cd4eaf61e2e39237 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 17:25:34 -0400 Subject: [PATCH 10/21] Revert PrefixedName changes; leave component untouched in this PR --- src/components/inputs/PrefixedName/index.tsx | 32 ++++++------------- .../inputs/PrefixedName/useValidatePrefix.ts | 17 +++++----- 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/src/components/inputs/PrefixedName/index.tsx b/src/components/inputs/PrefixedName/index.tsx index e06169b2b5..b9193ba518 100644 --- a/src/components/inputs/PrefixedName/index.tsx +++ b/src/components/inputs/PrefixedName/index.tsx @@ -169,28 +169,16 @@ function PrefixedName({ }, }} > - {singleOption ? ( - - ) : ( - handlers.setPrefix(newValue)} - options={objectRoles} - value={prefix} - variantString={variantString} - /> - )} + handlers.setPrefix(newValue)} + options={objectRoles} + value={prefix} + variantString={variantString} + /> state.selectedTenant); - const singleOption = objectRoles.length === 1 || hasLength(selectedTenant); + const singleOption = objectRoles.length === 1; + + // Fetch for the default value + // const [selectedTenant, setSelectedTenant] = useTenantStore(useShallow((state) => [ + // state.selectedTenant, + // state.setSelectedTenant, + // ])); // Local State for editing const [errors, setErrors] = useState(null); const [name, setName] = useState(''); const [nameError, setNameError] = useState(null); const [prefix, setPrefix] = useState( - hasLength(selectedTenant) - ? selectedTenant - : singleOption || defaultPrefix - ? objectRoles[0] - : '' + singleOption || defaultPrefix ? objectRoles[0] : '' //selectedTenant ); const [prefixError, setPrefixError] = useState(null); @@ -111,6 +111,7 @@ function useValidatePrefix({ ); setPrefix(prefixValue); + // setSelectedTenant(prefixValue); if (onPrefixChange) { onPrefixChange(prefixValue, errorString, { From 2fc90c781dbf32d5780179ad666f56f223a3aacd Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Mon, 15 Jun 2026 17:26:17 -0400 Subject: [PATCH 11/21] clean up comments --- src/components/fullPage/Error.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/fullPage/Error.tsx b/src/components/fullPage/Error.tsx index b893ca75f6..aa34066e67 100644 --- a/src/components/fullPage/Error.tsx +++ b/src/components/fullPage/Error.tsx @@ -58,10 +58,6 @@ function FullPageError({ - {/* A full-page error replaces the whole layout, including the - nav. Most of these failures are transient, so offer a reload; - and since every caller is behind auth, a logout escape hatch - too (signing out and back in clears stale checks). */} + {selectedInvoice.receipt_url ? ( + + ) : selectedInvoice.status === 'open' ? ( + + ) : selectedInvoice.status === 'paid' ? ( - {stripeInvoice?.status === 'open' ? ( - - ) : stripeInvoice?.status === 'paid' ? ( - - ) : ( - - )} - - ) : ( - <> - - } + disabled sx={{ marginLeft: 1 }} - variant="rectangular" - width={120} - height={25} - /> - - ) + variant="outlined" + size="small" + > + {intl.formatMessage({ + id: 'admin.billing.table.line_items.tooltip.pay_invoice', + })} + + )} + ) : null} {selectedInvoice ? ( diff --git a/src/gql-types/gql.ts b/src/gql-types/gql.ts index 674bd7e491..848c5862a8 100644 --- a/src/gql-types/gql.ts +++ b/src/gql-types/gql.ts @@ -19,7 +19,7 @@ type Documents = { "\n query AlertSubscriptions($prefix: Prefix!) {\n alertSubscriptions(by: { prefix: $prefix }) {\n alertTypes\n catalogPrefix\n email\n updatedAt\n }\n }\n": typeof types.AlertSubscriptionsDocument, "\n mutation UpdateAlertSubscriptionMutation(\n $prefix: Prefix!\n $email: String!\n $alertTypes: [AlertType!]\n $detail: String\n ) {\n updateAlertSubscription(\n prefix: $prefix\n email: $email\n alertTypes: $alertTypes\n detail: $detail\n ) {\n catalogPrefix\n email\n }\n }\n": typeof types.UpdateAlertSubscriptionMutationDocument, "\n query AlertType {\n alertTypes {\n alertType\n description\n displayName\n isDefault\n isSystem\n }\n }\n": typeof types.AlertTypeDocument, - "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n }\n }\n }\n }\n }\n": typeof types.TenantBillingInvoicesDocument, + "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n": typeof types.TenantBillingInvoicesDocument, "\n query ConnectorsGrid($filter: ConnectorsFilter, $after: String) {\n connectors(first: 500, after: $after, filter: $filter) {\n edges {\n cursor\n node {\n id\n imageName\n logoUrl\n title\n recommended\n detail\n defaultSpec {\n id\n imageTag\n documentationUrl\n protocol\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": typeof types.ConnectorsGridDocument, "\n query ConnectorTagData($imageName: String!, $fullImageName: String!) {\n connector(imageName: $imageName) {\n id\n imageName\n logoUrl\n title\n }\n connectorSpec(fullImageName: $fullImageName) {\n id\n imageTag\n defaultCaptureInterval\n disableBackfill\n documentationUrl\n endpointSpecSchema\n resourceSpecSchema\n protocol\n }\n }\n": typeof types.ConnectorTagDataDocument, "\n query DataPlanes($after: String) {\n dataPlanes(first: 100, after: $after) {\n edges {\n node {\n name\n cloudProvider\n region\n isPublic\n fqdn\n cidrBlocks\n awsIamUserArn\n gcpServiceAccountEmail\n azureApplicationClientId\n azureApplicationName\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": typeof types.DataPlanesDocument, @@ -45,7 +45,7 @@ const documents: Documents = { "\n query AlertSubscriptions($prefix: Prefix!) {\n alertSubscriptions(by: { prefix: $prefix }) {\n alertTypes\n catalogPrefix\n email\n updatedAt\n }\n }\n": types.AlertSubscriptionsDocument, "\n mutation UpdateAlertSubscriptionMutation(\n $prefix: Prefix!\n $email: String!\n $alertTypes: [AlertType!]\n $detail: String\n ) {\n updateAlertSubscription(\n prefix: $prefix\n email: $email\n alertTypes: $alertTypes\n detail: $detail\n ) {\n catalogPrefix\n email\n }\n }\n": types.UpdateAlertSubscriptionMutationDocument, "\n query AlertType {\n alertTypes {\n alertType\n description\n displayName\n isDefault\n isSystem\n }\n }\n": types.AlertTypeDocument, - "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n }\n }\n }\n }\n }\n": types.TenantBillingInvoicesDocument, + "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n": types.TenantBillingInvoicesDocument, "\n query ConnectorsGrid($filter: ConnectorsFilter, $after: String) {\n connectors(first: 500, after: $after, filter: $filter) {\n edges {\n cursor\n node {\n id\n imageName\n logoUrl\n title\n recommended\n detail\n defaultSpec {\n id\n imageTag\n documentationUrl\n protocol\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": types.ConnectorsGridDocument, "\n query ConnectorTagData($imageName: String!, $fullImageName: String!) {\n connector(imageName: $imageName) {\n id\n imageName\n logoUrl\n title\n }\n connectorSpec(fullImageName: $fullImageName) {\n id\n imageTag\n defaultCaptureInterval\n disableBackfill\n documentationUrl\n endpointSpecSchema\n resourceSpecSchema\n protocol\n }\n }\n": types.ConnectorTagDataDocument, "\n query DataPlanes($after: String) {\n dataPlanes(first: 100, after: $after) {\n edges {\n node {\n name\n cloudProvider\n region\n isPublic\n fqdn\n cidrBlocks\n awsIamUserArn\n gcpServiceAccountEmail\n azureApplicationClientId\n azureApplicationName\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": types.DataPlanesDocument, @@ -103,7 +103,7 @@ export function graphql(source: "\n query AlertType {\n alertTypes {\n /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n }\n }\n }\n }\n }\n"]; +export function graphql(source: "\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query TenantBillingInvoices($tenant: String!, $first: Int) {\n tenant(name: $tenant) {\n billing {\n invoices(first: $first) {\n nodes {\n dateStart\n dateEnd\n invoiceType\n subtotal\n lineItems\n extra\n status\n invoicePdf\n hostedInvoiceUrl\n paymentDetails {\n status\n receiptUrl\n }\n }\n }\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/src/gql-types/graphql.ts b/src/gql-types/graphql.ts index 5983bdbf01..7f7aa1a9dc 100644 --- a/src/gql-types/graphql.ts +++ b/src/gql-types/graphql.ts @@ -1792,7 +1792,7 @@ export type TenantBillingInvoicesQueryVariables = Exact<{ }>; -export type TenantBillingInvoicesQuery = { __typename?: 'QueryRoot', tenant?: { __typename?: 'Tenant', billing: { __typename?: 'TenantBilling', invoices: { __typename?: 'InvoiceConnection', nodes: Array<{ __typename?: 'Invoice', dateStart: string, dateEnd: string, invoiceType: InvoiceType, subtotal: number, lineItems: any, extra: any }> } } } | null }; +export type TenantBillingInvoicesQuery = { __typename?: 'QueryRoot', tenant?: { __typename?: 'Tenant', billing: { __typename?: 'TenantBilling', invoices: { __typename?: 'InvoiceConnection', nodes: Array<{ __typename?: 'Invoice', dateStart: string, dateEnd: string, invoiceType: InvoiceType, subtotal: number, lineItems: any, extra: any, status?: string | null, invoicePdf?: string | null, hostedInvoiceUrl?: string | null, paymentDetails?: { __typename?: 'InvoicePaymentDetails', status: ChargeStatus, receiptUrl?: string | null } | null }> } } } | null }; export type ConnectorsGridQueryVariables = Exact<{ filter?: InputMaybe; @@ -1936,7 +1936,7 @@ export const DeleteAlertSubscriptionMutationDocument = {"kind":"Document","defin export const AlertSubscriptionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AlertSubscriptions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Prefix"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertSubscriptions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"by"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertTypes"}},{"kind":"Field","name":{"kind":"Name","value":"catalogPrefix"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const UpdateAlertSubscriptionMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAlertSubscriptionMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Prefix"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"alertTypes"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AlertType"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"detail"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAlertSubscription"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"prefix"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefix"}}},{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"alertTypes"},"value":{"kind":"Variable","name":{"kind":"Name","value":"alertTypes"}}},{"kind":"Argument","name":{"kind":"Name","value":"detail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"detail"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"catalogPrefix"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]} as unknown as DocumentNode; export const AlertTypeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AlertType"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertTypes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"alertType"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"isDefault"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}}]}}]}}]} as unknown as DocumentNode; -export const TenantBillingInvoicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TenantBillingInvoices"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billing"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invoices"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dateStart"}},{"kind":"Field","name":{"kind":"Name","value":"dateEnd"}},{"kind":"Field","name":{"kind":"Name","value":"invoiceType"}},{"kind":"Field","name":{"kind":"Name","value":"subtotal"}},{"kind":"Field","name":{"kind":"Name","value":"lineItems"}},{"kind":"Field","name":{"kind":"Name","value":"extra"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const TenantBillingInvoicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TenantBillingInvoices"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenant"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tenant"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"billing"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invoices"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dateStart"}},{"kind":"Field","name":{"kind":"Name","value":"dateEnd"}},{"kind":"Field","name":{"kind":"Name","value":"invoiceType"}},{"kind":"Field","name":{"kind":"Name","value":"subtotal"}},{"kind":"Field","name":{"kind":"Name","value":"lineItems"}},{"kind":"Field","name":{"kind":"Name","value":"extra"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"invoicePdf"}},{"kind":"Field","name":{"kind":"Name","value":"hostedInvoiceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"paymentDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"receiptUrl"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ConnectorsGridDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectorsGrid"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ConnectorsFilter"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"500"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageName"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"recommended"}},{"kind":"Field","name":{"kind":"Name","value":"detail"}},{"kind":"Field","name":{"kind":"Name","value":"defaultSpec"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageTag"}},{"kind":"Field","name":{"kind":"Name","value":"documentationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"protocol"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; export const ConnectorTagDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectorTagData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"imageName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fullImageName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connector"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"imageName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"imageName"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageName"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}},{"kind":"Field","name":{"kind":"Name","value":"connectorSpec"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"fullImageName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fullImageName"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageTag"}},{"kind":"Field","name":{"kind":"Name","value":"defaultCaptureInterval"}},{"kind":"Field","name":{"kind":"Name","value":"disableBackfill"}},{"kind":"Field","name":{"kind":"Name","value":"documentationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"endpointSpecSchema"}},{"kind":"Field","name":{"kind":"Name","value":"resourceSpecSchema"}},{"kind":"Field","name":{"kind":"Name","value":"protocol"}}]}}]}}]} as unknown as DocumentNode; export const DataPlanesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DataPlanes"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dataPlanes"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"cloudProvider"}},{"kind":"Field","name":{"kind":"Name","value":"region"}},{"kind":"Field","name":{"kind":"Name","value":"isPublic"}},{"kind":"Field","name":{"kind":"Name","value":"fqdn"}},{"kind":"Field","name":{"kind":"Name","value":"cidrBlocks"}},{"kind":"Field","name":{"kind":"Name","value":"awsIamUserArn"}},{"kind":"Field","name":{"kind":"Name","value":"gcpServiceAccountEmail"}},{"kind":"Field","name":{"kind":"Name","value":"azureApplicationClientId"}},{"kind":"Field","name":{"kind":"Name","value":"azureApplicationName"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/src/hooks/billing/useBillingInvoices.ts b/src/hooks/billing/useBillingInvoices.ts index 05c5fc7e25..5cb8462d2a 100644 --- a/src/hooks/billing/useBillingInvoices.ts +++ b/src/hooks/billing/useBillingInvoices.ts @@ -47,6 +47,10 @@ const mapInvoice = ( subtotal: number; lineItems: unknown; extra: unknown; + status?: string | null; + invoicePdf?: string | null; + hostedInvoiceUrl?: string | null; + paymentDetails?: { receiptUrl?: string | null } | null; }, tenant: string ): Invoice => ({ @@ -57,6 +61,10 @@ const mapInvoice = ( subtotal: node.subtotal, line_items: (node.lineItems ?? []) as InvoiceLineItem[], extra: (node.extra ?? undefined) as Invoice['extra'], + status: node.status, + invoice_pdf: node.invoicePdf, + hosted_invoice_url: node.hostedInvoiceUrl, + receipt_url: node.paymentDetails?.receiptUrl ?? null, }); // Mirrors the predicate the previous PostgREST query enforced server-side: diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index 624ac51642..df6d460948 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -78,6 +78,7 @@ export const AdminPage: Record = { 'admin.billing.table.line_items.tooltip.download_pdf': `Download invoice PDF`, 'admin.billing.table.line_items.tooltip.pay_invoice': `Pay Invoice`, 'admin.billing.table.line_items.tooltip.invoice_paid': `Invoice Paid`, + 'admin.billing.table.line_items.tooltip.view_receipt': `View Receipt`, 'admin.billing.paymentMethods.header': `Payment Information`, 'admin.billing.paymentMethods.description': `Enter your payment information. You won't be charged until your account usage exceeds free tier limits.`, From 4c790d7282f8a215b2de75aa200f391c92ae5a58 Mon Sep 17 00:00:00 2001 From: Greg Shear Date: Tue, 16 Jun 2026 14:00:53 -0400 Subject: [PATCH 21/21] Show 'No invoice available' and open invoice links in a new tab When the selected invoice has no Stripe artifact (no PDF, hosted page, receipt, or status), replace the disabled Download/Pay buttons with a single 'No invoice available' button rather than two dead controls. The external invoice links (Download PDF, Pay Invoice, View Receipt) now open in a new tab (component=a + target=_blank, rel=noopener noreferrer) so they don't navigate the user out of the app. --- src/components/tables/BillLineItems/index.tsx | 144 +++++++++++------- src/lang/en-US/AdminPage.ts | 1 + 2 files changed, 87 insertions(+), 58 deletions(-) diff --git a/src/components/tables/BillLineItems/index.tsx b/src/components/tables/BillLineItems/index.tsx index f16e01b78b..f59884a866 100644 --- a/src/components/tables/BillLineItems/index.tsx +++ b/src/components/tables/BillLineItems/index.tsx @@ -55,6 +55,17 @@ function BillingLineItemsTable() { [selectedInvoice] ); + // Whether the selected invoice has any Stripe artifact (PDF, hosted page, + // receipt, or a status). When it has none, there's nothing to download or + // pay, so we show a single "No invoice available" button instead. + const hasInvoiceArtifact = Boolean( + selectedInvoice && + (selectedInvoice.invoice_pdf || + selectedInvoice.hosted_invoice_url || + selectedInvoice.receipt_url || + selectedInvoice.status) + ); + return ( <> @@ -103,71 +114,88 @@ function BillingLineItemsTable() { > {selectedInvoice && selectedInvoice.invoice_type !== 'preview' ? ( - - - {selectedInvoice.receipt_url ? ( + hasInvoiceArtifact ? ( + - ) : selectedInvoice.status === 'open' ? ( - - ) : selectedInvoice.status === 'paid' ? ( - - ) : ( - - )} - + {selectedInvoice.receipt_url ? ( + + ) : selectedInvoice.status === 'open' ? ( + + ) : selectedInvoice.status === 'paid' ? ( + + ) : ( + + )} + + ) : ( + + ) ) : null} {selectedInvoice ? ( diff --git a/src/lang/en-US/AdminPage.ts b/src/lang/en-US/AdminPage.ts index df6d460948..cf911e2c4b 100644 --- a/src/lang/en-US/AdminPage.ts +++ b/src/lang/en-US/AdminPage.ts @@ -79,6 +79,7 @@ export const AdminPage: Record = { 'admin.billing.table.line_items.tooltip.pay_invoice': `Pay Invoice`, 'admin.billing.table.line_items.tooltip.invoice_paid': `Invoice Paid`, 'admin.billing.table.line_items.tooltip.view_receipt': `View Receipt`, + 'admin.billing.table.line_items.tooltip.no_invoice': `No invoice available`, 'admin.billing.paymentMethods.header': `Payment Information`, 'admin.billing.paymentMethods.description': `Enter your payment information. You won't be charged until your account usage exceeds free tier limits.`,