OrganizationLayout.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import { LOCAL_STORAGE_KEYS } from 'common'
  2. import { ExternalLink, XIcon } from 'lucide-react'
  3. import Head from 'next/head'
  4. import { useRouter } from 'next/router'
  5. import type { PropsWithChildren } from 'react'
  6. import { Alert, AlertDescription, AlertTitle, Button, cn } from 'ui'
  7. import { useRegisterOrgMenu } from './OrganizationLayout/useRegisterOrgMenu'
  8. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  9. import PartnerIcon from '@/components/ui/PartnerIcon'
  10. import { useAwsRedirectQuery } from '@/data/integrations/aws-redirect-query'
  11. import { useVercelRedirectQuery } from '@/data/integrations/vercel-redirect-query'
  12. import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
  13. import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
  14. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  15. import { withAuth } from '@/hooks/misc/withAuth'
  16. import { MANAGED_BY } from '@/lib/constants/infrastructure'
  17. import { buildStudioPageTitle } from '@/lib/page-title'
  18. interface OrganizationLayoutProps {
  19. title: string
  20. }
  21. // [Joshen] Just for page title generation for org settings pages
  22. const settingsPages = ['general', 'security', 'sso', 'apps', 'audit', 'documents']
  23. type MarketplaceBannerRedirectSource = 'vercel' | 'aws'
  24. type MarketplaceBannerConfig = {
  25. title: string
  26. description: string
  27. redirectSource?: MarketplaceBannerRedirectSource
  28. }
  29. const MARKETPLACE_BANNER_CONFIG: Record<
  30. | typeof MANAGED_BY.VERCEL_MARKETPLACE
  31. | typeof MANAGED_BY.AWS_MARKETPLACE
  32. | typeof MANAGED_BY.STRIPE_PROJECTS,
  33. MarketplaceBannerConfig
  34. > = {
  35. [MANAGED_BY.VERCEL_MARKETPLACE]: {
  36. title: 'This organization is managed via Vercel Marketplace',
  37. description: 'Billing and some organization access settings are managed in Vercel.',
  38. redirectSource: 'vercel',
  39. },
  40. [MANAGED_BY.AWS_MARKETPLACE]: {
  41. title: 'This organization is billed via AWS Marketplace',
  42. description: 'Changes to billing and payment details must be made in AWS.',
  43. redirectSource: 'aws',
  44. },
  45. [MANAGED_BY.STRIPE_PROJECTS]: {
  46. title: 'This organization is connected to Stripe',
  47. description: 'Changes here will be reflected in your connected Stripe account.',
  48. },
  49. }
  50. const DEFAULT_ORGANIZATION_MARKETPLACE_BANNER_DISMISS_KEY =
  51. LOCAL_STORAGE_KEYS.ORGANIZATION_MARKETPLACE_BANNER_DISMISSED('unknown', MANAGED_BY.BRIVEN)
  52. function getMarketplaceBannerDismissKey({
  53. organizationSlug,
  54. managedBy,
  55. }: {
  56. organizationSlug?: string
  57. managedBy?: string
  58. }) {
  59. if (!organizationSlug || !managedBy) return DEFAULT_ORGANIZATION_MARKETPLACE_BANNER_DISMISS_KEY
  60. return LOCAL_STORAGE_KEYS.ORGANIZATION_MARKETPLACE_BANNER_DISMISSED(organizationSlug, managedBy)
  61. }
  62. function getMarketplaceBannerConfig(managedBy?: string): MarketplaceBannerConfig | undefined {
  63. switch (managedBy) {
  64. case MANAGED_BY.VERCEL_MARKETPLACE:
  65. return MARKETPLACE_BANNER_CONFIG[MANAGED_BY.VERCEL_MARKETPLACE]
  66. case MANAGED_BY.AWS_MARKETPLACE:
  67. return MARKETPLACE_BANNER_CONFIG[MANAGED_BY.AWS_MARKETPLACE]
  68. case MANAGED_BY.STRIPE_PROJECTS:
  69. return MARKETPLACE_BANNER_CONFIG[MANAGED_BY.STRIPE_PROJECTS]
  70. default:
  71. return undefined
  72. }
  73. }
  74. const OrganizationLayoutContent = ({
  75. children,
  76. title,
  77. }: PropsWithChildren<OrganizationLayoutProps>) => {
  78. const router = useRouter()
  79. const { data: selectedOrganization } = useSelectedOrganizationQuery()
  80. const { appTitle } = useCustomContent(['app:title'])
  81. const [isBannerDismissed, setIsBannerDismissed] = useLocalStorageQuery<boolean>(
  82. getMarketplaceBannerDismissKey({
  83. organizationSlug: selectedOrganization?.slug,
  84. managedBy: selectedOrganization?.managed_by,
  85. }),
  86. false
  87. )
  88. // Keep title intent close to each page (getLayout) to avoid route-to-title drift in this layout.
  89. const isSettingsSurface = settingsPages.some((x) => router.pathname.endsWith(x))
  90. const pageTitle = buildStudioPageTitle({
  91. section: title,
  92. surface: isSettingsSurface ? 'Organization Settings' : undefined,
  93. org: selectedOrganization?.name,
  94. brand: appTitle || 'Briven',
  95. })
  96. const vercelQuery = useVercelRedirectQuery(
  97. { installationId: selectedOrganization?.partner_id },
  98. { enabled: selectedOrganization?.managed_by === MANAGED_BY.VERCEL_MARKETPLACE }
  99. )
  100. const awsQuery = useAwsRedirectQuery(
  101. { organizationSlug: selectedOrganization?.slug },
  102. { enabled: selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE }
  103. )
  104. const bannerConfig = getMarketplaceBannerConfig(selectedOrganization?.managed_by)
  105. const selectedRedirectQuery = (() => {
  106. if (!bannerConfig?.redirectSource) return undefined
  107. switch (bannerConfig.redirectSource) {
  108. case 'aws':
  109. return awsQuery
  110. case 'vercel':
  111. return vercelQuery
  112. default:
  113. return undefined
  114. }
  115. })()
  116. return (
  117. <div className={cn('h-full w-full flex flex-col overflow-hidden')}>
  118. {pageTitle && (
  119. <Head>
  120. <title>{pageTitle}</title>
  121. <meta name="description" content="Briven Studio" />
  122. </Head>
  123. )}
  124. {selectedOrganization && bannerConfig && !isBannerDismissed && (
  125. <Alert
  126. variant="default"
  127. className="flex items-center gap-4 border-t-0 border-x-0 rounded-none"
  128. >
  129. <PartnerIcon organization={selectedOrganization} showTooltip={false} size="medium" />
  130. <div className="flex-1">
  131. <AlertTitle>{bannerConfig.title}</AlertTitle>
  132. <AlertDescription>{bannerConfig.description}</AlertDescription>
  133. </div>
  134. <div className="flex items-center gap-2">
  135. {selectedRedirectQuery?.data?.url && (
  136. <Button asChild type="default" iconRight={<ExternalLink />}>
  137. <a href={selectedRedirectQuery.data.url} target="_blank" rel="noopener noreferrer">
  138. Manage
  139. </a>
  140. </Button>
  141. )}
  142. <ButtonTooltip
  143. type="text"
  144. icon={<XIcon size={14} />}
  145. className="h-7 w-7 p-0"
  146. onClick={() => setIsBannerDismissed(true)}
  147. aria-label="Dismiss banner"
  148. tooltip={{ content: { text: 'Dismiss' } }}
  149. />
  150. </div>
  151. </Alert>
  152. )}
  153. <main className="h-full w-full overflow-y-auto flex flex-col">{children}</main>
  154. </div>
  155. )
  156. }
  157. const OrganizationLayout = ({ children, title }: PropsWithChildren<OrganizationLayoutProps>) => {
  158. useRegisterOrgMenu()
  159. return <OrganizationLayoutContent title={title}>{children}</OrganizationLayoutContent>
  160. }
  161. export default withAuth(OrganizationLayout)