GraphiQLTab.tsx174 lines · main
| 1 | import 'graphiql/style.css' |
| 2 | import 'graphiql/setup-workers/webpack' |
| 3 | |
| 4 | import { useMonaco, type GraphiQLPlugin } from '@graphiql/react' |
| 5 | import { createGraphiQLFetcher, Fetcher } from '@graphiql/toolkit' |
| 6 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 7 | import { useParams } from 'common' |
| 8 | import { GraphiQL, HISTORY_PLUGIN } from 'graphiql' |
| 9 | import { User as IconUser } from 'lucide-react' |
| 10 | import { useTheme } from 'next-themes' |
| 11 | import { useCallback, useEffect, useMemo, useState } from 'react' |
| 12 | import { toast } from 'sonner' |
| 13 | import { LogoLoader } from 'ui' |
| 14 | |
| 15 | import { DEFAULT_INTROSPECTION_SCHEMA } from './constants' |
| 16 | import styles from './graphiql.module.css' |
| 17 | import { IntrospectionDisabledNotice } from './IntrospectionDisabledNotice' |
| 18 | import { IntrospectionEnabledNotice } from './IntrospectionEnabledNotice' |
| 19 | import { usePgGraphqlIntrospectionStatus } from './usePgGraphqlIntrospectionStatus' |
| 20 | import { getTheme } from '@/components/interfaces/App/MonacoThemeProvider' |
| 21 | import { RoleImpersonationSelector } from '@/components/interfaces/RoleImpersonationSelector' |
| 22 | import { useSessionAccessTokenQuery } from '@/data/auth/session-access-token-query' |
| 23 | import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' |
| 24 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 25 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 26 | import { API_URL, IS_PLATFORM } from '@/lib/constants' |
| 27 | import { getRoleImpersonationJWT } from '@/lib/role-impersonation' |
| 28 | import { useGetImpersonatedRoleState } from '@/state/role-impersonation-state' |
| 29 | |
| 30 | const ROLE_IMPERSONATION_PLUGIN: GraphiQLPlugin = { |
| 31 | title: 'Role Impersonation', |
| 32 | icon: () => <IconUser />, |
| 33 | content: () => <RoleImpersonationSelector orientation="vertical" />, |
| 34 | } |
| 35 | |
| 36 | const MONACO_THEME = { dark: 'briven-graphql-dark', light: 'briven-graphql-light' } |
| 37 | |
| 38 | const GraphiQLMonacoTheme = ({ resolvedTheme }: { resolvedTheme: 'dark' | 'light' }) => { |
| 39 | const { monaco } = useMonaco() |
| 40 | |
| 41 | useEffect(() => { |
| 42 | if (!monaco) return |
| 43 | const dark = getTheme('dark') |
| 44 | const light = getTheme('light') |
| 45 | monaco.editor.defineTheme(MONACO_THEME.dark, { |
| 46 | ...dark, |
| 47 | rules: [...dark.rules, { token: 'argument.identifier.gql', foreground: '908aff' }], |
| 48 | }) |
| 49 | monaco.editor.defineTheme(MONACO_THEME.light, { |
| 50 | ...light, |
| 51 | rules: [...light.rules, { token: 'argument.identifier.gql', foreground: '6c69ce' }], |
| 52 | // Match the dashboard's bg-default in light mode so the editor doesn't read |
| 53 | // as a darker square against the surrounding UI. |
| 54 | colors: { ...light.colors, 'editor.background': '#fcfcfc' }, |
| 55 | }) |
| 56 | monaco.editor.setTheme(MONACO_THEME[resolvedTheme]) |
| 57 | }, [monaco, resolvedTheme]) |
| 58 | |
| 59 | return null |
| 60 | } |
| 61 | |
| 62 | export const GraphiQLTab = () => { |
| 63 | const { resolvedTheme } = useTheme() |
| 64 | const { ref: projectRef } = useParams() |
| 65 | const currentTheme = resolvedTheme?.includes('dark') ? 'dark' : 'light' |
| 66 | const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) |
| 67 | const { data: project } = useSelectedProjectQuery() |
| 68 | |
| 69 | const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) |
| 70 | const jwtSecret = config?.jwt_secret |
| 71 | |
| 72 | const getImpersonatedRoleState = useGetImpersonatedRoleState() |
| 73 | |
| 74 | const { can: canReadJWTSecret } = useAsyncCheckPermissions( |
| 75 | PermissionAction.READ, |
| 76 | 'field.jwt_secret' |
| 77 | ) |
| 78 | |
| 79 | const { notice, schemaComment } = usePgGraphqlIntrospectionStatus({ |
| 80 | projectRef, |
| 81 | connectionString: project?.connectionString, |
| 82 | schema: DEFAULT_INTROSPECTION_SCHEMA, |
| 83 | }) |
| 84 | |
| 85 | // Bumped to force GraphiQL to re-mount and re-run introspection after the |
| 86 | // introspection setting changes in either direction. |
| 87 | const [graphiqlKey, setGraphiqlKey] = useState(0) |
| 88 | |
| 89 | const plugins = useMemo<GraphiQLPlugin[]>( |
| 90 | () => (canReadJWTSecret ? [HISTORY_PLUGIN, ROLE_IMPERSONATION_PLUGIN] : [HISTORY_PLUGIN]), |
| 91 | [canReadJWTSecret] |
| 92 | ) |
| 93 | |
| 94 | const fetcher = useMemo(() => { |
| 95 | const fetcherFn = createGraphiQLFetcher({ |
| 96 | // [Joshen] Opting to hard code /platform for local to match the routes, so that it's clear what's happening |
| 97 | url: `${API_URL}${IS_PLATFORM ? '' : '/platform'}/projects/${projectRef}/api/graphql`, |
| 98 | fetch, |
| 99 | }) |
| 100 | const customFetcher: Fetcher = async (graphqlParams, opts) => { |
| 101 | let userAuthorization: string | undefined |
| 102 | |
| 103 | const role = getImpersonatedRoleState().role |
| 104 | if ( |
| 105 | projectRef !== undefined && |
| 106 | jwtSecret !== undefined && |
| 107 | role !== undefined && |
| 108 | role.type === 'postgrest' |
| 109 | ) { |
| 110 | try { |
| 111 | const token = await getRoleImpersonationJWT(projectRef, jwtSecret, role) |
| 112 | userAuthorization = 'Bearer ' + token |
| 113 | } catch (err: any) { |
| 114 | toast.error(`Failed to get JWT for role: ${err.message}`) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | return fetcherFn(graphqlParams, { |
| 119 | ...opts, |
| 120 | headers: { |
| 121 | ...opts?.headers, |
| 122 | ...(accessToken && { |
| 123 | Authorization: `Bearer ${accessToken}`, |
| 124 | }), |
| 125 | 'x-graphql-authorization': |
| 126 | opts?.headers?.['Authorization'] ?? |
| 127 | opts?.headers?.['authorization'] ?? |
| 128 | userAuthorization ?? |
| 129 | accessToken, |
| 130 | }, |
| 131 | }) |
| 132 | } |
| 133 | |
| 134 | return customFetcher |
| 135 | }, [projectRef, getImpersonatedRoleState, jwtSecret, accessToken]) |
| 136 | |
| 137 | const handleIntrospectionChanged = useCallback(() => { |
| 138 | setGraphiqlKey((k) => k + 1) |
| 139 | }, []) |
| 140 | |
| 141 | if (IS_PLATFORM && !accessToken) { |
| 142 | return <LogoLoader /> |
| 143 | } |
| 144 | |
| 145 | return ( |
| 146 | <div className="flex flex-col h-full"> |
| 147 | <GraphiQLMonacoTheme resolvedTheme={currentTheme} /> |
| 148 | {notice === 'opt-in' && ( |
| 149 | <IntrospectionDisabledNotice |
| 150 | schema={DEFAULT_INTROSPECTION_SCHEMA} |
| 151 | currentSchemaComment={schemaComment} |
| 152 | onEnabled={handleIntrospectionChanged} |
| 153 | /> |
| 154 | )} |
| 155 | {notice === 'opt-out' && ( |
| 156 | <IntrospectionEnabledNotice |
| 157 | schema={DEFAULT_INTROSPECTION_SCHEMA} |
| 158 | currentSchemaComment={schemaComment} |
| 159 | onDisabled={handleIntrospectionChanged} |
| 160 | /> |
| 161 | )} |
| 162 | <div className="flex-1 min-h-0"> |
| 163 | <GraphiQL |
| 164 | key={graphiqlKey} |
| 165 | fetcher={fetcher} |
| 166 | forcedTheme={currentTheme} |
| 167 | editorTheme={MONACO_THEME} |
| 168 | className={styles.root} |
| 169 | plugins={plugins} |
| 170 | /> |
| 171 | </div> |
| 172 | </div> |
| 173 | ) |
| 174 | } |