ApiKeyPill.tsx154 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { InputVariants } from '@ui/components/shadcn/ui/input'
3import { useParams } from 'common'
4import { Eye, EyeOff } from 'lucide-react'
5import { useEffect, useState } from 'react'
6import { toast } from 'sonner'
7import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
8
9import { useRevealedSecret } from './useRevealedSecret'
10import CopyButton from '@/components/ui/CopyButton'
11import { APIKeysData } from '@/data/api-keys/api-keys-query'
12import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
13
14export function ApiKeyPill({
15 apiKey,
16}: {
17 apiKey: Extract<APIKeysData[number], { type: 'secret' | 'publishable' }>
18}) {
19 const { ref: projectRef } = useParams()
20 const [show, setShow] = useState(false)
21
22 const isSecret = apiKey.type === 'secret'
23
24 const { can: canManageSecretKeys, isLoading: isLoadingPermission } = useAsyncCheckPermissions(
25 PermissionAction.READ,
26 'service_api_keys'
27 )
28
29 const {
30 data: revealedKey,
31 isLoading,
32 reveal,
33 clear,
34 } = useRevealedSecret({
35 projectRef,
36 id: apiKey.id as string,
37 })
38
39 // Auto-hide timer for the API key (security feature)
40 useEffect(() => {
41 if (show && revealedKey) {
42 const timer = setTimeout(() => {
43 setShow(false)
44 clear()
45 }, 10000)
46
47 return () => clearTimeout(timer)
48 }
49 }, [show, revealedKey, clear])
50
51 async function onToggleShow() {
52 if (isSecret && !canManageSecretKeys) return
53 if (isLoadingPermission) return
54
55 if (show) {
56 setShow(false)
57 clear()
58 } else {
59 setShow(true)
60 try {
61 await reveal()
62 } catch {
63 toast.error('Failed to reveal secret API key')
64 setShow(false)
65 }
66 }
67 }
68
69 async function onCopy() {
70 if (!isSecret) return apiKey.api_key
71 if (revealedKey) return revealedKey
72
73 try {
74 const key = await reveal()
75 clear()
76 return key ?? ''
77 } catch {
78 toast.error('Failed to copy secret API key')
79 return ''
80 }
81 }
82
83 const isRestricted = isSecret && !canManageSecretKeys
84
85 return (
86 <>
87 <div
88 className={cn(
89 InputVariants({ size: 'tiny' }),
90 'w-[100px] sm:w-[140px] md:w-[180px] lg:w-[340px] gap-0 font-mono rounded-full',
91 isSecret ? 'overflow-hidden' : '',
92 show ? 'ring-1 ring-foreground-lighter/50' : 'ring-0 ring-foreground-lighter/0',
93 'transition-all cursor-text relative'
94 )}
95 style={{ userSelect: 'all' }}
96 >
97 {isSecret ? (
98 <>
99 <span>{apiKey?.api_key.slice(0, 15)}</span>
100 <span>{show && revealedKey ? revealedKey.slice(15) : '••••••••••••••••'}</span>
101 </>
102 ) : (
103 <span title={apiKey.api_key} className="truncate">
104 {apiKey.api_key}
105 </span>
106 )}
107 </div>
108
109 {/* Toggle button */}
110 {isSecret && (
111 <Tooltip>
112 <TooltipTrigger asChild>
113 <Button
114 type="outline"
115 className="rounded-full px-2 pointer-events-auto"
116 loading={show && isLoading}
117 icon={show ? <EyeOff strokeWidth={2} /> : <Eye strokeWidth={2} />}
118 onClick={onToggleShow}
119 disabled={isRestricted}
120 />
121 </TooltipTrigger>
122 <TooltipContent side="bottom">
123 {isRestricted
124 ? 'You need additional permissions to reveal secret API keys'
125 : isLoadingPermission
126 ? 'Loading permissions...'
127 : show
128 ? 'Hide API key'
129 : 'Reveal API key'}
130 </TooltipContent>
131 </Tooltip>
132 )}
133
134 <Tooltip>
135 <TooltipTrigger asChild>
136 <CopyButton
137 type="default"
138 asyncText={onCopy}
139 iconOnly
140 className="rounded-full px-2 pointer-events-auto"
141 disabled={isRestricted || isLoadingPermission}
142 />
143 </TooltipTrigger>
144 <TooltipContent side="bottom">
145 {isRestricted
146 ? 'You need additional permissions to copy secret API keys'
147 : isLoadingPermission
148 ? 'Loading permissions...'
149 : 'Copy API key'}
150 </TooltipContent>
151 </Tooltip>
152 </>
153 )
154}