Sidebar.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. import { LOCAL_STORAGE_KEYS, useFlag, useIsMFAEnabled, useParams } from 'common'
  2. import { AnimatePresence, motion, MotionProps } from 'framer-motion'
  3. import { Home } from 'icons'
  4. import { isUndefined } from 'lodash'
  5. import { Blocks, Boxes, ChartArea, PanelLeftDashed, Receipt, Settings, Users } from 'lucide-react'
  6. import Link from 'next/link'
  7. import { useRouter } from 'next/router'
  8. import { ComponentProps, ComponentPropsWithoutRef, FC, ReactNode, useEffect } from 'react'
  9. import {
  10. Button,
  11. cn,
  12. DropdownMenu,
  13. DropdownMenuContent,
  14. DropdownMenuLabel,
  15. DropdownMenuRadioGroup,
  16. DropdownMenuRadioItem,
  17. DropdownMenuSeparator,
  18. DropdownMenuTrigger,
  19. Separator,
  20. SidebarContent as SidebarContentPrimitive,
  21. SidebarFooter,
  22. SidebarGroup,
  23. SidebarMenu,
  24. SidebarMenuButton,
  25. SidebarMenuItem,
  26. Sidebar as SidebarPrimitive,
  27. useSidebar,
  28. } from 'ui'
  29. import { Shortcut } from '../ui/Shortcut'
  30. import { Route } from '../ui/ui.types'
  31. import { useUnifiedLogsPreview } from './App/FeaturePreview/FeaturePreviewContext'
  32. import {
  33. generateOtherRoutes,
  34. generateProductRoutes,
  35. generateSettingsRoutes,
  36. generateToolRoutes,
  37. } from '@/components/layouts/Navigation/NavigationBar/NavigationBar.utils'
  38. import { ProjectIndexPageLink } from '@/data/prefetchers/project.$ref'
  39. import { useHideSidebar } from '@/hooks/misc/useHideSidebar'
  40. import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
  41. import { useLints } from '@/hooks/misc/useLints'
  42. import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
  43. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  44. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  45. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  46. export const ICON_SIZE = 32
  47. export const ICON_STROKE_WIDTH = 1.5
  48. export type SidebarBehaviourType = 'expandable' | 'open' | 'closed'
  49. export const DEFAULT_SIDEBAR_BEHAVIOR = 'expandable'
  50. const SidebarMotion = motion.create(SidebarPrimitive) as FC<
  51. ComponentProps<typeof SidebarPrimitive> & {
  52. transition?: MotionProps['transition']
  53. }
  54. >
  55. export interface SidebarProps extends ComponentPropsWithoutRef<typeof SidebarPrimitive> {}
  56. export const Sidebar = ({ className, ...props }: SidebarProps) => {
  57. const { setOpen } = useSidebar()
  58. const hideSideBar = useHideSidebar()
  59. const [sidebarBehaviour, setSidebarBehaviour] = useLocalStorageQuery(
  60. LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR,
  61. DEFAULT_SIDEBAR_BEHAVIOR
  62. )
  63. useEffect(() => {
  64. // logic to toggle sidebar open based on sidebarBehaviour state
  65. if (sidebarBehaviour === 'open') setOpen(true)
  66. if (sidebarBehaviour === 'closed') setOpen(false)
  67. }, [sidebarBehaviour, setOpen])
  68. return (
  69. <AnimatePresence>
  70. {!hideSideBar && (
  71. <SidebarMotion
  72. {...props}
  73. className={cn('z-50', className)}
  74. transition={{ delay: 0.4, duration: 0.4 }}
  75. overflowing={sidebarBehaviour === 'expandable'}
  76. collapsible="icon"
  77. variant="sidebar"
  78. onMouseEnter={() => {
  79. if (sidebarBehaviour === 'expandable') setOpen(true)
  80. }}
  81. onMouseLeave={() => {
  82. if (sidebarBehaviour === 'expandable') setOpen(false)
  83. }}
  84. >
  85. <SidebarContent
  86. footer={
  87. <DropdownMenu>
  88. <DropdownMenuTrigger asChild>
  89. <Button
  90. type="text"
  91. className={`w-min px-1.5 mx-0.5 ${sidebarBehaviour === 'open' ? 'px-2!' : ''}`}
  92. icon={<PanelLeftDashed size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />}
  93. aria-label="Sidebar control"
  94. />
  95. </DropdownMenuTrigger>
  96. <DropdownMenuContent side="top" align="start" className="w-40">
  97. <DropdownMenuRadioGroup
  98. value={sidebarBehaviour}
  99. onValueChange={(value) => setSidebarBehaviour(value as SidebarBehaviourType)}
  100. >
  101. <DropdownMenuLabel>Sidebar control</DropdownMenuLabel>
  102. <DropdownMenuSeparator />
  103. <DropdownMenuRadioItem value="open">Expanded</DropdownMenuRadioItem>
  104. <DropdownMenuRadioItem value="closed">Collapsed</DropdownMenuRadioItem>
  105. <DropdownMenuRadioItem value="expandable">
  106. Expand on hover
  107. </DropdownMenuRadioItem>
  108. </DropdownMenuRadioGroup>
  109. </DropdownMenuContent>
  110. </DropdownMenu>
  111. }
  112. />
  113. </SidebarMotion>
  114. )}
  115. </AnimatePresence>
  116. )
  117. }
  118. export const SidebarContent = ({ footer }: { footer?: ReactNode }) => {
  119. const { ref: projectRef } = useParams()
  120. return (
  121. <>
  122. <AnimatePresence mode="wait">
  123. <SidebarContentPrimitive>
  124. {projectRef ? (
  125. <motion.div key="project-links">
  126. <ProjectLinks />
  127. </motion.div>
  128. ) : (
  129. <motion.div
  130. key="org-links"
  131. initial={{ opacity: 0, y: -20 }}
  132. animate={{ opacity: 1, y: 0 }}
  133. exit={{ opacity: 0, y: -20 }}
  134. transition={{ duration: 0.2, ease: 'easeOut' }}
  135. >
  136. <OrganizationLinks />
  137. </motion.div>
  138. )}
  139. </SidebarContentPrimitive>
  140. </AnimatePresence>
  141. <SidebarFooter>
  142. <SidebarGroup className="p-0">{footer}</SidebarGroup>
  143. </SidebarFooter>
  144. </>
  145. )
  146. }
  147. export function SideBarNavLink({
  148. route,
  149. active,
  150. onClick,
  151. ...props
  152. }: {
  153. route: Route
  154. active?: boolean
  155. onClick?: () => void
  156. } & ComponentPropsWithoutRef<typeof SidebarMenuButton>) {
  157. const router = useRouter()
  158. const { state: sidebarState } = useSidebar()
  159. const [sidebarBehaviour] = useLocalStorageQuery(
  160. LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR,
  161. DEFAULT_SIDEBAR_BEHAVIOR
  162. )
  163. const isActiveLink = !!(route.link && !route.disabled)
  164. const hasShortcut = !!(route.shortcutId && isActiveLink)
  165. // Collapsed: show immediately (replaces the old label-only tooltip
  166. // that used to surface the name of an icon-only item). Expanded:
  167. // slight delay so the tooltip doesn't flash while skimming the nav.
  168. const shortcutPopoverDelay = sidebarState === 'collapsed' ? 0 : 1000
  169. const buttonProps = {
  170. disabled: route.disabled,
  171. isActive: active,
  172. className: cn('text-sm', sidebarBehaviour === 'open' ? 'px-2!' : ''),
  173. size: 'default' as const,
  174. onClick: onClick,
  175. }
  176. const content = props.children ? (
  177. props.children
  178. ) : (
  179. <>
  180. {route.icon}
  181. <span>{route.label}</span>
  182. </>
  183. )
  184. const button = isActiveLink ? (
  185. <SidebarMenuButton {...buttonProps} asChild>
  186. <Link href={route.link!}>{content}</Link>
  187. </SidebarMenuButton>
  188. ) : (
  189. <SidebarMenuButton {...buttonProps}>{content}</SidebarMenuButton>
  190. )
  191. return (
  192. <SidebarMenuItem>
  193. {hasShortcut ? (
  194. <Shortcut
  195. id={route.shortcutId!}
  196. onTrigger={() => router.push(route.link!)}
  197. side="right"
  198. delayDuration={shortcutPopoverDelay}
  199. >
  200. {button}
  201. </Shortcut>
  202. ) : (
  203. button
  204. )}
  205. </SidebarMenuItem>
  206. )
  207. }
  208. const ActiveDot = ({ hasErrors, hasWarnings }: { hasErrors: boolean; hasWarnings: boolean }) => {
  209. return (
  210. <div
  211. className={cn(
  212. 'absolute pointer-events-none flex h-2 w-2 left-[18px] group-data-[state=expanded]:left-[20px] top-2 z-10 rounded-full',
  213. hasErrors ? 'bg-destructive-600' : hasWarnings ? 'bg-warning-600' : 'bg-transparent'
  214. )}
  215. />
  216. )
  217. }
  218. const ProjectLinks = () => {
  219. const router = useRouter()
  220. const { ref } = useParams()
  221. const { data: project } = useSelectedProjectQuery()
  222. const { securityLints, errorLints } = useLints()
  223. const showReports = useIsFeatureEnabled('reports:all')
  224. const showLogs = useIsFeatureEnabled('logs:all')
  225. const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
  226. const activeRoute = router.pathname.split('/')[3]
  227. const {
  228. projectAuthAll: authEnabled,
  229. projectEdgeFunctionAll: edgeFunctionsEnabled,
  230. projectStorageAll: storageEnabled,
  231. realtimeAll: realtimeEnabled,
  232. } = useIsFeatureEnabled([
  233. 'project_auth:all',
  234. 'project_edge_function:all',
  235. 'project_storage:all',
  236. 'realtime:all',
  237. ])
  238. const authOverviewPageEnabled = useFlag('authOverviewPage')
  239. const toolRoutes = generateToolRoutes(ref, project)
  240. const productRoutes = generateProductRoutes(ref, project, {
  241. auth: authEnabled,
  242. edgeFunctions: edgeFunctionsEnabled,
  243. storage: storageEnabled,
  244. realtime: realtimeEnabled,
  245. authOverviewPage: authOverviewPageEnabled,
  246. })
  247. const otherRoutes = generateOtherRoutes(ref, project, {
  248. unifiedLogs: isUnifiedLogsEnabled,
  249. showReports,
  250. showLogs,
  251. })
  252. const settingsRoutes = generateSettingsRoutes(ref)
  253. return (
  254. <SidebarMenu>
  255. <SidebarGroup className="gap-0.5">
  256. <SideBarNavLink
  257. key="home"
  258. active={isUndefined(activeRoute) && !isUndefined(router.query.ref)}
  259. route={{
  260. key: 'HOME',
  261. label: 'Project Overview',
  262. icon: <Home size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
  263. link: `/project/${ref}`,
  264. linkElement: <ProjectIndexPageLink projectRef={ref} />,
  265. shortcutId: SHORTCUT_IDS.NAV_HOME,
  266. }}
  267. />
  268. {toolRoutes.map((route, i) => (
  269. <SideBarNavLink
  270. key={`tools-routes-${i}`}
  271. route={route}
  272. active={activeRoute === route.key}
  273. />
  274. ))}
  275. </SidebarGroup>
  276. <Separator className="w-[calc(100%-1rem)] mx-auto" />
  277. <SidebarGroup className="gap-0.5">
  278. {productRoutes.map((route, i) => (
  279. <SideBarNavLink
  280. key={`product-routes-${i}`}
  281. route={route}
  282. active={activeRoute === route.key}
  283. />
  284. ))}
  285. </SidebarGroup>
  286. <Separator className="w-[calc(100%-1rem)] mx-auto" />
  287. <SidebarGroup className="gap-0.5">
  288. {otherRoutes.map((route) => {
  289. if (route.key === 'advisors') {
  290. return (
  291. <div className="relative" key={route.key}>
  292. {!route.disabled && (
  293. <ActiveDot
  294. hasErrors={errorLints.length > 0}
  295. hasWarnings={securityLints.length > 0}
  296. />
  297. )}
  298. <SideBarNavLink key={route.key} route={route} active={activeRoute === route.key} />
  299. </div>
  300. )
  301. } else {
  302. return (
  303. <SideBarNavLink key={route.key} route={route} active={activeRoute === route.key} />
  304. )
  305. }
  306. })}
  307. </SidebarGroup>
  308. <Separator className="w-[calc(100%-1rem)] mx-auto" />
  309. {/* Settings routes to be added in with project/org nav */}
  310. <SidebarGroup className="gap-0.5">
  311. {settingsRoutes.map((route, i) => (
  312. <SideBarNavLink
  313. key={`settings-routes-${i}`}
  314. route={route}
  315. active={activeRoute === route.key}
  316. />
  317. ))}
  318. </SidebarGroup>
  319. </SidebarMenu>
  320. )
  321. }
  322. const OrganizationLinks = () => {
  323. const router = useRouter()
  324. const { slug } = useParams()
  325. const organizationSlug: string = slug ?? (router.query.orgSlug as string) ?? ''
  326. const { data: org } = useSelectedOrganizationQuery()
  327. const isUserMFAEnabled = useIsMFAEnabled()
  328. const disableAccessMfa = org?.organization_requires_mfa && !isUserMFAEnabled
  329. const showBilling = useIsFeatureEnabled('billing:all')
  330. const activeRoute = router.pathname.split('/')[3]
  331. const organizationSettingsRoutes = new Set([
  332. 'general',
  333. 'security',
  334. 'sso',
  335. 'apps',
  336. 'audit',
  337. 'documents',
  338. ])
  339. const navMenuItems = [
  340. {
  341. label: 'Projects',
  342. href: `/org/${organizationSlug}`,
  343. key: 'projects',
  344. icon: <Boxes size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
  345. shortcutId: SHORTCUT_IDS.NAV_ORG_PROJECTS,
  346. },
  347. {
  348. label: 'Team',
  349. href: `/org/${organizationSlug}/team`,
  350. key: 'team',
  351. icon: <Users size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
  352. shortcutId: SHORTCUT_IDS.NAV_ORG_TEAM,
  353. },
  354. {
  355. label: 'Integrations',
  356. href: `/org/${organizationSlug}/integrations`,
  357. key: 'integrations',
  358. icon: <Blocks size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
  359. shortcutId: SHORTCUT_IDS.NAV_ORG_INTEGRATIONS,
  360. },
  361. {
  362. label: 'Usage',
  363. href: `/org/${organizationSlug}/usage`,
  364. key: 'usage',
  365. icon: <ChartArea size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
  366. shortcutId: SHORTCUT_IDS.NAV_ORG_USAGE,
  367. },
  368. ...(showBilling
  369. ? [
  370. {
  371. label: 'Billing',
  372. href: `/org/${organizationSlug}/billing`,
  373. key: 'billing',
  374. icon: <Receipt size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
  375. shortcutId: SHORTCUT_IDS.NAV_ORG_BILLING,
  376. },
  377. ]
  378. : []),
  379. {
  380. label: 'Organization Settings',
  381. href: `/org/${organizationSlug}/general`,
  382. key: 'settings',
  383. icon: <Settings size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
  384. shortcutId: SHORTCUT_IDS.NAV_ORG_SETTINGS,
  385. },
  386. ]
  387. if (!organizationSlug) return null
  388. return (
  389. <SidebarMenu className="flex flex-col gap-1 items-start">
  390. <SidebarGroup className="gap-0.5">
  391. {navMenuItems.map((item, i) => (
  392. <SideBarNavLink
  393. key={item.key}
  394. active={
  395. i === 0
  396. ? activeRoute === undefined
  397. : item.key === 'settings'
  398. ? organizationSettingsRoutes.has(activeRoute ?? '')
  399. : activeRoute === item.key
  400. }
  401. route={{
  402. label: item.label,
  403. link: item.href,
  404. key: item.label,
  405. icon: item.icon,
  406. disabled: disableAccessMfa,
  407. shortcutId: item.shortcutId,
  408. }}
  409. />
  410. ))}
  411. </SidebarGroup>
  412. </SidebarMenu>
  413. )
  414. }