ProjectConnectionPopover.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { Check, ChevronDown, Copy, Database, KeyRound, Link2, Terminal } from 'lucide-react'
  3. import { parseAsBoolean, useQueryState } from 'nuqs'
  4. import { useEffect, useMemo, useRef, useState } from 'react'
  5. import {
  6. Button,
  7. cn,
  8. copyToClipboard,
  9. DropdownMenu,
  10. DropdownMenuContent,
  11. DropdownMenuItem,
  12. DropdownMenuSeparator,
  13. DropdownMenuTrigger,
  14. } from 'ui'
  15. import { ShimmeringLoader } from 'ui-patterns'
  16. import { getConnectionStrings } from '@/components/interfaces/Connect/DatabaseSettings.utils'
  17. import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query'
  18. import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
  19. import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
  20. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  21. import { pluckObjectFields } from '@/lib/helpers'
  22. const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user'] as const
  23. const EMPTY_CONNECTION_INFO = {
  24. db_user: '',
  25. db_host: '',
  26. db_port: '',
  27. db_name: '',
  28. }
  29. interface ProjectConnectionPopoverProps {
  30. projectRef?: string
  31. }
  32. export const ProjectConnectionPopover = ({ projectRef }: ProjectConnectionPopoverProps) => {
  33. const [open, setOpen] = useState(false)
  34. const [copiedItem, setCopiedItem] = useState<string | null>(null)
  35. const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  36. const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
  37. const { isLoading: isLoadingPermissions, can: canReadAPIKeys } = useAsyncCheckPermissions(
  38. PermissionAction.READ,
  39. 'service_api_keys'
  40. )
  41. const { data: projectUrl, isPending: isLoadingApiUrl } = useProjectApiUrl({ projectRef })
  42. const { data: apiKeys, isLoading: isLoadingKeys } = useAPIKeysQuery(
  43. { projectRef },
  44. { enabled: open && canReadAPIKeys }
  45. )
  46. const { publishableKey } = canReadAPIKeys ? getKeys(apiKeys) : { publishableKey: null }
  47. const { data: databases, isLoading: isLoadingDatabases } = useReadReplicasQuery(
  48. { projectRef },
  49. { enabled: open && !!projectRef }
  50. )
  51. const primaryDatabase = databases?.find((db) => db.identifier === projectRef)
  52. const directConnectionString = useMemo(() => {
  53. if (
  54. !primaryDatabase?.db_host ||
  55. !primaryDatabase?.db_name ||
  56. !primaryDatabase?.db_user ||
  57. !primaryDatabase?.db_port
  58. ) {
  59. return ''
  60. }
  61. const connectionInfo = pluckObjectFields(primaryDatabase, [...DB_FIELDS])
  62. return getConnectionStrings({
  63. connectionInfo: { ...EMPTY_CONNECTION_INFO, ...connectionInfo },
  64. metadata: { projectRef },
  65. }).direct.uri
  66. }, [primaryDatabase, projectRef])
  67. const cliCommands = useMemo(
  68. () =>
  69. [
  70. 'briven login',
  71. 'briven init',
  72. `briven link --project-ref ${projectRef ?? 'PROJECT_REF_UNAVAILABLE'}`,
  73. ].join('\n'),
  74. [projectRef]
  75. )
  76. const menuItems = useMemo(
  77. () => [
  78. {
  79. label: 'Project URL',
  80. value: projectUrl ?? '',
  81. displayValue: isLoadingApiUrl
  82. ? 'Loading project URL...'
  83. : (projectUrl ?? 'Project URL unavailable'),
  84. disabled: isLoadingApiUrl || !projectUrl,
  85. icon: Link2,
  86. },
  87. {
  88. label: 'Publishable key',
  89. value: publishableKey?.api_key ?? '',
  90. displayValue:
  91. isLoadingPermissions || isLoadingKeys
  92. ? 'Loading publishable key...'
  93. : canReadAPIKeys
  94. ? (publishableKey?.api_key ?? 'Publishable key unavailable')
  95. : "You don't have permission to view API keys.",
  96. disabled:
  97. isLoadingPermissions || isLoadingKeys || !canReadAPIKeys || !publishableKey?.api_key,
  98. icon: KeyRound,
  99. },
  100. {
  101. label: 'Direct connection string',
  102. value: directConnectionString,
  103. displayValue: isLoadingDatabases
  104. ? 'Loading connection string...'
  105. : directConnectionString || 'Connection string unavailable',
  106. disabled: isLoadingDatabases || !directConnectionString,
  107. icon: Database,
  108. },
  109. {
  110. label: 'CLI setup commands',
  111. value: cliCommands,
  112. displayValue: cliCommands.replace(/\n/g, ' - '),
  113. disabled: !projectRef,
  114. icon: Terminal,
  115. },
  116. ],
  117. [
  118. canReadAPIKeys,
  119. cliCommands,
  120. directConnectionString,
  121. isLoadingApiUrl,
  122. isLoadingDatabases,
  123. isLoadingKeys,
  124. isLoadingPermissions,
  125. projectRef,
  126. projectUrl,
  127. publishableKey?.api_key,
  128. ]
  129. )
  130. useEffect(() => {
  131. return () => {
  132. if (copiedTimeoutRef.current) clearTimeout(copiedTimeoutRef.current)
  133. }
  134. }, [])
  135. return (
  136. <div className="mt-3 inline-flex max-w-full items-center gap-3 min-w-0">
  137. {isLoadingApiUrl ? (
  138. <ShimmeringLoader className="w-32 shrink-0" />
  139. ) : (
  140. <span className="min-w-0 max-w-[320px] truncate text-left text-foreground-light">
  141. {projectUrl ?? 'Project URL unavailable'}
  142. </span>
  143. )}
  144. <DropdownMenu open={open} onOpenChange={setOpen}>
  145. <DropdownMenuTrigger asChild>
  146. <Button
  147. type="default"
  148. size="tiny"
  149. className="shrink-0"
  150. iconRight={
  151. <ChevronDown size={14} className={cn('transition-transform', open && 'rotate-180')} />
  152. }
  153. >
  154. Copy
  155. </Button>
  156. </DropdownMenuTrigger>
  157. <DropdownMenuContent side="bottom" align="end" className="w-80 p-1">
  158. {menuItems.map((item) => {
  159. const Icon = item.icon
  160. return (
  161. <DropdownMenuItem
  162. key={item.label}
  163. className="group relative items-center gap-3 pr-10"
  164. disabled={item.disabled}
  165. onSelect={(event) => {
  166. event.preventDefault()
  167. if (item.disabled) return
  168. copyToClipboard(item.value)
  169. setCopiedItem(item.label)
  170. if (copiedTimeoutRef.current) clearTimeout(copiedTimeoutRef.current)
  171. copiedTimeoutRef.current = setTimeout(() => setCopiedItem(null), 1500)
  172. }}
  173. >
  174. <Icon size={14} className="mt-0.5 shrink-0 text-foreground-light" />
  175. <div className="min-w-0 flex-1">
  176. <div className="text-sm text-foreground">{item.label}</div>
  177. <div className="truncate text-sm text-foreground-lighter">
  178. {item.displayValue}
  179. </div>
  180. </div>
  181. <div
  182. className={cn(
  183. 'absolute right-2 top-1/2 -translate-y-1/2 text-foreground-lighter opacity-0 transition-opacity group-hover:opacity-100',
  184. copiedItem === item.label && 'opacity-100 text-brand'
  185. )}
  186. >
  187. {copiedItem === item.label ? <Check size={14} /> : <Copy size={14} />}
  188. </div>
  189. </DropdownMenuItem>
  190. )
  191. })}
  192. <DropdownMenuSeparator />
  193. <div className="p-1">
  194. <Button
  195. type="default"
  196. size="tiny"
  197. className="w-full"
  198. onClick={() => {
  199. setOpen(false)
  200. setShowConnect(true)
  201. }}
  202. >
  203. Get Connected
  204. </Button>
  205. </div>
  206. </DropdownMenuContent>
  207. </DropdownMenu>
  208. </div>
  209. )
  210. }