ProjectConnectionPopover.tsx226 lines · main
| 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 | |
| 17 | import { getConnectionStrings } from '@/components/interfaces/Connect/DatabaseSettings.utils' |
| 18 | import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query' |
| 19 | import { useProjectApiUrl } from '@/data/config/project-endpoint-query' |
| 20 | import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' |
| 21 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 22 | import { pluckObjectFields } from '@/lib/helpers' |
| 23 | |
| 24 | const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user'] as const |
| 25 | const EMPTY_CONNECTION_INFO = { |
| 26 | db_user: '', |
| 27 | db_host: '', |
| 28 | db_port: '', |
| 29 | db_name: '', |
| 30 | } |
| 31 | |
| 32 | interface ProjectConnectionPopoverProps { |
| 33 | projectRef?: string |
| 34 | } |
| 35 | |
| 36 | export 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 | } |