GraphiQLTab.tsx174 lines · main
1import 'graphiql/style.css'
2import 'graphiql/setup-workers/webpack'
3
4import { useMonaco, type GraphiQLPlugin } from '@graphiql/react'
5import { createGraphiQLFetcher, Fetcher } from '@graphiql/toolkit'
6import { PermissionAction } from '@supabase/shared-types/out/constants'
7import { useParams } from 'common'
8import { GraphiQL, HISTORY_PLUGIN } from 'graphiql'
9import { User as IconUser } from 'lucide-react'
10import { useTheme } from 'next-themes'
11import { useCallback, useEffect, useMemo, useState } from 'react'
12import { toast } from 'sonner'
13import { LogoLoader } from 'ui'
14
15import { DEFAULT_INTROSPECTION_SCHEMA } from './constants'
16import styles from './graphiql.module.css'
17import { IntrospectionDisabledNotice } from './IntrospectionDisabledNotice'
18import { IntrospectionEnabledNotice } from './IntrospectionEnabledNotice'
19import { usePgGraphqlIntrospectionStatus } from './usePgGraphqlIntrospectionStatus'
20import { getTheme } from '@/components/interfaces/App/MonacoThemeProvider'
21import { RoleImpersonationSelector } from '@/components/interfaces/RoleImpersonationSelector'
22import { useSessionAccessTokenQuery } from '@/data/auth/session-access-token-query'
23import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
24import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
25import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
26import { API_URL, IS_PLATFORM } from '@/lib/constants'
27import { getRoleImpersonationJWT } from '@/lib/role-impersonation'
28import { useGetImpersonatedRoleState } from '@/state/role-impersonation-state'
29
30const ROLE_IMPERSONATION_PLUGIN: GraphiQLPlugin = {
31 title: 'Role Impersonation',
32 icon: () => <IconUser />,
33 content: () => <RoleImpersonationSelector orientation="vertical" />,
34}
35
36const MONACO_THEME = { dark: 'briven-graphql-dark', light: 'briven-graphql-light' }
37
38const 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
62export 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}