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