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