From 920571fcf7a798e738db2eebd19ddbab3b7d68c6 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Mon, 11 May 2026 09:11:45 -0600 Subject: [PATCH] feat(studio): rewrite secret key reveal flow without react-query [FE-3206] (#45792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) ## 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) --- .../interfaces/APIKeys/ApiKeyPill.tsx | 102 +++++++----------- .../interfaces/APIKeys/useRevealedSecret.ts | 43 ++++++++ apps/studio/data/api-keys/api-key-id-query.ts | 15 --- 3 files changed, 81 insertions(+), 79 deletions(-) create mode 100644 apps/studio/components/interfaces/APIKeys/useRevealedSecret.ts diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx index 04d1f5a9285..1ea719f27b3 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx @@ -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 }) { - 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 ? ( <> {apiKey?.api_key.slice(0, 15)} - {show && data?.api_key ? data?.api_key.slice(15) : '••••••••••••••••'} + {show && revealedKey ? revealedKey.slice(15) : '••••••••••••••••'} ) : ( @@ -141,7 +115,7 @@ export function ApiKeyPill({ className="rounded-full px-2 pointer-events-auto" loading={show && isLoading} icon={show ? : } - onClick={onSubmitToggle} + onClick={onToggleShow} disabled={isRestricted} /> diff --git a/apps/studio/components/interfaces/APIKeys/useRevealedSecret.ts b/apps/studio/components/interfaces/APIKeys/useRevealedSecret.ts new file mode 100644 index 00000000000..5af3b323928 --- /dev/null +++ b/apps/studio/components/interfaces/APIKeys/useRevealedSecret.ts @@ -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() + 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 } +} diff --git a/apps/studio/data/api-keys/api-key-id-query.ts b/apps/studio/data/api-keys/api-key-id-query.ts index fb7b8b1a525..9149361f401 100644 --- a/apps/studio/data/api-keys/api-key-id-query.ts +++ b/apps/studio/data/api-keys/api-key-id-query.ts @@ -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> - -export const useAPIKeyIdQuery = ( - { projectRef, id, reveal }: APIKeyVariables, - { enabled = true, ...options }: UseCustomQueryOptions = {} -) => - useQuery({ - queryKey: apiKeysKeys.single(projectRef, id), - queryFn: ({ signal }) => getAPIKeysById({ projectRef, id, reveal }, signal), - enabled: enabled && typeof projectRef !== 'undefined' && typeof id !== 'undefined', - ...options, - })