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.

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](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:
Ali Waseem authored and GitHub committed 2026-05-11 09:11:45 -06:00
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,
})