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