Files
supabase/apps/studio/components/interfaces/RoleImpersonationSelector/index.tsx
Alaister Young f8cc6c21bd [FE-2075] feat(studio): bump graphiql to v5 and use prebuilt component (#45404)
Adds `graphiql@5.2.2` and switches from our heavily-customised rebuild
(which used `@graphiql/react` + `@graphiql/toolkit` directly) to the
prebuilt component, restyled to match the dashboard. Role impersonation
re-added as a sidebar plugin.

This is a deliberately simpler setup than what we had – we lose some
layout customisation (sidebar is forced to the left, role impersonation
moves into the sidebar) but future upgrades become much easier since
we're no longer maintaining a fork-by-rewrite.

**Removed:**
- `apps/studio/components/interfaces/GraphQL/GraphiQL.tsx` – custom
rebuild
- `apps/studio/components/interfaces/GraphQL/graphiql.module.css` –
custom styles

**Changed:**
- Added `graphiql` ^5.2.2 (we previously didn't have the top-level
package, just the subpackages)
- `@graphiql/react` ^0.19.4 → ^0.37.3 (now Monaco-based; v0.19 was still
on CodeMirror 5)
- `@graphiql/toolkit` ^0.9.1 → ^0.11.3
- `GraphiQLTab.tsx` now wires up the prebuilt `<GraphiQL />` with worker
setup, theme bridge, and plugins
- New `graphiql.module.css` scopes restyling via `:global(...)` since we
can't add hashed classes to the library's DOM
- `RoleImpersonationSelector` gained an `orientation: 'horizontal' |
'vertical'` prop (default `horizontal`) so it fits in the sidebar pane –
all existing call sites unchanged
- `MonacoThemeProvider` exports `getTheme` so the GraphQL Monaco
instance can reuse Studio's theme

**Added:**
- Theme bridge: `supabase-graphql-dark` / `supabase-graphql-light`
Monaco themes synced with `next-themes` via `forcedTheme`
- Role impersonation sidebar plugin (gated on `field.jwt_secret` read
permission, same as before)

### Notes / tradeoffs

- We don't share Studio's monaco instance – Studio loads it via AMD/CDN,
GraphiQL bundles it as ESM. Both end up on `monaco-editor@0.52.2` but in
different module systems. Sharing would require ripping out Studio's CDN
loader (Studio-wide refactor, out of scope). GraphiQL's monaco is
dynamically imported and only loads when the GraphQL tab opens.
- The dark/light response panel uses different `--graphiql-response-bg`
tokens because the editor sits at very different baseline lightness in
each theme; a single token can't lift it meaningfully in both
directions.
- Session header (tabs row) is hidden – we don't expose multi-tab
workflows.

## To test

- Open `/project/<ref>/api/graphiql` in both light and dark themes –
editor + response panel backgrounds, sidebar borders, button radii
should all match the dashboard
- Run a query and confirm syntax highlighting works (GraphQL-specific
token `argument.identifier.gql` is purple)
- Open the doc explorer and history sidebar plugins
- As a user with `field.jwt_secret` read permission: open the Role
Impersonation sidebar plugin, pick a role, confirm subsequent queries
hit the API with the impersonated JWT
- As a user without that permission: confirm the Role Impersonation
plugin doesn't appear, history still does
- Toggle theme while GraphiQL is open – Monaco theme should swap without
a reload

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Vertical layout option for the role impersonation selector; radios can
expand to full width.

* **Improvements**
* Revamped GraphiQL integration with updated upstream package, plugins,
and editor theming for improved consistency and UX.
* New GraphiQL styling and layout for clearer pane separation and
polished controls.
* Role selector radios now support a full-width mode for improved
responsiveness.

* **Chores**
  * Updated GraphiQL-related dependencies.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-05-01 16:16:26 +08:00

164 lines
5.5 KiB
TypeScript

import { useState } from 'react'
import { Badge, Card, CardContent, CardHeader, CardTitle, cn } from 'ui'
import { AnonIcon, AuthenticatedIcon, ServiceRoleIcon } from './Icons'
import { RoleImpersonationRadio } from './RoleImpersonationRadio'
import { UserImpersonationSelector } from './UserImpersonationSelector'
import { DocsButton } from '@/components/ui/DocsButton'
import { DOCS_URL } from '@/lib/constants'
import { PostgrestRole } from '@/lib/role-impersonation'
import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
export interface RoleImpersonationSelectorProps {
header?: string
serviceRoleLabel?: string
disallowAuthenticatedOption?: boolean
title?: string
orientation?: 'horizontal' | 'vertical'
}
export const RoleImpersonationSelector = ({
header = 'Impersonate a database role',
serviceRoleLabel = 'Postgres',
disallowAuthenticatedOption = false,
orientation = 'horizontal',
}: RoleImpersonationSelectorProps) => {
const isVertical = orientation === 'vertical'
const state = useRoleImpersonationStateSnapshot()
const [selectedOption, setSelectedOption] = useState<PostgrestRole | undefined>(() => {
if (
state.role?.type === 'postgrest' &&
(state.role.role === 'anon' || state.role.role === 'authenticated')
) {
return state.role.role
}
return 'service_role'
})
const isAuthenticatedOptionFullySelected = Boolean(
selectedOption === 'authenticated' &&
state.role?.type === 'postgrest' &&
state.role.role === 'authenticated' &&
(('user' in state.role && state.role.user) ||
('externalAuth' in state.role && state.role.externalAuth)) // Check for either auth type
)
function onSelectedChange(value: PostgrestRole) {
if (value === 'service_role') {
// do not set a role for service role
// as the default role is the "service role"
state.setRole(undefined)
}
if (value === 'anon') {
state.setRole({
type: 'postgrest',
role: value,
})
}
setSelectedOption(value)
}
return (
<Card className="border-none">
<CardHeader className="flex-row items-center justify-between py-3 space-y-0">
<CardTitle>{header}</CardTitle>
<DocsButton
href={`${DOCS_URL}/guides/database/postgres/row-level-security#authenticated-and-unauthenticated-roles`}
/>
</CardHeader>
<CardContent className="flex flex-col gap-y-4">
<form
onSubmit={(e) => {
// don't allow form submission
e.preventDefault()
}}
>
<fieldset className={cn('flex gap-3', isVertical && 'flex-col gap-2')}>
<RoleImpersonationRadio
value="service_role"
isSelected={selectedOption === 'service_role'}
onSelectedChange={onSelectedChange}
label={serviceRoleLabel}
description="Superuser"
icon={<ServiceRoleIcon isSelected={selectedOption === 'service_role'} />}
fullWidth={isVertical}
/>
<RoleImpersonationRadio
value="anon"
label="Anonymous"
isSelected={selectedOption === 'anon'}
onSelectedChange={onSelectedChange}
description="Not logged in"
icon={<AnonIcon isSelected={selectedOption === 'anon'} />}
fullWidth={isVertical}
/>
{!disallowAuthenticatedOption && (
<RoleImpersonationRadio
value="authenticated"
label="Authenticated"
isSelected={
selectedOption === 'authenticated' &&
(isAuthenticatedOptionFullySelected || 'partially')
}
onSelectedChange={onSelectedChange}
description="Specific logged in user"
icon={<AuthenticatedIcon isSelected={selectedOption === 'authenticated'} />}
fullWidth={isVertical}
/>
)}
</fieldset>
</form>
{selectedOption === 'service_role' && (
<div>
<p className="text-sm">
Full admin access
<Badge className="ml-2">Default</Badge>
</p>
<p className="text-foreground-light text-sm">
The <code className="text-code-inline">postgres</code> role, which bypasses all Row
Level Security (RLS) policies.
</p>
</div>
)}
{selectedOption === 'anon' && (
<div>
<p className="text-sm">For unauthenticated access</p>
<p className="text-foreground-light text-sm">
The <code className="text-code-inline">anon</code> role, which the API (PostgREST)
uses when a user is not logged in.
<br />
Row Level Security (RLS) policies apply.
</p>
</div>
)}
{selectedOption === 'authenticated' && (
<div>
<p className="text-sm">For authenticated access</p>
<p className="text-foreground-light text-sm">
The <code className="text-code-inline">authenticated</code> role, which the API
(PostgREST) uses when a user is logged in.
<br />
Row Level Security (RLS) policies apply.
</p>
</div>
)}
</CardContent>
{selectedOption === 'authenticated' && (
<CardContent className="p-0">
<UserImpersonationSelector />
</CardContent>
)}
</Card>
)
}