mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
feat(studio): rewrite secret key reveal flow without react-query [FE-3206] (#45792)
## Summary
Rewrites the secret API key reveal flow in `ApiKeyPill` to remove its
dependency on React Query, replacing it with a lightweight custom hook.
## Changes
- **`useRevealedSecret` (new hook)**
A simple, reusable hook that:
- Fetches the unmasked secret key via `getAPIKeysById`
- Exposes `data`, `isLoading`, `reveal()`, and `clear()`
- Keeps sensitive data in local component state (no global cache)
- **`ApiKeyPill` (refactored)**
- Removes all React Query imports (`useQueryClient`, `useAPIKeyIdQuery`,
`apiKeysKeys`)
- Uses `useRevealedSecret` for reveal / copy operations
- Preserves existing UX:
- 10-second auto-hide timer
- Permission-based gating (`canManageSecretKeys`)
- Loading states on toggle / copy
- **`api-key-id-query.ts` (cleaned up)**
- Removes the now-unused `useAPIKeyIdQuery` hook
- Retains the `getAPIKeysById` fetcher for direct use
## Motivation
The previous React Query–based flow had to aggressively disable caching
(`staleTime: 0`, `gcTime: 0`) and manually purge queries from the cache
on every interaction, which was cumbersome and leaked implementation
details into the component. A plain fetch + local state is simpler and
safer for transient, sensitive data.
## Testing
- [x] Toggle reveal on a secret API key
- [x] Verify 10-second auto-hide
- [x] Copy a secret key (both revealed and unrevealed states)
- [x] Verify restricted users cannot reveal/copy
---
Resolves [FE-3206](https://linear.app/supabase/issue/FE-3206)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Refactor**
* Improved API key reveal/copy flow: uses a dedicated reveal/clear
mechanism, preserves permission checks and 10s auto-hide, and shows
reveal/copy failures via user-facing toasts. Copy now falls back to
masked key when needed and the reveal toggle behavior is more reliable.
[](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45792)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
f95d978cbc
commit
920571fcf7
3 files changed
+81
-79
No files matched your search
@@ -1,5 +1,4 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { InputVariants } from '@ui/components/shadcn/ui/input'
|
||||
import { useParams } from 'common'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
@@ -7,10 +6,9 @@ import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { useRevealedSecret } from './useRevealedSecret'
|
||||
import CopyButton from '@/components/ui/CopyButton'
|
||||
import { useAPIKeyIdQuery } from '@/data/api-keys/api-key-id-query'
|
||||
import { APIKeysData } from '@/data/api-keys/api-keys-query'
|
||||
import { apiKeysKeys } from '@/data/api-keys/keys'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
|
||||
export function ApiKeyPill({
|
||||
@@ -18,94 +16,70 @@ export function ApiKeyPill({
|
||||
}: {
|
||||
apiKey: Extract<APIKeysData[number], { type: 'secret' | 'publishable' }>
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
// State that controls whether to show the full API key
|
||||
const [show, setShowState] = useState(false)
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
const isSecret = apiKey.type === 'secret'
|
||||
|
||||
// Permission check for revealing/copying secret API keys
|
||||
const { can: canManageSecretKeys, isLoading: isLoadingPermission } = useAsyncCheckPermissions(
|
||||
PermissionAction.READ,
|
||||
'service_api_keys'
|
||||
)
|
||||
|
||||
// This query only runs when show=true (enabled: show)
|
||||
// It fetches the fully revealed API key when needed
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isPending: isLoading,
|
||||
refetch: refetchApiKey,
|
||||
} = useAPIKeyIdQuery(
|
||||
{
|
||||
projectRef,
|
||||
id: apiKey.id as string,
|
||||
reveal: true, // Request the unmasked key
|
||||
},
|
||||
{
|
||||
enabled: show, // Only run query when show is true
|
||||
staleTime: 0, // Always consider data stale
|
||||
gcTime: 0, // Don't cache the key data
|
||||
}
|
||||
)
|
||||
data: revealedKey,
|
||||
isLoading,
|
||||
reveal,
|
||||
clear,
|
||||
} = useRevealedSecret({
|
||||
projectRef,
|
||||
id: apiKey.id as string,
|
||||
})
|
||||
|
||||
// Auto-hide timer for the API key (security feature)
|
||||
useEffect(() => {
|
||||
if (show && data?.api_key) {
|
||||
// Auto-hide the key after 10 seconds
|
||||
if (show && revealedKey) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowState(false)
|
||||
// Clear the cached key from memory
|
||||
queryClient.removeQueries({
|
||||
queryKey: apiKeysKeys.single(projectRef, apiKey.id as string),
|
||||
exact: true,
|
||||
})
|
||||
}, 10000) // Hide after 10 seconds
|
||||
setShow(false)
|
||||
clear()
|
||||
}, 10000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [show, data?.api_key, projectRef, queryClient, apiKey.id])
|
||||
}, [show, revealedKey, clear])
|
||||
|
||||
async function onSubmitToggle() {
|
||||
// Don't reveal key if not allowed or loading
|
||||
async function onToggleShow() {
|
||||
if (isSecret && !canManageSecretKeys) return
|
||||
if (isLoadingPermission) return
|
||||
|
||||
// Toggle the show state
|
||||
setShowState(!show)
|
||||
if (show) {
|
||||
setShow(false)
|
||||
clear()
|
||||
} else {
|
||||
setShow(true)
|
||||
try {
|
||||
await reveal()
|
||||
} catch {
|
||||
toast.error('Failed to reveal secret API key')
|
||||
setShow(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopy() {
|
||||
// If key is already revealed, use that value
|
||||
if (data?.api_key) return data?.api_key ?? ''
|
||||
if (!isSecret) return apiKey.api_key
|
||||
if (revealedKey) return revealedKey
|
||||
|
||||
try {
|
||||
// Fetch full key and immediately clear from cache after copying
|
||||
const result = await refetchApiKey()
|
||||
queryClient.removeQueries({
|
||||
queryKey: apiKeysKeys.single(projectRef, apiKey.id as string),
|
||||
exact: true,
|
||||
})
|
||||
|
||||
if (result.isSuccess) return result.data.api_key ?? ''
|
||||
|
||||
if (error) {
|
||||
toast.error('Failed to copy secret API key')
|
||||
return ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch API key:', error)
|
||||
return ''
|
||||
const key = await reveal()
|
||||
clear()
|
||||
return key ?? ''
|
||||
} catch {
|
||||
toast.error('Failed to copy secret API key')
|
||||
return apiKey.api_key
|
||||
}
|
||||
|
||||
// Fallback to the masked version if fetch fails
|
||||
return apiKey.api_key
|
||||
}
|
||||
|
||||
// States for disabling buttons/showing tooltips
|
||||
const isRestricted = isSecret && !canManageSecretKeys
|
||||
|
||||
return (
|
||||
@@ -123,7 +97,7 @@ export function ApiKeyPill({
|
||||
{isSecret ? (
|
||||
<>
|
||||
<span>{apiKey?.api_key.slice(0, 15)}</span>
|
||||
<span>{show && data?.api_key ? data?.api_key.slice(15) : '••••••••••••••••'}</span>
|
||||
<span>{show && revealedKey ? revealedKey.slice(15) : '••••••••••••••••'}</span>
|
||||
</>
|
||||
) : (
|
||||
<span title={apiKey.api_key} className="truncate">
|
||||
@@ -141,7 +115,7 @@ export function ApiKeyPill({
|
||||
className="rounded-full px-2 pointer-events-auto"
|
||||
loading={show && isLoading}
|
||||
icon={show ? <EyeOff strokeWidth={2} /> : <Eye strokeWidth={2} />}
|
||||
onClick={onSubmitToggle}
|
||||
onClick={onToggleShow}
|
||||
disabled={isRestricted}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { getAPIKeysById } from '@/data/api-keys/api-key-id-query'
|
||||
|
||||
interface UseRevealedSecretOptions {
|
||||
projectRef?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function useRevealedSecret({ projectRef, id }: UseRevealedSecretOptions) {
|
||||
const [data, setData] = useState<string | undefined | null>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
const reveal = useCallback(async () => {
|
||||
if (!projectRef || !id) return
|
||||
|
||||
const requestId = ++requestIdRef.current
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const result = await getAPIKeysById({ projectRef, id, reveal: true })
|
||||
if (requestId !== requestIdRef.current) return
|
||||
setData(result.api_key)
|
||||
return result.api_key
|
||||
} catch (error) {
|
||||
if (requestId !== requestIdRef.current) return
|
||||
console.error('Failed to reveal secret key:', error)
|
||||
throw error
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [projectRef, id])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
requestIdRef.current++
|
||||
setData(undefined)
|
||||
}, [])
|
||||
|
||||
return { data, isLoading, reveal, clear }
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { apiKeysKeys } from './keys'
|
||||
import { get, handleError } from '@/data/fetchers'
|
||||
import type { ResponseError, UseCustomQueryOptions } from '@/types'
|
||||
|
||||
export interface APIKeyVariables {
|
||||
projectRef?: string
|
||||
@@ -33,14 +29,3 @@ export async function getAPIKeysById(
|
||||
}
|
||||
|
||||
export type APIKeyIdData = Awaited<ReturnType<typeof getAPIKeysById>>
|
||||
|
||||
export const useAPIKeyIdQuery = <TData = APIKeyIdData>(
|
||||
{ projectRef, id, reveal }: APIKeyVariables,
|
||||
{ enabled = true, ...options }: UseCustomQueryOptions<APIKeyIdData, ResponseError, TData> = {}
|
||||
) =>
|
||||
useQuery<APIKeyIdData, ResponseError, TData>({
|
||||
queryKey: apiKeysKeys.single(projectRef, id),
|
||||
queryFn: ({ signal }) => getAPIKeysById({ projectRef, id, reveal }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined' && typeof id !== 'undefined',
|
||||
...options,
|
||||
})
|
||||
Reference in new issue
Block a user